diff --git a/CHANGELOG.md b/CHANGELOG.md
index 05857d43fe..417681cee6 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,9 +5,10 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
-## [Unreleased]
+## [3.7.0] - 2026-09-10
### Added
+
- **The member-range scan now reports ranges that stop short of their own content, which it previously read as healthy** ([#3230]) - `ShapeOf` named two failing shapes and both were over-reads; a range ending early is closed, is under its successor's start, and overlaps nothing, so it satisfied every arm and reported `WholeMember`. `DeclaredRange` now carries a second, independently derived content boundary and `RangeShape.Truncated` reports the disagreement. Thirty members whose ranges stop short are carried as a set-asserted inventory rather than rewritten, because no merged consumer of the map under-reads because of them.
- **The clock-frame census now sees a renderer reached through a one-hop wrapper** ([#3207]) - four server-local `plan_correction` stamps in the Darling viewer were in the wrong frame and invisible to it, because the render scan keys on the renderer's name and a wrapper is a different name reaching the same renderer. The wrapper set is derived and pinned at set equality, so a new one must declare the frame it renders, and no wrapper may be handed a column every table declaring it frames the other way.
- **`.github/dependabot.yml` now tells whoever reviews a `Microsoft.Data.SqlClient` or `Azure.Identity` bump to check whether `ExcludeBrokerCredential`'s default or the broker's packaging changed** ([#3219]) - Lite's `EntraDefaultCredential` mode is broker-free only because `Azure.Identity.Broker` is absent from the graph, `Azure.Identity` is unpinned and arrives transitively through SqlClient's range, and the two pins guarding that absence assert the absence of the package rather than the outcome.
@@ -62,72 +63,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- **`plan_correction` stages its recommendations before touching Query Store** ([#2687]) - the per-database read joined `sys.query_store_query`, `sys.query_store_query_text` and `sys.query_store_plan` straight off `sys.dm_db_tuning_recommendations`, a DMV with no statistics, so on a bloated Query Store the optimizer guessed its cardinality high and satisfied those joins by SCANNING the whole plan store - the 260s tail [#2673] could only backstop with a wall-clock budget. The recommendation set is invariably small (the engine keeps a handful and erases them on restart), so it is now materialised into a `#temp` with its details JSON already shredded, and the Query Store views are joined off that - a real row count the optimizer turns into a nested-loop SEEK per `query_id`/`plan_id`. The sp_QuickieStore rule again (see [#2680]): never naively join a Query Store view you can seek. Output is unchanged - the same 38 columns in the same order, the enablement-only row still survives a zero-recommendation database, and the joins stay LEFT so an aged-out plan cannot drop a live recommendation - verified against a real Query Store on SQL 2025. The seek-versus-scan flip is the DMV-cardinality mechanism, not a rig-measured delta, so the [#2686] per-item wall-clock budget stays as the backstop and `collector_cost` will show the real before/after.
- **The live Query Store fetch stops joining a view it never reads** ([#2680]) - the per-item query joined `sys.query_store_query_text` even on the Darling path, where the text column is nulled and the text is pulled separately by-ids, so the join produced one probe per row and fed nothing. `query_store` is already the fleet's single heaviest collector - the #2674 self-metric now says so in numbers - and this is the sp_QuickieStore rule applied literally: don't join a Query Store view you don't consume. It is gated on the same flag that nulls the column, so Lite, which reads the text inline, keeps the join. Measured -12% (398ms -> 351ms) on a 40,388-plan catalog on the rig, provably non-filtering because `query_text_id` keys exactly one text row so the emitted rows are unchanged, and the join-less shape was run against a real Query Store to confirm identical output.
- **The tool now monitors its OWN cost on the servers it watches** ([#2674]) - which of our collectors is the most expensive to run on a monitored server was answerable only by scraping the service log. Schema **v105** adds `collect.collector_cost`, an INTERNAL self-metric table (the store_metrics pattern, not a CollectorCatalog collector): every collector run's cost - run count, total and max target-side query DURATION (ms, not pure CPU), store-write time and rows - is accumulated in memory per (server, collector) and flushed as one hourly aggregate on the store-metrics tick, with its own 90-day retention. **MAX is kept, not just an average**, because the tail is how a collector sticks out on a target - a single multi-minute execution an average would bury. `get_collector_cost` (and `/api/read/get_collector_cost`) return the ranked fleet list or one collector's daily trend. And it alerts on ITSELF: a fleet-level self-alert fires when a collector's latest day exceeds its own 14-day baseline by 2x (guarded by a 1s/day floor and three baseline days so a cheap or new collector cannot trip it), re-fires on the cooldown, and resolves when it drops back - so a collector that regresses into a hog pages us instead of surfacing in someone's server monitoring. Grew out of the #2675 investigation, where the same numbers had to be reconstructed from logs by hand.
-- **Store exposure admits both roles at once** ([#2665]) - `postgres.network.role` took one role, so a team could have an `admin` Viewer or read-only `viewer` ones on the LAN but not both, even though the store has always created and separately granted both roles and given each its own credential. What was single-valued was the one generated `hostssl` line. The field now accepts both - `"admin,viewer"`, separated by a comma, a `+`, or whitespace - and the reconciler writes one rule per role inside its own marked block, which is where it matters: the documented workaround was a second `hostssl` line added by hand outside the markers, and because the reconciler preserves everything outside them verbatim, that line silently kept the old CIDR when `allowFrom` was later tightened. Somebody narrowing access would reasonably have believed they had. Inside the block both rules narrow together. The guardrails hold as written: every generated line still names exactly one role and one CIDR, never `all` and never the superuser; the order is settled by the parser rather than by how the field was typed, so re-spelling `admin,viewer` as `viewer,admin` does not rewrite `pg_hba.conf` and reload the server; an unrecognised element rejects the whole value rather than opening the store with the half that parsed; and absent or blank still means `viewer` alone, so no existing configuration gains admin-capable access from the field learning to take a list. `--configure-network` offers both at the prompt (default still `viewer`) and `--print-viewer-connection` prints one paste-ready string per admitted role, each labelled with the seat it authenticates as.
-- **PostgreSQL I/O and database trends** ([#2663]) - the two deepest series in the store had no shape over time: `pg_io_stats` and `pg_database_stats` had over a thousand snapshots each on a two-day-old rig and nothing differenced them. `get_pg_io_trend` follows one (backend_type, context) pair - a PAIR, because a hit ratio summed across contexts is meaningless when `bulkread` is a sequential scan deliberately bypassing the buffer pool - and reports read, write and extend rates per second, the interval's own hit ratio, and per-operation latency where the server measures it. `get_pg_database_trend` follows one database's temp-file spills, cache hit ratio, deadlocks and rollback share. That last one is where the hit ratio becomes usable at all: `pg_stat_database`'s counters are cumulative since the last reset, so the ratio computed from them raw is a lifetime average that barely moves, and a database that fell off a cliff an hour ago still reports 99% because of the weeks behind it. On the rig a database averaging **98.78% across the window had an interval at 78.49%**, and only the differenced series can say so. Both read the target's own `track_io_timing` before reporting a latency: it is **off by default** and was off on both rig targets, so `read_time_ms / reads` would have printed 0.000 ms - a statement about the configuration that, read as a latency, is the most reassuring wrong number on the page. Byte volumes say whether they are MEASURED (PostgreSQL 18's `read_bytes`) or ESTIMATED as operations x block size (pre-18), because the two are different quantities and 18 moves several blocks per operation. Both subjects are optional and each read says which it chose - and buffer HITS qualify a pair for that choice, so a fully cached server, which is the healthy state, still gets a chart instead of "nothing to follow".
-- **The first PostgreSQL trend reads** ([#2663]) - the service shipped fourteen trend reads and not one worked on a PostgreSQL target, so "is this getting worse" - the question a monitoring tool exists to answer - had no PostgreSQL answer while every `get_pg_*` read described a single window. The data was already there: over a thousand stored snapshots of I/O and database stats on a two-day-old rig, and nothing differenced them. `get_pg_wait_trend` follows one wait event over time, per second; `get_pg_query_duration_trend` follows one statement's cost per execution, which is the regression read - a query that doubled halfway through the window still ranks where its average puts it in a single-window grid. Both take their subject as an optional parameter and choose a sensible one when it is omitted, and say which they chose. The wait trend's automatic choice deliberately skips the CPU class: `pg_wait_sampling`'s `Running` means the backend was NOT waiting and dominates any healthy server's profile, so defaulting to it would answer the opposite of the question.
-- **PostgreSQL deadlocks, not just the count** ([#2661]) - we collected `pg_stat_database.deadlocks`, a number that goes up, and nothing else. PostgreSQL writes a complete report to its server log: the wait graph, every participant, and each one's full statement text. `get_pg_deadlocks` lists them with the victim, the lock modes and the resources; `get_pg_deadlock_detail` returns the graph as the server wrote it. In one respect this beats the SQL Server graph, which names the victim's statement and often leaves the other side as a handle. It needs **nothing configured on the target** - unlike plan capture there is no setting that suppresses a deadlock report, so it works on a managed fleet today; the only precondition is being able to read the log, which plan capture already established. The collector re-reads an overlapping tail on purpose so a report cut in half at one edge is whole in the next, and every row carries a hash of its graph so the same report is reported once, with a sighting count, rather than once per cycle.
-- **PostgreSQL configuration, and what changed in it** ([#2658]) - `pg_settings` was never collected, so neither "what is `work_mem` set to on this server" nor "what changed last Tuesday" had an answer, and the second is the kind that cannot be recovered later at any price: a configuration history nobody recorded is not sitting on the server waiting to be read. `get_pg_server_config` reports the settings somebody actually chose, non-default first, with where each value came from and whether changing it needs a restart or a reload. `get_pg_server_config_changes` reports value changes between snapshots, old beside new. Both name `pending_restart` loudly - the state where `postgresql.conf` has been edited and reloaded but the running server is still on the old value, so the file and the server disagree with no symptom until a restart months later changes behaviour during someone else's incident.
-- **Test an index from the predicate grid** ([#2612]) - right-click a row in PostgreSQL predicate statistics and ask whether the planner would actually use an index on that column. The command shipped with no caller: the only way to reach it was hand-writing a row into the command queue, which is how it was tested and is not a feature. It hangs off that grid and nowhere else, which is the shape it was scoped to - on demand only, never scheduled, driven from a row somebody is already looking at. The confirmation says what it costs the server before it runs (nothing executed, no index built, session reset), and a predicate whose estimate error is already large is flagged BEFORE the round trip, because an index does not fix a plan built on a wrong row count.
-- **Azure SQL DB now reports every database's size, not just the connected one** ([#2643], raised from the field) - `sys.database_files` is database-scoped, so a Viewer pointed at `master` showed `master`'s two files and nothing else, which is correct and reads exactly like a broken collector. `sys.resource_stats` is a master-only view carrying `storage_in_megabytes` per database, so from a `master` connection the siblings now appear too - as one row each, labelled `(whole database)` with a NULL `file_id`, because that view has no per-file breakdown and a fabricated file name would make the grid look complete and be wrong. The sibling read runs through `sp_executesql`: the view does not exist in a user database and name resolution happens at parse time, so a guarded UNION still fails with 208 everywhere else. Verified against a live Azure SQL Database from both a `master` and a user-database connection.
-- **Mark rows in grids** ([#2645], requested from the field) - right-click any FinOps grid and mark the selected rows **Done**, **To Do** or **Do Not Do**, so you can work through a result set and remember which rows you have dealt with and which you have decided against. Asked for on Index Analysis, where you decide index by index. Marks are held against the row objects, so they last exactly as long as the result set does: on a run-on-demand grid until you run it again, on a live grid until the next refresh. They are painted from `LoadingRow`, so no row model gained a property and no grid gained a column.
-- **Test a PostgreSQL index without building it** ([#2612]) - the new `test_hypothetical_index` command plans one stored statement twice, with and without a candidate index the planner can see and nothing builds, and answers whether the planner would switch and by how much estimated cost. It closes the loop on `pg_predicate_stats`, which names columns that are filtered on heavily with nothing supporting them: without this every recommendation is an inference, and with it there is an answer. **On demand only, never scheduled** - no collector, no cadence, no sweep. Nothing is executed (`EXPLAIN` without `ANALYZE`), nothing is written, the candidate is visible only inside one session, and the session is reset before the call returns. A "no" is a real result and says so: on the verification rig a candidate on an already-served predicate left the plan and its cost unchanged, which is the answer that saves somebody from building an index.
-- **The last eight PostgreSQL collectors are readable outside the Windows Viewer** ([#2629]) - `get_pg_predicate_stats`, `get_pg_index_bloat`, `get_pg_column_stats`, `get_pg_buffer_usage`, `get_pg_extensions`, `get_pg_lock_stats`, `get_pg_write_stats` and `get_pg_replication_stats`. Every PostgreSQL collector except `pg_plan_capture_readiness` now has an MCP tool and a web panel, and that one is the case the ratchet was built to tolerate - a single row of configuration state is a panel, not a question anyone asks an agent. The ratchet drops from nine to one.
-- **Sampled waits and per-query OS CPU are readable from the MCP and the web dashboard** ([#2629]) - `get_pg_wait_sampling` and `get_pg_kernel_stats`. Both collectors were storing data reachable only from the Windows WPF Viewer: collected on every cycle on a Linux host and invisible from that host, and invisible to every agent. It mattered most for `pg_wait_sampling`, which #2625 had just started POINTING AT - a stock-PostgreSQL operator was told, correctly, that it answers what Aurora's wait instrumentation cannot, and sent to a panel the MCP could not read. Seven PostgreSQL collectors remain Viewer-only and a ratchet now stops that number rising.
-- **Self-hosted PostgreSQL now answers "which queries cost the most"** ([#2625]) - `pg_statement_stats` read `aurora_stat_statements()` and gated itself Aurora-only, so on a non-Aurora target the single question a database monitor exists to answer had NO answer at all: the read returned "does not run on that engine, and never will". It now reads the vanilla `pg_stat_statements` view on any other PostgreSQL, chosen at query-build time - one collector, one table, identical ordinals - and reports the six Aurora-only columns as NULL rather than zero, because zero would be a claim about storage hardware the server does not have. Verified against a self-hosted PostgreSQL 17: 77 statement rows, and the read tool returns them with `storage_blks_read` and `max_exec_peakmem_bytes` honestly null. `toplevel` is guarded for PostgreSQL 13 and older, where nested tracking did not exist and every row IS top level. A target without the extension keeps the existing `42P01` -> ObjectMissing answer, which names the fix.
-- **Permanent-gap messages now name the collector that DOES answer the question** ([#2625]) - "and never will" is right about the source and wrong to leave an operator with when a sibling covers it. On stock PostgreSQL the `pg_wait_stats` gap now adds that `pg_wait_sampling` answers the same question from a different source. Deliberately sparse: an entry is a promise the named collector answers substantially the same question, and a pointer at something merely adjacent is worse than none.
-- **PostgreSQL plan capture reaches Aurora and RDS** ([#2538]) - `auto_explain` writes plans to the server log and nowhere else, and managed PostgreSQL has no filesystem to read: `pg_read_server_files` is not grantable and `pg_read_file` is denied. Those targets now get their plans from `DownloadDBLogFilePortion` instead. **One table, two transports**, and the route is chosen at DISPATCH rather than by gating the collector - gating would make the capability model report plan capture as a permanent gap on Aurora, which is false, and four capability tests said so. Both routes write through the SAME collector definition, so the column order, COPY command and standard prefix have one owner and cannot drift. **Three decisions worth knowing**: a cluster endpoint is resolved to its WRITER, because `DownloadDBLogFilePortion` takes an instance identifier and passing a cluster name through yields `DBInstanceNotFound` against a healthy cluster; the newest log is chosen by `LastWritten` rather than by NAME, because the filename embeds a date and sorting text puts `2026-08-09` after `2026-08-25`, which would pin the collector to a stale log forever without ever erroring; and reader and custom endpoints are REFUSED with a reason, because they round-robin across replicas so plans pulled through one would be attributed to whichever replica answered. **No credentials are stored** - the SDK's default chain finds the instance profile the service already runs under, so nothing can leak from config. The resume marker is in memory on purpose: a restart re-reads a bounded tail, which is harmless because plan rows dedup on `(queryid, plan_hash)`, and persisting it would add a schema rung that could disagree with reality after a log rotation. Redaction is not re-derived anywhere - it lives once in `PgPlanLogParser`, shared by both routes.
-- **`get_pg_plans`: the plan itself, not a pointer to one** ([#2567]) - an agent consuming the MCP has no viewer to follow a reference into, so a read that answers with an id has not answered. Returns captured PostgreSQL plans grouped by shape and ranked by total time, with the plan emitted as **navigable JSON** rather than an opaque string. **`queryid` is a STRING on the wire** (#2548): it is a signed int8 spread over the whole 64-bit range, so most ids exceed 2^53 and any consumer decoding JSON numbers as IEEE-754 doubles rounds one - and since queryid is an equality join key, a rounded value does not approximate the answer, it matches nothing. The tool also **accepts** it as a string for the same reason. **The empty answers are the work**: 'no plan' has three unrelated causes with three unrelated remedies, and this names which one it is rather than guessing in prose - capture not configured (naming the specific unsatisfied facet from `pg_plan_capture_readiness`, with its remedy), capture configured but the statement never crossed `auto_explain.log_min_duration` (the healthy answer), or the plan aged out under `plan_content_retention_days`. **`captures` is named for what it counts** - the collector reads an overlapping tail of the server log so one execution can be seen twice; `get_pg_top_queries.calls` remains the authority on how often a statement ran. Nothing here re-derives redaction: the plan is stripped at collection, so there is no un-redacted copy in the store for a read to leak.
-- **PostgreSQL execution plans, captured and redacted** ([#2566], part of [#2538]) - schema **v99** adds `collect.pg_plan_capture`, reading `auto_explain`'s JSON output out of the server log. **Its own table, NOT `query_plan_dim`**: that one holds SQL Server plan XML and `plan_xml_compression = none` is a documented contract with direct-SQL consumers who read the column AS XML - dropping JSON rows into it would break their dashboards with no schema change to notice, no error, and no version to key off. **No query text and no literals reach the store, and that is the load-bearing part.** `auto_explain` emits `Query Text` verbatim and `auto_explain.log_parameter_max_length = 0` does NOT suppress it (that setting covers bind parameters only - measured on #2565); literals also sit inside the plan tree in `Filter` and its relatives. So the text is dropped outright - statement identity is `query_id`, whose normalised text already lives in `pg_statement_stats` - and every remaining string is redacted before storage. **Redaction is deliberately asymmetric**: quoted literals go from every string, but bare numbers only from condition fields, because a blanket numeric strip would rewrite a relation genuinely named `transactionitems1` into `transactionitems?` - destroying identity to hide a value that was never there. Verified against 25 real captured plans: **zero literals survived**, and the digit-bearing name survived intact. `plan_hash` is taken of the REDACTED plan so the same shape recurs to the same hash whatever values it ran with. The log read is **bounded to a 4 MB tail** because #2565 measured 772 MB of log in twenty seconds at capture-everything, and truncated blocks at the window edge are skipped rather than stored half-parsed. **Self-hosted in practice**: reading the log needs `pg_read_server_files` AND an explicit `GRANT EXECUTE ON FUNCTION pg_read_file` - measured, the role alone does NOT carry it, because the function's ACL is `postgres=X/postgres` - and on Aurora or RDS there is no filesystem at all, so the panel stays empty by design and says so.
-- **Which PostgreSQL columns are filtered on, and where the planner is wrong about them** ([#2603]) - `pg_index_usage_stats` records indexes that were USED; schema **v98** adds `collect.pg_predicate_stats` from `pg_qualstats`, which records predicates that were EVALUATED **including the ones with no index behind them**. That is the index-candidate signal, and it is invisible to every other collector because nothing records a scan that had no index to record. **The counts are a SAMPLE and the rate is stored on every row**: `pg_qualstats.sample_rate` defaults to `1/max_connections` - 0.01 on the verification rig, where `pg_qualstats()` returned ZERO rows on a server that had just run the queries it was meant to record. A small count means the sampler fired rarely, not that the predicate is rare, and the counts are deliberately NOT scaled up by the rate because three observations at 1% is not three hundred. **`worst_estimate_error_ratio` is a different problem from selectivity** - measured at **57.9x** on one predicate beside 1.04x on another: the first means the plan was built on a wrong row count, which an index will not fix. **Runs per database, and that is load-bearing**: the function returns cluster-wide rows keyed by `dbid`, but `lrelid`/`lattnum` are OIDs meaningful only inside their own database while `pg_class` and `pg_attribute` are per-database catalogs - unscoped, the join either silently DROPS other databases' rows or resolves them against whatever local object shares the OID and reports a confident wrong column name. Measured: two cross-database rows present, neither colliding locally that day, so the failure would have been silent loss and different OID luck would have produced the wrong name. The extension is itself per-database, so databases without it degrade to a named non-fatal skip.
-- **OS CPU and disk per query on PostgreSQL** ([#2603]) - `pg_stat_statements` reports elapsed time, which cannot separate a query that was WAITING from one burning processor. Schema **v97** adds `collect.pg_kernel_stats` from `pg_stat_kcache`: the kernel's own user CPU, system CPU and bytes that reached the disk, keyed by the same `queryid` so the two halves compose. **`exec_read_bytes = 0` does NOT mean the query read nothing** - the counters come from `getrusage` and measure I/O that reached the DEVICE, so a read served by the OS page cache is genuinely zero, which was the case on every row of the verification rig while writes were not. Zero reads with high CPU is a cached workload working properly; it is not comparable to a logical-read figure, and the columns are named `_bytes` so nothing in a SQL Server codebase reads them as one. **Only top-level statements are collected**: with `pg_stat_statements.track = 'all'` a nested statement appears both on its own row and inside its caller's, and summing them double-counts every function body on the server - the rig ran `track = 'top'` where that filter is inert, which is exactly why it is pinned rather than remembered. Times convert seconds to milliseconds once, in the collector, because a column named `_ms` holding seconds is wrong by a thousand and looks entirely plausible. Ranked by **CPU**, not bytes. Cumulative like the wait profile, but this table can do better than inferring a reset from a counter moving backwards: `stats_since` is collected, so the read detects a reset from the stamp changing AND from the backwards check. **Carries `database_name`, and that is not a contradiction of v95** - the function exposes `dbid` and `pg_database` is a SHARED catalog, so the name resolves correctly from any connection; `pg_wait_sampling` omits it because its catalog genuinely cannot support the claim.
-- **PostgreSQL wait analysis stops being Aurora-only** ([#2603]) - `PgWaitStatsCollector` reads `aurora_stat_system_waits()`, so every self-hosted, on-prem and plain-RDS target had **no wait data at all**, which is backwards: self-hosted is where the extension story is richest. Schema **v96** adds `collect.pg_wait_sampling` from the `pg_wait_sampling` module, attributing each wait to the `queryid` that waited so it joins `pg_statement_stats` - the "which query waited on what" question this product answers on SQL Server. **Every design choice came from running it.** `event_type = 'Activity'` is excluded at the source: measured on an idle PostgreSQL 17 the entire top of the raw profile was background processes waiting for work (`AutovacuumMain`, `LogicalLauncherMain`, `WalWriterMain`, `CheckpointerMain`), accumulating samples forever precisely BECAUSE nothing was happening, while attributed waits returned nothing at all - rank that and every healthy server reports autovacuum's idle loop as its top wait. The filter is `IS DISTINCT FROM`, never `<>`, because a NULL event means the backend was **not waiting** and `NULL <> 'Activity'` is NULL rather than true, which would silently discard every on-CPU sample; those are kept and labelled **`CPU`/`Running`** instead of stored as a blank row that sorts near the top of a count-ranked grid (23 samples across 11 backends arrived exactly that way). **`sample_count` is a tally, not a duration** - `profile_period_ms` travels beside it because a count is uninterpretable alone, and the samples-to-milliseconds multiplication happens once in the read where the header says *Est. Wait*. The profile is **cumulative**, so a counter going BACKWARDS is a reset (`pg_wait_sampling_reset_profile`, or a restart nobody chose) rather than a negative wait: the read uses the newest value whole in that case and flags `counter_reset`, where `GREATEST(new - old, 0)` would have quietly reported zero waits across a restart. **No `database_name`, deliberately** - the profile is cluster-wide and version 1.1 exposes no database column, so claiming that attribution would be the same scope error v95 removed from three other tables. Absent module degrades to a named non-fatal skip, and the Extensions panel says whether installing it would help.
-- **Three PostgreSQL collectors could not say which database their rows described** ([#2599]) - found by two of our own collectors contradicting each other on a live Aurora target. `pg_table_bloat_stats`, which runs per database, reported a `pg_extension` row for `pgstattuple`; `pg_extension_availability`, which ran once per target, reported the same extension as merely `available`. Both reads were correct and they were looking at different databases, and neither row said which. **`pg_extension` is per-database while `pg_available_extensions` is cluster-wide**, so that collector was mixing two scopes in one `state` column - its own doc comment already spelled out why that is wrong, which is the part worth keeping: the documentation was right and the code did the thing it warned against. Schema **v95** adds `database_name` to `collect.pg_column_stats`, `collect.pg_index_bloat` and `collect.pg_extension_availability`, and `pg_extension_availability` now runs per database. **The reads were the user-visible half**: all three used `DISTINCT ON` over a key that did not include the database, so on any cluster carrying the same schema in two databases - the ordinary multi-tenant shape - the rows collapsed to one and the newest `collection_time` silently decided which database the grid was describing. The invariant is now pinned rather than remembered: a test walks the catalog and fails the build for any collector that returns true from `RunsPerDatabase` without declaring a `database_name` column. It found a third offender the investigation had missed - `pg_index_bloat`, added the same day.
-- **PostgreSQL index bloat, MEASURED rather than estimated** ([#2561]) - and the estimator this issue proposed is the one thing that could not work. #2561 suggested porting the ioguix btree estimator, which derives an expected page count from `pg_stats` column widths. Measured: **`pg_stats` returns ZERO rows to a `pg_monitor`-only login**, because the view filters on `has_column_privilege` - the same trap that produced #2542's 88.59% reported against a true 0.50%. Meanwhile `pgstatindex` **does** run for `pg_monitor`, because pgstattuple grants EXECUTE to `pg_stat_scan_tables` which `pg_monitor` includes. So under exactly the permission set this product runs as, the exact function works and the estimator is blind. Schema **v94** adds `collect.pg_index_bloat`, collected daily per-database on primaries, matching `pg_index_usage_stats` so a read can join the two halves of one question - is this index earning its keep, and is it wasting space doing it. **`avg_leaf_density` is stored RAW and never converted to a bloat percentage**: measured across seven freshly built indexes it sits between 89.98 and 91.48, and between 87.07 and 90.81 after `REINDEX`, so `100 - density` invents about ten points of bloat on a perfect index and there is no constant to subtract because the healthy value varies per index. The read derives a reclaimable-bytes estimate against a **90% floor** rather than a full page, and presents it beside the raw figure rather than replacing it. **Ranked by reclaimable BYTES, never by density** - a tiny index at 20% density looks alarming and is worth kilobytes next to a large one at 70%, verified against seeded data where a 10 GB index at 45% showed 5.37 GB reclaimable while a 64 KB index at 20% showed 50 KB. **Only b-tree indexes are candidates, and the filter is fenced with `OFFSET 0`**: `pgstatindex` RAISES on anything else - verified on GIN, BRIN and hash, all `relation "x" is not a btree index` - so one GIN index would otherwise fail the whole collection every cycle; the planner did filter first without the fence in testing, but correctness should not depend on plan shape when the failure is total. **Nothing is silently skipped**: the function reads every page, so indexes above a 20 GB ceiling are recorded with NULL measurements and a stated reason, and the read sorts them FIRST - unknown is not zero, and they are the likeliest big win. The function is qualified `public.pgstatindex`, not `pg_catalog.` - it is an extension function and lives where pgstattuple was created, and `pg_catalog.pgstatindex(oid)` does not resolve at all (verified).
-- **What is resident in PostgreSQL shared buffers, with the join every published example gets wrong** ([#2544]) - the last named slice. A hit ratio says how often the pool worked; this says what is IN it, which is what answers "does the working set fit" and "which relation is holding the memory something else needs". Schema **v93** adds `collect.pg_buffer_usage`, collected **hourly**. **Three correctness traps, all measured**: (1) `relfilenode` is NOT `oid` - the join every example writes, `pg_class.oid = pg_buffercache.relfilenode`, silently loses any table that has ever been rewritten, and after a single `VACUUM FULL` the naive join reported **0** buffers for a table holding **6,667**; `pg_relation_filenode(oid)` is the correct key. (2) Mapped catalogs carry `relfilenode = 0` in `pg_class` - measured on `pg_class` and `pg_proc` - so joining the raw column drops those too, which the same function fixes. (3) The pool is CLUSTER-wide while `pg_class` is per-database, the fourth catalog in this effort whose scope does not match its name, and worse here than elsewhere: a filenode from another database can collide with a local OID and resolve to a confidently WRONG name. So the relation name is resolved only when the buffer belongs to the connected database, and foreign buffers are KEPT with their database named and a NULL relation - dropping them would understate how full the pool is, which is the one number this exists to report. Pool totals ride on every row, taken from a window over the SAME scan rather than a second query or `pg_buffercache_summary()`, because sampling a moving pool twice puts the disagreement straight into the percentage a reader computes. **Hourly for two reasons that agree**: the full view is a scan of every buffer, measured at 6.1 ms for a 512 MB pool and scaling linearly to roughly 780 ms at 64 GB of `shared_buffers`; and a server WITHOUT the `pg_buffercache` extension records an `ObjectMissing` outcome each cycle, which at a minute grain would be thousands of rows a day of noise. That miss is now actionable rather than dead: `pg_extension_availability` (#2545) reports whether the extension is `available` and one `CREATE EXTENSION` away, so the collector that cannot run and the panel that says how to make it run are the same story.
-- **PostgreSQL replication connections, with the measure that actually catches a stalled standby** ([#2544]) - the replication slice, and distinct from `pg_replication_slots` in a way worth stating: a SLOT is a promise to retain WAL and exists whether or not anybody is attached (an abandoned one is the classic way to fill a disk with nothing connected to it), while this is the live CONNECTION. A server can have a slot with no standby, a standby with no slot, or both. Schema **v92** adds `collect.pg_replication_stats`, sampled per minute on every PostgreSQL target including standbys - a cascading replica's downstream is as worth watching as a primary's, and recovery state changes on failover without a dispatch gate noticing. **Both the byte distance and the time lag are stored, because the time lag understates a replay stall badly.** Measured twice against a real standby holding `pg_wal_replay_pause()`: 26 MB behind reporting 209 ms, and 33.7 MB behind reporting 2.8 seconds. The lag columns do move - they time the round trip of the most recently replayed record - but they scale with nothing a reader can act on, so the byte distance is the proportionate measure and the one to alert on. **Four distances rather than one, because they localise the fault**: in that same stalled run `sent`, `write` and `flush` were all ZERO and only `replay` was behind, so the WAL had been shipped, written and fsynced and the problem was purely apply - while `state` still read `streaming`. No single column would have shown that. Every distance is measured from `pg_current_wal_lsn()` rather than from `sent_lsn`, so a sender that has itself fallen behind is visible instead of being used as the baseline. The read returns the latest sample AND the window's WORST, because a replica that drifts hundreds of megabytes behind each afternoon and recovers by evening reads as perfectly healthy in every single sample - and is the one most likely to be useless when somebody needs to fail over to it. Standbys are identified by `(application_name, client_addr)` rather than name alone, since an unconfigured replica reports the default `walreceiver` and two of them would otherwise average into one row; the join uses `IS NOT DISTINCT FROM` because `client_addr` is NULL for a standby on a Unix socket and an equality join would silently drop it. A standby appearing in fewer samples than were taken has been DISCONNECTING, which every other column reports as healthy, so the sample count is surfaced too. Zero rows on a replica is CORRECT rather than a fault - `pg_stat_replication` is the primary-side view - and the panel says which of those it is looking at.
-- **PostgreSQL column statistics, with the customer data deliberately left behind** ([#2543]) - a plan says what the planner DID; these say why it thought that was reasonable, which for the commonest class of PostgreSQL problem (a bad row estimate producing a nested loop over millions of rows) is the difference between the symptom and the cause. Schema **v91** adds `collect.pg_column_stats`, collected daily per-database and retained a year. **The central decision is what is NOT collected.** `most_common_vals` and `histogram_bounds` hold raw column values - measured on a realistic table they came back as customer names, an identifier fragment and a list of live email addresses - so collecting them would copy customer data into the monitoring store under OUR retention, the same exposure as the `auto_explain` literals on #2538. Neither is collected, and a test asserts it against the SELECT list rather than the whole query so the comment explaining the exclusion cannot satisfy it. **Every finding the issue wanted survives without them**: `most_common_freqs[1]` is a frequency with no value attached, and skew is the entire parameter-sensitivity signal - measured, a status column whose top value covers 59.85% of the table is parameter-sensitive, and knowing WHICH value adds nothing to that conclusion. `most_common_vals` is touched only through `cardinality()`, which reads the array length and never its contents, so "how many values dominate" is answerable without copying any of them. `histogram_bounds` was also the largest column by bytes (3,251 against 228 and 140), so dropping both is cheaper as well as safer. Two traps handled: `n_distinct` is stored as a floating type and **never an integer count**, because negatives are a RATIO of row count and `-1` means distinct-is-about-every-row - an integer column would let a read print "-1 distinct values" for a unique key; and a **1 MB size floor** bounds what is the widest fan-out of any collector here (columns x tables x databases), since statistics on a tiny table cannot produce a misestimate anyone notices. **Zero rows has two causes and the panel says so**: `pg_stats` filters on `has_column_privilege`, so a monitoring login without SELECT sees nothing for a table that exists - measured, a `pg_monitor`-only role gets ZERO rows where a superuser gets all of them, and `pg_statistic` underneath is permission-denied outright, which is why Datadog ships a SECURITY DEFINER helper. Row-level security empties the view the same way. This slice deliberately installs no helper object; it collects what the granted role can see and reports the shortfall rather than presenting missing statistics as clean ones.
-- **PostgreSQL lock state by mode, type and relation** ([#2544]) - the locks slice, and it does NOT duplicate the blocking read even though it looks like it should. `PgBlockingCollector` reads `pg_blocking_pids()` and stores blocked/blocker PAIRS; it never touches `pg_locks`. So the product could say *pid 4821 is blocked by pid 3390* and could not say **what lock, in what mode, on which relation** - which is the half that decides what to do. Schema **v90** adds `collect.pg_lock_stats`, sampled per minute on every PostgreSQL target including standbys, where a lock conflicting with WAL replay stalls recovery with no primary-side equivalent. **The mode IS the remedy**, measured on a real pile-up: one `AccessExclusiveLock` granted on a relation with two `AccessShareLock` requests queued behind it is a DDL blocking every reader and the fix is to kill it or wait; the identical pair shape with `RowExclusiveLock` is ordinary write concurrency needing no action at all. It also sees waiters the pairs view structurally cannot - a lock waiting on a PREPARED TRANSACTION has no live backend for `pg_blocking_pids()` to name, so it is invisible there while being exactly the thing nobody can find. Aggregated by (database, locktype, mode, granted, relation) rather than one row per lock, so the row count is bounded by CONTENTION rather than by concurrency. **A third catalog whose scope does not match its name**: `pg_locks` is CLUSTER-WIDE while `pg_class` is PER-DATABASE, so a lock held in another database resolves to an OID with no name - measured, not assumed. Both columns are stored and the row is kept, because dropping it would hide real contention and naming it from the connected database catalog would name the wrong table; `relation_oid` is `bigint` and not `integer`, since OIDs are unsigned 32-bit and one past 2^31 lands negative in a signed int. Wait time comes from `pg_stat_activity.state_change` rather than `query_start` - a backend waiting on a lock has been in its current STATE since it began waiting, whereas `query_start` also covers the work it did beforehand and would overstate every wait - and is NULL on granted rows rather than 0, because 0 reads as "granted instantly", which is a measurement where NULL is the absence of one. The read carries the CAPTURE COUNT on every row: these are samples, so three ungranted rows means something different in 60 captures than in 4, and queue size is `max(backend_count)` and never a SUM - a queue of three persisting across forty captures is three backends, not a hundred and twenty.
-- **PostgreSQL extension availability: the third capability axis, and the only actionable one** ([#2545]) - engine KIND (#2536) and engine EDITION (#2511) both answer "this collector cannot run here, permanently". Extensions answer something better: "this read needs pg_stat_kcache, which this server offers and has not installed" is a **setup step**, not a wall. Schema **v89** adds `collect.pg_extension_availability`, collected daily on every PostgreSQL target and retained a year, because "when did this extension appear" is asked months later - usually right after a plan changed shape and nobody can say why. **Four states rather than a boolean**, since a boolean collapses the actionable one into the hopeless one: `installed`, `outdated` (a newer `default_version` exists - its own state because a stale extension can be missing columns a collector reads, which surfaces as a confusing 42703 rather than as a capability gap), `available` (one `CREATE EXTENSION` away), and `absent` (the server does not offer it, so an OS-level install or simply not on the menu). All four were exercised against a live PostgreSQL 17, including forcing `outdated` by installing pg_stat_statements at 1.10 against a default of 1.11 and watching `ALTER EXTENSION ... UPDATE` return it to `installed`. **The scope trap this is built around**: `pg_available_extensions` is CLUSTER-WIDE while `pg_extension` is PER-DATABASE, which their names do not say - measured on one cluster reporting pgstattuple installed in one database and not in another while the available list said yes in both. So `installed_version` is a claim about the connected database only, and the column header, the panel note and the migration comment all say so; reporting "not installed" from the maintenance database about an extension living in the application database is the obvious way to get this wrong. The row set is a **derived** half (a full outer join of both catalogs, so nothing is missed for not being on a list) plus a small **enumerated** roster that exists only because absence is not a row in any catalog and cannot be derived - a server's other extensions are still recorded, they simply carry no advice from us. **auto_explain and pg_wait_sampling are deliberately excluded and it is pinned**: both are preload-only modules that never appear in `pg_available_extensions` on ANY server including ones running them, so listing them would manufacture a permanent false `absent` - exactly the defect shipped in #2564 and fixed in #2584. They are detected instead by `shared_preload_libraries` plus their own GUCs, which `pg_plan_capture_readiness` already does. Unlike the column-statistics axis (#2543, where `pg_stats` returns **zero** rows to a `pg_monitor` role and needs a SECURITY DEFINER helper), both catalogs here read fine for `pg_monitor` - measured at 45 and 2 rows - so this ships without touching the monitored database.
-- **PostgreSQL write-side collection: checkpoints, background writer and WAL** ([#2544]) - the first slice of the second-tier gaps, and the one whose difficulty is entirely the VERSION SURFACE. Schema **v88** adds `collect.pg_write_stats`, collected per minute on any PostgreSQL 14+ target including standbys. One collector for three views because they are one story: a requested checkpoint climbing against timed ones means `max_wal_size` is too small, which shows as WAL volume in the same breath and as buffers the background writer could not clean - split across three collectors the numerator and denominator of every useful ratio would sit in different tables with different collection times. All three sources are cluster-wide singletons, so a snapshot is genuinely ONE row. **The version problem is worse than the 16-to-17 view split the issue described, and it was measured across four majors rather than read from release notes**: 17 took **seven of `pg_stat_bgwriter`'s eleven columns** - five renamed into the new `pg_stat_checkpointer`, and `buffers_backend`/`buffers_backend_fsync` to `pg_stat_io`, which is a different view with a different shape and **no successor here at all**, so a rename table alone would silently drop the two columns that say whether backends are writing their own buffers. Then **18 REMOVED four columns from `pg_stat_wal`** (`wal_write`, `wal_sync`, `wal_write_time`, `wal_sync_time`) while adding `num_done` and `slru_written` to the checkpointer - so a collector written and tested against 17 raises **42703 undefined column** on an 18 target and fails every cycle. The stored shape is therefore the **UNION and never varies**: a column a major does not supply is NULL, never zero and never absent, so one store can hold a 16 and an 18 target and mean the same thing in both rows. Every gate is a `>=` floor rather than an equality, pinned by asserting a major that does not exist yet behaves like the newest known one. Post-17 names are canonical (`num_timed`, not `checkpoints_timed`). `wal_bytes` is `numeric(38,0)` and not `bigint`, because upstream types it `numeric` precisely so cumulative WAL volume may exceed 2^63. **All three `stats_reset` stamps are stored**, since `pg_stat_reset_shared` takes a target and the families reset independently - and the read differences each family only while its OWN stamp held still, because a difference taken across a reset reports an enormous positive number that looks like a catastrophe and means nothing. Verified by running the shipped SQL against live PostgreSQL 16, 17 and 18: identical 26-column shape on all three, with the NULLs landing exactly where the major lacks the column. The read was verified against seeded data too, including a mid-window WAL reset where the WAL family blanks while the checkpointer and background-writer families still difference correctly, and a single-sample window that returns nothing rather than a confident zero.
-- **A captured PostgreSQL plan is an orphan without `%Q`, so plan-capture readiness now checks for it** ([#2538]) - measured on PostgreSQL 17 while working out which capture mechanism is viable, and it is the kind of thing that makes a feature look like it is working while producing nothing usable. `auto_explain` with `log_format=json` emits **no query identifier at all**, even with `compute_query_id=on` - the JSON body carries `Query Text` and `Plan` and nothing that identifies the statement. The id appears in exactly one place, the **log line prefix**, and with `%Q` present it equals `pg_stat_statements.queryid` exactly (verified both sides: `-4828029293864693941`). So without `%Q` every captured plan is an orphan that cannot be joined to the statement it came from, and nothing about the configuration looks wrong. The new `plan_attribution` facet reports it, and needs **no migration** - the row-per-facet shape means a new precondition is a query change, not a schema change. Matched with `strpos` rather than `LIKE`, because `LIKE '%%Q%'` parses as wildcard-wildcard-Q-wildcard and reports success on any value containing the letter Q; and case-sensitively, because `%q` and `%Q` are **different** prefix escapes - `%q` truncates the prefix in non-session processes and is common in real configurations. Both wrong answers were reproduced against a live server before the right one was: a prefix of `%m [%p] %q%u@%d QUEUE ` correctly reports unsatisfied.
-- **The web session cookie can now carry WHO is holding it, and the signature covers it** ([#2550]) - the prerequisite for per-user sign-in, landed on its own because it is the part that is provable without an identity provider. The session cookie minted after the token exchange was `{expiryUnix}.{base64url(HMAC)}` - an expiry and nothing else - so even with OIDC standing in front of the dashboard, the subject established at sign-in was discarded at the redirect and every request after it arrived anonymous again. That is why `updated_by` on a web-authored custom view is the constant `web`: not an oversight in the write path, but the plain fact that nothing in the request carried a name. The cookie now has a second shape, `{expiryUnix}.{base64url(subject)}.{base64url(HMAC)}`, and **the HMAC covers the whole prefix before the final dot** rather than the expiry alone. That detail is the entire security content of the change: signing only the expiry and parking the subject beside it would leave identity unauthenticated, so any holder of a valid cookie could rewrite the subject and be served as anybody - a strictly worse position than the shared token this exists to improve on, because it would LOOK like identity while providing none. Pinned both directions: a rewritten subject fails, and a signature lifted from another principal's genuine unexpired cookie fails on this one. The two shapes cannot be confused for each other even though they share a separator, because the signature is always the last segment and the signed payload always everything before it - re-presenting a 3-segment cookie as a 2-segment one would need the subject to be a valid HMAC over the expiry, and the reverse needs the key. A smuggled fourth segment is refused outright rather than parsed, so two distinct cookies can never resolve to one subject. The shared-token seat reports **null, not empty string** - "the shared token did this" and "somebody signed in whose name we could not read" are different facts and a provenance stamp has to keep them apart - and an over-long subject is **refused rather than truncated**, because two subjects sharing a prefix collapsing into one seat would attribute one person's writes to another, which is worse than a failed sign-in. The subjectless form is byte-for-byte what was minted before, so cookies already in browsers keep validating instead of signing everyone out on upgrade. No producer sets a subject yet; that arrives with the OIDC wiring in [#2550].
-- **PostgreSQL targets now say whether plan capture is even possible** ([#2564]) - the shippable first slice of #2538, and the failure it fixes is SILENCE. A PostgreSQL target has no execution plans today, and somebody evaluating this against DBM on Aurora cannot tell whether the product does not support plans, their server is not configured for them, or something is broken - three different actions, and we said nothing. Schema **v87** adds `collect.pg_plan_capture_readiness`, collected hourly on every PostgreSQL target including standbys (a replica can carry a different parameter group from its writer, and gating to writers would hide exactly that). It does not capture a plan; it records whether capture is POSSIBLE, as **one row per facet** rather than a wide row - because each facet has a different remedy and collapsing them produces the single "plans unavailable" that tells nobody what to do. `library_loaded` is a custom CLUSTER parameter group plus a writer reboot on Aurora/RDS, not a `SET`, which is the misconception worth heading off. `capture_threshold` is the trap the issue was filed about: `auto_explain.log_min_duration = -1` means loaded and capturing **nothing**, indistinguishable from not-loaded from the outside but a completely different fix - so `-1`, an absent setting, and a real threshold are three distinct states with three distinct remedies. `extension_available` separates "could this server ever" from "is it doing it", which is the difference between a configuration task and a platform limitation. Two things came out of running the query against a live PostgreSQL 17 rather than reasoning about it: the remedy text had to become CONDITIONAL, because one fixed sentence per facet meant a store with the threshold at 0 reported "log_min_duration = -1 means capturing NOTHING" beside `is_satisfied = true` and contradicted itself; and `log_min_duration = 250` comes back as **`250ms`**, the GUC carrying its unit, which is why `observed` is stored as the server's own text and never cast - the `track_activity_query_size` defect this codebase has already paid for once. It deliberately does NOT claim plans are being captured: the library being loaded, capture being configured, and the plans being readable by us are three separate facts, and the last is #2566/#2567's.
-- **HTTPS for the web dashboard, so the access token stops crossing the LAN in the clear** ([#2562]) - `grep -c UseHttps` across `Darling/` returned **0**: the dashboard was plain HTTP in every mode, which meant the whole token-to-cookie exchange - constant-time compare, HMAC-signed HttpOnly `SameSite=Strict` cookie, CIDR allowlist, Host-header anti-rebind, all of it carefully built - rode an unencrypted transport the moment the listener left loopback. An on-path attacker on the segment could lift the `?token=` or the cookie and get the entire collected store plus the Custom Views write path. A new **opt-in** `web.network.tls` block takes either a PKCS#12 bundle (`pfxPath`, with its password in `encryptedPfxPassword` (DPAPI), in `pfxPassword` as a `file:`/`env:` reference, or absent for an unprotected bundle) or a PEM pair (`certPath` + `keyPath`, which is what the compose distribution mounts) - **one form or the other, never both**, which is refused rather than resolved by precedence because silently serving one certificate while the operator watches the other expire looks exactly like working right up until it does not. TLS applies to the **network listener only**; the loopback listeners stay plain HTTP on purpose, since the certificate names the LAN address rather than `localhost` and loopback traffic never reaches the segment being protected. That also settles redirect-versus-refuse by mechanism: one port cannot speak both schemes, so a plain-HTTP client fails the handshake, and adding a second HTTP port to redirect from would reopen the cleartext surface the feature exists to close. The session cookie's `Secure` flag became **per-request** (`context.Request.IsHttps`) for the same reason - one host now serves both schemes, so a hardcoded `true` would mint a cookie the loopback browser refuses to send back and loop its login forever, while a hardcoded `false` would let a cookie minted over TLS ride an `http://` downgrade. Loading **fails closed** exactly as an undecryptable token does (Critical, then loopback-only) for a certificate that is missing, unreadable, ambiguous, not yet valid, or **expired** - never a silent downgrade to serving the LAN over HTTP - and because an expired certificate takes the dashboard down, the service warns for the **30 days** before that happens and logs subject, thumbprint and expiry at every start. Both loaders carry the **intermediate chain**, which is not incidental: `CreateFromPemFile` materializes only the FIRST certificate in a PEM and `LoadPkcs12FromFile` only one certificate of a bundle, so a leaf-plus-intermediate file - exactly what the config documents and what an internal CA hands you - loaded as a bare leaf and Kestrel served an incomplete chain, failing the handshake on any client that had not independently cached the intermediate. Measured with `openssl s_client -showcerts` before and after: a PEM holding two certificates put **one** on the wire, and now puts two. A self-issued root in the bundle is deliberately dropped rather than sent - a client that does not trust it is not persuaded by receiving it - and the PKCS#12 leaf is found by which certificate holds the private key, because bundle ordering is not a contract. Two things worth knowing that only turned up by running the real thing: the private key needs `MachineKeySet` on Windows (an ephemeral key loads fine and then fails every handshake, because Windows cannot use one for TLS server authentication) while macOS refuses `EphemeralKeySet` outright, and a certificate built straight from PEM carries an ephemeral key that has to be round-tripped through an in-memory PKCS#12 before Kestrel can serve it. The certificate must also carry an **iPAddress SAN** for the listen IP, because the anti-DNS-rebind Host allowlist accepts only `localhost`, a loopback literal, or that exact IP - so the DNS-name certificate an internal CA issues by default can never match, and the service now says so at startup instead of leaving it to a browser warning. `--configure-network`'s exposure summary reports the TLS state alongside listen/allowFrom. MCP is deliberately unchanged: its no-TLS rationale is that a self-signed certificate breaks real MCP clients, which is an argument about MCP clients rather than about the wire, and does not transfer to a surface whose only client is a browser. Prerequisite for [#2550] (OIDC), which most identity providers will not do over a non-HTTPS `redirect_uri`.
-- **The sessions behind a pinned xmin horizon, and the ones that only look like it** ([#2540]) - `pg_xmin_horizon` already named the CLASS of thing holding the horizon back, and when that class is `session` it emitted exactly one row: a pid and a formatted detail string. Enough to know a session is the cause, not enough to do anything about it - so the product could show vacuum falling behind, show the bloat that produced, show where it ends, and give no way to see what started it. Schema **v86** adds `collect.pg_session_states`, sampled every minute on any PostgreSQL target including standbys, and `get_pg_session_states` reads it rolled up per BACKEND rather than per sample - a session parked idle in transaction for two hours is in a hundred and twenty captures, and a raw read would report one problem a hundred and twenty times while pushing everything else under the row limit. The grouping key is the collector's synthetic `(backend_start, pid)` identity, the same construction `pg_blocking_edges` uses, so a pid reused by a second backend inside the window is two findings and not one merged average - and so the same backend can be followed across both collectors.
-- **`idle in transaction` is not automatically starving your vacuum, and this is the finding the read exists to make** ([#2540]) - the obvious version of this feature reports a state string and a duration, and the obvious version is wrong about half the sessions it flags. Four idle-in-transaction shapes were created on a live PostgreSQL 16.15 instance and **two of them pin nothing at all**: a READ COMMITTED transaction that has only read reports `backend_xmin` NULL because it released its snapshot when the statement ended, and one whose `UPDATE` matched **zero rows** reports both columns NULL because no transaction id was ever assigned. Only the transaction that actually wrote (holding `backend_xid`) and the REPEATABLE READ one (holding `backend_xmin`) pin the horizon, and neither column alone sees both cases. So `horizon_age` is stamped at collection from the GREATER of the two ages, and is **`-1` rather than `0`** when the session holds neither - 0 would read as "holding the newest possible xid", which is the opposite finding. The tool gates its causal claim on that column and not on the clock: a session that pinned nothing is told so in those words and explicitly refused the vacuum argument, because terminating it reclaims not one dead row. It is not waved away either - at five minutes it gets a different argument on its own terms, since a transaction idle that long is holding a connection and its locks whatever it is doing to the horizon.
-- **A held horizon is a share of samples, not a flag** ([#2540]) - every write transaction is momentarily the oldest holder on the instance, because that is what a transaction IS, so a boolean "was the holder" would fire on ordinary traffic and be ignored within a day. `horizon_holder_samples` is a count against `sample_count`, and the read only calls a session a sustained holder past half the samples that saw it - reporting both raw numbers beside the verdict so a caller can disagree with the threshold rather than having to trust it. One sighting in a hundred and ninety-eight in a hundred are opposite findings and the stored data can tell them apart. The oldest holder is also force-included in every capture regardless of the duration floor, and is chosen over the FULL activity set rather than the reportable subset: a young transaction can be the oldest holder, and crowning a filtered survivor would name the wrong session precisely when the real one fell below the floor.
-- **No statement text is stored, and that is a decision rather than an omission** ([#2540]) - `pg_stat_activity.query` is the statement as submitted, literal parameter values inline; a probe session on the live rig came back carrying its literal argument verbatim. `pg_blocking_edges` stores that text because blocking is an exceptional condition where the rows are rare and the text is the finding. This table fills on a DURATION FLOOR that a perfectly ordinary application can cross, so the same column would mean routinely accumulating user data in the store to answer a question that does not need it. What it stores instead gives up nothing: **`query_id`**, PostgreSQL's own normalised statement fingerprint, which joins to `pg_statement_stats` whose text is already normalised to `$1` placeholders - full actionability, zero literals - plus **`command_tag`**, the leading SQL keyword matched against a closed whitelist. A whitelist and not a substring, because plenty of ORMs prepend a comment block, so the first token of a real statement can be a comment opener followed by anything the application chose to put in it and a leading-N-characters rule would carry a `WHERE` clause's literals straight out.
-- **The permission that gutted this feature silently, caught before it shipped** ([#2540]) - the same check [#2542] made for `pg_stats`, and the answer is worse here because there is no error. Measured against a least-privileged role on a live target: without `pg_monitor` PostgreSQL does **not** refuse the read - for every backend the login does not own it returns the row with all but SIX columns NULL. Measured column by column on PostgreSQL 16.15 rather than enumerated from memory: what survives is `pid`, `application_name`, `datname`, `usename`, `backend_xid` and `backend_xmin`, while `state`, `state_change`, `xact_start`, `query_start`, `backend_start`, `wait_event_type`, `wait_event`, `backend_type`, `client_addr`, `leader_pid` and `query_id` all come back NULL and the query text is replaced by an insufficient-privilege literal - **so the two that survive are exactly `backend_xmin` and `backend_xid`**. So the horizon still reads as pinned and every column that could explain it is gone. On one capture over the same nine backends the privileged role saw four sessions idle in transaction and the unprivileged one saw **zero**, because `is_idle_in_transaction` is derived from a state that is NULL. `state_is_redacted` is therefore stamped per row from the privilege literal and **not** from `state IS NULL` - background workers legitimately report a NULL state under full privilege (`checkpointer`, `walwriter` and the autovacuum launcher all do, measured), so NULL alone cannot tell a permission problem from a background process. Redacted rows outrank every severity with a band of their own rather than being painted healthy or critical, the same treatment `estimate_unavailable` gets on the bloat surface. Unlike that one, plain `pg_monitor` is enough here - no `pg_read_all_data` needed.
-- **The denominator, and a defect only a live run could find** ([#2540]) - `pg_stat_activity` is a point-in-time view and PostgreSQL records nothing about session state unless something asks, so a transaction that opened and closed between two samples is genuinely invisible - which the read says out loud rather than letting "no long transactions" be read as "none happened". Capture counts come from `collection_log` and not from the data, because this is an exception surface like `pg_blocking_edges` where zero rows is the HEALTHY answer and probing the table would report a well-monitored server as uncollected ([#2508]). Every capture also carries its own session counts and its pre-limit reportable count, so two idle-in-transaction sessions out of six connections is distinguishable from two out of four thousand, and a capture truncated by the row cap is self-evident. The defect: the first draft of the collector query contained a literal SQL comment opener **inside** a comment. PostgreSQL block comments **nest**, so it opened a nested comment that never closed and the entire collection failed to parse - every cycle, on every target. No text assertion could see it and nothing about reading the C# reveals it; running the shipped string against a live instance is what caught it, and a nesting-depth test now pins it. Version floors were measured the same way: `pg_attribute` read for the view on a live PostgreSQL **13.23** instance shows 21 columns including `leader_pid`, `backend_type`, `backend_xid` and `backend_xmin`, and **no `query_id`** - so that one column is substituted with a typed NULL below 14 rather than gating the collector off, and the PG13 form was executed against that instance.
-- **PostgreSQL index usage, with the half that decides whether an index can actually go** ([#2541]) - nothing collected `pg_stat_user_indexes`, so SQL Server users had `get_index_usage` and PostgreSQL users had nothing. An unused index costs MORE here than on SQL Server: it costs the same write throughput and space **plus** it participates in every `VACUUM`, and index cleanup is a large share of vacuum work - so a dead index on a hot table slows the maintenance that [#2530]'s vacuum surface exists to watch. Schema **v84** adds `collect.pg_index_usage_stats`, collected daily per database on writers only, and `get_pg_index_usage` reads it. The scan counts are the easy half. The half that makes it shippable is that **"unused" is not "droppable"**, and the tool refuses to say otherwise: every row carries whether the index backs a primary key, a unique or exclusion constraint, or the table's replica identity, whether it is partial, an expression index, or INVALID, and its full `pg_get_indexdef` text - because the single most common zero-scan index on any schema is a unique index enforcing a constraint, and enforcing uniqueness is not a scan of the kind `idx_scan` counts. Advice derived from the counter alone tells somebody to drop their primary key. It also refuses to call anything unused on a window it has fewer than two samples of, since PostgreSQL records no index creation time anywhere and how long we have been watching is the only evidence an index is old enough to judge.
-- **The scan count only a monitoring store can produce** ([#2541]) - `pg_stat_user_indexes.idx_scan` is cumulative since the statistics were last reset, which is the only figure a live query can give: an index with nine million lifetime scans and none in the last ninety days reads as heavily used. The read reports **both** - the lifetime counter, so it cannot appear to disagree with psql, and `scans_in_window` differenced across the stored samples, which is the number that decides anything. That difference is clamped at zero and the clamp is not decoration: measured against a live store, a fixture whose counter runs 9000 -> 50 -> 100 across a statistics reset returns **-8900** unclamped, and a negative scan count sorts straight to the top of a list ranked least-used-first. The clamp alone would then hide the reset, so `stats_were_reset_in_window` travels beside it, taken from the server's own `stats_reset` timestamp with `IS DISTINCT FROM` rather than `<>` - `stats_reset` is NULL until a database's first-ever reset, so `<>` evaluates to NULL there and misses precisely the reset the counter arithmetic cannot see either. Retention is **90 days** because the retention window IS the evidence: an index can only be called unused for as long as we have been watching it, and 30 days cannot clear a monthly report. It still cannot clear an annual one, which the read says rather than glossing.
-- **PostgreSQL table bloat: the damage, next to the cause chain we already collected** ([#2542]) - `pg_autovacuum_stats`, `pg_wraparound_stats` and `pg_xmin_horizon` between them collected the entire CAUSE chain for bloat and nothing that measured what it cost, so a user could watch vacuum fall behind and had no way to see the damage - which is the question they ask immediately afterwards. Schema **v85** adds `collect.pg_table_bloat_stats`, collected hourly per database on writers only, and `get_pg_table_bloat` reads it. **Hourly matches `pg_autovacuum_stats` deliberately rather than by copying**: this measures the damage whose cause that one measures, and "vacuum fell behind at 14:00 and bloat grew" is only a sentence the data can support if both are sampled on the same grain.
-- **The bloat measurement decision, settled by measurement rather than by reasoning** ([#2542]) - three options, and the cheap one wins on a margin that was measured, not assumed. `pgstattuple` is exact and reads the **whole relation**: 11.8 ms warm on a 54 MB fully-cached table, i.e. roughly 4.6 GB/s in the best case, so a 1 TB table costs minutes per table per cycle even entirely in memory - and it is an extension, absent unless somebody installed it. `pgstattuple_approx` measured 0.58 ms on the same table, but that is its BEST case and not a representative one: the pages it must still read are exactly the recently-written ones, so on the churning table you actually care about it degrades toward the full scan, and it shares the availability problem without solving the cost one. The **statistics-based estimate** ships: it reads `pg_class` and `pg_stats` only, never touches the relation, and so costs by table COUNT rather than by table SIZE - **44 ms for a 2,001-table / 854 MB database against 860 ms for `pgstattuple` over the same tables**, and that 19x is a floor on the ratio because those tables were tiny and fully cached. Erik's inclination in the issue was the estimate on cadence with `pgstattuple` available on demand for one table; the on-demand half is **not implementable as a tool**, because the MCP surface is strictly read-only over collected rows and has no ad-hoc path back to a monitored server. So each row instead records whether `pgstattuple` is installed on that database and ships the exact command that would settle it, which is the reachable form of the same idea.
-- **The bloat estimate is suppressed, not captioned, when its inputs cannot be trusted** ([#2542]) - a bloat number that is quietly wrong gets used to justify a `VACUUM FULL` on a production table, so how wrong it can be was measured rather than hedged. With current statistics the estimate was within about **2 percentage points** of `pgstattuple` on every table tested (churn-bloated 75.14 vs 74.82, delete-bloated 60.73 vs 59.52, clean 0.58 vs 0.50). With **stale** statistics it was wrong by **81 percentage points**: two byte-identical 8,998-page tables with a true bloat of 10.93% estimated 92.64% and 11.01%, differing only in whether `ANALYZE` had run after a widening UPDATE, with nothing in the arithmetic to show it. The stale table had modified 200% of its rows since its last analyze and the fresh one 0%, so `mods_since_analyze` is carried as the trust signal - and it is a sound proxy rather than a coincidence, because width statistics can only go stale THROUGH modifications. Above a fifth of the table modified since the last analyze, the number is withheld and the row says why; the threshold is set low deliberately, because the failure is asymmetric - a needlessly cautious estimate costs a second look, an over-trusted one costs a table rewrite. Every field carrying the figure is named `_estimate` in the store as well as in the read, so the qualifier cannot be lost between the two.
-- **The permissions trap that would have shipped a tool telling everyone to VACUUM FULL everything** ([#2542]) - `pg_stats` is filtered by `has_column_privilege(..., 'select')`, and **`pg_monitor` - the one grant the PostgreSQL runbook says Darling needs - does not confer SELECT on user tables**. Measured against a `pg_monitor`-only role on a live PostgreSQL 16 target: **zero** `pg_stats` rows visible, and the estimator did not fail. It returned a confident **88.59%** for a table whose true bloat is 0.50%, **95.03%** for one that is really 74.82%, and **22.57%** for one that is really 0.46% - every row an argument for rewriting a production table. `estimate_unavailable` is therefore load-bearing rather than advisory: it is TRUE in exactly that state and the read suppresses the number outright, because a footnote under a large red percentage is not a safeguard. Adding `pg_read_all_data` (PostgreSQL 14+; the role does not exist on 13, where explicit `GRANT SELECT` is the only route - both verified) restored byte-identical agreement with the superuser's numbers, and the runbook now carries that grant with the reasoning. What survives the suppression is the **dead-tuple fraction**, computed from the server's own counters with no width model and no SELECT needed - narrower than bloat, since it counts dead tuples and not the free space a completed vacuum left behind, but measured rather than inferred, so a permissions gap degrades the answer instead of removing it.
-- **A Storage tab on both front ends, and neither read is MCP-only** ([#2541], [#2542]) - the two new reads land together on a new **Storage** tab in the web dashboard and in the WPF viewer, with the same id and the same grouping, because both answer one question: where the space went and whether it is earning its keep. Deliberately NOT on the Vacuum tab despite bloat being what vacuum lag costs - that tab is the cause chain read in causal order, and dropping the damage into the middle of it breaks the sequence that makes those three panels one story; the bloat panel points back at it instead. Both ratchets that would have caught an MCP-only ship were green before the panels existed and are green after: the web pin derives its "must have a home" set from the read dispatch, the viewer pin derives its own from `CollectorCatalog`. The viewer grid renders a suppressed estimate as a **dash** rather than a number, re-deriving the suppression rule rather than trusting a flag, so the two surfaces cannot disagree about whether a figure is publishable.
-- **Version floors for both collectors, measured against six live majors rather than read from documentation** ([#2541], [#2542]) - every column was checked by listing the actual catalogs on PostgreSQL **13, 14, 15, 16, 17 and 18**, and both shipped queries were then executed on all six. Exactly one floor exists: **`last_idx_scan` is PostgreSQL 16+**, proven by running the gated-on form against 13/14/15 and getting `ERROR: column i.last_idx_scan does not exist` - which fails the whole collection every cycle, not just that column. It is substituted with a typed NULL rather than omitted, so the row shape does not change across a mixed-version fleet, and NULL is the honest value because a PostgreSQL 15 server genuinely does not know when an index was last scanned. Everything else - `pg_stat_user_indexes`, `pg_statio_user_indexes`, `pg_index`'s droppability columns, `pg_constraint.conindid`, `pg_stats`, `pg_class.reltuples`/`relpages` - was confirmed present on all six, so that is stated as checked rather than assumed, and the bloat query carries **no version gate at all** because it produced identical output on every major. The MAXALIGN detection the estimate depends on names `aarch64` and `arm64` explicitly (Graviton RDS and Aurora are aarch64) and stores the value it actually used, so a platform where the detection is wrong shows up in the data instead of skewing every row silently.
-- **Both new collectors are fan-outs, and the cost of that was budgeted rather than discovered** ([#2541], [#2542]) - `pg_stat_user_indexes` and `pg_stats` are both scoped to the connected database and PostgreSQL has no cross-database read, so these are the second and third per-database fan-outs after `pg_autovacuum_stats` - and on PostgreSQL a fan-out means one **connection** per database per cycle. Index usage runs **daily**, inherited from `index_object_stats` (the SQL Server collector answering the same question) rather than from the PostgreSQL sibling: "has anything scanned this index" is a structural question, and an hourly sample would record the same catalog facts 24 times a day at 24x the connections. Both carry a size floor as the cost control - 64 KB for an index, 1 MB for a table, below which there is nothing to model, since the whole argument for dropping an unused index is the write, space and vacuum cost it carries and all three scale with its size. Measured on a 2,001-table / 6,005-index database, the floors removed 2,000 tables and 2,001 indexes. An INVALID index is kept regardless of size, and that escape hatch was verified live: a 16 KB invalid index came through a 64 KB floor.
-- **A fourth kind of nothing: `precondition`, for a setup step somebody can actually change** ([#2546]) - the miss vocabulary had three words and none of them fitted a Query Store that is switched off, a capture session that is not running, a PostgreSQL extension that was never created, or a login refused msdb. `empty` claims we looked and there was nothing; `unavailable` sends the reader to collection health, where they find a collector that is running and doing its best; `not_collected` is the PERMANENT answer and tells them to stop looking. The store already held every one of these facts - the runners classify a denied grant, a missing source object and a disabled feature out of the general ERROR bucket and write the remedy into `collection_log.error_message`, and the hourly `query_store_health` collector records `actual_state` per database - and **no read reported any of it**. Two Query Store reads were literally guessing in prose ("Query Store may not be enabled on target databases"), which is equally true of a server where it is on and the window simply reached past retention. Now the read states it, names the databases, and carries the remedy for the state they are actually in - OFF, READ_ONLY and ERROR each get a different one, because telling an operator to "turn it on" about a database whose Query Store is already on and broken is an ALTER that silently no-ops - or quotes what the server itself said, including the `CREATE EXTENSION` a 42P01 already stores. Crucially it is evaluated at READ time, not at gate time: an `AppliesTo` gate is decided once when the connection is made and would go on reporting a precondition after somebody satisfied it, which is the worst possible direction to be wrong in. Wired on five reads per SKU (`get_deadlocks`, `get_blocked_process_xml`, `get_running_jobs`, `get_long_query_completions`, `get_query_store_top`) plus Darling's `get_pg_top_queries`, always AFTER the engine-capability answer so a permanent gap still wins - an order a `??` chain cannot enforce on its own, so a source scan pins it across both trees.
-- **The WPF viewer renders PostgreSQL tabs at a PostgreSQL target, and stops rendering nineteen empty SQL Server ones** ([#2530]) - `git grep IsPostgres` across `PerformanceMonitor.Darling.Viewer` returned **nothing**, so an Aurora target opened on the SQL Server correlated-lane Overview and got the whole nineteen-tab strip - tempdb, Trace Flags, Query Store, Plan Viewer, Always On - nearly all of it permanently empty and several tabs meaningless for the engine. It now gets **six**: Overview, Activity, Vacuum, Waits, I/O and Replication, the same six the web dashboard chose in [#2547], with the same ids and the same grouping so the two front ends do not teach one engine two shapes. All **nine** PostgreSQL collectors land on exactly one tab, and that placement is DERIVED from `CollectorCatalog` in both directions - a tenth collector turns the pin red naming itself, which is the check that did not exist while eight of them shipped MCP-only for three releases. The `ViewerCollectorCoverageTests` allow-list, which carried all nine as tracked debt, is now **empty**.
-- **The two Aurora-only PostgreSQL panels are SHOWN on stock PostgreSQL, not hidden** ([#2530]) - `pg_wait_stats` and `pg_statement_stats` both gate on `IsAurora`, so on a stock target they can never have content. Each panel prints `CollectorEngineCapability.NotCollectedMessage` instead - the same sentence the MCP surface and the web dashboard print, naming the server, the engine, the collector and the exact `aurora_stat_*` surface, ending "and never will". The defect being closed is UNEXPLAINED emptiness, not emptiness; hiding them would also make the tab strip a different shape on two PostgreSQL servers in one fleet. The Overview tab is built from the CATALOG rather than from `collection_log` for the same reason: a gated-off collector writes no log row at all, so a log-driven grid would have dropped precisely the row an operator most needs explained.
-- **One copy of the PostgreSQL read SQL, shared by the MCP surface and the desktop viewer** ([#2530]) - the nine `DarlingPg*Reader` classes moved from the service's MCP folder into `PerformanceMonitor.Darling.Storage`, which both front ends reference. Copying them into the viewer would have meant a second copy of, among others, a 200-line recursive blocking walk whose revisit guard, root attribution and truncation flag were each a separate review finding - and the copy that drifts is never the one being read. No query text changed; every existing reader test still pins the same constants.
-- **The viewer's server rows carry the engine discriminator, from BOTH server reads** ([#2530]) - `DarlingServer` gained `EngineKind`, `EngineEdition`, `IsPostgres` and `EngineDescription`, and both `ServersSql` and `ManagedServersSql` select `engine_kind` and `sql_engine_edition`. Both, because the sidebar uses `ManagedServersSql` on any seeded store - i.e. every real deployment - so a column added to only the other one would have left every PostgreSQL target on the SQL Server tab set while a unit test passed. Only a POSITIVE claim switches: a null or unrecognised token keeps the SQL Server tabs and shows NO engine badge, because the tabs such a server gets are a default rather than a finding.
-- **PostgreSQL temp-file spills, cache hit ratio, deadlocks and the rollback ratio - one collector, one read** ([#2539]) - nothing touched `pg_stat_database`, so four separate questions had no answer on a PostgreSQL target. The one that mattered: **temp files are work `work_mem` could not hold**, which is the most common reason a PostgreSQL query is slow for a cause its plan shape does not show - and a spilling query looks identical to an expensive one in the statement statistics. `pg_statement_stats` does carry per-query `temp_blks_written`, but it reads `aurora_stat_statements()` and is **Aurora only**, so on stock PostgreSQL there was no temp-file evidence anywhere; on Aurora it is a top-N over statements `pg_stat_statements` still retains, and this is the ground truth those are a share of. Schema **v83** adds `collect.pg_database_stats`, the new `pg_database_stats` collector reads the view per minute, and `get_pg_database_stats` differences it across a window. Every major and every target shape: the newest column selected arrived in PostgreSQL 9.2 (verified live on 13, 16 and 17), it is core rather than Aurora, and a standby is included deliberately because a sort on a read replica spills exactly the way it does on a writer.
-- **A PostgreSQL statistics reset is reported AS a reset** ([#2539]) - `pg_stat_database`'s counters are cumulative since the last `pg_stat_reset()`, so a reset silently zeroes them, and the two obvious ways to difference them are both wrong: last-minus-first goes **negative**, max-minus-min **spikes** to the pre-reset lifetime total. Both look like measurements. The read clamps per interval so neither can happen, and then says so: `stats_reset_count` from the server's own per-database `stats_reset` moving, `counter_rewind_count` from a counter falling below its predecessor, and a per-database note that the window totals are LOWER BOUNDS. Both signals are carried because neither sees every reset - a reset followed by enough activity to climb back past the old value inside one collection interval leaves every difference positive, and only the timestamp knows. The sharpest case is a database's FIRST-EVER reset, where `stats_reset` moves NULL -> timestamp: the first sample of a series is excluded by its ROW_NUMBER rather than by its `LAG` being NULL, because `LAG(stats_reset)` is NULL both when there is no previous row AND when the previous row had never been reset - the ordinary state - so a guard on it would miss the one reset nothing else can see. Rows are stored **per database** for the same reason: `stats_reset` is per database, so an aggregate would have to pick one timestamp for rows that legitimately disagree, and a single database's reset would corrupt the cluster's delta with nothing left in the data to say so.
-- **The web dashboard renders PostgreSQL tabs at a PostgreSQL target, and stops rendering twelve empty SQL Server ones** ([#2530]) - the per-server page's `SERVER_TABS` was a FLAT list applied to every server, so an Aurora instance got Wait Stats, CPU, Memory, Blocking, File I/O, Queries, Configuration, Config Changes, tempdb and the rest - nearly all permanently empty, several meaningless for the engine - while the eight `get_pg_*` reads that DO have data were reachable only through MCP. The registry is now two registries, and `serverTabsFor(card)` chooses from the fleet card's server-derived `is_postgres`. A PostgreSQL target gets **six** tabs: **Overview** (freeze headroom, xmin horizon, autovacuum backlog and replication-slot vitals over findings and collection health), **Activity** (`get_pg_blocking` with its capture denominator, top query shapes, and [#2539]'s per-database temp-file spills directly beneath them, because a spill is the explanation a statement's own stats cannot give), **Vacuum**, **Waits**, **I/O** and **Replication**. Six against twelve is the design and not a shortfall: parity was explicitly not the constraint, because bloat, wraparound, the xmin horizon and autovacuum have no SQL Server analogue and are what actually pages a PostgreSQL DBA. **Vacuum is one tab on purpose** - an old xmin horizon starves vacuum, starved vacuum falls behind on freezing, freezing falling behind ends in wraparound; read separately each looks survivable and together they are one escalating story, so the three reads sit in that causal order under a note that says why. **Waits is SHOWN on stock PostgreSQL** even though `aurora_stat_system_waits()` means it can never fill there: [#2532] taught that read to answer `not_collected` naming the server, the engine, the collector and the exact Aurora surface and to say the gap is permanent, so the panel explains itself - and hiding it would have made the tab set change shape between two PostgreSQL servers in one fleet and made the one Aurora-only capability invisible from a stock instance. A **NULL** `engine_kind` keeps the SQL Server set, unchanged: only a positive PostgreSQL claim switches, because absence is not evidence for either engine. The bar and the grid wait for the ONE `/api/fleet` read the page already made rather than flashing the wrong twelve tabs and correcting them. The card also gained `engine_description` (the engine's name in words, from `MonitoredEngineKind`), so the header can badge the engine without any surface owning a second copy of the vocabulary - and `vizStat` gained the `emptyText` guard `vizLine` already had, because several PostgreSQL reads answer their HEALTHY case with a data body carrying prose and none of the summary keys, which used to render as a row of em-dashes.
-- **The store records what ENGINE a monitored target is** ([#2530]) - `collect.servers` recorded `sql_engine_edition` and `sql_major_version` and nothing that said a target was PostgreSQL. `SERVERPROPERTY` does not exist on PostgreSQL, so a PostgreSQL target landed at engine edition **0** - byte-identical to a SQL Server that has never completed a connect. Two different facts, one representation, and no reader could tell them apart. Schema **v82** adds a nullable `collect.servers.engine_kind` carrying `sqlserver`, `postgres` or `aurora-postgres`; a token rather than an `is_postgres` boolean because Aurora versus stock PostgreSQL is already a fact the collectors gate on (the `aurora_stat_*` surface has no equivalent in any core PostgreSQL version) and a boolean cannot grow a third value. Nullable with no default and no backfill: a row written before the rung genuinely does not know, and defaulting to `sqlserver` would have every PostgreSQL target assert it was SQL Server until its next connect. The registry upsert stamps it on every connect, so it populates itself within one collection cycle. The `/api/fleet` card and `get_fleet_overview` now carry `engine_kind` plus the derived `is_postgres` / `is_aurora`, which `docs/uat-onboarding.md` §3.4 named as the blocker for any PostgreSQL panel. **No UI branches on it yet** - a PostgreSQL target still renders SQL Server tabs on both SKUs; that is the next wave of [#2530].
-- **Ten viewer surfaces the browser and MCP could not reach now have read endpoints** ([#2484]) - `/api/read/*` is machine-derived from the MCP tool catalog, so a read with no tool was absent from BOTH surfaces: the WPF viewer on a Windows desktop was the only way to that data, and an agent asked about it got nothing. Every one of them was a missing ENDPOINT rather than missing collection - the store already held the rows. Now served on both SKUs: `get_collection_log` (the raw per-run log under `get_collection_health`'s seven-day rollup, which is what you reach for when the rollup reads HEALTHY and collection still looks wrong), `get_current_waits_trend`, `get_blocking_stats` (severity, where the existing trends only counted incidents - ten one-second blocks and one ten-minute block are the same count and a different problem), `get_health_parser_significant_waits` (the ninth member of a family whose other eight already had one), the three Performance Trends siblings, `get_query_store_regressions`, `get_query_heatmap`, `get_lock_wait_trend` and `get_daily_summary_range` (without which the web calendar could only ever show today). One correction came out of it: the viewer's execution-count trend was a DUPLICATE of a number `get_query_duration_trend` already returned rather than a missing sibling, so it did not become an eleventh read - what it got instead was `executions_per_second`, because that count shipped truncated to a `long` and a server running 0.4 executions a second reported **zero** and read as idle.
-- **An in-place upgrade can now remove the files the new build stopped shipping, and always names them ([#2529])** - an upgrade is an OVERLAY: `Expand-Archive -Force` writes what the new build ships and deletes nothing else, so a file the old version had and the new one dropped stayed in the install tree forever, and [#2185]'s layout report could not see it either - that report walks top-level DIRECTORIES, so a stale DLL in the install root, or inside `viewer\` or `runtimes\`, is structurally invisible to it. **The measurement came before the code**, because the whole question was whether this has ever actually happened: diffing the file lists of consecutive release zips says the Lite package dropped **44 shipped files across twelve consecutive releases** - 43 of them in ONE step, where a target-framework move stranded every `runtimes\*\lib\net8.0\` assembly including two copies of `Microsoft.Data.SqlClient.dll`, and one lone assembly in a later step - while the Darling package has dropped none in the four steps it has existed for. So it is rare, bursty, tied to a packaging or framework change rather than to ordinary development, and when it happens it arrives forty files at a time **in a .NET probing directory**, where a stale assembly is a candidate for loading rather than inert clutter. **The authority is a manifest derived from the payload, never a list anybody maintains**: after each successful copy `upgrade-darling.ps1` records the files that copy laid down, and the next upgrade diffs its own payload against it - so every path it can name provably came out of one of our own zips. That disposes of the entire false-positive class the obvious approach has. It cannot warn about `darling.json`, the DPAPI blobs, the `.bak-*` config copies, the `_rollback_manual_*` backups or `pg-runtime`, because none of those was ever in a payload and so none of them is ever in the manifest - a property of where the list comes from, not a list of exceptions somebody keeps up to date, which is [#2525] again with a new subject. **It fails safe and says so**: no manifest yet, one that will not parse, one that disagrees with its own `file-count`, or a source it cannot read all mean remove NOTHING, on its own line, with the reason. `-RemoveStaleFiles` is the switch that turns naming into deleting and it is **off for the first release** - the check reports on every upgrade either way, and a delete that runs inside a monitoring host's install directory should spend a few deploys showing operators its answer first. Directories emptied by their own removal are pruned with NON-RECURSIVE deletes, deepest first, which is not tidiness: the layout report identifies a satellite-resource directory structurally, an EMPTY one fails that test, and leaving the shell of `de\` behind would turn one stale file into a permanent startup warning. Guards at three depths, each proved RED by reverting only itself: the `pg-runtime` **prefix** rule (a prefix, not the two names we ship, because an enumeration one entry out of date here costs the store), an empty payload selecting nothing rather than everything, an empty shipped-set refusing the delete outright, case-INSENSITIVE matching so a respelled `App.js`/`app.js` is never deleted after the copy just wrote it, and containment resolved against the filesystem rather than the string. Pinned by handing the shipped delete a deliberately POISONED list holding the store, the config, its backups, a credential blob, a rollback backup and a relative escape, then asserting against the **disk** rather than the function's own account of what it did. `upgrade-darling.ps1` also now has a parse-and-AST pin in CI, because `$x | ForEach-Object { } -join ', '` binds `-join` as a PARAMETER, parses clean, and throws at runtime - which in this script means after the service has been stopped. **That pin went red on its first CI round, against lines nobody had touched, and it was right**: `upgrade-darling.ps1` did not parse under Windows PowerShell 5.1 at all - the default `powershell.exe` on Windows Server and the one SSM runs. The file is BOM-less UTF-8, 5.1 decodes such a file with the machine's ANSI code page, and the third byte of a UTF-8 em dash becomes `”` in CP1252 - which PowerShell honours as a **closing double quote**. So every em dash [#2525] had placed inside a double-quoted message terminated its string early, and the script failed to parse before doing anything. Reproduced byte for byte, down to the same "The '<' operator is reserved for future use" CI reported. The five em dashes already in `install-darling.ps1` and `fetch-pg-runtime.ps1` were harmless only because they happened to sit in COMMENTS, where a mis-decoded character is never parsed - not a property anyone can maintain by eye. Fixed by making all three shipped scripts **ASCII-only** rather than by adding a byte-order mark: a BOM works and then decays, one careless save from being gone and invisible when it is, while "no byte above 127" holds under every code page and is a rule anybody can check. Pinned both ways, each proved red by planting a single em dash back into one double-quoted string. Never shipped in a release - [#2525] is in this same unreleased section.
-- **A supported in-place upgrade script, because the step that had no script was the one accumulating 5.48 GB** ([#2525]) - `install-darling.ps1` registers a service and `uninstall-darling.ps1` removes one; laying a new build over an old one was a procedure that lived in people's heads and in ad-hoc SSM scripts, and the part of it nobody remembered by hand was deleting the backups from the last twenty deploys. A dogfood box was found carrying **46 `_rollback_manual_*` directories, 5.48 GB, the oldest three weeks old** - and the [#2185] install-location report, doing exactly its job, named every one of them on every start. **46 warnings for something our own procedure created is a guard that has stopped guarding by being too loud**: a real layout problem - a stray DLL, a half-extracted upgrade - arrives as warning 47 in a list of 46 identical ones, which is the same failure as a pin that never bites wearing the opposite clothes. So both halves changed. `upgrade-darling.ps1` ships in the zip and owns retention **at deploy time**, keeping the newest `-KeepRollbacks` (3 - enough for "the release, the one before, and the one before that"; the fourth can only roll back to a version nobody wants) and pruning the rest AFTER taking the new backup, so the tree never passes through a moment with fewer rollback points than retention promises. It verifies the source zip's SHA256, refuses to copy while anything is still running out of the install tree - **naming what, never killing it**, because the bundled PostgreSQL lives under `pg-runtime` and a blanket kill takes the store down - refuses to run from the install directory itself (the copy would overwrite the script PowerShell is reading), confirms `darling.json` is byte-identical afterwards, and is safe to re-run at every step: a backup taken in the last `-BackupWindowMinutes` is REUSED rather than replaced, so a re-run after a failed extract cannot overwrite the good pre-upgrade copy with a copy of a half-upgraded tree. And the service now RECOGNISES the convention rather than deleting anything - it never deletes what it did not create - reporting the whole set on **one** line with a count, a total, the oldest, and `upgrade-darling.ps1 -PruneOnly`, informational within retention and a warning past it. That half matters even where retention is running, because the boxes carrying the backlog got it before any script pruned anything and no upgrade removes it. The naming convention is a single shared constant with the script's own predicate executed against the service's in CI, because two spellings of one convention is how this happened in the first place - and the drift would be silent, since each of those 46 warnings was true. Two defects were found by running the script's functions against planted trees rather than reading them: a backup dated in the FUTURE (clock skew) read as "recent" and would have suppressed the deploy's backup entirely, and a failure while composing a LOG LINE was caught by the delete's handler and reported as a failed delete - two removed and two failures, for the same two directories. Nothing here could be tested on real Windows from the machine that wrote it; the pins run in CI.
-- **The web dashboard gets a per-query drill-down, which is what makes `get_query_trend` reachable there at all** ([#2520]) - the read answers "is *this* query getting worse", the question you ask second and the one that decides whether you act, and it keys on a **required** `query_hash` plus a **required** `database_name`. Every panel on the per-server page fetched with nothing but a server and a window, so there was no `query_hash` anywhere on the surface to send and `/api/read/get_query_trend` could not be called from a browser - the ONLY read in the catalog whose absence was missing UI rather than a stated product boundary (the other nineteen each have one: a dedicated endpoint already serves the data, a write path the web deliberately lacks, a payload shape the table renderer cannot draw, or a desktop capability a web imitation would be worse than). The Queries tab's Top Queries table now carries a picker, in the shape the Wait Stats tab established: **the options are the rows of the table directly above it**, and an option's VALUE is that row's index into the array the table rendered - so the query trended is the same array element the reader is looking at, not a name that matched. That is deliberately not a "list every query" read: `get_wait_types` is unread on the Wait Stats tab for exactly this reason, because a full distinct set would offer queries absent from the table and make the two disagree. The chart carries avg CPU and avg elapsed only - both are milliseconds, so one y-domain holds them honestly, and both survive the hourly rollup [#2353] falls back to past the raw tier's four days; executions, DOP and the plan hash go in a grid of the per-collection snapshots below it, where the read's NULL reads as the blank it is instead of flattening a chart to a zero nobody measured. When the rollup answers, its `aggregate_note` and the truncated-window disclosure are RENDERED rather than dropped - the page's range reaches 30 days, so without them those columns simply go blank and a blank column reads as "nothing to see" rather than "not measured at this resolution". Rows carrying no `query_hash` (history predating the column) are shown in the table and not offered in the picker, because a request the read answers with a 400 is worse than an absence. A new pin asserts the category rather than the instance: every REQUIRED parameter of every read the page fetches is one the page actually sends - the existing pin only asked whether the keys sent were bound, which a read fetched with NO keys passes vacuously.
-- **The analysis family can be anchored at a past window too, and `analyze_server` refuses to persist when it is** ([#2506]) - [#2495] anchored 57 Darling reads and 50 Lite reads and deliberately left four out, and they are the four an incident investigation reaches for first: `analyze_server`, `get_analysis_facts`, `compare_analysis` and `get_analysis_findings`. Their window is not built in the tool; it is built inside the shared analysis engine, which the tools hand a bare `hours_back`. **`as_of` now reaches the engine** - same parameter name, semantics and description text as the other 107, from the same shared constant - so `analyze_server` and `get_analysis_facts` collect and score facts over the anchored window, `compare_analysis` hangs **both** of its windows off the anchor (`baseline_hours_back` has always been measured from the comparison window's end), and the anomaly detector's hour-of-day x day-of-week baseline moves with the window - which matters more than the window itself, because a run pinned to now compares Tuesday 03:00 against whatever baseline bucket today happens to be in. `get_analysis_findings` is the one whose window is on **analysis time**, so anchoring it asks what a scheduled pass was *saying* then rather than re-analyzing that window now; its read also gained an **upper** bound, without which an anchor could only ever move the window's start earlier and every anchored read would still have returned everything up to now.
-- **`analyze_server` accepts the anchor and does not persist an anchored run** ([#2506]) - it is the one tool in the family that WRITES, and an anchored run persisted normally would be worse than no anchor at all. A finding row's identity, for every consumer there is, is its `analysis_time` - the moment the pass ran, not the window it looked at: the viewers' Recommendations tab reads `MAX(analysis_time)` and calls the result the server's current state, and `get_analysis_findings` filters on `analysis_time` and then collapses on `(story_path_hash, incident_id)` to produce occurrences / first_seen / last_seen / peak_severity. So a backdated pass stamped now would become "what is wrong with this server" for every human looking at the viewer and would inflate the very occurrence stats an operator uses to judge whether a live incident is getting worse - caused, invisibly, by somebody else's exploratory read. Recording the window on the row does not fix either: `time_range_start` / `time_range_end` are **already** persisted and already returned, and no consumer filters on them. So an anchored pass is exploratory by definition - its findings come back in full, and neither the row nor the completion event that feeds notification is emitted. The rule is **derived** in the engine (`AnalysisContext.PersistFindings` is false exactly when the window was anchored) rather than a flag a caller sets, because there is no legitimate caller for "anchored AND persist" and so there should be no way to express it. The result says which it was, in `persisted` / `persistence_note`.
-- **Every windowed read can be anchored at a past incident, not just at now** ([#2495]) - every MCP read that takes `hours_back` measured it backwards from *now*, so no caller could ask what a server was doing during last Tuesday's incident. Widening `hours_back` until Tuesday falls inside is not the same question: for an aggregate read a wider window is a **different answer**, not the same answer with more rows - it changes what a top-N returns, what an average is taken over, and how much a capped read truncates. **`as_of`** is now an optional ISO-8601 UTC anchor on **57 Darling reads and 50 Lite reads** (identical name, semantics and description on both SKUs, from one shared constant), moving the window's END off now while `hours_back` stays its LENGTH - so "the four hours around 03:00 last Tuesday" is one call instead of a 170-hour window filtered by hand. It reaches the web surface too: `/api/read/{name}` is machine-derived from the MCP catalog, so each read's descriptor carries `as_of` and a test pins the two surfaces together in both directions. **Backward compatible by construction** - the anchor defaults to now, so a caller sending only `hours_back` gets byte-for-byte the window it always got, and `ValidateWindow` returns exactly what `ValidateHoursBack` returned when no anchor is sent. Following the `ValidateTop` convention, an anchor we cannot use is **refused** rather than quietly replaced: an unparseable value, or one in the future (past a five-minute client-clock allowance), comes back as a message, because a read that silently reverts to now is indistinguishable from a correct one. An anchor older than anything the store still holds is **not** refused - retention is per-deployment, per-server and per-collector, so a hardcoded floor would be a guess; it returns the read's own `empty` / `unavailable` status, which is unambiguous because the caller chose the window. Three reads that bounded only the LOWER edge (`get_cpu_utilization`, `get_tempdb_trend`, `get_alert_history`) now bound both, which for alert history is a correctness fix as well: its cap is applied by the database, so trimming after the read would have spent the whole `LIMIT` on rows newer than the anchor. Left unanchored on purpose, and stated in the instructions: the analysis family (`analyze_server`, `get_analysis_facts`, `compare_analysis`, `get_analysis_findings`) windows inside the analysis engine rather than at the read, and `get_pvs_stats` / `get_fleet_overview` mix a latest-snapshot measurement with a windowed one, where anchoring only the windowed half would return a result whose two halves describe different instants.
-- **Five ready-made dashboards, so a new user's Custom Views page is not an empty one** ([#2480]) - no custom view ships seeded, so the first thing a new user meets at `#/views` is a blank page and a blank canvas over an 82-read catalog. The first-run hero already knew what would help and rendered it as three **inert** chips - "Top waits by server", "CPU trend over time", "Slowest procedures by database", no handler, no href - which is the [#2437] defect shape (a promise rendered as a caption) on the one page nobody arrives at with context. Those chips are now the real templates: **Server health at a glance**, **CPU investigation**, **Blocking and deadlocks**, **Memory pressure** and **Configuration review**, each created in one click against a server picked beside them, and they sit in the existing "New from template" menu alongside the notebook seeds ([#1563] D7) rather than in a second affordance. **Templates, not seeded rows**: seeding would need a migration rung, a `StorageVersion` bump, four pinned test files and a Viewer probe sentinel for content that is not schema - and a seeded row RESURRECTS itself on the next upgrade after the user deletes it, with no reset path if an editing seat breaks one. A template is created only when asked for, and what lands is an ordinary view the user owns, edits and can delete for good. The two halves of the menu behave differently on purpose: a notebook template links to the composer pre-filled because its composed panels re-scope live from the notebook's own controls, while a dashboard template is v1 READ panels whose `server` param is **static** (`renderView` threads variables and range into composed panels only), so it is created against the server chosen in the menu - and the server name goes into the view NAME, so two servers' copies of one template do not collide on the unique-name constraint. **Honest on day one**, which for a starter dashboard is the whole game - it is the first screen a UAT tester opens, on the store with the least data it will ever have. Every table and chart panel carries its own empty-state sentence (a count pin, not a spot-check), and the two reads that CANNOT be honest on a fresh install are deliberately absent rather than merely unused: analysis findings (the pass writes nothing for 24 hours) and Query Store (a target with it off has nothing, ever). The chart case is the one that bites without looking like it - `get_blocking_trend` and `get_deadlock_trend` answer an IDLE server with `trend: []` and no `{status,message}` envelope, so those panels say an empty trend means none happened rather than inheriting a sentence about collection. Every empty and failure state is stated rather than blank: a fleet with no servers says why the dashboards are unavailable instead of rendering an empty picker, and a refused create surfaces the backend's own message verbatim (with a 409 getting its own sentence, since a second click of the same template is the likely failure) because flattening it to "could not create" would hide the one line that says which panel is wrong. Pinned by the same invariant the built-in pages carry - every read exists in the shipped dispatch, every parameter key is one its read binds, every viz is in the shipped vocabulary - and each of the five definitions was fed to the service's own `ValidateDefinition`, the authority that would refuse the POST.
-- **The web dashboard's server page gets the viewer's tabs: twelve sections, 61 of the 82 served reads, and a time range** ([#2475]) - the service dispatched **82** reads at `GET /api/read/{name}` and the built-in pages reached **23** of them; `#/server/{name}` was one scroll of seven panels against the desktop viewer's **65** `TabItem`s. The gap was never backend work - `panels.js` has been a generic renderer over those reads since #1562 - so this is descriptors over the unchanged `renderPanel` seam: twelve sub-tabs (Overview, Wait Stats, CPU, Memory, Blocking, File I/O, Queries, Configuration, Config Changes, Activity, System Events, Collection Health) carrying ~70 panels, reaching **61** reads. The header carries the WHY beneath the band badge - `Warning` has three unrelated causes (a real metric breach, a server awaiting its first collection, a collector error), so a badge reading "Warning" with no way to ask why is [#2422] rebuilt on a new surface; the fleet's own reason string and the fleet card's severity chips are rendered there, the chips through `fleet.js`'s own `metricBands` so there is one implementation rather than two, and neither is re-derived in the browser (R1). Every web grid that renders query text now puts it immediately right of its time/identity anchor ([#1949]) and the pin that enforced that on one array enforces it on all seven. The tab id rides in the hash (`#/server/{name}/{tab}`) so a section is deep-linkable and survives the 60s refresh, and an unknown or absent id resolves to Overview, which is what keeps every existing `#/server/{name}` link working. A page-level range picker (1h/4h/12h/24h/7d/30d) is the twin of the viewer's toolbar presets - **not** persisted, because a page that reopens on a 30-day window is slow for a reason the reader cannot see; panels whose read takes no window at all say "latest snapshot" rather than inheriting a label that would misdescribe them. Two reads were previously **unreachable from a browser** because they require a parameter no UI collected - `get_wait_trend` needs a `wait_type` and `get_perfmon_trend` a `counter_name` - and both now have a picker, the wait one seeded from the rows of the table directly above it so the picker and the table cannot disagree. **No fifth viz kind was added**: the property-grid shape that tempted one is served by `stat` (the reads returning a flat object) and `table` (the ones already returning rows), and a fifth kind that lived only in `panels.js` would be a page-only special case, while doing it properly means `KnownVizList`, `derive.js` and an editor config arm - composer surface this change does not need. What the browser genuinely cannot do is **stated in the tab where a reader goes looking for it** rather than left as a page that quietly lacks a feature: plan analysis, the query heatmap, cached-plan retrieval and actual-plan re-execution need a plan renderer and a command back to the monitored server, and the block-chain view and interactive deadlock graph need a graph viewer - the Blocking tab hands over the captured blocked-process-report and deadlock-graph XML verbatim instead of pretending. Every data panel supplies its own empty-state sentence and both helpers THROW without one. vizTable's generic "No rows in this window" reads as a fault on a collector that is off, opt-in, or daily; and the chart case was worse - `get_blocking_trend` and `get_deadlock_trend` answer an IDLE server with `trend: []` and no `{status,message}` envelope at all, so a perfectly healthy server was told its blocking chart did not have "enough data points to chart yet". `vizLine` now renders a descriptor's `emptyText` at exactly ZERO rows and still falls through at one (where the chart's own sentence is the true one) and when no `emptyText` was authored, so every stored view predating this is unchanged. A read feeding several panels on one tab is fetched ONCE (`fanout`) rather than per descriptor - `readTool` has no cache, so `get_collection_health`, which rolls up seven days of collector logs and computes sweep pressure, was running three times to open its own tab; six such duplicates existed across five tabs and a pin now refuses a seventh. Guarded by an invariant rather than by spot-checks: every read name the module mentions must exist in the shipped dispatch, every parameter key must be one its read actually binds (an unknown query key is silently ignored, so `limit` sent to a read binding `top` quietly returns the default), every viz must be in the shipped vocabulary, and no `get_pg_*` read may appear until the fleet payload can tell a PostgreSQL target from a SQL Server one - it carries `engine_edition`, not a `CollectorTargetEngine`, so a PostgreSQL panel today would render on all 42 SQL Servers, permanently empty.
-
### Changed
+
- **A census count stated over a population the summary does not republish can no longer be satisfied by assertion** ([#3200]) - `RefreshCeilingProvenancePinTests` now bounds the light-refresh ceiling's census count by how many published readings exceed that summary's own stated 95th percentile, derived from the same nearest-rank definition.
- **A sizing constant cannot be tightened on a population too thin for a maximum over it to bound anything** ([#3193]) - `RefreshCeilingProvenancePinTests` now requires each hourly-refresh ceiling's published population to clear a floor derived from its own nearest-rank percentile definition.
- **Web dashboard custom views now record WHO changed them** ([#2550]) - `updated_by` carries the authenticated OIDC subject instead of the constant `web`, and `/api/session` reports the requesting seat's own edit right so a read-only seat is rendered as one. A deployment without OIDC is unchanged — a seat with no subject still stamps `web`. The MCP surface keeps its `mcp` provenance constant, having no per-user identity to stamp. No schema change.
@@ -153,14 +90,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- **Fixture display names and short-name comment references now use synthetic slugs too** ([#2490]) - [#2490] pinned fixture HOSTNAMES to invented placeholder slugs, but `FleetIdentifierScrubTests` matches only the full hostname shape, so two other kinds of reference went unpinned: a `display_name` sitting beside an already-synthetic hostname on the same fixture row, and comments naming a server by its short name rather than a full hostname. Thirteen locations across `PerformanceMonitor.Collectors`, the Darling service and `Darling.Tests` now read `omega`, a slug that named no other server in any of those files; every measurement in those comments is unchanged. `pgmonitor` joins the allowlist as well - a role word like `monitor`, so a legitimate reference to that host cannot trip the guard.
- **Darling's slice-repair collapse keeps staging the FULL projection, because on PostgreSQL the width is what makes the plan safe** ([#2876]) - the sibling question raised by [#2771], which rewrote Lite's collapse after pushing `query_plan_text` through a per-group aggregate raised a hard `Out of Memory Error` at 31,426 split intervals. Measured on PostgreSQL 18 rather than assumed to differ, at the same 31,426 groups and again at 51,426, carrying 3,033 MB of decompressed plan text. Three independent reasons it does not transfer: PostgreSQL SPILLS where DuckDB dies - the planner picks `GroupAggregate` over an external merge sort (4.3 s, 125 MB temp, negligible resident), and forced onto the `HashAggregate` that is Lite's failure shape it still bounded itself at 45 batches / 1.1 GB peak and completed; TOAST keeps the wide values out of the sort payload, so 3,033 MB of logical plan text spilled as 125 MB because the tuples carry out-of-line pointers that `min()` detoasts lazily; and the ~50 transition states per group are what make `GroupAggregate` win on cost, where a cut-down three-column form of the same query plans as an UNSPILLED `HashAggregate` at 1.6 GB. So narrowing this projection toward Lite's shape would move it TOWARD the risk. Independently, the shipped survey over the whole production hypertable returns zero split groups - [#1907] stopped the collector emitting them and the historical ones have aged past raw's 4-day edge. Pinned over the collector's own `PayloadColumns` so the narrowing cannot be made quietly, and the reasoning recorded beside `BuildCollapseSql` so the question is not re-opened.
- **Lite's server-scoped watermark read stays UNBOUNDED, and the asymmetry with its per-database twin is now documented as deliberate** ([#2800]) - filed as the Lite half of [#2795], on the reasonable read that `GetLastCollectedTimeForDatabaseAsync` taking a `collectedSince` bound while `GetLastCollectedTimeAsync` does not is the same oversight [#2344] left on the Postgres side. Measured against DuckDB rather than inherited from Darling's numbers, it is not. **The failure [#2795] fixed cannot occur here**: its mechanism was Npgsql's undocumented 30 s default `CommandTimeout` cancelling a 40,743-50,560 ms read, the cancellation being swallowed, and the resulting null being indistinguishable from a first run - silently downgrading the collector to its fallback window. `DuckDBCommand.CommandTimeout` defaults to **0**, meaning no limit, so there is no ceiling to exceed and nothing to cancel. **And the bound does not pay.** Measured on DuckDB 1.5.5 (the version Lite ships) against this table's shipped generated DDL at 50M rows / 4.7 GB - far past any realistic Lite store - with the connection already open so the figure is query cost rather than connect cost: with ONE monitored server, the common Lite deployment where `server_id` selects everything, unbounded is **0.34 ms** and bounded is **0.56 ms**, a 65% LOSS, because DuckDB answers the unfiltered `MAX` from column zonemap metadata and the `collection_time` predicate forces real evaluation instead. With five servers the bound is a genuine 9.8x (8.26 ms to 0.84 ms) but saves ~7 ms once per five-minute cycle. **The per-database twin's bound is earned and stays**: 8.40 ms to 0.79 ms at one server and 10.42 ms to 0.93 ms at five, roughly 10x in BOTH shapes, because `database_name` is not in `idx_query_store_time(server_id, collection_time)` so that `MAX` genuinely scans and genuinely prunes. The two reads have different cost structures in a columnar engine; in Postgres both scanned every chunk, which is why the shapes match there and diverge here. Verified across 1M-50M rows, narrow and realistic payload widths (identical, as expected when the query reads three narrow columns), and correlated vs scattered `last_execution_time` - the conclusion holds on all of them. No behaviour change: the method's doc comment now carries the measurement and names `CommandTimeout`'s default as the load-bearing assumption to re-check.
-- **The SQL-Agent collectors no longer gate on msdb access, so running the GRANT we advise actually does something** ([#2559]) - `HasMsdbAccess` is a **grant**, not an engine capability, but it was probed once at connect and cached for the connection's life. Three collectors gated dispatch on it, so the sequence a user actually follows was broken end to end: read our advice, run the `GRANT`, and nothing happens until the service restarts, with nothing to indicate why. `running_jobs`, `job_history` and `agent_status` now attempt regardless and fail into `PERMISSIONS`, which is a first-class outcome here rather than a defect - error **916 is already in `SqlServerPermissionErrors`**, so the run is classified as a permission denial and never as an ERROR, and `CollectorHealthClassifier` bands a collector that has only ever been denied as `NO_PERMISSIONS`, a check that runs **before** FAILING and STALE. So a server that will never have the grant does not read as broken, raises no alert, and the grant now takes effect on the next cycle instead of the next reconnect. The cost this trades for is three fast-failing statements per cycle on such a server - a compile-time permission check with no execution behind it. The fleet number that made the call: across both stores over three hours, **all 84 servers already dispatch all three collectors with zero non-SUCCESS runs**, so `HasMsdbAccess` never fires here and removing it changes nothing on this fleet while fixing the journey for anyone it does fire on. `HasMsdbAccess` is kept and still probed - it is honest information for a connection surface - it just no longer decides dispatch. One pin was **deleted rather than left passing**: the PostgreSQL-leak guard asserted no SQL Server gate reads `HasMsdbAccess`, which is now true for a reason that has nothing to do with the filter it was written to test, so it could no longer go red and was reading as coverage it did not provide.
-- **`get_pg_top_queries` and `get_pg_blocking` return their int8 IDENTITIES as strings, because a JSON number was rounding them** ([#2548]) - PostgreSQL's `queryid` is a signed `int8` derived from a hash, so its values are spread over the whole 64-bit range and most of them sit past 2^53. Serialized as a JSON **number**, every parser that decodes numbers as IEEE-754 doubles - `JSON.parse`, `json.loads`, most agent tooling - silently rounded one: `-4185925123159566327` came back as `-4185925123159566300`, verified against real `JSON.parse`. The value was never wrong on the wire; it was unrecoverable after parsing, which for an identity is the same thing. **This is a breaking change to the response shape and it is the right trade**: `queryid` is the ONLY identity a PostgreSQL statement has, and every use of it - `SELECT ... FROM pg_stat_statements WHERE queryid = ...`, matching a row on our screen against one on the instance, quoting it in a ticket - is an equality join. A rounded metric is still approximately true; a rounded key matches nothing. **`get_pg_blocking`'s `root_backend_id` had the same defect in a worse form**, and it is fixed in the same change rather than noted for later. The collector builds that id by CONCATENATING the backend's start epoch with its zero-padded pid, so every value is a 17-digit integer around 1.79e16 - roughly 2x past 2^53, where adjacent doubles are 2 apart. Half of all backend ids are therefore odd and have no representation at all, and an unrepresentable one does not round to nothing: it rounds onto its even NEIGHBOUR, which is a different backend. Measured on four adjacent pids, two of the four collided; over 200, 100 lost precision and 99 landed on an id belonging to another backend. That is a worse failure than `queryid`'s - joining to the wrong row rather than to none - in the field the tool's own description tells a reader to prefer for comparing a root blocker across captures. The fix is scoped to those two: `database_id` and `user_id` are PostgreSQL `oid`s (unsigned 32-bit, structurally unable to reach the range), `root_pid` is an `int`, SQL Server's `query_hash` / `plan_hash` already reach this surface as `0x...` text, and Query Store's `query_id` / `plan_id` are sequential `bigint` IDENTITY columns whose real values are nowhere near 2^53 - so none of them has a defect to fix and none was touched. Web rendering needed no change: the Query ID column already rendered the raw value, so it simply started showing the true digits. Guarded by `PgInt64IdentityWireShapeTests`, which pins both fields as strings, pins `database_id` and `root_pid` as numbers so "stringify everything" cannot pass, and asserts its own fixture is genuinely out of double range - the assertion that stops the guard being vacuous.
-- **Fifteen reads stopped answering "nothing happened" and "nothing was collected" with the same sentence** ([#2485]) - a read that returns no rows was saying so identically whether the window was genuinely quiet or the collector had never run, and those want opposite responses: widen the window, versus go find out why collection is not running. They now use the miss vocabulary honestly - `empty` for a true negative, `unavailable` for data that was never collected - with the second saying outright that it is NOT an all-clear and naming what to check. The design rule that came out of it is worth stating: **for an EDGE table the denominator is whether we LOOKED, not whether we ever FOUND anything**. Blocking and deadlocks only write a row when something went wrong, so probing the data reports a server that has been collected perfectly for months and simply never blocked as uncollected - a false alarm sending someone to fix collection that works. Those reads count successful collector runs instead. A parity pin now asserts each shared sentence appears in both source trees, because every one of these messages exists twice and nothing previously stopped one copy drifting.
-- **`get_collection_health` serves what a HEAVY run costs, not only what runs cost on average** ([#2460]) - `query_store` on one dogfood server reported `avg_duration_ms: 13,834` over 1,155 runs. 958 of those runs carried the `enumeration yielded 0 items` note, and an empty enumeration costs **36 ms** - measured on a control server that yields nothing on all 1,551 of its runs and pays 36 ms for every one. Back that out and the remaining 197 PRODUCTIVE runs cost **~80,900 ms EACH**: more than the entire 60,000 ms sweep budget, on their own, once every few cycles. 13,834 ms describes neither population - it is an 83/17 blend that happens to land in a range reading like a plausible single number, and everything downstream inherited it, [#2459]'s brand-new `peak_cycle_ms` included, which understated that server's worst body by ~67,000 ms. Every collector row now carries `p95_duration_ms` and `max_duration_ms` beside `avg_duration_ms`, and `peak_cycle_ms` is built from the p95 rather than the mean. **The store already held this** - `duration_ms` has been written per run since the schema's second rung, and nothing had ever read it as anything but a mean - so this is two aggregates over a table already being grouped, no new collection and no new column. p95 rather than max for the number a decision is made from, because a max is one run and a single pathological cycle would make a collector read as permanently terrible for a week; the max is served BESIDE it as a fact, and comparing the two is what tells a routine tail from a one-off (avg ~ p95 ~ max is one population, avg << p95 is two, p95 << max is one bad run). p95 also scales itself to the sample: over 3,500 runs it discards the outlier, and over the six runs a daily collector gets in a week it lands on the max, which is right, because with six samples there is no outlier anyone can afford to throw away. Each collector is charged the p95 **floored at its mean**, so the aligned cycle can only ever rise - a p95 CAN sit below a mean (99 runs at 10 ms and one at 1,000,000 ms), and taking it unconditionally could have retracted a `BODY_OVERRUN` [#2446] correctly caught. **The verdict is untouched and still amortizes the mean**: sustained demand over a window IS the mean, and a rate built from a tail claims work the server never sustains. On the measured server the aligned body goes from 73,408 ms to 140,507 ms and `peak_collector` changes from `index_object_stats` (the larger MEAN, once a day) to `query_store` (the larger heavy RUN, every five minutes) - which is the collector that was actually overrunning those bodies. `peak_cycle_note` now states the mean beside the heavy run and the gap between them; `heaviest_collectors` rows carry both new statistics and take `pct_of_sweep_budget_per_run` from the heavy run, since a "per run" percentage computed from a mean that describes no run is the defect itself. Both SKUs, one query shape, and the same fixture pinned against live DuckDB and live Postgres so the two engines have to agree - including that both ignore a NULL `duration_ms` in an ordered-set aggregate, which neither query states.
-- **`sweep_pressure` answers the single-sweep question as well as the sustained one** ([#2446]) - a server logging six "collection body has not completed after 60-69s of execution - skipping relaunch" warnings in three hours reported `busy_percent: 20.4`, `verdict: OK`, every collector HEALTHY - and both numbers were right about what they measured. [#2296]'s amortized model asks "does this server's total demand fit its cadence on average"; an operator reading a skipped relaunch is asking "did THIS sweep overrun". Those diverge exactly when one collector's single run approaches the budget while its amortized cost is negligible: `index_object_stats` took 37,207 ms of a 60,000 ms body and, at a 1440-minute cadence, contributed 26 ms/min to the verdict. The block now also carries `peak_cycle_ms` / `peak_cycle_percent` - what the body costs on the cycle where every scheduled cadence comes due together, the collectors' averages added WITHOUT being amortized - and `peak_cycle_risk` (FITS / BODY_OVERRUN). That cycle is not a hypothetical worst case: the shipped cadences are strictly nested (1 | 5 | 60 | 1440), so alignment is a guaranteed periodic event, and on that server it costs 73,408 ms against the 60,000 ms budget, which is what the pinned fixture reproduces. `peak_collector` names the collector that owns the most of one sweep and `peak_cycle_note` explains it, because `heaviest_collectors` ranks by amortized contribution and therefore ranks the offending collector out of sight by construction - that list now also carries `amortized_ms_per_minute` and `pct_of_sweep_budget_per_run` per row, so the two costs sit side by side. **The verdict is deliberately unchanged.** A once-daily 37-second collector is not saturation; calling it SATURATED would spend the word on a case whose lever is the schedule's shape rather than the capacity that verdict recommends, and an operator who learns to discount SATURATED loses the signal [#2296] built. Separate field and separate vocabulary, so neither can be read as the other and a fleet scan can filter on either. Measured across the dogfood fleet: the two servers that logged skipped relaunches read OK/BODY_OVERRUN (122% and 109% of budget) while a quiet one read OK/FITS at 19%. The decision stays in the shared SweepPressureClassifier (PerformanceMonitor.Common) with the same table pinned in both suites, and both SKUs' tools serve the identical shape.
-- **Lite's portable ZIP is self-contained, which HALVED it** ([#2501]) - `Publish Lite` is now `-r win-x64 --self-contained` in both `build.yml` and `nightly.yml`, so neither Lite artifact has a .NET prerequisite any more and the failure [#2489] documented stops existing: a tester who unzips onto a stock Windows Server no longer meets the .NET host's bare `You must install .NET to run this application` before a line of our code runs. **The size went the opposite way from what bundling a runtime suggests.** The old publish was RID-agnostic, so it copied every platform its packages ship - **537 MB of `runtimes\` on a 565 MB tree** (osx 130, linux-x64 116, linux-arm64 70, win-arm64 56, then win-x86, musl, loongarch64 and riscv64), of which only the **52 MB `win-x64`** folder could ever load on Windows. `DuckDB.NET.Bindings.Full` is most of it, SkiaSharp and SqlClient behind it. Dropping ~485 MB of unloadable native payload beats the cost of bundling .NET, WPF and ASP.NET Core by roughly two to one: measured on one commit and one SDK, **565 MB tree / 212.7 MB zipped becomes 277 MB / 114.2 MB**. It matters most for the **nightly** ZIP, which is the UAT download and is not offered as a `Setup.exe` at all. **A RID-specific publish needed two more files than the flag.** `Lite/packages.lock.json` had only a `net10.0-windows7.0` target, and a RID restore adds `net10.0-windows7.0/win-x64` to it - after which the `dotnet restore --locked-mode` that BOTH workflows run before the publish fails `NU1004: the project's runtime identifiers have changed`, because locked mode compares the PROJECT's RID set (empty) against the lock file's (win-x64). Reproduced locally; that is a red CI run on every PR, not the future `--no-restore` trap it was filed as. The fix is `win-x64` in `PerformanceMonitorLite.csproj`, so the project itself asks for that graph and one committed lock file satisfies the RID-less locked-mode restore and the RID publish alike; `RuntimeIdentifiers` (plural) sets no RID on the build, so a plain `dotnet build` stays RID-agnostic and `Lite.Tests` is untouched. **SignPath needed nothing** - the `Lite` artifact-configuration slug already receives both shapes today, and the signed re-zip reads `signed/Lite/*`, inheriting whatever shape `publish/Lite` has. Auto-update is unaffected; the ZIP is not a Velopack channel. `LiteRuntimePrerequisiteDocsTests` went red on the flag alone (3 of its 7 facts) and was rewritten to state every claim BOTH ways round: [#2499]'s version asserted only that the docs DID name the runtimes, so two of its facts stayed green while the prose went stale. It now also derives the lock file's RID coverage from the `-r` flags in the workflows, and every new assertion was proven red with its fix reverted.
-
### Fixed
+
- **Fleet cards now report a PostgreSQL/Aurora target's instance CPU** ([#3267]) - `get_fleet_overview`, `/api/fleet` and the viewer's Overview cards read CPU only from the SQL Server ring-buffer table, so every PostgreSQL card carried a null CPU and an Unknown band permanently. Instance CPU is now read from `collect.pg_cpu_utilization` (Performance Insights) through the same severity ladder, and a new `cpu_source` field on each card names which collector answered — separating a reading that has not arrived from a PostgreSQL target for which none is collected at all. The transfer of `CpuSeverity`'s cutoffs was measured rather than assumed: Performance Insights' `os.cpuUtilization.total.avg` and the ring buffer's `100 - SystemIdle` share quantity, denominator and averaging window, and across a 50-target PostgreSQL fleet the median reads 8.4% with 0.48% of samples reaching 80. Memory, buffer pool and the threads block stay null on those cards: no PostgreSQL equivalent is collected and none is invented.
- **A server card no longer reports a metric as Healthy when nothing measured it** ([#3271]) - the memory-pressure, blocking and deadlock bands read off SQL Server DMV captures a PostgreSQL target has no row in, and their non-nullable inputs meant the resulting zeros banded Healthy — a green dot claiming health beside a `deadlock_source` already reporting the count as structurally uncountable. All three now band Unknown for an absent source, as CPU and worker threads already did, and the discriminant is the target's ENGINE rather than collector state, so a SQL Server whose collector is permission-denied still bands on its own failure count. The published counts are unchanged, so the fleet totals and their coverage denominator still reconcile, and an Unknown can move neither a band nor the worst-first ranking.
- **Azure master-connected size collection no longer dies the moment sys.resource_stats has sibling rows** ([#3262]) - #2643's sibling arm deliberately emits rows whose database_id, file_id and physical_name are NULL, because sys.resource_stats carries per-database sizes with no per-file breakdown, and ReadAsync read exactly those three ordinals unguarded - so the first sibling row, which appears once the view finishes ingesting (roughly an hour after database creation, which is why every fresh-server validation window missed it), threw a DBNull cast that aborted the whole read, master's own file rows included, permanently, on every master-connected Azure target. The reader now passes the NULLs through as nullable Row fields; Lite's DuckDB store drops its NOT NULL on the three columns (schema v57, dropping the table's index first per the #2748 ALTER COLUMN trap; Darling's Postgres store was always nullable here); and the has_user_databases inventory screen in both SKUs' collection health admits sibling rows through an IS NULL arm, since on a master-connected Azure target they are the only evidence of user databases the store has. A reader-level seam test now drives ReadAsync over the sibling shape the arm's own tests always pinned, proven red against the unfixed reader.
@@ -371,6 +302,87 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- **PMLite lost Azure SQL DB deadlock and blocked-process capture until an app restart** ([#2677], reported from the field) - a database-scoped XE session on Azure SQL DB can end up in `sys.database_event_sessions` but not `sys.dm_xe_database_sessions` after a failover or scale operation, invisible to the ring-buffer reader. The recovery path recreated it once and, if that did not immediately restore visibility, set a give-up flag that was NEVER cleared - so a single TRANSIENT Azure blip stopped capture for the life of the process, and a 24/7 PMLite never restarts. The reporter's deadlocks stopped on one day and never resumed; blocked-process rides the same shared driver and the same latch, so it went dark too. The give-up is now a 30-minute COOLDOWN rather than a permanent flag - the recreate is re-attempted after it, because the invisibility usually clears once the failover settles - and the entry is cleared the instant the session reads back as visible, so capture self-heals without a restart. The #1535 concern the flag existed for (not churning DROP/CREATE every cycle, which wipes captured-but-unread events) is kept by the cooldown.
- **Query Store plan and text capture decompressed each item up to 3x per cycle** ([#2675]) - the by-ids plan/text fetch measured a plan's decompressed size with DATALENGTH and emitted the plan from the SAME inlined CTE, and because a CTE inlines rather than materialises, `CONVERT(nvarchar(max), query_plan)` — the decompression, which the view performs on every access — re-ran for each of the three references (the byte measure, the running total, and the passthrough). Measured on a SQL Server 2025 rig at 1,500 plans x ~68 KB: 2,440 ms CPU that way vs ~540 ms decompressing once — ~4.5x on the single most expensive operation in the heaviest collector. Both by-ids fetches now materialise the payload to a #temp ONCE, then run the unchanged byte-budget cut over the stored column; the query-text fetch got the same shape (text is not compressed, but the repeated nvarchar(max) reads scale with real text size, where in the field it is the larger of the two phases). No behaviour change — same missing-only set, same watermark, same budget, same rows, same order.
- **`upgrade-darling.ps1` crashed before copying anything on an install with no prior rollback backups** ([#2671], reported from the field) - a box only ever upgraded by hand, or on this script's first run, has zero `_rollback_manual_*` directories, and the run aborted right after it stopped the service with "Cannot find an overload for op_Subtraction" - leaving the operator with a stopped monitoring host and no upgrade. The cause is a PowerShell return trap: `Get-DarlingRollbackBackups` returned `@()` when nothing matched, but a bare `return @()` UNROLLS on the way out of a function, so the caller's `$backups` collected nothing and became `$null`. `@($null)` is then a one-element array holding `$null`, which slips past the `$ordered.Count -eq 0` guard, and the recency check reaches `$nowUtc - $null`. The producer now returns a real empty array with a unary-comma return, restoring the "always a list" contract every caller here already assumed, and the two consumers that index and subtract filter `$null` as defense in depth. The zero-backup state now short-circuits to "nothing recent" and the upgrade proceeds to take its first backup, exactly as the recency guard's own comment always claimed it would. Pinned by a test that plants no backups and runs the shipped functions.
+
+## [3.6.0] - 2026-08-27
+
+### Added
+
+- **Store exposure admits both roles at once** ([#2665]) - `postgres.network.role` took one role, so a team could have an `admin` Viewer or read-only `viewer` ones on the LAN but not both, even though the store has always created and separately granted both roles and given each its own credential. What was single-valued was the one generated `hostssl` line. The field now accepts both - `"admin,viewer"`, separated by a comma, a `+`, or whitespace - and the reconciler writes one rule per role inside its own marked block, which is where it matters: the documented workaround was a second `hostssl` line added by hand outside the markers, and because the reconciler preserves everything outside them verbatim, that line silently kept the old CIDR when `allowFrom` was later tightened. Somebody narrowing access would reasonably have believed they had. Inside the block both rules narrow together. The guardrails hold as written: every generated line still names exactly one role and one CIDR, never `all` and never the superuser; the order is settled by the parser rather than by how the field was typed, so re-spelling `admin,viewer` as `viewer,admin` does not rewrite `pg_hba.conf` and reload the server; an unrecognised element rejects the whole value rather than opening the store with the half that parsed; and absent or blank still means `viewer` alone, so no existing configuration gains admin-capable access from the field learning to take a list. `--configure-network` offers both at the prompt (default still `viewer`) and `--print-viewer-connection` prints one paste-ready string per admitted role, each labelled with the seat it authenticates as.
+- **PostgreSQL I/O and database trends** ([#2663]) - the two deepest series in the store had no shape over time: `pg_io_stats` and `pg_database_stats` had over a thousand snapshots each on a two-day-old rig and nothing differenced them. `get_pg_io_trend` follows one (backend_type, context) pair - a PAIR, because a hit ratio summed across contexts is meaningless when `bulkread` is a sequential scan deliberately bypassing the buffer pool - and reports read, write and extend rates per second, the interval's own hit ratio, and per-operation latency where the server measures it. `get_pg_database_trend` follows one database's temp-file spills, cache hit ratio, deadlocks and rollback share. That last one is where the hit ratio becomes usable at all: `pg_stat_database`'s counters are cumulative since the last reset, so the ratio computed from them raw is a lifetime average that barely moves, and a database that fell off a cliff an hour ago still reports 99% because of the weeks behind it. On the rig a database averaging **98.78% across the window had an interval at 78.49%**, and only the differenced series can say so. Both read the target's own `track_io_timing` before reporting a latency: it is **off by default** and was off on both rig targets, so `read_time_ms / reads` would have printed 0.000 ms - a statement about the configuration that, read as a latency, is the most reassuring wrong number on the page. Byte volumes say whether they are MEASURED (PostgreSQL 18's `read_bytes`) or ESTIMATED as operations x block size (pre-18), because the two are different quantities and 18 moves several blocks per operation. Both subjects are optional and each read says which it chose - and buffer HITS qualify a pair for that choice, so a fully cached server, which is the healthy state, still gets a chart instead of "nothing to follow".
+- **The first PostgreSQL trend reads** ([#2663]) - the service shipped fourteen trend reads and not one worked on a PostgreSQL target, so "is this getting worse" - the question a monitoring tool exists to answer - had no PostgreSQL answer while every `get_pg_*` read described a single window. The data was already there: over a thousand stored snapshots of I/O and database stats on a two-day-old rig, and nothing differenced them. `get_pg_wait_trend` follows one wait event over time, per second; `get_pg_query_duration_trend` follows one statement's cost per execution, which is the regression read - a query that doubled halfway through the window still ranks where its average puts it in a single-window grid. Both take their subject as an optional parameter and choose a sensible one when it is omitted, and say which they chose. The wait trend's automatic choice deliberately skips the CPU class: `pg_wait_sampling`'s `Running` means the backend was NOT waiting and dominates any healthy server's profile, so defaulting to it would answer the opposite of the question.
+- **PostgreSQL deadlocks, not just the count** ([#2661]) - we collected `pg_stat_database.deadlocks`, a number that goes up, and nothing else. PostgreSQL writes a complete report to its server log: the wait graph, every participant, and each one's full statement text. `get_pg_deadlocks` lists them with the victim, the lock modes and the resources; `get_pg_deadlock_detail` returns the graph as the server wrote it. In one respect this beats the SQL Server graph, which names the victim's statement and often leaves the other side as a handle. It needs **nothing configured on the target** - unlike plan capture there is no setting that suppresses a deadlock report, so it works on a managed fleet today; the only precondition is being able to read the log, which plan capture already established. The collector re-reads an overlapping tail on purpose so a report cut in half at one edge is whole in the next, and every row carries a hash of its graph so the same report is reported once, with a sighting count, rather than once per cycle.
+- **PostgreSQL configuration, and what changed in it** ([#2658]) - `pg_settings` was never collected, so neither "what is `work_mem` set to on this server" nor "what changed last Tuesday" had an answer, and the second is the kind that cannot be recovered later at any price: a configuration history nobody recorded is not sitting on the server waiting to be read. `get_pg_server_config` reports the settings somebody actually chose, non-default first, with where each value came from and whether changing it needs a restart or a reload. `get_pg_server_config_changes` reports value changes between snapshots, old beside new. Both name `pending_restart` loudly - the state where `postgresql.conf` has been edited and reloaded but the running server is still on the old value, so the file and the server disagree with no symptom until a restart months later changes behaviour during someone else's incident.
+- **Test an index from the predicate grid** ([#2612]) - right-click a row in PostgreSQL predicate statistics and ask whether the planner would actually use an index on that column. The command shipped with no caller: the only way to reach it was hand-writing a row into the command queue, which is how it was tested and is not a feature. It hangs off that grid and nowhere else, which is the shape it was scoped to - on demand only, never scheduled, driven from a row somebody is already looking at. The confirmation says what it costs the server before it runs (nothing executed, no index built, session reset), and a predicate whose estimate error is already large is flagged BEFORE the round trip, because an index does not fix a plan built on a wrong row count.
+- **Azure SQL DB now reports every database's size, not just the connected one** ([#2643], raised from the field) - `sys.database_files` is database-scoped, so a Viewer pointed at `master` showed `master`'s two files and nothing else, which is correct and reads exactly like a broken collector. `sys.resource_stats` is a master-only view carrying `storage_in_megabytes` per database, so from a `master` connection the siblings now appear too - as one row each, labelled `(whole database)` with a NULL `file_id`, because that view has no per-file breakdown and a fabricated file name would make the grid look complete and be wrong. The sibling read runs through `sp_executesql`: the view does not exist in a user database and name resolution happens at parse time, so a guarded UNION still fails with 208 everywhere else. Verified against a live Azure SQL Database from both a `master` and a user-database connection.
+- **Mark rows in grids** ([#2645], requested from the field) - right-click any FinOps grid and mark the selected rows **Done**, **To Do** or **Do Not Do**, so you can work through a result set and remember which rows you have dealt with and which you have decided against. Asked for on Index Analysis, where you decide index by index. Marks are held against the row objects, so they last exactly as long as the result set does: on a run-on-demand grid until you run it again, on a live grid until the next refresh. They are painted from `LoadingRow`, so no row model gained a property and no grid gained a column.
+- **Test a PostgreSQL index without building it** ([#2612]) - the new `test_hypothetical_index` command plans one stored statement twice, with and without a candidate index the planner can see and nothing builds, and answers whether the planner would switch and by how much estimated cost. It closes the loop on `pg_predicate_stats`, which names columns that are filtered on heavily with nothing supporting them: without this every recommendation is an inference, and with it there is an answer. **On demand only, never scheduled** - no collector, no cadence, no sweep. Nothing is executed (`EXPLAIN` without `ANALYZE`), nothing is written, the candidate is visible only inside one session, and the session is reset before the call returns. A "no" is a real result and says so: on the verification rig a candidate on an already-served predicate left the plan and its cost unchanged, which is the answer that saves somebody from building an index.
+- **The last eight PostgreSQL collectors are readable outside the Windows Viewer** ([#2629]) - `get_pg_predicate_stats`, `get_pg_index_bloat`, `get_pg_column_stats`, `get_pg_buffer_usage`, `get_pg_extensions`, `get_pg_lock_stats`, `get_pg_write_stats` and `get_pg_replication_stats`. Every PostgreSQL collector except `pg_plan_capture_readiness` now has an MCP tool and a web panel, and that one is the case the ratchet was built to tolerate - a single row of configuration state is a panel, not a question anyone asks an agent. The ratchet drops from nine to one.
+- **Sampled waits and per-query OS CPU are readable from the MCP and the web dashboard** ([#2629]) - `get_pg_wait_sampling` and `get_pg_kernel_stats`. Both collectors were storing data reachable only from the Windows WPF Viewer: collected on every cycle on a Linux host and invisible from that host, and invisible to every agent. It mattered most for `pg_wait_sampling`, which #2625 had just started POINTING AT - a stock-PostgreSQL operator was told, correctly, that it answers what Aurora's wait instrumentation cannot, and sent to a panel the MCP could not read. Seven PostgreSQL collectors remain Viewer-only and a ratchet now stops that number rising.
+- **Self-hosted PostgreSQL now answers "which queries cost the most"** ([#2625]) - `pg_statement_stats` read `aurora_stat_statements()` and gated itself Aurora-only, so on a non-Aurora target the single question a database monitor exists to answer had NO answer at all: the read returned "does not run on that engine, and never will". It now reads the vanilla `pg_stat_statements` view on any other PostgreSQL, chosen at query-build time - one collector, one table, identical ordinals - and reports the six Aurora-only columns as NULL rather than zero, because zero would be a claim about storage hardware the server does not have. Verified against a self-hosted PostgreSQL 17: 77 statement rows, and the read tool returns them with `storage_blks_read` and `max_exec_peakmem_bytes` honestly null. `toplevel` is guarded for PostgreSQL 13 and older, where nested tracking did not exist and every row IS top level. A target without the extension keeps the existing `42P01` -> ObjectMissing answer, which names the fix.
+- **Permanent-gap messages now name the collector that DOES answer the question** ([#2625]) - "and never will" is right about the source and wrong to leave an operator with when a sibling covers it. On stock PostgreSQL the `pg_wait_stats` gap now adds that `pg_wait_sampling` answers the same question from a different source. Deliberately sparse: an entry is a promise the named collector answers substantially the same question, and a pointer at something merely adjacent is worse than none.
+- **PostgreSQL plan capture reaches Aurora and RDS** ([#2538]) - `auto_explain` writes plans to the server log and nowhere else, and managed PostgreSQL has no filesystem to read: `pg_read_server_files` is not grantable and `pg_read_file` is denied. Those targets now get their plans from `DownloadDBLogFilePortion` instead. **One table, two transports**, and the route is chosen at DISPATCH rather than by gating the collector - gating would make the capability model report plan capture as a permanent gap on Aurora, which is false, and four capability tests said so. Both routes write through the SAME collector definition, so the column order, COPY command and standard prefix have one owner and cannot drift. **Three decisions worth knowing**: a cluster endpoint is resolved to its WRITER, because `DownloadDBLogFilePortion` takes an instance identifier and passing a cluster name through yields `DBInstanceNotFound` against a healthy cluster; the newest log is chosen by `LastWritten` rather than by NAME, because the filename embeds a date and sorting text puts `2026-08-09` after `2026-08-25`, which would pin the collector to a stale log forever without ever erroring; and reader and custom endpoints are REFUSED with a reason, because they round-robin across replicas so plans pulled through one would be attributed to whichever replica answered. **No credentials are stored** - the SDK's default chain finds the instance profile the service already runs under, so nothing can leak from config. The resume marker is in memory on purpose: a restart re-reads a bounded tail, which is harmless because plan rows dedup on `(queryid, plan_hash)`, and persisting it would add a schema rung that could disagree with reality after a log rotation. Redaction is not re-derived anywhere - it lives once in `PgPlanLogParser`, shared by both routes.
+- **`get_pg_plans`: the plan itself, not a pointer to one** ([#2567]) - an agent consuming the MCP has no viewer to follow a reference into, so a read that answers with an id has not answered. Returns captured PostgreSQL plans grouped by shape and ranked by total time, with the plan emitted as **navigable JSON** rather than an opaque string. **`queryid` is a STRING on the wire** (#2548): it is a signed int8 spread over the whole 64-bit range, so most ids exceed 2^53 and any consumer decoding JSON numbers as IEEE-754 doubles rounds one - and since queryid is an equality join key, a rounded value does not approximate the answer, it matches nothing. The tool also **accepts** it as a string for the same reason. **The empty answers are the work**: 'no plan' has three unrelated causes with three unrelated remedies, and this names which one it is rather than guessing in prose - capture not configured (naming the specific unsatisfied facet from `pg_plan_capture_readiness`, with its remedy), capture configured but the statement never crossed `auto_explain.log_min_duration` (the healthy answer), or the plan aged out under `plan_content_retention_days`. **`captures` is named for what it counts** - the collector reads an overlapping tail of the server log so one execution can be seen twice; `get_pg_top_queries.calls` remains the authority on how often a statement ran. Nothing here re-derives redaction: the plan is stripped at collection, so there is no un-redacted copy in the store for a read to leak.
+- **PostgreSQL execution plans, captured and redacted** ([#2566], part of [#2538]) - schema **v99** adds `collect.pg_plan_capture`, reading `auto_explain`'s JSON output out of the server log. **Its own table, NOT `query_plan_dim`**: that one holds SQL Server plan XML and `plan_xml_compression = none` is a documented contract with direct-SQL consumers who read the column AS XML - dropping JSON rows into it would break their dashboards with no schema change to notice, no error, and no version to key off. **No query text and no literals reach the store, and that is the load-bearing part.** `auto_explain` emits `Query Text` verbatim and `auto_explain.log_parameter_max_length = 0` does NOT suppress it (that setting covers bind parameters only - measured on #2565); literals also sit inside the plan tree in `Filter` and its relatives. So the text is dropped outright - statement identity is `query_id`, whose normalised text already lives in `pg_statement_stats` - and every remaining string is redacted before storage. **Redaction is deliberately asymmetric**: quoted literals go from every string, but bare numbers only from condition fields, because a blanket numeric strip would rewrite a relation genuinely named `transactionitems1` into `transactionitems?` - destroying identity to hide a value that was never there. Verified against 25 real captured plans: **zero literals survived**, and the digit-bearing name survived intact. `plan_hash` is taken of the REDACTED plan so the same shape recurs to the same hash whatever values it ran with. The log read is **bounded to a 4 MB tail** because #2565 measured 772 MB of log in twenty seconds at capture-everything, and truncated blocks at the window edge are skipped rather than stored half-parsed. **Self-hosted in practice**: reading the log needs `pg_read_server_files` AND an explicit `GRANT EXECUTE ON FUNCTION pg_read_file` - measured, the role alone does NOT carry it, because the function's ACL is `postgres=X/postgres` - and on Aurora or RDS there is no filesystem at all, so the panel stays empty by design and says so.
+- **Which PostgreSQL columns are filtered on, and where the planner is wrong about them** ([#2603]) - `pg_index_usage_stats` records indexes that were USED; schema **v98** adds `collect.pg_predicate_stats` from `pg_qualstats`, which records predicates that were EVALUATED **including the ones with no index behind them**. That is the index-candidate signal, and it is invisible to every other collector because nothing records a scan that had no index to record. **The counts are a SAMPLE and the rate is stored on every row**: `pg_qualstats.sample_rate` defaults to `1/max_connections` - 0.01 on the verification rig, where `pg_qualstats()` returned ZERO rows on a server that had just run the queries it was meant to record. A small count means the sampler fired rarely, not that the predicate is rare, and the counts are deliberately NOT scaled up by the rate because three observations at 1% is not three hundred. **`worst_estimate_error_ratio` is a different problem from selectivity** - measured at **57.9x** on one predicate beside 1.04x on another: the first means the plan was built on a wrong row count, which an index will not fix. **Runs per database, and that is load-bearing**: the function returns cluster-wide rows keyed by `dbid`, but `lrelid`/`lattnum` are OIDs meaningful only inside their own database while `pg_class` and `pg_attribute` are per-database catalogs - unscoped, the join either silently DROPS other databases' rows or resolves them against whatever local object shares the OID and reports a confident wrong column name. Measured: two cross-database rows present, neither colliding locally that day, so the failure would have been silent loss and different OID luck would have produced the wrong name. The extension is itself per-database, so databases without it degrade to a named non-fatal skip.
+- **OS CPU and disk per query on PostgreSQL** ([#2603]) - `pg_stat_statements` reports elapsed time, which cannot separate a query that was WAITING from one burning processor. Schema **v97** adds `collect.pg_kernel_stats` from `pg_stat_kcache`: the kernel's own user CPU, system CPU and bytes that reached the disk, keyed by the same `queryid` so the two halves compose. **`exec_read_bytes = 0` does NOT mean the query read nothing** - the counters come from `getrusage` and measure I/O that reached the DEVICE, so a read served by the OS page cache is genuinely zero, which was the case on every row of the verification rig while writes were not. Zero reads with high CPU is a cached workload working properly; it is not comparable to a logical-read figure, and the columns are named `_bytes` so nothing in a SQL Server codebase reads them as one. **Only top-level statements are collected**: with `pg_stat_statements.track = 'all'` a nested statement appears both on its own row and inside its caller's, and summing them double-counts every function body on the server - the rig ran `track = 'top'` where that filter is inert, which is exactly why it is pinned rather than remembered. Times convert seconds to milliseconds once, in the collector, because a column named `_ms` holding seconds is wrong by a thousand and looks entirely plausible. Ranked by **CPU**, not bytes. Cumulative like the wait profile, but this table can do better than inferring a reset from a counter moving backwards: `stats_since` is collected, so the read detects a reset from the stamp changing AND from the backwards check. **Carries `database_name`, and that is not a contradiction of v95** - the function exposes `dbid` and `pg_database` is a SHARED catalog, so the name resolves correctly from any connection; `pg_wait_sampling` omits it because its catalog genuinely cannot support the claim.
+- **PostgreSQL wait analysis stops being Aurora-only** ([#2603]) - `PgWaitStatsCollector` reads `aurora_stat_system_waits()`, so every self-hosted, on-prem and plain-RDS target had **no wait data at all**, which is backwards: self-hosted is where the extension story is richest. Schema **v96** adds `collect.pg_wait_sampling` from the `pg_wait_sampling` module, attributing each wait to the `queryid` that waited so it joins `pg_statement_stats` - the "which query waited on what" question this product answers on SQL Server. **Every design choice came from running it.** `event_type = 'Activity'` is excluded at the source: measured on an idle PostgreSQL 17 the entire top of the raw profile was background processes waiting for work (`AutovacuumMain`, `LogicalLauncherMain`, `WalWriterMain`, `CheckpointerMain`), accumulating samples forever precisely BECAUSE nothing was happening, while attributed waits returned nothing at all - rank that and every healthy server reports autovacuum's idle loop as its top wait. The filter is `IS DISTINCT FROM`, never `<>`, because a NULL event means the backend was **not waiting** and `NULL <> 'Activity'` is NULL rather than true, which would silently discard every on-CPU sample; those are kept and labelled **`CPU`/`Running`** instead of stored as a blank row that sorts near the top of a count-ranked grid (23 samples across 11 backends arrived exactly that way). **`sample_count` is a tally, not a duration** - `profile_period_ms` travels beside it because a count is uninterpretable alone, and the samples-to-milliseconds multiplication happens once in the read where the header says *Est. Wait*. The profile is **cumulative**, so a counter going BACKWARDS is a reset (`pg_wait_sampling_reset_profile`, or a restart nobody chose) rather than a negative wait: the read uses the newest value whole in that case and flags `counter_reset`, where `GREATEST(new - old, 0)` would have quietly reported zero waits across a restart. **No `database_name`, deliberately** - the profile is cluster-wide and version 1.1 exposes no database column, so claiming that attribution would be the same scope error v95 removed from three other tables. Absent module degrades to a named non-fatal skip, and the Extensions panel says whether installing it would help.
+- **Three PostgreSQL collectors could not say which database their rows described** ([#2599]) - found by two of our own collectors contradicting each other on a live Aurora target. `pg_table_bloat_stats`, which runs per database, reported a `pg_extension` row for `pgstattuple`; `pg_extension_availability`, which ran once per target, reported the same extension as merely `available`. Both reads were correct and they were looking at different databases, and neither row said which. **`pg_extension` is per-database while `pg_available_extensions` is cluster-wide**, so that collector was mixing two scopes in one `state` column - its own doc comment already spelled out why that is wrong, which is the part worth keeping: the documentation was right and the code did the thing it warned against. Schema **v95** adds `database_name` to `collect.pg_column_stats`, `collect.pg_index_bloat` and `collect.pg_extension_availability`, and `pg_extension_availability` now runs per database. **The reads were the user-visible half**: all three used `DISTINCT ON` over a key that did not include the database, so on any cluster carrying the same schema in two databases - the ordinary multi-tenant shape - the rows collapsed to one and the newest `collection_time` silently decided which database the grid was describing. The invariant is now pinned rather than remembered: a test walks the catalog and fails the build for any collector that returns true from `RunsPerDatabase` without declaring a `database_name` column. It found a third offender the investigation had missed - `pg_index_bloat`, added the same day.
+- **PostgreSQL index bloat, MEASURED rather than estimated** ([#2561]) - and the estimator this issue proposed is the one thing that could not work. #2561 suggested porting the ioguix btree estimator, which derives an expected page count from `pg_stats` column widths. Measured: **`pg_stats` returns ZERO rows to a `pg_monitor`-only login**, because the view filters on `has_column_privilege` - the same trap that produced #2542's 88.59% reported against a true 0.50%. Meanwhile `pgstatindex` **does** run for `pg_monitor`, because pgstattuple grants EXECUTE to `pg_stat_scan_tables` which `pg_monitor` includes. So under exactly the permission set this product runs as, the exact function works and the estimator is blind. Schema **v94** adds `collect.pg_index_bloat`, collected daily per-database on primaries, matching `pg_index_usage_stats` so a read can join the two halves of one question - is this index earning its keep, and is it wasting space doing it. **`avg_leaf_density` is stored RAW and never converted to a bloat percentage**: measured across seven freshly built indexes it sits between 89.98 and 91.48, and between 87.07 and 90.81 after `REINDEX`, so `100 - density` invents about ten points of bloat on a perfect index and there is no constant to subtract because the healthy value varies per index. The read derives a reclaimable-bytes estimate against a **90% floor** rather than a full page, and presents it beside the raw figure rather than replacing it. **Ranked by reclaimable BYTES, never by density** - a tiny index at 20% density looks alarming and is worth kilobytes next to a large one at 70%, verified against seeded data where a 10 GB index at 45% showed 5.37 GB reclaimable while a 64 KB index at 20% showed 50 KB. **Only b-tree indexes are candidates, and the filter is fenced with `OFFSET 0`**: `pgstatindex` RAISES on anything else - verified on GIN, BRIN and hash, all `relation "x" is not a btree index` - so one GIN index would otherwise fail the whole collection every cycle; the planner did filter first without the fence in testing, but correctness should not depend on plan shape when the failure is total. **Nothing is silently skipped**: the function reads every page, so indexes above a 20 GB ceiling are recorded with NULL measurements and a stated reason, and the read sorts them FIRST - unknown is not zero, and they are the likeliest big win. The function is qualified `public.pgstatindex`, not `pg_catalog.` - it is an extension function and lives where pgstattuple was created, and `pg_catalog.pgstatindex(oid)` does not resolve at all (verified).
+- **What is resident in PostgreSQL shared buffers, with the join every published example gets wrong** ([#2544]) - the last named slice. A hit ratio says how often the pool worked; this says what is IN it, which is what answers "does the working set fit" and "which relation is holding the memory something else needs". Schema **v93** adds `collect.pg_buffer_usage`, collected **hourly**. **Three correctness traps, all measured**: (1) `relfilenode` is NOT `oid` - the join every example writes, `pg_class.oid = pg_buffercache.relfilenode`, silently loses any table that has ever been rewritten, and after a single `VACUUM FULL` the naive join reported **0** buffers for a table holding **6,667**; `pg_relation_filenode(oid)` is the correct key. (2) Mapped catalogs carry `relfilenode = 0` in `pg_class` - measured on `pg_class` and `pg_proc` - so joining the raw column drops those too, which the same function fixes. (3) The pool is CLUSTER-wide while `pg_class` is per-database, the fourth catalog in this effort whose scope does not match its name, and worse here than elsewhere: a filenode from another database can collide with a local OID and resolve to a confidently WRONG name. So the relation name is resolved only when the buffer belongs to the connected database, and foreign buffers are KEPT with their database named and a NULL relation - dropping them would understate how full the pool is, which is the one number this exists to report. Pool totals ride on every row, taken from a window over the SAME scan rather than a second query or `pg_buffercache_summary()`, because sampling a moving pool twice puts the disagreement straight into the percentage a reader computes. **Hourly for two reasons that agree**: the full view is a scan of every buffer, measured at 6.1 ms for a 512 MB pool and scaling linearly to roughly 780 ms at 64 GB of `shared_buffers`; and a server WITHOUT the `pg_buffercache` extension records an `ObjectMissing` outcome each cycle, which at a minute grain would be thousands of rows a day of noise. That miss is now actionable rather than dead: `pg_extension_availability` (#2545) reports whether the extension is `available` and one `CREATE EXTENSION` away, so the collector that cannot run and the panel that says how to make it run are the same story.
+- **PostgreSQL replication connections, with the measure that actually catches a stalled standby** ([#2544]) - the replication slice, and distinct from `pg_replication_slots` in a way worth stating: a SLOT is a promise to retain WAL and exists whether or not anybody is attached (an abandoned one is the classic way to fill a disk with nothing connected to it), while this is the live CONNECTION. A server can have a slot with no standby, a standby with no slot, or both. Schema **v92** adds `collect.pg_replication_stats`, sampled per minute on every PostgreSQL target including standbys - a cascading replica's downstream is as worth watching as a primary's, and recovery state changes on failover without a dispatch gate noticing. **Both the byte distance and the time lag are stored, because the time lag understates a replay stall badly.** Measured twice against a real standby holding `pg_wal_replay_pause()`: 26 MB behind reporting 209 ms, and 33.7 MB behind reporting 2.8 seconds. The lag columns do move - they time the round trip of the most recently replayed record - but they scale with nothing a reader can act on, so the byte distance is the proportionate measure and the one to alert on. **Four distances rather than one, because they localise the fault**: in that same stalled run `sent`, `write` and `flush` were all ZERO and only `replay` was behind, so the WAL had been shipped, written and fsynced and the problem was purely apply - while `state` still read `streaming`. No single column would have shown that. Every distance is measured from `pg_current_wal_lsn()` rather than from `sent_lsn`, so a sender that has itself fallen behind is visible instead of being used as the baseline. The read returns the latest sample AND the window's WORST, because a replica that drifts hundreds of megabytes behind each afternoon and recovers by evening reads as perfectly healthy in every single sample - and is the one most likely to be useless when somebody needs to fail over to it. Standbys are identified by `(application_name, client_addr)` rather than name alone, since an unconfigured replica reports the default `walreceiver` and two of them would otherwise average into one row; the join uses `IS NOT DISTINCT FROM` because `client_addr` is NULL for a standby on a Unix socket and an equality join would silently drop it. A standby appearing in fewer samples than were taken has been DISCONNECTING, which every other column reports as healthy, so the sample count is surfaced too. Zero rows on a replica is CORRECT rather than a fault - `pg_stat_replication` is the primary-side view - and the panel says which of those it is looking at.
+- **PostgreSQL column statistics, with the customer data deliberately left behind** ([#2543]) - a plan says what the planner DID; these say why it thought that was reasonable, which for the commonest class of PostgreSQL problem (a bad row estimate producing a nested loop over millions of rows) is the difference between the symptom and the cause. Schema **v91** adds `collect.pg_column_stats`, collected daily per-database and retained a year. **The central decision is what is NOT collected.** `most_common_vals` and `histogram_bounds` hold raw column values - measured on a realistic table they came back as customer names, an identifier fragment and a list of live email addresses - so collecting them would copy customer data into the monitoring store under OUR retention, the same exposure as the `auto_explain` literals on #2538. Neither is collected, and a test asserts it against the SELECT list rather than the whole query so the comment explaining the exclusion cannot satisfy it. **Every finding the issue wanted survives without them**: `most_common_freqs[1]` is a frequency with no value attached, and skew is the entire parameter-sensitivity signal - measured, a status column whose top value covers 59.85% of the table is parameter-sensitive, and knowing WHICH value adds nothing to that conclusion. `most_common_vals` is touched only through `cardinality()`, which reads the array length and never its contents, so "how many values dominate" is answerable without copying any of them. `histogram_bounds` was also the largest column by bytes (3,251 against 228 and 140), so dropping both is cheaper as well as safer. Two traps handled: `n_distinct` is stored as a floating type and **never an integer count**, because negatives are a RATIO of row count and `-1` means distinct-is-about-every-row - an integer column would let a read print "-1 distinct values" for a unique key; and a **1 MB size floor** bounds what is the widest fan-out of any collector here (columns x tables x databases), since statistics on a tiny table cannot produce a misestimate anyone notices. **Zero rows has two causes and the panel says so**: `pg_stats` filters on `has_column_privilege`, so a monitoring login without SELECT sees nothing for a table that exists - measured, a `pg_monitor`-only role gets ZERO rows where a superuser gets all of them, and `pg_statistic` underneath is permission-denied outright, which is why Datadog ships a SECURITY DEFINER helper. Row-level security empties the view the same way. This slice deliberately installs no helper object; it collects what the granted role can see and reports the shortfall rather than presenting missing statistics as clean ones.
+- **PostgreSQL lock state by mode, type and relation** ([#2544]) - the locks slice, and it does NOT duplicate the blocking read even though it looks like it should. `PgBlockingCollector` reads `pg_blocking_pids()` and stores blocked/blocker PAIRS; it never touches `pg_locks`. So the product could say *pid 4821 is blocked by pid 3390* and could not say **what lock, in what mode, on which relation** - which is the half that decides what to do. Schema **v90** adds `collect.pg_lock_stats`, sampled per minute on every PostgreSQL target including standbys, where a lock conflicting with WAL replay stalls recovery with no primary-side equivalent. **The mode IS the remedy**, measured on a real pile-up: one `AccessExclusiveLock` granted on a relation with two `AccessShareLock` requests queued behind it is a DDL blocking every reader and the fix is to kill it or wait; the identical pair shape with `RowExclusiveLock` is ordinary write concurrency needing no action at all. It also sees waiters the pairs view structurally cannot - a lock waiting on a PREPARED TRANSACTION has no live backend for `pg_blocking_pids()` to name, so it is invisible there while being exactly the thing nobody can find. Aggregated by (database, locktype, mode, granted, relation) rather than one row per lock, so the row count is bounded by CONTENTION rather than by concurrency. **A third catalog whose scope does not match its name**: `pg_locks` is CLUSTER-WIDE while `pg_class` is PER-DATABASE, so a lock held in another database resolves to an OID with no name - measured, not assumed. Both columns are stored and the row is kept, because dropping it would hide real contention and naming it from the connected database catalog would name the wrong table; `relation_oid` is `bigint` and not `integer`, since OIDs are unsigned 32-bit and one past 2^31 lands negative in a signed int. Wait time comes from `pg_stat_activity.state_change` rather than `query_start` - a backend waiting on a lock has been in its current STATE since it began waiting, whereas `query_start` also covers the work it did beforehand and would overstate every wait - and is NULL on granted rows rather than 0, because 0 reads as "granted instantly", which is a measurement where NULL is the absence of one. The read carries the CAPTURE COUNT on every row: these are samples, so three ungranted rows means something different in 60 captures than in 4, and queue size is `max(backend_count)` and never a SUM - a queue of three persisting across forty captures is three backends, not a hundred and twenty.
+- **PostgreSQL extension availability: the third capability axis, and the only actionable one** ([#2545]) - engine KIND (#2536) and engine EDITION (#2511) both answer "this collector cannot run here, permanently". Extensions answer something better: "this read needs pg_stat_kcache, which this server offers and has not installed" is a **setup step**, not a wall. Schema **v89** adds `collect.pg_extension_availability`, collected daily on every PostgreSQL target and retained a year, because "when did this extension appear" is asked months later - usually right after a plan changed shape and nobody can say why. **Four states rather than a boolean**, since a boolean collapses the actionable one into the hopeless one: `installed`, `outdated` (a newer `default_version` exists - its own state because a stale extension can be missing columns a collector reads, which surfaces as a confusing 42703 rather than as a capability gap), `available` (one `CREATE EXTENSION` away), and `absent` (the server does not offer it, so an OS-level install or simply not on the menu). All four were exercised against a live PostgreSQL 17, including forcing `outdated` by installing pg_stat_statements at 1.10 against a default of 1.11 and watching `ALTER EXTENSION ... UPDATE` return it to `installed`. **The scope trap this is built around**: `pg_available_extensions` is CLUSTER-WIDE while `pg_extension` is PER-DATABASE, which their names do not say - measured on one cluster reporting pgstattuple installed in one database and not in another while the available list said yes in both. So `installed_version` is a claim about the connected database only, and the column header, the panel note and the migration comment all say so; reporting "not installed" from the maintenance database about an extension living in the application database is the obvious way to get this wrong. The row set is a **derived** half (a full outer join of both catalogs, so nothing is missed for not being on a list) plus a small **enumerated** roster that exists only because absence is not a row in any catalog and cannot be derived - a server's other extensions are still recorded, they simply carry no advice from us. **auto_explain and pg_wait_sampling are deliberately excluded and it is pinned**: both are preload-only modules that never appear in `pg_available_extensions` on ANY server including ones running them, so listing them would manufacture a permanent false `absent` - exactly the defect shipped in #2564 and fixed in #2584. They are detected instead by `shared_preload_libraries` plus their own GUCs, which `pg_plan_capture_readiness` already does. Unlike the column-statistics axis (#2543, where `pg_stats` returns **zero** rows to a `pg_monitor` role and needs a SECURITY DEFINER helper), both catalogs here read fine for `pg_monitor` - measured at 45 and 2 rows - so this ships without touching the monitored database.
+- **PostgreSQL write-side collection: checkpoints, background writer and WAL** ([#2544]) - the first slice of the second-tier gaps, and the one whose difficulty is entirely the VERSION SURFACE. Schema **v88** adds `collect.pg_write_stats`, collected per minute on any PostgreSQL 14+ target including standbys. One collector for three views because they are one story: a requested checkpoint climbing against timed ones means `max_wal_size` is too small, which shows as WAL volume in the same breath and as buffers the background writer could not clean - split across three collectors the numerator and denominator of every useful ratio would sit in different tables with different collection times. All three sources are cluster-wide singletons, so a snapshot is genuinely ONE row. **The version problem is worse than the 16-to-17 view split the issue described, and it was measured across four majors rather than read from release notes**: 17 took **seven of `pg_stat_bgwriter`'s eleven columns** - five renamed into the new `pg_stat_checkpointer`, and `buffers_backend`/`buffers_backend_fsync` to `pg_stat_io`, which is a different view with a different shape and **no successor here at all**, so a rename table alone would silently drop the two columns that say whether backends are writing their own buffers. Then **18 REMOVED four columns from `pg_stat_wal`** (`wal_write`, `wal_sync`, `wal_write_time`, `wal_sync_time`) while adding `num_done` and `slru_written` to the checkpointer - so a collector written and tested against 17 raises **42703 undefined column** on an 18 target and fails every cycle. The stored shape is therefore the **UNION and never varies**: a column a major does not supply is NULL, never zero and never absent, so one store can hold a 16 and an 18 target and mean the same thing in both rows. Every gate is a `>=` floor rather than an equality, pinned by asserting a major that does not exist yet behaves like the newest known one. Post-17 names are canonical (`num_timed`, not `checkpoints_timed`). `wal_bytes` is `numeric(38,0)` and not `bigint`, because upstream types it `numeric` precisely so cumulative WAL volume may exceed 2^63. **All three `stats_reset` stamps are stored**, since `pg_stat_reset_shared` takes a target and the families reset independently - and the read differences each family only while its OWN stamp held still, because a difference taken across a reset reports an enormous positive number that looks like a catastrophe and means nothing. Verified by running the shipped SQL against live PostgreSQL 16, 17 and 18: identical 26-column shape on all three, with the NULLs landing exactly where the major lacks the column. The read was verified against seeded data too, including a mid-window WAL reset where the WAL family blanks while the checkpointer and background-writer families still difference correctly, and a single-sample window that returns nothing rather than a confident zero.
+- **A captured PostgreSQL plan is an orphan without `%Q`, so plan-capture readiness now checks for it** ([#2538]) - measured on PostgreSQL 17 while working out which capture mechanism is viable, and it is the kind of thing that makes a feature look like it is working while producing nothing usable. `auto_explain` with `log_format=json` emits **no query identifier at all**, even with `compute_query_id=on` - the JSON body carries `Query Text` and `Plan` and nothing that identifies the statement. The id appears in exactly one place, the **log line prefix**, and with `%Q` present it equals `pg_stat_statements.queryid` exactly (verified both sides: `-4828029293864693941`). So without `%Q` every captured plan is an orphan that cannot be joined to the statement it came from, and nothing about the configuration looks wrong. The new `plan_attribution` facet reports it, and needs **no migration** - the row-per-facet shape means a new precondition is a query change, not a schema change. Matched with `strpos` rather than `LIKE`, because `LIKE '%%Q%'` parses as wildcard-wildcard-Q-wildcard and reports success on any value containing the letter Q; and case-sensitively, because `%q` and `%Q` are **different** prefix escapes - `%q` truncates the prefix in non-session processes and is common in real configurations. Both wrong answers were reproduced against a live server before the right one was: a prefix of `%m [%p] %q%u@%d QUEUE ` correctly reports unsatisfied.
+- **The web session cookie can now carry WHO is holding it, and the signature covers it** ([#2550]) - the prerequisite for per-user sign-in, landed on its own because it is the part that is provable without an identity provider. The session cookie minted after the token exchange was `{expiryUnix}.{base64url(HMAC)}` - an expiry and nothing else - so even with OIDC standing in front of the dashboard, the subject established at sign-in was discarded at the redirect and every request after it arrived anonymous again. That is why `updated_by` on a web-authored custom view is the constant `web`: not an oversight in the write path, but the plain fact that nothing in the request carried a name. The cookie now has a second shape, `{expiryUnix}.{base64url(subject)}.{base64url(HMAC)}`, and **the HMAC covers the whole prefix before the final dot** rather than the expiry alone. That detail is the entire security content of the change: signing only the expiry and parking the subject beside it would leave identity unauthenticated, so any holder of a valid cookie could rewrite the subject and be served as anybody - a strictly worse position than the shared token this exists to improve on, because it would LOOK like identity while providing none. Pinned both directions: a rewritten subject fails, and a signature lifted from another principal's genuine unexpired cookie fails on this one. The two shapes cannot be confused for each other even though they share a separator, because the signature is always the last segment and the signed payload always everything before it - re-presenting a 3-segment cookie as a 2-segment one would need the subject to be a valid HMAC over the expiry, and the reverse needs the key. A smuggled fourth segment is refused outright rather than parsed, so two distinct cookies can never resolve to one subject. The shared-token seat reports **null, not empty string** - "the shared token did this" and "somebody signed in whose name we could not read" are different facts and a provenance stamp has to keep them apart - and an over-long subject is **refused rather than truncated**, because two subjects sharing a prefix collapsing into one seat would attribute one person's writes to another, which is worse than a failed sign-in. The subjectless form is byte-for-byte what was minted before, so cookies already in browsers keep validating instead of signing everyone out on upgrade. No producer sets a subject yet; that arrives with the OIDC wiring in [#2550].
+- **PostgreSQL targets now say whether plan capture is even possible** ([#2564]) - the shippable first slice of #2538, and the failure it fixes is SILENCE. A PostgreSQL target has no execution plans today, and somebody evaluating this against DBM on Aurora cannot tell whether the product does not support plans, their server is not configured for them, or something is broken - three different actions, and we said nothing. Schema **v87** adds `collect.pg_plan_capture_readiness`, collected hourly on every PostgreSQL target including standbys (a replica can carry a different parameter group from its writer, and gating to writers would hide exactly that). It does not capture a plan; it records whether capture is POSSIBLE, as **one row per facet** rather than a wide row - because each facet has a different remedy and collapsing them produces the single "plans unavailable" that tells nobody what to do. `library_loaded` is a custom CLUSTER parameter group plus a writer reboot on Aurora/RDS, not a `SET`, which is the misconception worth heading off. `capture_threshold` is the trap the issue was filed about: `auto_explain.log_min_duration = -1` means loaded and capturing **nothing**, indistinguishable from not-loaded from the outside but a completely different fix - so `-1`, an absent setting, and a real threshold are three distinct states with three distinct remedies. `extension_available` separates "could this server ever" from "is it doing it", which is the difference between a configuration task and a platform limitation. Two things came out of running the query against a live PostgreSQL 17 rather than reasoning about it: the remedy text had to become CONDITIONAL, because one fixed sentence per facet meant a store with the threshold at 0 reported "log_min_duration = -1 means capturing NOTHING" beside `is_satisfied = true` and contradicted itself; and `log_min_duration = 250` comes back as **`250ms`**, the GUC carrying its unit, which is why `observed` is stored as the server's own text and never cast - the `track_activity_query_size` defect this codebase has already paid for once. It deliberately does NOT claim plans are being captured: the library being loaded, capture being configured, and the plans being readable by us are three separate facts, and the last is #2566/#2567's.
+- **HTTPS for the web dashboard, so the access token stops crossing the LAN in the clear** ([#2562]) - `grep -c UseHttps` across `Darling/` returned **0**: the dashboard was plain HTTP in every mode, which meant the whole token-to-cookie exchange - constant-time compare, HMAC-signed HttpOnly `SameSite=Strict` cookie, CIDR allowlist, Host-header anti-rebind, all of it carefully built - rode an unencrypted transport the moment the listener left loopback. An on-path attacker on the segment could lift the `?token=` or the cookie and get the entire collected store plus the Custom Views write path. A new **opt-in** `web.network.tls` block takes either a PKCS#12 bundle (`pfxPath`, with its password in `encryptedPfxPassword` (DPAPI), in `pfxPassword` as a `file:`/`env:` reference, or absent for an unprotected bundle) or a PEM pair (`certPath` + `keyPath`, which is what the compose distribution mounts) - **one form or the other, never both**, which is refused rather than resolved by precedence because silently serving one certificate while the operator watches the other expire looks exactly like working right up until it does not. TLS applies to the **network listener only**; the loopback listeners stay plain HTTP on purpose, since the certificate names the LAN address rather than `localhost` and loopback traffic never reaches the segment being protected. That also settles redirect-versus-refuse by mechanism: one port cannot speak both schemes, so a plain-HTTP client fails the handshake, and adding a second HTTP port to redirect from would reopen the cleartext surface the feature exists to close. The session cookie's `Secure` flag became **per-request** (`context.Request.IsHttps`) for the same reason - one host now serves both schemes, so a hardcoded `true` would mint a cookie the loopback browser refuses to send back and loop its login forever, while a hardcoded `false` would let a cookie minted over TLS ride an `http://` downgrade. Loading **fails closed** exactly as an undecryptable token does (Critical, then loopback-only) for a certificate that is missing, unreadable, ambiguous, not yet valid, or **expired** - never a silent downgrade to serving the LAN over HTTP - and because an expired certificate takes the dashboard down, the service warns for the **30 days** before that happens and logs subject, thumbprint and expiry at every start. Both loaders carry the **intermediate chain**, which is not incidental: `CreateFromPemFile` materializes only the FIRST certificate in a PEM and `LoadPkcs12FromFile` only one certificate of a bundle, so a leaf-plus-intermediate file - exactly what the config documents and what an internal CA hands you - loaded as a bare leaf and Kestrel served an incomplete chain, failing the handshake on any client that had not independently cached the intermediate. Measured with `openssl s_client -showcerts` before and after: a PEM holding two certificates put **one** on the wire, and now puts two. A self-issued root in the bundle is deliberately dropped rather than sent - a client that does not trust it is not persuaded by receiving it - and the PKCS#12 leaf is found by which certificate holds the private key, because bundle ordering is not a contract. Two things worth knowing that only turned up by running the real thing: the private key needs `MachineKeySet` on Windows (an ephemeral key loads fine and then fails every handshake, because Windows cannot use one for TLS server authentication) while macOS refuses `EphemeralKeySet` outright, and a certificate built straight from PEM carries an ephemeral key that has to be round-tripped through an in-memory PKCS#12 before Kestrel can serve it. The certificate must also carry an **iPAddress SAN** for the listen IP, because the anti-DNS-rebind Host allowlist accepts only `localhost`, a loopback literal, or that exact IP - so the DNS-name certificate an internal CA issues by default can never match, and the service now says so at startup instead of leaving it to a browser warning. `--configure-network`'s exposure summary reports the TLS state alongside listen/allowFrom. MCP is deliberately unchanged: its no-TLS rationale is that a self-signed certificate breaks real MCP clients, which is an argument about MCP clients rather than about the wire, and does not transfer to a surface whose only client is a browser. Prerequisite for [#2550] (OIDC), which most identity providers will not do over a non-HTTPS `redirect_uri`.
+- **The sessions behind a pinned xmin horizon, and the ones that only look like it** ([#2540]) - `pg_xmin_horizon` already named the CLASS of thing holding the horizon back, and when that class is `session` it emitted exactly one row: a pid and a formatted detail string. Enough to know a session is the cause, not enough to do anything about it - so the product could show vacuum falling behind, show the bloat that produced, show where it ends, and give no way to see what started it. Schema **v86** adds `collect.pg_session_states`, sampled every minute on any PostgreSQL target including standbys, and `get_pg_session_states` reads it rolled up per BACKEND rather than per sample - a session parked idle in transaction for two hours is in a hundred and twenty captures, and a raw read would report one problem a hundred and twenty times while pushing everything else under the row limit. The grouping key is the collector's synthetic `(backend_start, pid)` identity, the same construction `pg_blocking_edges` uses, so a pid reused by a second backend inside the window is two findings and not one merged average - and so the same backend can be followed across both collectors.
+- **`idle in transaction` is not automatically starving your vacuum, and this is the finding the read exists to make** ([#2540]) - the obvious version of this feature reports a state string and a duration, and the obvious version is wrong about half the sessions it flags. Four idle-in-transaction shapes were created on a live PostgreSQL 16.15 instance and **two of them pin nothing at all**: a READ COMMITTED transaction that has only read reports `backend_xmin` NULL because it released its snapshot when the statement ended, and one whose `UPDATE` matched **zero rows** reports both columns NULL because no transaction id was ever assigned. Only the transaction that actually wrote (holding `backend_xid`) and the REPEATABLE READ one (holding `backend_xmin`) pin the horizon, and neither column alone sees both cases. So `horizon_age` is stamped at collection from the GREATER of the two ages, and is **`-1` rather than `0`** when the session holds neither - 0 would read as "holding the newest possible xid", which is the opposite finding. The tool gates its causal claim on that column and not on the clock: a session that pinned nothing is told so in those words and explicitly refused the vacuum argument, because terminating it reclaims not one dead row. It is not waved away either - at five minutes it gets a different argument on its own terms, since a transaction idle that long is holding a connection and its locks whatever it is doing to the horizon.
+- **A held horizon is a share of samples, not a flag** ([#2540]) - every write transaction is momentarily the oldest holder on the instance, because that is what a transaction IS, so a boolean "was the holder" would fire on ordinary traffic and be ignored within a day. `horizon_holder_samples` is a count against `sample_count`, and the read only calls a session a sustained holder past half the samples that saw it - reporting both raw numbers beside the verdict so a caller can disagree with the threshold rather than having to trust it. One sighting in a hundred and ninety-eight in a hundred are opposite findings and the stored data can tell them apart. The oldest holder is also force-included in every capture regardless of the duration floor, and is chosen over the FULL activity set rather than the reportable subset: a young transaction can be the oldest holder, and crowning a filtered survivor would name the wrong session precisely when the real one fell below the floor.
+- **No statement text is stored, and that is a decision rather than an omission** ([#2540]) - `pg_stat_activity.query` is the statement as submitted, literal parameter values inline; a probe session on the live rig came back carrying its literal argument verbatim. `pg_blocking_edges` stores that text because blocking is an exceptional condition where the rows are rare and the text is the finding. This table fills on a DURATION FLOOR that a perfectly ordinary application can cross, so the same column would mean routinely accumulating user data in the store to answer a question that does not need it. What it stores instead gives up nothing: **`query_id`**, PostgreSQL's own normalised statement fingerprint, which joins to `pg_statement_stats` whose text is already normalised to `$1` placeholders - full actionability, zero literals - plus **`command_tag`**, the leading SQL keyword matched against a closed whitelist. A whitelist and not a substring, because plenty of ORMs prepend a comment block, so the first token of a real statement can be a comment opener followed by anything the application chose to put in it and a leading-N-characters rule would carry a `WHERE` clause's literals straight out.
+- **The permission that gutted this feature silently, caught before it shipped** ([#2540]) - the same check [#2542] made for `pg_stats`, and the answer is worse here because there is no error. Measured against a least-privileged role on a live target: without `pg_monitor` PostgreSQL does **not** refuse the read - for every backend the login does not own it returns the row with all but SIX columns NULL. Measured column by column on PostgreSQL 16.15 rather than enumerated from memory: what survives is `pid`, `application_name`, `datname`, `usename`, `backend_xid` and `backend_xmin`, while `state`, `state_change`, `xact_start`, `query_start`, `backend_start`, `wait_event_type`, `wait_event`, `backend_type`, `client_addr`, `leader_pid` and `query_id` all come back NULL and the query text is replaced by an insufficient-privilege literal - **so the two that survive are exactly `backend_xmin` and `backend_xid`**. So the horizon still reads as pinned and every column that could explain it is gone. On one capture over the same nine backends the privileged role saw four sessions idle in transaction and the unprivileged one saw **zero**, because `is_idle_in_transaction` is derived from a state that is NULL. `state_is_redacted` is therefore stamped per row from the privilege literal and **not** from `state IS NULL` - background workers legitimately report a NULL state under full privilege (`checkpointer`, `walwriter` and the autovacuum launcher all do, measured), so NULL alone cannot tell a permission problem from a background process. Redacted rows outrank every severity with a band of their own rather than being painted healthy or critical, the same treatment `estimate_unavailable` gets on the bloat surface. Unlike that one, plain `pg_monitor` is enough here - no `pg_read_all_data` needed.
+- **The denominator, and a defect only a live run could find** ([#2540]) - `pg_stat_activity` is a point-in-time view and PostgreSQL records nothing about session state unless something asks, so a transaction that opened and closed between two samples is genuinely invisible - which the read says out loud rather than letting "no long transactions" be read as "none happened". Capture counts come from `collection_log` and not from the data, because this is an exception surface like `pg_blocking_edges` where zero rows is the HEALTHY answer and probing the table would report a well-monitored server as uncollected ([#2508]). Every capture also carries its own session counts and its pre-limit reportable count, so two idle-in-transaction sessions out of six connections is distinguishable from two out of four thousand, and a capture truncated by the row cap is self-evident. The defect: the first draft of the collector query contained a literal SQL comment opener **inside** a comment. PostgreSQL block comments **nest**, so it opened a nested comment that never closed and the entire collection failed to parse - every cycle, on every target. No text assertion could see it and nothing about reading the C# reveals it; running the shipped string against a live instance is what caught it, and a nesting-depth test now pins it. Version floors were measured the same way: `pg_attribute` read for the view on a live PostgreSQL **13.23** instance shows 21 columns including `leader_pid`, `backend_type`, `backend_xid` and `backend_xmin`, and **no `query_id`** - so that one column is substituted with a typed NULL below 14 rather than gating the collector off, and the PG13 form was executed against that instance.
+- **PostgreSQL index usage, with the half that decides whether an index can actually go** ([#2541]) - nothing collected `pg_stat_user_indexes`, so SQL Server users had `get_index_usage` and PostgreSQL users had nothing. An unused index costs MORE here than on SQL Server: it costs the same write throughput and space **plus** it participates in every `VACUUM`, and index cleanup is a large share of vacuum work - so a dead index on a hot table slows the maintenance that [#2530]'s vacuum surface exists to watch. Schema **v84** adds `collect.pg_index_usage_stats`, collected daily per database on writers only, and `get_pg_index_usage` reads it. The scan counts are the easy half. The half that makes it shippable is that **"unused" is not "droppable"**, and the tool refuses to say otherwise: every row carries whether the index backs a primary key, a unique or exclusion constraint, or the table's replica identity, whether it is partial, an expression index, or INVALID, and its full `pg_get_indexdef` text - because the single most common zero-scan index on any schema is a unique index enforcing a constraint, and enforcing uniqueness is not a scan of the kind `idx_scan` counts. Advice derived from the counter alone tells somebody to drop their primary key. It also refuses to call anything unused on a window it has fewer than two samples of, since PostgreSQL records no index creation time anywhere and how long we have been watching is the only evidence an index is old enough to judge.
+- **The scan count only a monitoring store can produce** ([#2541]) - `pg_stat_user_indexes.idx_scan` is cumulative since the statistics were last reset, which is the only figure a live query can give: an index with nine million lifetime scans and none in the last ninety days reads as heavily used. The read reports **both** - the lifetime counter, so it cannot appear to disagree with psql, and `scans_in_window` differenced across the stored samples, which is the number that decides anything. That difference is clamped at zero and the clamp is not decoration: measured against a live store, a fixture whose counter runs 9000 -> 50 -> 100 across a statistics reset returns **-8900** unclamped, and a negative scan count sorts straight to the top of a list ranked least-used-first. The clamp alone would then hide the reset, so `stats_were_reset_in_window` travels beside it, taken from the server's own `stats_reset` timestamp with `IS DISTINCT FROM` rather than `<>` - `stats_reset` is NULL until a database's first-ever reset, so `<>` evaluates to NULL there and misses precisely the reset the counter arithmetic cannot see either. Retention is **90 days** because the retention window IS the evidence: an index can only be called unused for as long as we have been watching it, and 30 days cannot clear a monthly report. It still cannot clear an annual one, which the read says rather than glossing.
+- **PostgreSQL table bloat: the damage, next to the cause chain we already collected** ([#2542]) - `pg_autovacuum_stats`, `pg_wraparound_stats` and `pg_xmin_horizon` between them collected the entire CAUSE chain for bloat and nothing that measured what it cost, so a user could watch vacuum fall behind and had no way to see the damage - which is the question they ask immediately afterwards. Schema **v85** adds `collect.pg_table_bloat_stats`, collected hourly per database on writers only, and `get_pg_table_bloat` reads it. **Hourly matches `pg_autovacuum_stats` deliberately rather than by copying**: this measures the damage whose cause that one measures, and "vacuum fell behind at 14:00 and bloat grew" is only a sentence the data can support if both are sampled on the same grain.
+- **The bloat measurement decision, settled by measurement rather than by reasoning** ([#2542]) - three options, and the cheap one wins on a margin that was measured, not assumed. `pgstattuple` is exact and reads the **whole relation**: 11.8 ms warm on a 54 MB fully-cached table, i.e. roughly 4.6 GB/s in the best case, so a 1 TB table costs minutes per table per cycle even entirely in memory - and it is an extension, absent unless somebody installed it. `pgstattuple_approx` measured 0.58 ms on the same table, but that is its BEST case and not a representative one: the pages it must still read are exactly the recently-written ones, so on the churning table you actually care about it degrades toward the full scan, and it shares the availability problem without solving the cost one. The **statistics-based estimate** ships: it reads `pg_class` and `pg_stats` only, never touches the relation, and so costs by table COUNT rather than by table SIZE - **44 ms for a 2,001-table / 854 MB database against 860 ms for `pgstattuple` over the same tables**, and that 19x is a floor on the ratio because those tables were tiny and fully cached. Erik's inclination in the issue was the estimate on cadence with `pgstattuple` available on demand for one table; the on-demand half is **not implementable as a tool**, because the MCP surface is strictly read-only over collected rows and has no ad-hoc path back to a monitored server. So each row instead records whether `pgstattuple` is installed on that database and ships the exact command that would settle it, which is the reachable form of the same idea.
+- **The bloat estimate is suppressed, not captioned, when its inputs cannot be trusted** ([#2542]) - a bloat number that is quietly wrong gets used to justify a `VACUUM FULL` on a production table, so how wrong it can be was measured rather than hedged. With current statistics the estimate was within about **2 percentage points** of `pgstattuple` on every table tested (churn-bloated 75.14 vs 74.82, delete-bloated 60.73 vs 59.52, clean 0.58 vs 0.50). With **stale** statistics it was wrong by **81 percentage points**: two byte-identical 8,998-page tables with a true bloat of 10.93% estimated 92.64% and 11.01%, differing only in whether `ANALYZE` had run after a widening UPDATE, with nothing in the arithmetic to show it. The stale table had modified 200% of its rows since its last analyze and the fresh one 0%, so `mods_since_analyze` is carried as the trust signal - and it is a sound proxy rather than a coincidence, because width statistics can only go stale THROUGH modifications. Above a fifth of the table modified since the last analyze, the number is withheld and the row says why; the threshold is set low deliberately, because the failure is asymmetric - a needlessly cautious estimate costs a second look, an over-trusted one costs a table rewrite. Every field carrying the figure is named `_estimate` in the store as well as in the read, so the qualifier cannot be lost between the two.
+- **The permissions trap that would have shipped a tool telling everyone to VACUUM FULL everything** ([#2542]) - `pg_stats` is filtered by `has_column_privilege(..., 'select')`, and **`pg_monitor` - the one grant the PostgreSQL runbook says Darling needs - does not confer SELECT on user tables**. Measured against a `pg_monitor`-only role on a live PostgreSQL 16 target: **zero** `pg_stats` rows visible, and the estimator did not fail. It returned a confident **88.59%** for a table whose true bloat is 0.50%, **95.03%** for one that is really 74.82%, and **22.57%** for one that is really 0.46% - every row an argument for rewriting a production table. `estimate_unavailable` is therefore load-bearing rather than advisory: it is TRUE in exactly that state and the read suppresses the number outright, because a footnote under a large red percentage is not a safeguard. Adding `pg_read_all_data` (PostgreSQL 14+; the role does not exist on 13, where explicit `GRANT SELECT` is the only route - both verified) restored byte-identical agreement with the superuser's numbers, and the runbook now carries that grant with the reasoning. What survives the suppression is the **dead-tuple fraction**, computed from the server's own counters with no width model and no SELECT needed - narrower than bloat, since it counts dead tuples and not the free space a completed vacuum left behind, but measured rather than inferred, so a permissions gap degrades the answer instead of removing it.
+- **A Storage tab on both front ends, and neither read is MCP-only** ([#2541], [#2542]) - the two new reads land together on a new **Storage** tab in the web dashboard and in the WPF viewer, with the same id and the same grouping, because both answer one question: where the space went and whether it is earning its keep. Deliberately NOT on the Vacuum tab despite bloat being what vacuum lag costs - that tab is the cause chain read in causal order, and dropping the damage into the middle of it breaks the sequence that makes those three panels one story; the bloat panel points back at it instead. Both ratchets that would have caught an MCP-only ship were green before the panels existed and are green after: the web pin derives its "must have a home" set from the read dispatch, the viewer pin derives its own from `CollectorCatalog`. The viewer grid renders a suppressed estimate as a **dash** rather than a number, re-deriving the suppression rule rather than trusting a flag, so the two surfaces cannot disagree about whether a figure is publishable.
+- **Version floors for both collectors, measured against six live majors rather than read from documentation** ([#2541], [#2542]) - every column was checked by listing the actual catalogs on PostgreSQL **13, 14, 15, 16, 17 and 18**, and both shipped queries were then executed on all six. Exactly one floor exists: **`last_idx_scan` is PostgreSQL 16+**, proven by running the gated-on form against 13/14/15 and getting `ERROR: column i.last_idx_scan does not exist` - which fails the whole collection every cycle, not just that column. It is substituted with a typed NULL rather than omitted, so the row shape does not change across a mixed-version fleet, and NULL is the honest value because a PostgreSQL 15 server genuinely does not know when an index was last scanned. Everything else - `pg_stat_user_indexes`, `pg_statio_user_indexes`, `pg_index`'s droppability columns, `pg_constraint.conindid`, `pg_stats`, `pg_class.reltuples`/`relpages` - was confirmed present on all six, so that is stated as checked rather than assumed, and the bloat query carries **no version gate at all** because it produced identical output on every major. The MAXALIGN detection the estimate depends on names `aarch64` and `arm64` explicitly (Graviton RDS and Aurora are aarch64) and stores the value it actually used, so a platform where the detection is wrong shows up in the data instead of skewing every row silently.
+- **Both new collectors are fan-outs, and the cost of that was budgeted rather than discovered** ([#2541], [#2542]) - `pg_stat_user_indexes` and `pg_stats` are both scoped to the connected database and PostgreSQL has no cross-database read, so these are the second and third per-database fan-outs after `pg_autovacuum_stats` - and on PostgreSQL a fan-out means one **connection** per database per cycle. Index usage runs **daily**, inherited from `index_object_stats` (the SQL Server collector answering the same question) rather than from the PostgreSQL sibling: "has anything scanned this index" is a structural question, and an hourly sample would record the same catalog facts 24 times a day at 24x the connections. Both carry a size floor as the cost control - 64 KB for an index, 1 MB for a table, below which there is nothing to model, since the whole argument for dropping an unused index is the write, space and vacuum cost it carries and all three scale with its size. Measured on a 2,001-table / 6,005-index database, the floors removed 2,000 tables and 2,001 indexes. An INVALID index is kept regardless of size, and that escape hatch was verified live: a 16 KB invalid index came through a 64 KB floor.
+- **A fourth kind of nothing: `precondition`, for a setup step somebody can actually change** ([#2546]) - the miss vocabulary had three words and none of them fitted a Query Store that is switched off, a capture session that is not running, a PostgreSQL extension that was never created, or a login refused msdb. `empty` claims we looked and there was nothing; `unavailable` sends the reader to collection health, where they find a collector that is running and doing its best; `not_collected` is the PERMANENT answer and tells them to stop looking. The store already held every one of these facts - the runners classify a denied grant, a missing source object and a disabled feature out of the general ERROR bucket and write the remedy into `collection_log.error_message`, and the hourly `query_store_health` collector records `actual_state` per database - and **no read reported any of it**. Two Query Store reads were literally guessing in prose ("Query Store may not be enabled on target databases"), which is equally true of a server where it is on and the window simply reached past retention. Now the read states it, names the databases, and carries the remedy for the state they are actually in - OFF, READ_ONLY and ERROR each get a different one, because telling an operator to "turn it on" about a database whose Query Store is already on and broken is an ALTER that silently no-ops - or quotes what the server itself said, including the `CREATE EXTENSION` a 42P01 already stores. Crucially it is evaluated at READ time, not at gate time: an `AppliesTo` gate is decided once when the connection is made and would go on reporting a precondition after somebody satisfied it, which is the worst possible direction to be wrong in. Wired on five reads per SKU (`get_deadlocks`, `get_blocked_process_xml`, `get_running_jobs`, `get_long_query_completions`, `get_query_store_top`) plus Darling's `get_pg_top_queries`, always AFTER the engine-capability answer so a permanent gap still wins - an order a `??` chain cannot enforce on its own, so a source scan pins it across both trees.
+- **The WPF viewer renders PostgreSQL tabs at a PostgreSQL target, and stops rendering nineteen empty SQL Server ones** ([#2530]) - `git grep IsPostgres` across `PerformanceMonitor.Darling.Viewer` returned **nothing**, so an Aurora target opened on the SQL Server correlated-lane Overview and got the whole nineteen-tab strip - tempdb, Trace Flags, Query Store, Plan Viewer, Always On - nearly all of it permanently empty and several tabs meaningless for the engine. It now gets **six**: Overview, Activity, Vacuum, Waits, I/O and Replication, the same six the web dashboard chose in [#2547], with the same ids and the same grouping so the two front ends do not teach one engine two shapes. All **nine** PostgreSQL collectors land on exactly one tab, and that placement is DERIVED from `CollectorCatalog` in both directions - a tenth collector turns the pin red naming itself, which is the check that did not exist while eight of them shipped MCP-only for three releases. The `ViewerCollectorCoverageTests` allow-list, which carried all nine as tracked debt, is now **empty**.
+- **The two Aurora-only PostgreSQL panels are SHOWN on stock PostgreSQL, not hidden** ([#2530]) - `pg_wait_stats` and `pg_statement_stats` both gate on `IsAurora`, so on a stock target they can never have content. Each panel prints `CollectorEngineCapability.NotCollectedMessage` instead - the same sentence the MCP surface and the web dashboard print, naming the server, the engine, the collector and the exact `aurora_stat_*` surface, ending "and never will". The defect being closed is UNEXPLAINED emptiness, not emptiness; hiding them would also make the tab strip a different shape on two PostgreSQL servers in one fleet. The Overview tab is built from the CATALOG rather than from `collection_log` for the same reason: a gated-off collector writes no log row at all, so a log-driven grid would have dropped precisely the row an operator most needs explained.
+- **One copy of the PostgreSQL read SQL, shared by the MCP surface and the desktop viewer** ([#2530]) - the nine `DarlingPg*Reader` classes moved from the service's MCP folder into `PerformanceMonitor.Darling.Storage`, which both front ends reference. Copying them into the viewer would have meant a second copy of, among others, a 200-line recursive blocking walk whose revisit guard, root attribution and truncation flag were each a separate review finding - and the copy that drifts is never the one being read. No query text changed; every existing reader test still pins the same constants.
+- **The viewer's server rows carry the engine discriminator, from BOTH server reads** ([#2530]) - `DarlingServer` gained `EngineKind`, `EngineEdition`, `IsPostgres` and `EngineDescription`, and both `ServersSql` and `ManagedServersSql` select `engine_kind` and `sql_engine_edition`. Both, because the sidebar uses `ManagedServersSql` on any seeded store - i.e. every real deployment - so a column added to only the other one would have left every PostgreSQL target on the SQL Server tab set while a unit test passed. Only a POSITIVE claim switches: a null or unrecognised token keeps the SQL Server tabs and shows NO engine badge, because the tabs such a server gets are a default rather than a finding.
+- **PostgreSQL temp-file spills, cache hit ratio, deadlocks and the rollback ratio - one collector, one read** ([#2539]) - nothing touched `pg_stat_database`, so four separate questions had no answer on a PostgreSQL target. The one that mattered: **temp files are work `work_mem` could not hold**, which is the most common reason a PostgreSQL query is slow for a cause its plan shape does not show - and a spilling query looks identical to an expensive one in the statement statistics. `pg_statement_stats` does carry per-query `temp_blks_written`, but it reads `aurora_stat_statements()` and is **Aurora only**, so on stock PostgreSQL there was no temp-file evidence anywhere; on Aurora it is a top-N over statements `pg_stat_statements` still retains, and this is the ground truth those are a share of. Schema **v83** adds `collect.pg_database_stats`, the new `pg_database_stats` collector reads the view per minute, and `get_pg_database_stats` differences it across a window. Every major and every target shape: the newest column selected arrived in PostgreSQL 9.2 (verified live on 13, 16 and 17), it is core rather than Aurora, and a standby is included deliberately because a sort on a read replica spills exactly the way it does on a writer.
+- **A PostgreSQL statistics reset is reported AS a reset** ([#2539]) - `pg_stat_database`'s counters are cumulative since the last `pg_stat_reset()`, so a reset silently zeroes them, and the two obvious ways to difference them are both wrong: last-minus-first goes **negative**, max-minus-min **spikes** to the pre-reset lifetime total. Both look like measurements. The read clamps per interval so neither can happen, and then says so: `stats_reset_count` from the server's own per-database `stats_reset` moving, `counter_rewind_count` from a counter falling below its predecessor, and a per-database note that the window totals are LOWER BOUNDS. Both signals are carried because neither sees every reset - a reset followed by enough activity to climb back past the old value inside one collection interval leaves every difference positive, and only the timestamp knows. The sharpest case is a database's FIRST-EVER reset, where `stats_reset` moves NULL -> timestamp: the first sample of a series is excluded by its ROW_NUMBER rather than by its `LAG` being NULL, because `LAG(stats_reset)` is NULL both when there is no previous row AND when the previous row had never been reset - the ordinary state - so a guard on it would miss the one reset nothing else can see. Rows are stored **per database** for the same reason: `stats_reset` is per database, so an aggregate would have to pick one timestamp for rows that legitimately disagree, and a single database's reset would corrupt the cluster's delta with nothing left in the data to say so.
+- **The web dashboard renders PostgreSQL tabs at a PostgreSQL target, and stops rendering twelve empty SQL Server ones** ([#2530]) - the per-server page's `SERVER_TABS` was a FLAT list applied to every server, so an Aurora instance got Wait Stats, CPU, Memory, Blocking, File I/O, Queries, Configuration, Config Changes, tempdb and the rest - nearly all permanently empty, several meaningless for the engine - while the eight `get_pg_*` reads that DO have data were reachable only through MCP. The registry is now two registries, and `serverTabsFor(card)` chooses from the fleet card's server-derived `is_postgres`. A PostgreSQL target gets **six** tabs: **Overview** (freeze headroom, xmin horizon, autovacuum backlog and replication-slot vitals over findings and collection health), **Activity** (`get_pg_blocking` with its capture denominator, top query shapes, and [#2539]'s per-database temp-file spills directly beneath them, because a spill is the explanation a statement's own stats cannot give), **Vacuum**, **Waits**, **I/O** and **Replication**. Six against twelve is the design and not a shortfall: parity was explicitly not the constraint, because bloat, wraparound, the xmin horizon and autovacuum have no SQL Server analogue and are what actually pages a PostgreSQL DBA. **Vacuum is one tab on purpose** - an old xmin horizon starves vacuum, starved vacuum falls behind on freezing, freezing falling behind ends in wraparound; read separately each looks survivable and together they are one escalating story, so the three reads sit in that causal order under a note that says why. **Waits is SHOWN on stock PostgreSQL** even though `aurora_stat_system_waits()` means it can never fill there: [#2532] taught that read to answer `not_collected` naming the server, the engine, the collector and the exact Aurora surface and to say the gap is permanent, so the panel explains itself - and hiding it would have made the tab set change shape between two PostgreSQL servers in one fleet and made the one Aurora-only capability invisible from a stock instance. A **NULL** `engine_kind` keeps the SQL Server set, unchanged: only a positive PostgreSQL claim switches, because absence is not evidence for either engine. The bar and the grid wait for the ONE `/api/fleet` read the page already made rather than flashing the wrong twelve tabs and correcting them. The card also gained `engine_description` (the engine's name in words, from `MonitoredEngineKind`), so the header can badge the engine without any surface owning a second copy of the vocabulary - and `vizStat` gained the `emptyText` guard `vizLine` already had, because several PostgreSQL reads answer their HEALTHY case with a data body carrying prose and none of the summary keys, which used to render as a row of em-dashes.
+- **The store records what ENGINE a monitored target is** ([#2530]) - `collect.servers` recorded `sql_engine_edition` and `sql_major_version` and nothing that said a target was PostgreSQL. `SERVERPROPERTY` does not exist on PostgreSQL, so a PostgreSQL target landed at engine edition **0** - byte-identical to a SQL Server that has never completed a connect. Two different facts, one representation, and no reader could tell them apart. Schema **v82** adds a nullable `collect.servers.engine_kind` carrying `sqlserver`, `postgres` or `aurora-postgres`; a token rather than an `is_postgres` boolean because Aurora versus stock PostgreSQL is already a fact the collectors gate on (the `aurora_stat_*` surface has no equivalent in any core PostgreSQL version) and a boolean cannot grow a third value. Nullable with no default and no backfill: a row written before the rung genuinely does not know, and defaulting to `sqlserver` would have every PostgreSQL target assert it was SQL Server until its next connect. The registry upsert stamps it on every connect, so it populates itself within one collection cycle. The `/api/fleet` card and `get_fleet_overview` now carry `engine_kind` plus the derived `is_postgres` / `is_aurora`, which `docs/uat-onboarding.md` §3.4 named as the blocker for any PostgreSQL panel. **No UI branches on it yet** - a PostgreSQL target still renders SQL Server tabs on both SKUs; that is the next wave of [#2530].
+- **Ten viewer surfaces the browser and MCP could not reach now have read endpoints** ([#2484]) - `/api/read/*` is machine-derived from the MCP tool catalog, so a read with no tool was absent from BOTH surfaces: the WPF viewer on a Windows desktop was the only way to that data, and an agent asked about it got nothing. Every one of them was a missing ENDPOINT rather than missing collection - the store already held the rows. Now served on both SKUs: `get_collection_log` (the raw per-run log under `get_collection_health`'s seven-day rollup, which is what you reach for when the rollup reads HEALTHY and collection still looks wrong), `get_current_waits_trend`, `get_blocking_stats` (severity, where the existing trends only counted incidents - ten one-second blocks and one ten-minute block are the same count and a different problem), `get_health_parser_significant_waits` (the ninth member of a family whose other eight already had one), the three Performance Trends siblings, `get_query_store_regressions`, `get_query_heatmap`, `get_lock_wait_trend` and `get_daily_summary_range` (without which the web calendar could only ever show today). One correction came out of it: the viewer's execution-count trend was a DUPLICATE of a number `get_query_duration_trend` already returned rather than a missing sibling, so it did not become an eleventh read - what it got instead was `executions_per_second`, because that count shipped truncated to a `long` and a server running 0.4 executions a second reported **zero** and read as idle.
+- **An in-place upgrade can now remove the files the new build stopped shipping, and always names them ([#2529])** - an upgrade is an OVERLAY: `Expand-Archive -Force` writes what the new build ships and deletes nothing else, so a file the old version had and the new one dropped stayed in the install tree forever, and [#2185]'s layout report could not see it either - that report walks top-level DIRECTORIES, so a stale DLL in the install root, or inside `viewer\` or `runtimes\`, is structurally invisible to it. **The measurement came before the code**, because the whole question was whether this has ever actually happened: diffing the file lists of consecutive release zips says the Lite package dropped **44 shipped files across twelve consecutive releases** - 43 of them in ONE step, where a target-framework move stranded every `runtimes\*\lib\net8.0\` assembly including two copies of `Microsoft.Data.SqlClient.dll`, and one lone assembly in a later step - while the Darling package has dropped none in the four steps it has existed for. So it is rare, bursty, tied to a packaging or framework change rather than to ordinary development, and when it happens it arrives forty files at a time **in a .NET probing directory**, where a stale assembly is a candidate for loading rather than inert clutter. **The authority is a manifest derived from the payload, never a list anybody maintains**: after each successful copy `upgrade-darling.ps1` records the files that copy laid down, and the next upgrade diffs its own payload against it - so every path it can name provably came out of one of our own zips. That disposes of the entire false-positive class the obvious approach has. It cannot warn about `darling.json`, the DPAPI blobs, the `.bak-*` config copies, the `_rollback_manual_*` backups or `pg-runtime`, because none of those was ever in a payload and so none of them is ever in the manifest - a property of where the list comes from, not a list of exceptions somebody keeps up to date, which is [#2525] again with a new subject. **It fails safe and says so**: no manifest yet, one that will not parse, one that disagrees with its own `file-count`, or a source it cannot read all mean remove NOTHING, on its own line, with the reason. `-RemoveStaleFiles` is the switch that turns naming into deleting and it is **off for the first release** - the check reports on every upgrade either way, and a delete that runs inside a monitoring host's install directory should spend a few deploys showing operators its answer first. Directories emptied by their own removal are pruned with NON-RECURSIVE deletes, deepest first, which is not tidiness: the layout report identifies a satellite-resource directory structurally, an EMPTY one fails that test, and leaving the shell of `de\` behind would turn one stale file into a permanent startup warning. Guards at three depths, each proved RED by reverting only itself: the `pg-runtime` **prefix** rule (a prefix, not the two names we ship, because an enumeration one entry out of date here costs the store), an empty payload selecting nothing rather than everything, an empty shipped-set refusing the delete outright, case-INSENSITIVE matching so a respelled `App.js`/`app.js` is never deleted after the copy just wrote it, and containment resolved against the filesystem rather than the string. Pinned by handing the shipped delete a deliberately POISONED list holding the store, the config, its backups, a credential blob, a rollback backup and a relative escape, then asserting against the **disk** rather than the function's own account of what it did. `upgrade-darling.ps1` also now has a parse-and-AST pin in CI, because `$x | ForEach-Object { } -join ', '` binds `-join` as a PARAMETER, parses clean, and throws at runtime - which in this script means after the service has been stopped. **That pin went red on its first CI round, against lines nobody had touched, and it was right**: `upgrade-darling.ps1` did not parse under Windows PowerShell 5.1 at all - the default `powershell.exe` on Windows Server and the one SSM runs. The file is BOM-less UTF-8, 5.1 decodes such a file with the machine's ANSI code page, and the third byte of a UTF-8 em dash becomes `”` in CP1252 - which PowerShell honours as a **closing double quote**. So every em dash [#2525] had placed inside a double-quoted message terminated its string early, and the script failed to parse before doing anything. Reproduced byte for byte, down to the same "The '<' operator is reserved for future use" CI reported. The five em dashes already in `install-darling.ps1` and `fetch-pg-runtime.ps1` were harmless only because they happened to sit in COMMENTS, where a mis-decoded character is never parsed - not a property anyone can maintain by eye. Fixed by making all three shipped scripts **ASCII-only** rather than by adding a byte-order mark: a BOM works and then decays, one careless save from being gone and invisible when it is, while "no byte above 127" holds under every code page and is a rule anybody can check. Pinned both ways, each proved red by planting a single em dash back into one double-quoted string. Never shipped in a release - [#2525] is in this same unreleased section.
+- **A supported in-place upgrade script, because the step that had no script was the one accumulating 5.48 GB** ([#2525]) - `install-darling.ps1` registers a service and `uninstall-darling.ps1` removes one; laying a new build over an old one was a procedure that lived in people's heads and in ad-hoc SSM scripts, and the part of it nobody remembered by hand was deleting the backups from the last twenty deploys. A dogfood box was found carrying **46 `_rollback_manual_*` directories, 5.48 GB, the oldest three weeks old** - and the [#2185] install-location report, doing exactly its job, named every one of them on every start. **46 warnings for something our own procedure created is a guard that has stopped guarding by being too loud**: a real layout problem - a stray DLL, a half-extracted upgrade - arrives as warning 47 in a list of 46 identical ones, which is the same failure as a pin that never bites wearing the opposite clothes. So both halves changed. `upgrade-darling.ps1` ships in the zip and owns retention **at deploy time**, keeping the newest `-KeepRollbacks` (3 - enough for "the release, the one before, and the one before that"; the fourth can only roll back to a version nobody wants) and pruning the rest AFTER taking the new backup, so the tree never passes through a moment with fewer rollback points than retention promises. It verifies the source zip's SHA256, refuses to copy while anything is still running out of the install tree - **naming what, never killing it**, because the bundled PostgreSQL lives under `pg-runtime` and a blanket kill takes the store down - refuses to run from the install directory itself (the copy would overwrite the script PowerShell is reading), confirms `darling.json` is byte-identical afterwards, and is safe to re-run at every step: a backup taken in the last `-BackupWindowMinutes` is REUSED rather than replaced, so a re-run after a failed extract cannot overwrite the good pre-upgrade copy with a copy of a half-upgraded tree. And the service now RECOGNISES the convention rather than deleting anything - it never deletes what it did not create - reporting the whole set on **one** line with a count, a total, the oldest, and `upgrade-darling.ps1 -PruneOnly`, informational within retention and a warning past it. That half matters even where retention is running, because the boxes carrying the backlog got it before any script pruned anything and no upgrade removes it. The naming convention is a single shared constant with the script's own predicate executed against the service's in CI, because two spellings of one convention is how this happened in the first place - and the drift would be silent, since each of those 46 warnings was true. Two defects were found by running the script's functions against planted trees rather than reading them: a backup dated in the FUTURE (clock skew) read as "recent" and would have suppressed the deploy's backup entirely, and a failure while composing a LOG LINE was caught by the delete's handler and reported as a failed delete - two removed and two failures, for the same two directories. Nothing here could be tested on real Windows from the machine that wrote it; the pins run in CI.
+- **The web dashboard gets a per-query drill-down, which is what makes `get_query_trend` reachable there at all** ([#2520]) - the read answers "is *this* query getting worse", the question you ask second and the one that decides whether you act, and it keys on a **required** `query_hash` plus a **required** `database_name`. Every panel on the per-server page fetched with nothing but a server and a window, so there was no `query_hash` anywhere on the surface to send and `/api/read/get_query_trend` could not be called from a browser - the ONLY read in the catalog whose absence was missing UI rather than a stated product boundary (the other nineteen each have one: a dedicated endpoint already serves the data, a write path the web deliberately lacks, a payload shape the table renderer cannot draw, or a desktop capability a web imitation would be worse than). The Queries tab's Top Queries table now carries a picker, in the shape the Wait Stats tab established: **the options are the rows of the table directly above it**, and an option's VALUE is that row's index into the array the table rendered - so the query trended is the same array element the reader is looking at, not a name that matched. That is deliberately not a "list every query" read: `get_wait_types` is unread on the Wait Stats tab for exactly this reason, because a full distinct set would offer queries absent from the table and make the two disagree. The chart carries avg CPU and avg elapsed only - both are milliseconds, so one y-domain holds them honestly, and both survive the hourly rollup [#2353] falls back to past the raw tier's four days; executions, DOP and the plan hash go in a grid of the per-collection snapshots below it, where the read's NULL reads as the blank it is instead of flattening a chart to a zero nobody measured. When the rollup answers, its `aggregate_note` and the truncated-window disclosure are RENDERED rather than dropped - the page's range reaches 30 days, so without them those columns simply go blank and a blank column reads as "nothing to see" rather than "not measured at this resolution". Rows carrying no `query_hash` (history predating the column) are shown in the table and not offered in the picker, because a request the read answers with a 400 is worse than an absence. A new pin asserts the category rather than the instance: every REQUIRED parameter of every read the page fetches is one the page actually sends - the existing pin only asked whether the keys sent were bound, which a read fetched with NO keys passes vacuously.
+- **The analysis family can be anchored at a past window too, and `analyze_server` refuses to persist when it is** ([#2506]) - [#2495] anchored 57 Darling reads and 50 Lite reads and deliberately left four out, and they are the four an incident investigation reaches for first: `analyze_server`, `get_analysis_facts`, `compare_analysis` and `get_analysis_findings`. Their window is not built in the tool; it is built inside the shared analysis engine, which the tools hand a bare `hours_back`. **`as_of` now reaches the engine** - same parameter name, semantics and description text as the other 107, from the same shared constant - so `analyze_server` and `get_analysis_facts` collect and score facts over the anchored window, `compare_analysis` hangs **both** of its windows off the anchor (`baseline_hours_back` has always been measured from the comparison window's end), and the anomaly detector's hour-of-day x day-of-week baseline moves with the window - which matters more than the window itself, because a run pinned to now compares Tuesday 03:00 against whatever baseline bucket today happens to be in. `get_analysis_findings` is the one whose window is on **analysis time**, so anchoring it asks what a scheduled pass was *saying* then rather than re-analyzing that window now; its read also gained an **upper** bound, without which an anchor could only ever move the window's start earlier and every anchored read would still have returned everything up to now.
+- **`analyze_server` accepts the anchor and does not persist an anchored run** ([#2506]) - it is the one tool in the family that WRITES, and an anchored run persisted normally would be worse than no anchor at all. A finding row's identity, for every consumer there is, is its `analysis_time` - the moment the pass ran, not the window it looked at: the viewers' Recommendations tab reads `MAX(analysis_time)` and calls the result the server's current state, and `get_analysis_findings` filters on `analysis_time` and then collapses on `(story_path_hash, incident_id)` to produce occurrences / first_seen / last_seen / peak_severity. So a backdated pass stamped now would become "what is wrong with this server" for every human looking at the viewer and would inflate the very occurrence stats an operator uses to judge whether a live incident is getting worse - caused, invisibly, by somebody else's exploratory read. Recording the window on the row does not fix either: `time_range_start` / `time_range_end` are **already** persisted and already returned, and no consumer filters on them. So an anchored pass is exploratory by definition - its findings come back in full, and neither the row nor the completion event that feeds notification is emitted. The rule is **derived** in the engine (`AnalysisContext.PersistFindings` is false exactly when the window was anchored) rather than a flag a caller sets, because there is no legitimate caller for "anchored AND persist" and so there should be no way to express it. The result says which it was, in `persisted` / `persistence_note`.
+- **Every windowed read can be anchored at a past incident, not just at now** ([#2495]) - every MCP read that takes `hours_back` measured it backwards from *now*, so no caller could ask what a server was doing during last Tuesday's incident. Widening `hours_back` until Tuesday falls inside is not the same question: for an aggregate read a wider window is a **different answer**, not the same answer with more rows - it changes what a top-N returns, what an average is taken over, and how much a capped read truncates. **`as_of`** is now an optional ISO-8601 UTC anchor on **57 Darling reads and 50 Lite reads** (identical name, semantics and description on both SKUs, from one shared constant), moving the window's END off now while `hours_back` stays its LENGTH - so "the four hours around 03:00 last Tuesday" is one call instead of a 170-hour window filtered by hand. It reaches the web surface too: `/api/read/{name}` is machine-derived from the MCP catalog, so each read's descriptor carries `as_of` and a test pins the two surfaces together in both directions. **Backward compatible by construction** - the anchor defaults to now, so a caller sending only `hours_back` gets byte-for-byte the window it always got, and `ValidateWindow` returns exactly what `ValidateHoursBack` returned when no anchor is sent. Following the `ValidateTop` convention, an anchor we cannot use is **refused** rather than quietly replaced: an unparseable value, or one in the future (past a five-minute client-clock allowance), comes back as a message, because a read that silently reverts to now is indistinguishable from a correct one. An anchor older than anything the store still holds is **not** refused - retention is per-deployment, per-server and per-collector, so a hardcoded floor would be a guess; it returns the read's own `empty` / `unavailable` status, which is unambiguous because the caller chose the window. Three reads that bounded only the LOWER edge (`get_cpu_utilization`, `get_tempdb_trend`, `get_alert_history`) now bound both, which for alert history is a correctness fix as well: its cap is applied by the database, so trimming after the read would have spent the whole `LIMIT` on rows newer than the anchor. Left unanchored on purpose, and stated in the instructions: the analysis family (`analyze_server`, `get_analysis_facts`, `compare_analysis`, `get_analysis_findings`) windows inside the analysis engine rather than at the read, and `get_pvs_stats` / `get_fleet_overview` mix a latest-snapshot measurement with a windowed one, where anchoring only the windowed half would return a result whose two halves describe different instants.
+- **Five ready-made dashboards, so a new user's Custom Views page is not an empty one** ([#2480]) - no custom view ships seeded, so the first thing a new user meets at `#/views` is a blank page and a blank canvas over an 82-read catalog. The first-run hero already knew what would help and rendered it as three **inert** chips - "Top waits by server", "CPU trend over time", "Slowest procedures by database", no handler, no href - which is the [#2437] defect shape (a promise rendered as a caption) on the one page nobody arrives at with context. Those chips are now the real templates: **Server health at a glance**, **CPU investigation**, **Blocking and deadlocks**, **Memory pressure** and **Configuration review**, each created in one click against a server picked beside them, and they sit in the existing "New from template" menu alongside the notebook seeds ([#1563] D7) rather than in a second affordance. **Templates, not seeded rows**: seeding would need a migration rung, a `StorageVersion` bump, four pinned test files and a Viewer probe sentinel for content that is not schema - and a seeded row RESURRECTS itself on the next upgrade after the user deletes it, with no reset path if an editing seat breaks one. A template is created only when asked for, and what lands is an ordinary view the user owns, edits and can delete for good. The two halves of the menu behave differently on purpose: a notebook template links to the composer pre-filled because its composed panels re-scope live from the notebook's own controls, while a dashboard template is v1 READ panels whose `server` param is **static** (`renderView` threads variables and range into composed panels only), so it is created against the server chosen in the menu - and the server name goes into the view NAME, so two servers' copies of one template do not collide on the unique-name constraint. **Honest on day one**, which for a starter dashboard is the whole game - it is the first screen a UAT tester opens, on the store with the least data it will ever have. Every table and chart panel carries its own empty-state sentence (a count pin, not a spot-check), and the two reads that CANNOT be honest on a fresh install are deliberately absent rather than merely unused: analysis findings (the pass writes nothing for 24 hours) and Query Store (a target with it off has nothing, ever). The chart case is the one that bites without looking like it - `get_blocking_trend` and `get_deadlock_trend` answer an IDLE server with `trend: []` and no `{status,message}` envelope, so those panels say an empty trend means none happened rather than inheriting a sentence about collection. Every empty and failure state is stated rather than blank: a fleet with no servers says why the dashboards are unavailable instead of rendering an empty picker, and a refused create surfaces the backend's own message verbatim (with a 409 getting its own sentence, since a second click of the same template is the likely failure) because flattening it to "could not create" would hide the one line that says which panel is wrong. Pinned by the same invariant the built-in pages carry - every read exists in the shipped dispatch, every parameter key is one its read binds, every viz is in the shipped vocabulary - and each of the five definitions was fed to the service's own `ValidateDefinition`, the authority that would refuse the POST.
+- **The web dashboard's server page gets the viewer's tabs: twelve sections, 61 of the 82 served reads, and a time range** ([#2475]) - the service dispatched **82** reads at `GET /api/read/{name}` and the built-in pages reached **23** of them; `#/server/{name}` was one scroll of seven panels against the desktop viewer's **65** `TabItem`s. The gap was never backend work - `panels.js` has been a generic renderer over those reads since #1562 - so this is descriptors over the unchanged `renderPanel` seam: twelve sub-tabs (Overview, Wait Stats, CPU, Memory, Blocking, File I/O, Queries, Configuration, Config Changes, Activity, System Events, Collection Health) carrying ~70 panels, reaching **61** reads. The header carries the WHY beneath the band badge - `Warning` has three unrelated causes (a real metric breach, a server awaiting its first collection, a collector error), so a badge reading "Warning" with no way to ask why is [#2422] rebuilt on a new surface; the fleet's own reason string and the fleet card's severity chips are rendered there, the chips through `fleet.js`'s own `metricBands` so there is one implementation rather than two, and neither is re-derived in the browser (R1). Every web grid that renders query text now puts it immediately right of its time/identity anchor ([#1949]) and the pin that enforced that on one array enforces it on all seven. The tab id rides in the hash (`#/server/{name}/{tab}`) so a section is deep-linkable and survives the 60s refresh, and an unknown or absent id resolves to Overview, which is what keeps every existing `#/server/{name}` link working. A page-level range picker (1h/4h/12h/24h/7d/30d) is the twin of the viewer's toolbar presets - **not** persisted, because a page that reopens on a 30-day window is slow for a reason the reader cannot see; panels whose read takes no window at all say "latest snapshot" rather than inheriting a label that would misdescribe them. Two reads were previously **unreachable from a browser** because they require a parameter no UI collected - `get_wait_trend` needs a `wait_type` and `get_perfmon_trend` a `counter_name` - and both now have a picker, the wait one seeded from the rows of the table directly above it so the picker and the table cannot disagree. **No fifth viz kind was added**: the property-grid shape that tempted one is served by `stat` (the reads returning a flat object) and `table` (the ones already returning rows), and a fifth kind that lived only in `panels.js` would be a page-only special case, while doing it properly means `KnownVizList`, `derive.js` and an editor config arm - composer surface this change does not need. What the browser genuinely cannot do is **stated in the tab where a reader goes looking for it** rather than left as a page that quietly lacks a feature: plan analysis, the query heatmap, cached-plan retrieval and actual-plan re-execution need a plan renderer and a command back to the monitored server, and the block-chain view and interactive deadlock graph need a graph viewer - the Blocking tab hands over the captured blocked-process-report and deadlock-graph XML verbatim instead of pretending. Every data panel supplies its own empty-state sentence and both helpers THROW without one. vizTable's generic "No rows in this window" reads as a fault on a collector that is off, opt-in, or daily; and the chart case was worse - `get_blocking_trend` and `get_deadlock_trend` answer an IDLE server with `trend: []` and no `{status,message}` envelope at all, so a perfectly healthy server was told its blocking chart did not have "enough data points to chart yet". `vizLine` now renders a descriptor's `emptyText` at exactly ZERO rows and still falls through at one (where the chart's own sentence is the true one) and when no `emptyText` was authored, so every stored view predating this is unchanged. A read feeding several panels on one tab is fetched ONCE (`fanout`) rather than per descriptor - `readTool` has no cache, so `get_collection_health`, which rolls up seven days of collector logs and computes sweep pressure, was running three times to open its own tab; six such duplicates existed across five tabs and a pin now refuses a seventh. Guarded by an invariant rather than by spot-checks: every read name the module mentions must exist in the shipped dispatch, every parameter key must be one its read actually binds (an unknown query key is silently ignored, so `limit` sent to a read binding `top` quietly returns the default), every viz must be in the shipped vocabulary, and no `get_pg_*` read may appear until the fleet payload can tell a PostgreSQL target from a SQL Server one - it carries `engine_edition`, not a `CollectorTargetEngine`, so a PostgreSQL panel today would render on all 42 SQL Servers, permanently empty.
+
+### Changed
+
+- **The SQL-Agent collectors no longer gate on msdb access, so running the GRANT we advise actually does something** ([#2559]) - `HasMsdbAccess` is a **grant**, not an engine capability, but it was probed once at connect and cached for the connection's life. Three collectors gated dispatch on it, so the sequence a user actually follows was broken end to end: read our advice, run the `GRANT`, and nothing happens until the service restarts, with nothing to indicate why. `running_jobs`, `job_history` and `agent_status` now attempt regardless and fail into `PERMISSIONS`, which is a first-class outcome here rather than a defect - error **916 is already in `SqlServerPermissionErrors`**, so the run is classified as a permission denial and never as an ERROR, and `CollectorHealthClassifier` bands a collector that has only ever been denied as `NO_PERMISSIONS`, a check that runs **before** FAILING and STALE. So a server that will never have the grant does not read as broken, raises no alert, and the grant now takes effect on the next cycle instead of the next reconnect. The cost this trades for is three fast-failing statements per cycle on such a server - a compile-time permission check with no execution behind it. The fleet number that made the call: across both stores over three hours, **all 84 servers already dispatch all three collectors with zero non-SUCCESS runs**, so `HasMsdbAccess` never fires here and removing it changes nothing on this fleet while fixing the journey for anyone it does fire on. `HasMsdbAccess` is kept and still probed - it is honest information for a connection surface - it just no longer decides dispatch. One pin was **deleted rather than left passing**: the PostgreSQL-leak guard asserted no SQL Server gate reads `HasMsdbAccess`, which is now true for a reason that has nothing to do with the filter it was written to test, so it could no longer go red and was reading as coverage it did not provide.
+- **`get_pg_top_queries` and `get_pg_blocking` return their int8 IDENTITIES as strings, because a JSON number was rounding them** ([#2548]) - PostgreSQL's `queryid` is a signed `int8` derived from a hash, so its values are spread over the whole 64-bit range and most of them sit past 2^53. Serialized as a JSON **number**, every parser that decodes numbers as IEEE-754 doubles - `JSON.parse`, `json.loads`, most agent tooling - silently rounded one: `-4185925123159566327` came back as `-4185925123159566300`, verified against real `JSON.parse`. The value was never wrong on the wire; it was unrecoverable after parsing, which for an identity is the same thing. **This is a breaking change to the response shape and it is the right trade**: `queryid` is the ONLY identity a PostgreSQL statement has, and every use of it - `SELECT ... FROM pg_stat_statements WHERE queryid = ...`, matching a row on our screen against one on the instance, quoting it in a ticket - is an equality join. A rounded metric is still approximately true; a rounded key matches nothing. **`get_pg_blocking`'s `root_backend_id` had the same defect in a worse form**, and it is fixed in the same change rather than noted for later. The collector builds that id by CONCATENATING the backend's start epoch with its zero-padded pid, so every value is a 17-digit integer around 1.79e16 - roughly 2x past 2^53, where adjacent doubles are 2 apart. Half of all backend ids are therefore odd and have no representation at all, and an unrepresentable one does not round to nothing: it rounds onto its even NEIGHBOUR, which is a different backend. Measured on four adjacent pids, two of the four collided; over 200, 100 lost precision and 99 landed on an id belonging to another backend. That is a worse failure than `queryid`'s - joining to the wrong row rather than to none - in the field the tool's own description tells a reader to prefer for comparing a root blocker across captures. The fix is scoped to those two: `database_id` and `user_id` are PostgreSQL `oid`s (unsigned 32-bit, structurally unable to reach the range), `root_pid` is an `int`, SQL Server's `query_hash` / `plan_hash` already reach this surface as `0x...` text, and Query Store's `query_id` / `plan_id` are sequential `bigint` IDENTITY columns whose real values are nowhere near 2^53 - so none of them has a defect to fix and none was touched. Web rendering needed no change: the Query ID column already rendered the raw value, so it simply started showing the true digits. Guarded by `PgInt64IdentityWireShapeTests`, which pins both fields as strings, pins `database_id` and `root_pid` as numbers so "stringify everything" cannot pass, and asserts its own fixture is genuinely out of double range - the assertion that stops the guard being vacuous.
+- **Fifteen reads stopped answering "nothing happened" and "nothing was collected" with the same sentence** ([#2485]) - a read that returns no rows was saying so identically whether the window was genuinely quiet or the collector had never run, and those want opposite responses: widen the window, versus go find out why collection is not running. They now use the miss vocabulary honestly - `empty` for a true negative, `unavailable` for data that was never collected - with the second saying outright that it is NOT an all-clear and naming what to check. The design rule that came out of it is worth stating: **for an EDGE table the denominator is whether we LOOKED, not whether we ever FOUND anything**. Blocking and deadlocks only write a row when something went wrong, so probing the data reports a server that has been collected perfectly for months and simply never blocked as uncollected - a false alarm sending someone to fix collection that works. Those reads count successful collector runs instead. A parity pin now asserts each shared sentence appears in both source trees, because every one of these messages exists twice and nothing previously stopped one copy drifting.
+- **`get_collection_health` serves what a HEAVY run costs, not only what runs cost on average** ([#2460]) - `query_store` on one dogfood server reported `avg_duration_ms: 13,834` over 1,155 runs. 958 of those runs carried the `enumeration yielded 0 items` note, and an empty enumeration costs **36 ms** - measured on a control server that yields nothing on all 1,551 of its runs and pays 36 ms for every one. Back that out and the remaining 197 PRODUCTIVE runs cost **~80,900 ms EACH**: more than the entire 60,000 ms sweep budget, on their own, once every few cycles. 13,834 ms describes neither population - it is an 83/17 blend that happens to land in a range reading like a plausible single number, and everything downstream inherited it, [#2459]'s brand-new `peak_cycle_ms` included, which understated that server's worst body by ~67,000 ms. Every collector row now carries `p95_duration_ms` and `max_duration_ms` beside `avg_duration_ms`, and `peak_cycle_ms` is built from the p95 rather than the mean. **The store already held this** - `duration_ms` has been written per run since the schema's second rung, and nothing had ever read it as anything but a mean - so this is two aggregates over a table already being grouped, no new collection and no new column. p95 rather than max for the number a decision is made from, because a max is one run and a single pathological cycle would make a collector read as permanently terrible for a week; the max is served BESIDE it as a fact, and comparing the two is what tells a routine tail from a one-off (avg ~ p95 ~ max is one population, avg << p95 is two, p95 << max is one bad run). p95 also scales itself to the sample: over 3,500 runs it discards the outlier, and over the six runs a daily collector gets in a week it lands on the max, which is right, because with six samples there is no outlier anyone can afford to throw away. Each collector is charged the p95 **floored at its mean**, so the aligned cycle can only ever rise - a p95 CAN sit below a mean (99 runs at 10 ms and one at 1,000,000 ms), and taking it unconditionally could have retracted a `BODY_OVERRUN` [#2446] correctly caught. **The verdict is untouched and still amortizes the mean**: sustained demand over a window IS the mean, and a rate built from a tail claims work the server never sustains. On the measured server the aligned body goes from 73,408 ms to 140,507 ms and `peak_collector` changes from `index_object_stats` (the larger MEAN, once a day) to `query_store` (the larger heavy RUN, every five minutes) - which is the collector that was actually overrunning those bodies. `peak_cycle_note` now states the mean beside the heavy run and the gap between them; `heaviest_collectors` rows carry both new statistics and take `pct_of_sweep_budget_per_run` from the heavy run, since a "per run" percentage computed from a mean that describes no run is the defect itself. Both SKUs, one query shape, and the same fixture pinned against live DuckDB and live Postgres so the two engines have to agree - including that both ignore a NULL `duration_ms` in an ordered-set aggregate, which neither query states.
+- **`sweep_pressure` answers the single-sweep question as well as the sustained one** ([#2446]) - a server logging six "collection body has not completed after 60-69s of execution - skipping relaunch" warnings in three hours reported `busy_percent: 20.4`, `verdict: OK`, every collector HEALTHY - and both numbers were right about what they measured. [#2296]'s amortized model asks "does this server's total demand fit its cadence on average"; an operator reading a skipped relaunch is asking "did THIS sweep overrun". Those diverge exactly when one collector's single run approaches the budget while its amortized cost is negligible: `index_object_stats` took 37,207 ms of a 60,000 ms body and, at a 1440-minute cadence, contributed 26 ms/min to the verdict. The block now also carries `peak_cycle_ms` / `peak_cycle_percent` - what the body costs on the cycle where every scheduled cadence comes due together, the collectors' averages added WITHOUT being amortized - and `peak_cycle_risk` (FITS / BODY_OVERRUN). That cycle is not a hypothetical worst case: the shipped cadences are strictly nested (1 | 5 | 60 | 1440), so alignment is a guaranteed periodic event, and on that server it costs 73,408 ms against the 60,000 ms budget, which is what the pinned fixture reproduces. `peak_collector` names the collector that owns the most of one sweep and `peak_cycle_note` explains it, because `heaviest_collectors` ranks by amortized contribution and therefore ranks the offending collector out of sight by construction - that list now also carries `amortized_ms_per_minute` and `pct_of_sweep_budget_per_run` per row, so the two costs sit side by side. **The verdict is deliberately unchanged.** A once-daily 37-second collector is not saturation; calling it SATURATED would spend the word on a case whose lever is the schedule's shape rather than the capacity that verdict recommends, and an operator who learns to discount SATURATED loses the signal [#2296] built. Separate field and separate vocabulary, so neither can be read as the other and a fleet scan can filter on either. Measured across the dogfood fleet: the two servers that logged skipped relaunches read OK/BODY_OVERRUN (122% and 109% of budget) while a quiet one read OK/FITS at 19%. The decision stays in the shared SweepPressureClassifier (PerformanceMonitor.Common) with the same table pinned in both suites, and both SKUs' tools serve the identical shape.
+- **Lite's portable ZIP is self-contained, which HALVED it** ([#2501]) - `Publish Lite` is now `-r win-x64 --self-contained` in both `build.yml` and `nightly.yml`, so neither Lite artifact has a .NET prerequisite any more and the failure [#2489] documented stops existing: a tester who unzips onto a stock Windows Server no longer meets the .NET host's bare `You must install .NET to run this application` before a line of our code runs. **The size went the opposite way from what bundling a runtime suggests.** The old publish was RID-agnostic, so it copied every platform its packages ship - **537 MB of `runtimes\` on a 565 MB tree** (osx 130, linux-x64 116, linux-arm64 70, win-arm64 56, then win-x86, musl, loongarch64 and riscv64), of which only the **52 MB `win-x64`** folder could ever load on Windows. `DuckDB.NET.Bindings.Full` is most of it, SkiaSharp and SqlClient behind it. Dropping ~485 MB of unloadable native payload beats the cost of bundling .NET, WPF and ASP.NET Core by roughly two to one: measured on one commit and one SDK, **565 MB tree / 212.7 MB zipped becomes 277 MB / 114.2 MB**. It matters most for the **nightly** ZIP, which is the UAT download and is not offered as a `Setup.exe` at all. **A RID-specific publish needed two more files than the flag.** `Lite/packages.lock.json` had only a `net10.0-windows7.0` target, and a RID restore adds `net10.0-windows7.0/win-x64` to it - after which the `dotnet restore --locked-mode` that BOTH workflows run before the publish fails `NU1004: the project's runtime identifiers have changed`, because locked mode compares the PROJECT's RID set (empty) against the lock file's (win-x64). Reproduced locally; that is a red CI run on every PR, not the future `--no-restore` trap it was filed as. The fix is `win-x64` in `PerformanceMonitorLite.csproj`, so the project itself asks for that graph and one committed lock file satisfies the RID-less locked-mode restore and the RID publish alike; `RuntimeIdentifiers` (plural) sets no RID on the build, so a plain `dotnet build` stays RID-agnostic and `Lite.Tests` is untouched. **SignPath needed nothing** - the `Lite` artifact-configuration slug already receives both shapes today, and the signed re-zip reads `signed/Lite/*`, inheriting whatever shape `publish/Lite` has. Auto-update is unaffected; the ZIP is not a Velopack channel. `LiteRuntimePrerequisiteDocsTests` went red on the flag alone (3 of its 7 facts) and was rewritten to state every claim BOTH ways round: [#2499]'s version asserted only that the docs DID name the runtimes, so two of its facts stayed green while the prose went stale. It now also derives the lock file's RID coverage from the `-r` flags in the workflows, and every new assertion was proven red with its fix reverted.
+
+### Fixed
+
- **Ten PostgreSQL MCP tools were never registered with the host, so no agent could call them** ([#2659]) - `get_pg_write_stats`, `get_pg_buffer_usage`, `get_pg_extensions`, `get_pg_lock_stats`, `get_pg_index_bloat`, `get_pg_column_stats`, `get_pg_kernel_stats`, `get_pg_predicate_stats`, `get_pg_replication_stats` and `get_pg_wait_sampling` were implemented, documented, dispatched by the web dashboard and counted in the instructions census, and `tools/list` answered 116 tools where the census claimed 126. Registration is per class and explicit, and nothing failed when a class was left out: the inventory pin checks tool NAMES, which exist either way, and the tab pin exists to stop a read shipping reachable only through MCP - this was the exact inverse. A reflection-derived pin now asserts every `[McpServerToolType]` class is registered, so it fails when someone adds a class rather than when an agent next reaches for the tool.
- **PostgreSQL 18 silently lost every I/O byte figure, and the estimate it replaced was off by an order of magnitude** ([#2655]) - 18 removed `op_bytes` from `pg_stat_io`, and both byte figures `get_pg_io_stats` serves were derived from it, so on 18 they came back null with no note, no status and nothing to distinguish a version change from a collector that had stopped. The columns 18 replaced it with are better than what was lost: `op_bytes` was the per-operation block size that the read multiplied by a count to ESTIMATE volume, while `read_bytes`/`write_bytes`/`extend_bytes` are measured totals. 18 also introduced vectored reads, so one entry in `reads` can cover several blocks and the old estimate undercounts - measured through the running service against a real 18.6 target, one combination reported 4,742 reads against 448,724,992 bytes where the estimate would have said 38,846,464, an 11.6x undercount, and three combinations ran 10x to 16x. They are now collected and served, with `bytes_source` on every row and on the envelope saying whether a figure was measured or estimated, because the two are not comparable and must never share a name silently.
- **A PostgreSQL column that the server's VERSION removed read as a missing measurement** ([#2653]) - PostgreSQL 17 removed `buffers_backend` and `buffers_backend_fsync` from `pg_stat_bgwriter` outright, and `get_pg_write_stats` returned both as bare nulls under a note that went on explaining `buffers_backend` as a live backpressure signal. The collector was right - it emits NULL for them deliberately from 17 on - but nothing anywhere recorded the target's PostgreSQL major, so no read could tell a column the version does not have from one nothing collected. Seven PostgreSQL collectors gate on that version and the read layer had no access to it at all. The registry now carries it, stamped on every connect like the engine kind next door, and this read spends it: on 17 and later it names the removal, says it is not a measurement gap, and points at `get_pg_io_stats`, where the fact actually lives now.
@@ -413,6 +425,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- **Azure SQL Database gets tempdb monitoring back, and the reason it lost it was checkably false** ([#2512]) - `TempDbStatsCollector.AppliesTo` was `!target.IsAzureSqlDb`, so the entire tier - General Purpose and Hyperscale alike - collected **no tempdb data at all**: no `get_tempdb_trend`, no tempdb space alerting, nothing in the viewer's tempdb surfaces, on a platform where the customer cannot go and look at the box themselves. The doc comment justified that by saying the first result set's three-part `tempdb.sys.dm_db_file_space_usage` reference could not be served there and the collector "could only ever fail". Running the collector's SQL verbatim against live Azure SQL Database says otherwise: on `GP_S_Gen5_2` (EngineEdition 5, 12.0.2000.8) it returns user 5.44 MB / internal 1.81 MB / version store 0.00 MB / unallocated 54.19 MB plus one session over threshold, on `HS_S_Gen5_2` user 1.88 MB / unallocated 60.69 MB, and `SELECT COUNT(*) FROM tempdb.sys.dm_db_file_space_usage` returns 4 on both. **Both** result sets bind, so the whole row this collector promises is available - the second one (`sys.dm_db_session_space_usage`) was never in doubt. The numbers also *describe* something rather than merely returning: allocating ~57 MB into a `#temp` table moved `user_mb` 1.88 -> 59.75 and `unallocated_mb` 60.69 -> 2.69, with the session view attributing 59.25 MB to the session that did it. And it is worth **more** here than on a box, because the tempdb ceiling on Azure SQL Database is governed by the SERVICE TIER: you cannot add files and cannot grow past the cap, so "tempdb is filling" means "change service objective", not "go look at the disk". **Managed Instance is unchanged** - it was never gated, it has a real tempdb, and the pin asserts both directions so this cannot be re-narrowed on the strength of a stale comment. What the #2150 field report was actually about is PERMISSIONS, and that is now handled where it belongs: error 262 ("permission denied in database 'tempdb'") was classified `Unclassified`, which means **ERROR every cycle forever** - so a login that cannot read the DMV now degrades to PERMISSIONS with the same kind of explanatory Azure sentence error 300 already gets, rather than polluting collection health. The number set behind that classification lived in **six** hand-maintained places and had already drifted three different ways (916 was in `SqlServerTargetProvider.Classify` and the two failed-jobs msdb reads but in neither collector catch; 8189 was in the collector catches but in neither failed-jobs read; Lite's XE-session catch had neither); all six now read one shared `SqlServerPermissionErrors.IsPermissionDenied`, which is also the only way to pin them, since `SqlException` cannot be constructed in a test. **One thing to know before turning tempdb alerting on for Azure targets**: `unallocated_mb` is free space in the tempdb files *as currently allocated*, and Azure SQL Database creates them small (4 files, ~62 MB total on both 2-vCore tiers measured) and autogrows toward the tier cap - so the `reserved / (reserved + unallocated)` ratio the `tempdb Space` alert fires on measures distance to the next autogrow, not distance to the ceiling. The measurement above takes that ratio from 3% to 96% with a single `#temp` table, against an 80% default. The absolute MB and the trend are the honest signal on this tier; giving the ratio a size floor (the shape `ScorePlanCacheBloat` already uses for the same reason) is filed as [#2515] rather than retuning a threshold every existing on-prem target depends on. Applies to Lite and Darling, which share the collector definition, the classifier and the alert engine
- **Lite's .NET runtime prerequisites were documented against the wrong artifact** ([#2489]) - the README hung "requires .NET 10 Desktop Runtime" on the **`Setup.exe`** download line. That is the one artifact with no prerequisite at all: it is packed by `vpk` from a `--self-contained -r win-x64` publish, and its `runtimeconfig.json` carries `includedFrameworks`, not `frameworks`. Meanwhile the portable **ZIP** - a framework-dependent publish whose `runtimeconfig.json` names **three** shared frameworks - had its prerequisites written down nowhere, and `Lite/README.md` had no prerequisites section at all. So a tester who read the one runtime sentence we published installed a runtime they did not need, while a tester who unzipped onto a stock Windows Server got the .NET host's bare `You must install .NET to run this application` with nothing of ours on it. Each artifact now carries its OWN prerequisites, in the root README and in a new **Prerequisites** section in `Lite/README.md`. **ASP.NET Core Runtime 10 is required unconditionally** for the ZIP, and that is the part nobody expects: `ModelContextProtocol.AspNetCore` brings the framework reference in transitively, so it is named at build time and leaving MCP switched off does not remove it - and because the host reports only the FIRST framework it cannot find, installing just the Desktop Runtime buys a second identical failure. Lite cannot gate on this the way [#2481] did for Darling, because Lite has no install script and the host error precedes every line of our code; what ships instead is `READ-ME-FIRST.txt` beside `PerformanceMonitorLite.exe`, the one surface of ours a stranded reader can still reach. `LiteRuntimePrerequisiteDocsTests` keeps all of it honest by DERIVING the claims - the required runtimes from the built `runtimeconfig.json`, and which artifact is self-contained from the `dotnet publish` lines in `build.yml` and `nightly.yml` - so dropping the MCP package, making the ZIP self-contained, or moving to a new .NET major turns the docs red instead of stale. `README.md` and `Lite/README.md` are named in build.yml's `lite` path filter for the same reason: markdown is carved out of every area filter and a docs-only PR skips .NET setup entirely, so the guard would otherwise be unrunnable on precisely the change it exists to catch.
+
## [3.5.0] - 2026-08-19
### Added
diff --git a/Darling/Darling.Tests/Fixtures/migration-ladder-v3.6.0.sql b/Darling/Darling.Tests/Fixtures/migration-ladder-v3.6.0.sql
new file mode 100644
index 0000000000..2ad9b7587d
--- /dev/null
+++ b/Darling/Darling.Tests/Fixtures/migration-ladder-v3.6.0.sql
@@ -0,0 +1,3972 @@
+-- Migration ladder as RESOLVED by the release this fixture is named for — every rung's
+-- SQL frozen as that era's code (including its generators) emitted it, plus the same
+-- version-table DDL and stamps MigrateLockedAsync writes. Generated by tools/generate-
+-- ladder-fixture; regenerate at each release cut from the release tag. DO NOT HAND-EDIT.
+-- ===BATCH=== bootstrap
+SET search_path = collect, config, public;
+CREATE TABLE IF NOT EXISTS darling_schema_version (
+ version integer NOT NULL PRIMARY KEY,
+ name text NOT NULL,
+ applied_at timestamp NOT NULL
+);
+-- ===BATCH=== V1 collector-tables
+/* Darling collector tables — generated from PerformanceMonitor.Collectors definitions.
+ Column names and types mirror Lite's DuckDB schema (see PgSchemaGenerator remarks). */
+
+CREATE TABLE IF NOT EXISTS wait_stats (
+ collection_id bigint NOT NULL,
+ collection_time timestamp NOT NULL,
+ server_id integer NOT NULL,
+ server_name text NOT NULL,
+ wait_type text,
+ waiting_tasks_count bigint,
+ wait_time_ms bigint,
+ signal_wait_time_ms bigint,
+ delta_waiting_tasks bigint,
+ delta_wait_time_ms bigint,
+ delta_signal_wait_time_ms bigint
+);
+CREATE INDEX IF NOT EXISTS idx_wait_stats_time ON wait_stats(server_id, collection_time);
+
+CREATE TABLE IF NOT EXISTS latch_stats (
+ collection_id bigint NOT NULL,
+ collection_time timestamp NOT NULL,
+ server_id integer NOT NULL,
+ server_name text NOT NULL,
+ latch_class text,
+ waiting_requests_count bigint,
+ wait_time_ms bigint,
+ max_wait_time_ms bigint,
+ delta_waiting_requests_count bigint,
+ delta_wait_time_ms bigint,
+ delta_max_wait_time_ms bigint
+);
+CREATE INDEX IF NOT EXISTS idx_latch_stats_time ON latch_stats(server_id, collection_time);
+
+CREATE TABLE IF NOT EXISTS spinlock_stats (
+ collection_id bigint NOT NULL,
+ collection_time timestamp NOT NULL,
+ server_id integer NOT NULL,
+ server_name text NOT NULL,
+ spinlock_name text,
+ collisions bigint,
+ spins bigint,
+ spins_per_collision double precision,
+ sleep_time bigint,
+ backoffs bigint,
+ delta_collisions bigint,
+ delta_spins bigint,
+ delta_sleep_time bigint,
+ delta_backoffs bigint
+);
+CREATE INDEX IF NOT EXISTS idx_spinlock_stats_time ON spinlock_stats(server_id, collection_time);
+
+CREATE TABLE IF NOT EXISTS cpu_scheduler_stats (
+ collection_id bigint NOT NULL,
+ collection_time timestamp NOT NULL,
+ server_id integer NOT NULL,
+ server_name text NOT NULL,
+ max_workers_count integer,
+ scheduler_count integer,
+ cpu_count integer,
+ total_runnable_tasks_count integer,
+ total_work_queue_count bigint,
+ total_current_workers_count integer,
+ avg_runnable_tasks_count numeric(38,2),
+ total_active_request_count integer,
+ total_queued_request_count integer,
+ total_blocked_task_count integer,
+ total_active_parallel_thread_count bigint,
+ runnable_request_count integer,
+ total_request_count integer,
+ runnable_percent numeric(38,2),
+ worker_thread_exhaustion_warning boolean,
+ runnable_tasks_warning boolean,
+ blocked_tasks_warning boolean,
+ queued_requests_warning boolean,
+ total_physical_memory_kb bigint,
+ available_physical_memory_kb bigint,
+ system_memory_state_desc text,
+ physical_memory_pressure_warning boolean,
+ total_node_count integer,
+ nodes_online_count integer,
+ offline_cpu_count integer,
+ offline_cpu_warning boolean
+);
+CREATE INDEX IF NOT EXISTS idx_cpu_scheduler_stats_time ON cpu_scheduler_stats(server_id, collection_time);
+
+CREATE TABLE IF NOT EXISTS plan_cache_stats (
+ collection_id bigint NOT NULL,
+ collection_time timestamp NOT NULL,
+ server_id integer NOT NULL,
+ server_name text NOT NULL,
+ cacheobjtype text,
+ objtype text,
+ total_plans integer,
+ total_size_mb integer,
+ single_use_plans integer,
+ single_use_size_mb integer,
+ multi_use_plans integer,
+ multi_use_size_mb integer,
+ avg_use_count numeric(38,2),
+ avg_size_kb integer,
+ oldest_plan_create_time timestamp
+);
+CREATE INDEX IF NOT EXISTS idx_plan_cache_stats_time ON plan_cache_stats(server_id, collection_time);
+
+CREATE TABLE IF NOT EXISTS tempdb_stats (
+ collection_id bigint NOT NULL,
+ collection_time timestamp NOT NULL,
+ server_id integer NOT NULL,
+ server_name text NOT NULL,
+ user_object_reserved_mb numeric(18,2),
+ internal_object_reserved_mb numeric(18,2),
+ version_store_reserved_mb numeric(18,2),
+ total_reserved_mb numeric(18,2),
+ unallocated_mb numeric(18,2),
+ total_sessions_using_tempdb bigint,
+ top_session_id integer,
+ top_session_tempdb_mb numeric(18,2),
+ max_size_mb numeric(18,2)
+);
+CREATE INDEX IF NOT EXISTS idx_tempdb_stats_time ON tempdb_stats(server_id, collection_time);
+
+CREATE TABLE IF NOT EXISTS memory_grant_stats (
+ collection_id bigint NOT NULL,
+ collection_time timestamp NOT NULL,
+ server_id integer NOT NULL,
+ server_name text NOT NULL,
+ resource_semaphore_id smallint,
+ pool_id integer,
+ target_memory_mb numeric(18,2),
+ max_target_memory_mb numeric(18,2),
+ total_memory_mb numeric(18,2),
+ available_memory_mb numeric(18,2),
+ granted_memory_mb numeric(18,2),
+ used_memory_mb numeric(18,2),
+ grantee_count integer,
+ waiter_count integer,
+ timeout_error_count bigint,
+ forced_grant_count bigint,
+ timeout_error_count_delta bigint,
+ forced_grant_count_delta bigint
+);
+CREATE INDEX IF NOT EXISTS idx_memory_grant_stats_time ON memory_grant_stats(server_id, collection_time);
+
+CREATE TABLE IF NOT EXISTS cpu_utilization_stats (
+ collection_id bigint NOT NULL,
+ collection_time timestamp NOT NULL,
+ server_id integer NOT NULL,
+ server_name text NOT NULL,
+ sample_time timestamp,
+ sqlserver_cpu_utilization integer,
+ other_process_cpu_utilization integer
+);
+CREATE INDEX IF NOT EXISTS idx_cpu_utilization_stats_time ON cpu_utilization_stats(server_id, collection_time);
+
+CREATE TABLE IF NOT EXISTS memory_stats (
+ collection_id bigint NOT NULL,
+ collection_time timestamp NOT NULL,
+ server_id integer NOT NULL,
+ server_name text NOT NULL,
+ total_physical_memory_mb numeric(18,2),
+ available_physical_memory_mb numeric(18,2),
+ total_page_file_mb numeric(18,2),
+ available_page_file_mb numeric(18,2),
+ system_memory_state text,
+ sql_memory_model text,
+ target_server_memory_mb numeric(18,2),
+ total_server_memory_mb numeric(18,2),
+ buffer_pool_mb numeric(18,2),
+ plan_cache_mb numeric(18,2),
+ max_workers_count integer,
+ current_workers_count integer
+);
+CREATE INDEX IF NOT EXISTS idx_memory_stats_time ON memory_stats(server_id, collection_time);
+
+CREATE TABLE IF NOT EXISTS memory_clerks (
+ collection_id bigint NOT NULL,
+ collection_time timestamp NOT NULL,
+ server_id integer NOT NULL,
+ server_name text NOT NULL,
+ clerk_type text,
+ memory_mb numeric(18,2)
+);
+CREATE INDEX IF NOT EXISTS idx_memory_clerks_time ON memory_clerks(server_id, collection_time);
+
+CREATE TABLE IF NOT EXISTS memory_pressure_events (
+ collection_id bigint NOT NULL,
+ collection_time timestamp NOT NULL,
+ server_id integer NOT NULL,
+ server_name text NOT NULL,
+ sample_time timestamp,
+ memory_notification text,
+ memory_indicators_process integer,
+ memory_indicators_system integer
+);
+CREATE INDEX IF NOT EXISTS idx_memory_pressure_events_time ON memory_pressure_events(server_id, sample_time);
+
+CREATE TABLE IF NOT EXISTS file_io_stats (
+ collection_id bigint NOT NULL,
+ collection_time timestamp NOT NULL,
+ server_id integer NOT NULL,
+ server_name text NOT NULL,
+ database_name text,
+ file_name text,
+ file_type text,
+ physical_name text,
+ size_mb numeric(18,2),
+ num_of_reads bigint,
+ num_of_writes bigint,
+ read_bytes bigint,
+ write_bytes bigint,
+ io_stall_read_ms bigint,
+ io_stall_write_ms bigint,
+ io_stall_queued_read_ms bigint,
+ io_stall_queued_write_ms bigint,
+ delta_reads bigint,
+ delta_writes bigint,
+ delta_read_bytes bigint,
+ delta_write_bytes bigint,
+ delta_stall_read_ms bigint,
+ delta_stall_write_ms bigint,
+ delta_stall_queued_read_ms bigint,
+ delta_stall_queued_write_ms bigint
+);
+CREATE INDEX IF NOT EXISTS idx_file_io_stats_time ON file_io_stats(server_id, collection_time);
+
+CREATE TABLE IF NOT EXISTS server_properties (
+ collection_id bigint NOT NULL,
+ collection_time timestamp NOT NULL,
+ server_id integer NOT NULL,
+ server_name text NOT NULL,
+ edition text,
+ product_version text,
+ product_level text,
+ product_update_level text,
+ engine_edition integer,
+ cpu_count integer,
+ hyperthread_ratio integer,
+ physical_memory_mb bigint,
+ socket_count integer,
+ cores_per_socket integer,
+ is_hadr_enabled boolean,
+ is_clustered boolean,
+ enterprise_features text,
+ service_objective text,
+ vcore_count integer,
+ lock_pages_in_memory boolean,
+ instant_file_initialization_enabled boolean,
+ memory_dump_count integer,
+ sqlserver_start_time timestamp,
+ host_os_version text,
+ ag_replica_role text,
+ utc_offset_minutes integer
+);
+CREATE INDEX IF NOT EXISTS idx_server_properties_time ON server_properties(server_id, collection_time);
+
+CREATE TABLE IF NOT EXISTS server_config (
+ config_id bigint NOT NULL,
+ capture_time timestamp NOT NULL,
+ server_id integer NOT NULL,
+ server_name text NOT NULL,
+ configuration_name text,
+ value_configured bigint,
+ value_in_use bigint,
+ is_dynamic boolean,
+ is_advanced boolean
+);
+
+CREATE TABLE IF NOT EXISTS database_config (
+ config_id bigint NOT NULL,
+ capture_time timestamp NOT NULL,
+ server_id integer NOT NULL,
+ server_name text NOT NULL,
+ database_name text,
+ state_desc text,
+ compatibility_level integer,
+ collation_name text,
+ recovery_model text,
+ is_read_only boolean,
+ is_auto_close_on boolean,
+ is_auto_shrink_on boolean,
+ is_auto_create_stats_on boolean,
+ is_auto_update_stats_on boolean,
+ is_auto_update_stats_async_on boolean,
+ is_read_committed_snapshot_on boolean,
+ snapshot_isolation_state text,
+ is_parameterization_forced boolean,
+ is_query_store_on boolean,
+ is_encrypted boolean,
+ is_trustworthy_on boolean,
+ is_db_chaining_on boolean,
+ is_broker_enabled boolean,
+ is_cdc_enabled boolean,
+ is_mixed_page_allocation_on boolean,
+ log_reuse_wait_desc text,
+ page_verify_option text,
+ target_recovery_time_seconds integer,
+ delayed_durability text,
+ is_accelerated_database_recovery_on boolean,
+ is_memory_optimized_enabled boolean,
+ is_optimized_locking_on boolean
+);
+
+CREATE TABLE IF NOT EXISTS database_states (
+ collection_id bigint NOT NULL,
+ collection_time timestamp NOT NULL,
+ server_id integer NOT NULL,
+ server_name text NOT NULL,
+ database_name text,
+ database_id integer,
+ state_desc text,
+ is_in_standby boolean
+);
+CREATE INDEX IF NOT EXISTS idx_database_states_time ON database_states(server_id, collection_time);
+
+CREATE TABLE IF NOT EXISTS trace_flags (
+ config_id bigint NOT NULL,
+ capture_time timestamp NOT NULL,
+ server_id integer NOT NULL,
+ server_name text NOT NULL,
+ trace_flag integer,
+ status boolean,
+ is_global boolean,
+ is_session boolean
+);
+CREATE INDEX IF NOT EXISTS idx_trace_flags_time ON trace_flags(server_id, capture_time);
+
+CREATE TABLE IF NOT EXISTS database_scoped_config (
+ config_id bigint NOT NULL,
+ capture_time timestamp NOT NULL,
+ server_id integer NOT NULL,
+ server_name text NOT NULL,
+ database_name text,
+ configuration_name text,
+ value text,
+ value_for_secondary text
+);
+CREATE INDEX IF NOT EXISTS idx_database_scoped_config_time ON database_scoped_config(server_id, capture_time);
+
+CREATE TABLE IF NOT EXISTS query_store_health (
+ config_id bigint NOT NULL,
+ capture_time timestamp NOT NULL,
+ server_id integer NOT NULL,
+ server_name text NOT NULL,
+ database_name text,
+ actual_state text,
+ desired_state text,
+ readonly_reason integer,
+ current_storage_size_mb bigint,
+ max_storage_size_mb bigint,
+ size_based_cleanup_mode text,
+ stale_query_threshold_days bigint,
+ max_plans_per_query bigint,
+ interval_length_minutes bigint
+);
+CREATE INDEX IF NOT EXISTS idx_query_store_health_time ON query_store_health(server_id, capture_time);
+
+CREATE TABLE IF NOT EXISTS session_stats (
+ collection_id bigint NOT NULL,
+ collection_time timestamp NOT NULL,
+ server_id integer NOT NULL,
+ server_name text NOT NULL,
+ program_name text,
+ connection_count bigint,
+ running_count integer,
+ sleeping_count integer,
+ dormant_count integer,
+ total_cpu_time_ms bigint,
+ total_reads bigint,
+ total_writes bigint,
+ total_logical_reads bigint
+);
+CREATE INDEX IF NOT EXISTS idx_session_stats_time ON session_stats(server_id, collection_time);
+
+CREATE TABLE IF NOT EXISTS session_summary_stats (
+ collection_id bigint NOT NULL,
+ collection_time timestamp NOT NULL,
+ server_id integer NOT NULL,
+ server_name text NOT NULL,
+ total_sessions integer,
+ running_sessions integer,
+ sleeping_sessions integer,
+ background_sessions integer,
+ dormant_sessions integer,
+ idle_sessions_over_30min integer,
+ sessions_waiting_for_memory integer,
+ databases_with_connections integer,
+ top_application_name text,
+ top_application_connections integer,
+ top_host_name text,
+ top_host_connections integer
+);
+CREATE INDEX IF NOT EXISTS idx_session_summary_stats_time ON session_summary_stats(server_id, collection_time);
+
+CREATE TABLE IF NOT EXISTS waiting_tasks (
+ collection_id bigint NOT NULL,
+ collection_time timestamp NOT NULL,
+ server_id integer NOT NULL,
+ server_name text NOT NULL,
+ session_id integer,
+ wait_type text,
+ wait_duration_ms bigint,
+ blocking_session_id integer,
+ resource_description text,
+ database_name text
+);
+CREATE INDEX IF NOT EXISTS idx_waiting_tasks_time ON waiting_tasks(server_id, collection_time);
+
+CREATE TABLE IF NOT EXISTS procedure_stats (
+ collection_id bigint NOT NULL,
+ collection_time timestamp NOT NULL,
+ server_id integer NOT NULL,
+ server_name text NOT NULL,
+ database_name text,
+ schema_name text,
+ object_name text,
+ object_type text,
+ cached_time timestamp,
+ last_execution_time timestamp,
+ execution_count bigint,
+ total_worker_time bigint,
+ total_elapsed_time bigint,
+ total_logical_reads bigint,
+ total_physical_reads bigint,
+ total_logical_writes bigint,
+ min_worker_time bigint,
+ max_worker_time bigint,
+ min_elapsed_time bigint,
+ max_elapsed_time bigint,
+ min_logical_reads bigint,
+ max_logical_reads bigint,
+ min_physical_reads bigint,
+ max_physical_reads bigint,
+ min_logical_writes bigint,
+ max_logical_writes bigint,
+ total_spills bigint,
+ min_spills bigint,
+ max_spills bigint,
+ sql_handle text,
+ plan_handle text,
+ delta_execution_count bigint,
+ delta_worker_time bigint,
+ delta_elapsed_time bigint,
+ delta_logical_reads bigint,
+ delta_logical_writes bigint,
+ delta_physical_reads bigint,
+ delta_spills bigint,
+ query_plan_xml text
+);
+CREATE INDEX IF NOT EXISTS idx_procedure_stats_time ON procedure_stats(server_id, collection_time);
+
+CREATE TABLE IF NOT EXISTS running_jobs (
+ collection_time timestamp NOT NULL,
+ server_id integer NOT NULL,
+ server_name text NOT NULL,
+ job_name text,
+ job_id text,
+ job_enabled boolean,
+ start_time timestamp,
+ current_duration_seconds bigint,
+ avg_duration_seconds bigint,
+ p95_duration_seconds bigint,
+ successful_run_count bigint,
+ is_running_long boolean,
+ percent_of_average numeric(10,1)
+);
+CREATE INDEX IF NOT EXISTS idx_running_jobs_time ON running_jobs(server_id, collection_time);
+
+CREATE TABLE IF NOT EXISTS perfmon_stats (
+ collection_id bigint NOT NULL,
+ collection_time timestamp NOT NULL,
+ server_id integer NOT NULL,
+ server_name text NOT NULL,
+ object_name text,
+ counter_name text,
+ instance_name text,
+ cntr_value bigint,
+ delta_cntr_value bigint,
+ sample_interval_seconds integer
+);
+CREATE INDEX IF NOT EXISTS idx_perfmon_stats_time ON perfmon_stats(server_id, collection_time);
+
+CREATE TABLE IF NOT EXISTS dmv_blocking_snapshots (
+ collection_id bigint NOT NULL,
+ collection_time timestamp NOT NULL,
+ server_id integer NOT NULL,
+ server_name text NOT NULL,
+ monitor_loop integer,
+ event_time timestamp,
+ database_name text,
+ blocked_spid integer,
+ blocked_ecid integer,
+ blocked_last_tran_started timestamp,
+ blocking_spid integer,
+ blocking_ecid integer,
+ blocking_last_tran_started timestamp,
+ wait_time_ms bigint,
+ lock_mode text,
+ blocking_status text,
+ contentious_object text,
+ blocked_sql_text text,
+ blocking_sql_text text,
+ blocked_login_name text,
+ blocked_host_name text,
+ blocked_client_app text,
+ blocking_login_name text,
+ blocking_host_name text,
+ blocking_client_app text
+);
+CREATE INDEX IF NOT EXISTS idx_dmv_blocking_snapshots_time ON dmv_blocking_snapshots(server_id, collection_time);
+
+CREATE TABLE IF NOT EXISTS database_size_stats (
+ collection_id bigint NOT NULL,
+ collection_time timestamp NOT NULL,
+ server_id integer NOT NULL,
+ server_name text NOT NULL,
+ database_name text,
+ database_id integer,
+ file_id integer,
+ file_type_desc text,
+ file_name text,
+ physical_name text,
+ total_size_mb numeric(19,2),
+ used_size_mb numeric(19,2),
+ auto_growth_mb numeric(19,2),
+ max_size_mb numeric(19,2),
+ recovery_model_desc text,
+ compatibility_level integer,
+ state_desc text,
+ volume_mount_point text,
+ volume_total_mb numeric(19,2),
+ volume_free_mb numeric(19,2),
+ is_percent_growth boolean,
+ growth_pct integer,
+ vlf_count integer
+);
+CREATE INDEX IF NOT EXISTS idx_database_size_stats_time ON database_size_stats(server_id, collection_time);
+
+CREATE TABLE IF NOT EXISTS index_object_stats (
+ collection_id bigint NOT NULL,
+ collection_time timestamp NOT NULL,
+ server_id integer NOT NULL,
+ server_name text NOT NULL,
+ sqlserver_start_time timestamp,
+ database_name text,
+ database_id integer,
+ schema_name text,
+ object_id integer,
+ table_name text,
+ index_id integer,
+ index_name text,
+ index_type_desc text,
+ is_unique boolean,
+ is_primary_key boolean,
+ is_filtered boolean,
+ partition_count integer,
+ reserved_mb numeric(19,2),
+ used_mb numeric(19,2),
+ in_row_data_mb numeric(19,2),
+ lob_data_mb numeric(19,2),
+ row_overflow_mb numeric(19,2),
+ total_rows bigint,
+ user_seeks bigint,
+ user_scans bigint,
+ user_lookups bigint,
+ user_updates bigint,
+ last_user_seek timestamp,
+ last_user_scan timestamp,
+ last_user_lookup timestamp,
+ last_user_update timestamp,
+ leaf_insert_count bigint,
+ leaf_update_count bigint,
+ leaf_delete_count bigint,
+ range_scan_count bigint,
+ singleton_lookup_count bigint,
+ row_lock_count bigint,
+ row_lock_wait_count bigint,
+ row_lock_wait_in_ms bigint,
+ page_lock_count bigint,
+ page_lock_wait_count bigint,
+ page_lock_wait_in_ms bigint,
+ index_lock_promotion_attempt_count bigint,
+ index_lock_promotion_count bigint,
+ page_latch_wait_count bigint,
+ page_latch_wait_in_ms bigint,
+ page_io_latch_wait_count bigint,
+ page_io_latch_wait_in_ms bigint,
+ key_columns text,
+ included_columns text,
+ filter_definition text,
+ is_unique_constraint boolean,
+ is_foreign_key boolean,
+ is_foreign_key_reference boolean,
+ is_disabled boolean,
+ data_compression_desc text,
+ optimize_for_sequential_key boolean,
+ fill_factor smallint,
+ is_padded boolean,
+ allow_page_locks boolean,
+ allow_row_locks boolean,
+ is_indexed_view boolean
+);
+CREATE INDEX IF NOT EXISTS idx_index_object_stats_object ON index_object_stats(server_id, database_name, object_id, index_id, collection_time);
+
+CREATE TABLE IF NOT EXISTS query_stats (
+ collection_id bigint NOT NULL,
+ collection_time timestamp NOT NULL,
+ server_id integer NOT NULL,
+ server_name text NOT NULL,
+ database_name text,
+ query_hash text,
+ query_plan_hash text,
+ creation_time timestamp,
+ last_execution_time timestamp,
+ execution_count bigint,
+ total_worker_time bigint,
+ total_elapsed_time bigint,
+ total_logical_reads bigint,
+ total_logical_writes bigint,
+ total_physical_reads bigint,
+ total_clr_time bigint,
+ total_rows bigint,
+ total_spills bigint,
+ min_worker_time bigint,
+ max_worker_time bigint,
+ min_elapsed_time bigint,
+ max_elapsed_time bigint,
+ min_physical_reads bigint,
+ max_physical_reads bigint,
+ min_rows bigint,
+ max_rows bigint,
+ min_dop bigint,
+ max_dop bigint,
+ min_grant_kb bigint,
+ max_grant_kb bigint,
+ min_used_grant_kb bigint,
+ max_used_grant_kb bigint,
+ min_ideal_grant_kb bigint,
+ max_ideal_grant_kb bigint,
+ min_reserved_threads bigint,
+ max_reserved_threads bigint,
+ min_used_threads bigint,
+ max_used_threads bigint,
+ min_spills bigint,
+ max_spills bigint,
+ query_text text,
+ query_plan_xml text,
+ sql_handle text,
+ plan_handle text,
+ delta_execution_count bigint,
+ delta_worker_time bigint,
+ delta_elapsed_time bigint,
+ delta_logical_reads bigint,
+ delta_logical_writes bigint,
+ delta_physical_reads bigint,
+ delta_rows bigint,
+ delta_spills bigint,
+ plan_generation_num bigint,
+ sample_interval_seconds integer,
+ host_object_name text
+);
+CREATE INDEX IF NOT EXISTS idx_query_stats_time ON query_stats(server_id, collection_time);
+
+CREATE TABLE IF NOT EXISTS query_snapshots (
+ collection_id bigint NOT NULL,
+ collection_time timestamp NOT NULL,
+ server_id integer NOT NULL,
+ server_name text NOT NULL,
+ session_id integer,
+ database_name text,
+ elapsed_time_formatted text,
+ query_text text,
+ query_plan text,
+ live_query_plan text,
+ status text,
+ blocking_session_id integer,
+ wait_type text,
+ wait_time_ms bigint,
+ wait_resource text,
+ cpu_time_ms bigint,
+ total_elapsed_time_ms bigint,
+ reads bigint,
+ writes bigint,
+ logical_reads bigint,
+ granted_query_memory_gb numeric(18,2),
+ transaction_isolation_level text,
+ dop integer,
+ parallel_worker_count integer,
+ login_name text,
+ host_name text,
+ program_name text,
+ open_transaction_count integer,
+ percent_complete numeric(5,2),
+ is_cdc_capture boolean,
+ query_hash text,
+ requested_memory_mb double precision,
+ used_memory_mb double precision,
+ max_used_memory_mb double precision,
+ tempdb_current_mb double precision,
+ tempdb_allocations_mb double precision,
+ tran_log_used_mb double precision,
+ tran_start_time timestamp,
+ request_id integer
+);
+CREATE INDEX IF NOT EXISTS idx_query_snapshots_time ON query_snapshots(server_id, collection_time);
+
+CREATE TABLE IF NOT EXISTS query_store_stats (
+ collection_id bigint NOT NULL,
+ collection_time timestamp NOT NULL,
+ server_id integer NOT NULL,
+ server_name text NOT NULL,
+ database_name text,
+ query_id bigint,
+ plan_id bigint,
+ execution_type_desc text,
+ first_execution_time timestamp,
+ last_execution_time timestamp,
+ module_name text,
+ query_text text,
+ query_hash text,
+ execution_count bigint,
+ avg_duration_us bigint,
+ min_duration_us bigint,
+ max_duration_us bigint,
+ avg_cpu_time_us bigint,
+ min_cpu_time_us bigint,
+ max_cpu_time_us bigint,
+ avg_logical_io_reads bigint,
+ min_logical_io_reads bigint,
+ max_logical_io_reads bigint,
+ avg_logical_io_writes bigint,
+ min_logical_io_writes bigint,
+ max_logical_io_writes bigint,
+ avg_physical_io_reads bigint,
+ min_physical_io_reads bigint,
+ max_physical_io_reads bigint,
+ avg_clr_time_us bigint,
+ min_clr_time_us bigint,
+ max_clr_time_us bigint,
+ min_dop bigint,
+ max_dop bigint,
+ avg_query_max_used_memory bigint,
+ min_query_max_used_memory bigint,
+ max_query_max_used_memory bigint,
+ avg_rowcount bigint,
+ min_rowcount bigint,
+ max_rowcount bigint,
+ avg_num_physical_io_reads bigint,
+ min_num_physical_io_reads bigint,
+ max_num_physical_io_reads bigint,
+ avg_log_bytes_used bigint,
+ min_log_bytes_used bigint,
+ max_log_bytes_used bigint,
+ avg_tempdb_space_used bigint,
+ min_tempdb_space_used bigint,
+ max_tempdb_space_used bigint,
+ plan_type text,
+ plan_forcing_type text,
+ is_forced_plan boolean,
+ force_failure_count bigint,
+ last_force_failure_reason text,
+ compatibility_level integer,
+ query_plan_text text,
+ query_plan_hash text,
+ replica_role text,
+ runtime_stats_interval_id bigint,
+ interval_start_time_utc timestamp
+);
+CREATE INDEX IF NOT EXISTS idx_query_store_stats_time ON query_store_stats(server_id, collection_time);
+
+CREATE TABLE IF NOT EXISTS deadlocks (
+ deadlock_id bigint NOT NULL,
+ collection_time timestamp NOT NULL,
+ server_id integer NOT NULL,
+ server_name text NOT NULL,
+ deadlock_time timestamp,
+ victim_process_id text,
+ victim_sql_text text,
+ deadlock_graph_xml text,
+ victim_query_plan_xml text,
+ database_name text
+);
+CREATE INDEX IF NOT EXISTS idx_deadlocks_time ON deadlocks(server_id, collection_time);
+
+CREATE TABLE IF NOT EXISTS blocked_process_reports (
+ blocked_report_id bigint NOT NULL,
+ collection_time timestamp NOT NULL,
+ server_id integer NOT NULL,
+ server_name text NOT NULL,
+ event_time timestamp,
+ database_name text,
+ blocked_spid integer,
+ blocked_ecid integer,
+ blocking_spid integer,
+ blocking_ecid integer,
+ wait_time_ms bigint,
+ wait_resource text,
+ lock_mode text,
+ blocked_status text,
+ blocked_isolation_level text,
+ blocked_log_used bigint,
+ blocked_transaction_count integer,
+ blocked_client_app text,
+ blocked_host_name text,
+ blocked_login_name text,
+ blocked_sql_text text,
+ blocking_status text,
+ blocking_isolation_level text,
+ blocking_client_app text,
+ blocking_host_name text,
+ blocking_login_name text,
+ blocking_sql_text text,
+ blocked_transaction_name text,
+ blocking_transaction_name text,
+ blocked_last_tran_started timestamp,
+ blocking_last_tran_started timestamp,
+ blocked_last_batch_started timestamp,
+ blocking_last_batch_started timestamp,
+ blocked_last_batch_completed timestamp,
+ blocking_last_batch_completed timestamp,
+ blocked_priority integer,
+ blocking_priority integer,
+ blocked_process_report_xml text,
+ object_id integer,
+ database_id integer,
+ contentious_object text,
+ monitor_loop integer,
+ blocked_query_plan_xml text,
+ blocking_query_plan_xml text
+);
+CREATE INDEX IF NOT EXISTS idx_blocked_process_reports_time ON blocked_process_reports(server_id, collection_time);
+
+CREATE TABLE IF NOT EXISTS long_query_completions (
+ long_query_completion_id bigint NOT NULL,
+ collection_time timestamp NOT NULL,
+ server_id integer NOT NULL,
+ server_name text NOT NULL,
+ event_time timestamp,
+ event_type text,
+ database_id integer,
+ database_name text,
+ session_id integer,
+ client_app_name text,
+ client_pid integer,
+ nt_username text,
+ server_principal_name text,
+ query_hash text,
+ event_sequence bigint,
+ duration_microseconds bigint,
+ cpu_time_microseconds bigint,
+ physical_reads bigint,
+ logical_reads bigint,
+ writes bigint,
+ row_count bigint,
+ result text,
+ statement_text text,
+ object_name text
+);
+CREATE INDEX IF NOT EXISTS idx_long_query_completions_time ON long_query_completions(server_id, collection_time);
+
+CREATE TABLE IF NOT EXISTS system_health_events (
+ system_health_event_id bigint NOT NULL,
+ collection_time timestamp NOT NULL,
+ server_id integer NOT NULL,
+ server_name text NOT NULL,
+ event_time timestamp,
+ event_type text,
+ event_xml text
+);
+CREATE INDEX IF NOT EXISTS idx_system_health_events_time ON system_health_events(server_id, collection_time);
+
+CREATE TABLE IF NOT EXISTS default_trace_events (
+ default_trace_event_id bigint NOT NULL,
+ collection_time timestamp NOT NULL,
+ server_id integer NOT NULL,
+ server_name text NOT NULL,
+ event_time timestamp,
+ event_name text,
+ event_class integer,
+ spid integer,
+ database_name text,
+ database_id integer,
+ login_name text,
+ host_name text,
+ application_name text,
+ object_name text,
+ filename text,
+ integer_data bigint,
+ integer_data_2 bigint,
+ text_data text,
+ session_login_name text,
+ error_number integer,
+ severity integer,
+ state integer,
+ event_sequence bigint,
+ duration_us bigint,
+ end_time timestamp
+);
+CREATE INDEX IF NOT EXISTS idx_default_trace_events_time ON default_trace_events(server_id, collection_time);
+
+CREATE TABLE IF NOT EXISTS job_history (
+ job_history_id bigint NOT NULL,
+ collection_time timestamp NOT NULL,
+ server_id integer NOT NULL,
+ server_name text NOT NULL,
+ instance_id bigint,
+ job_id text,
+ job_name text,
+ job_enabled boolean,
+ category_name text,
+ step_id integer,
+ step_name text,
+ run_status integer,
+ run_status_desc text,
+ run_datetime timestamp,
+ run_duration_seconds bigint,
+ retries_attempted integer,
+ message text
+);
+CREATE INDEX IF NOT EXISTS idx_job_history_time ON job_history(server_id, collection_time);
+
+CREATE TABLE IF NOT EXISTS agent_status (
+ collection_id bigint NOT NULL,
+ collection_time timestamp NOT NULL,
+ server_id integer NOT NULL,
+ server_name text NOT NULL,
+ agent_running boolean,
+ agent_status_desc text,
+ agent_startup_desc text,
+ next_scheduled_run timestamp
+);
+CREATE INDEX IF NOT EXISTS idx_agent_status_time ON agent_status(server_id, collection_time);
+
+CREATE TABLE IF NOT EXISTS ag_replica_states (
+ collection_id bigint NOT NULL,
+ collection_time timestamp NOT NULL,
+ server_id integer NOT NULL,
+ server_name text NOT NULL,
+ ag_name text,
+ replica_server_name text,
+ role_desc text,
+ operational_state_desc text,
+ connected_state_desc text,
+ recovery_health_desc text,
+ synchronization_health_desc text,
+ availability_mode_desc text,
+ failover_mode_desc text,
+ endpoint_url text,
+ is_local boolean
+);
+CREATE INDEX IF NOT EXISTS idx_ag_replica_states_time ON ag_replica_states(server_id, collection_time);
+
+CREATE TABLE IF NOT EXISTS ag_database_replica_states (
+ collection_id bigint NOT NULL,
+ collection_time timestamp NOT NULL,
+ server_id integer NOT NULL,
+ server_name text NOT NULL,
+ ag_name text,
+ database_name text,
+ replica_server_name text,
+ is_local boolean,
+ synchronization_state_desc text,
+ last_hardened_lsn text,
+ last_commit_lsn text,
+ log_send_queue_size bigint,
+ redo_queue_size bigint,
+ log_send_rate bigint,
+ redo_rate bigint,
+ is_suspended boolean,
+ suspend_reason_desc text,
+ availability_mode_desc text,
+ secondary_lag_seconds bigint,
+ last_commit_time timestamp,
+ last_hardened_time timestamp,
+ last_redone_time timestamp,
+ last_received_time timestamp,
+ est_redo_completion_time_min double precision,
+ est_send_drain_time_min double precision
+);
+CREATE INDEX IF NOT EXISTS idx_ag_database_replica_states_time ON ag_database_replica_states(server_id, collection_time);
+
+CREATE TABLE IF NOT EXISTS plan_correction (
+ collection_id bigint NOT NULL,
+ collection_time timestamp NOT NULL,
+ server_id integer NOT NULL,
+ server_name text NOT NULL,
+ database_name text,
+ force_last_good_plan_desired_state text,
+ force_last_good_plan_actual_state text,
+ force_last_good_plan_reason text,
+ create_index_actual_state text,
+ drop_index_actual_state text,
+ recommendation_name text,
+ recommendation_type text,
+ recommendation_state text,
+ recommendation_state_reason text,
+ recommendation_reason text,
+ valid_since timestamp,
+ last_refresh timestamp,
+ score integer,
+ query_id bigint,
+ query_text text,
+ regressed_plan_id bigint,
+ last_good_plan_id bigint,
+ last_good_plan_forcing_type text,
+ last_good_plan_is_forced boolean,
+ last_good_plan_force_failure_reason text,
+ regressed_plan_execution_count bigint,
+ regressed_plan_cpu_time_average_ms double precision,
+ regressed_plan_error_count bigint,
+ last_good_plan_execution_count bigint,
+ last_good_plan_cpu_time_average_ms double precision,
+ last_good_plan_error_count bigint,
+ estimated_gain_seconds double precision,
+ is_executable_action boolean,
+ is_revertable_action boolean,
+ execute_action_initiated_by text,
+ execute_action_initiated_time timestamp,
+ execute_action_start_time timestamp,
+ execute_action_duration_seconds double precision,
+ revert_action_initiated_by text,
+ revert_action_initiated_time timestamp,
+ revert_action_start_time timestamp,
+ revert_action_duration_seconds double precision,
+ implementation_script text
+);
+CREATE INDEX IF NOT EXISTS idx_plan_correction_time ON plan_correction(server_id, collection_time);
+
+CREATE TABLE IF NOT EXISTS pvs_stats (
+ collection_id bigint NOT NULL,
+ collection_time timestamp NOT NULL,
+ server_id integer NOT NULL,
+ server_name text NOT NULL,
+ database_name text,
+ database_id integer,
+ is_accelerated_database_recovery_on boolean,
+ pvs_filegroup_id smallint,
+ persistent_version_store_size_mb numeric(19,2),
+ online_index_version_store_size_mb numeric(19,2),
+ database_data_size_mb numeric(19,2),
+ current_aborted_transaction_count bigint,
+ oldest_active_transaction_id bigint,
+ oldest_aborted_transaction_id bigint,
+ min_transaction_timestamp bigint,
+ online_index_min_transaction_timestamp bigint,
+ secondary_low_water_mark bigint,
+ offrow_version_cleaner_start_time timestamp,
+ offrow_version_cleaner_end_time timestamp,
+ aborted_version_cleaner_start_time timestamp,
+ aborted_version_cleaner_end_time timestamp,
+ pvs_off_row_page_skipped_low_water_mark bigint,
+ pvs_off_row_page_skipped_transaction_not_cleaned bigint,
+ pvs_off_row_page_skipped_oldest_active_xdesid bigint,
+ pvs_off_row_page_skipped_min_useful_xts bigint,
+ pvs_off_row_page_skipped_oldest_snapshot bigint,
+ pvs_off_row_page_skipped_oldest_aborted_xdesid bigint
+);
+CREATE INDEX IF NOT EXISTS idx_pvs_stats_time ON pvs_stats(server_id, collection_time);
+
+CREATE TABLE IF NOT EXISTS pg_wait_stats (
+ collection_id bigint NOT NULL,
+ collection_time timestamp NOT NULL,
+ server_id integer NOT NULL,
+ server_name text NOT NULL,
+ wait_type_id integer,
+ wait_event_id bigint,
+ wait_type text,
+ wait_event text,
+ waits bigint,
+ wait_time_us bigint,
+ delta_waits bigint,
+ delta_wait_time_us bigint
+);
+CREATE INDEX IF NOT EXISTS idx_pg_wait_stats_time ON pg_wait_stats(server_id, collection_time);
+
+CREATE TABLE IF NOT EXISTS pg_statement_stats (
+ collection_id bigint NOT NULL,
+ collection_time timestamp NOT NULL,
+ server_id integer NOT NULL,
+ server_name text NOT NULL,
+ queryid bigint,
+ database_id bigint,
+ user_id bigint,
+ toplevel boolean,
+ calls bigint,
+ total_exec_time_ms double precision,
+ min_exec_time_ms double precision,
+ max_exec_time_ms double precision,
+ mean_exec_time_ms double precision,
+ rows_returned bigint,
+ shared_blks_hit bigint,
+ shared_blks_read bigint,
+ shared_blks_dirtied bigint,
+ shared_blks_written bigint,
+ temp_blks_read bigint,
+ temp_blks_written bigint,
+ blk_read_time_ms double precision,
+ blk_write_time_ms double precision,
+ storage_blks_read bigint,
+ orcache_blks_hit bigint,
+ storage_blk_read_time_ms double precision,
+ orcache_blk_read_time_ms double precision,
+ wal_records bigint,
+ wal_fpi bigint,
+ wal_bytes bigint,
+ total_exec_peakmem_bytes bigint,
+ max_exec_peakmem_bytes bigint,
+ delta_calls bigint,
+ delta_total_exec_time_ms bigint,
+ delta_rows bigint
+);
+CREATE INDEX IF NOT EXISTS idx_pg_statement_stats_time ON pg_statement_stats(server_id, collection_time);
+
+CREATE TABLE IF NOT EXISTS pg_wraparound_stats (
+ collection_id bigint NOT NULL,
+ collection_time timestamp NOT NULL,
+ server_id integer NOT NULL,
+ server_name text NOT NULL,
+ database_name text,
+ frozen_xid_age bigint,
+ min_multixid_age bigint,
+ autovacuum_freeze_max_age bigint,
+ autovacuum_multixact_freeze_max_age bigint,
+ pct_toward_emergency_vacuum double precision,
+ pct_toward_wraparound double precision,
+ pct_toward_multixact_emergency double precision,
+ pct_toward_multixact_wraparound double precision,
+ xids_remaining bigint,
+ multixids_remaining bigint,
+ allows_connections boolean
+);
+CREATE INDEX IF NOT EXISTS idx_pg_wraparound_stats_time ON pg_wraparound_stats(server_id, collection_time);
+
+CREATE TABLE IF NOT EXISTS pg_server_config (
+ collection_id bigint NOT NULL,
+ collection_time timestamp NOT NULL,
+ server_id integer NOT NULL,
+ server_name text NOT NULL,
+ name text,
+ setting text,
+ unit text,
+ category text,
+ context text,
+ vartype text,
+ source text,
+ boot_val text,
+ reset_val text,
+ sourcefile text,
+ sourceline integer,
+ pending_restart boolean,
+ short_desc text
+);
+CREATE INDEX IF NOT EXISTS idx_pg_server_config_time ON pg_server_config(server_id, collection_time);
+
+CREATE TABLE IF NOT EXISTS pg_deadlocks (
+ collection_id bigint NOT NULL,
+ collection_time timestamp NOT NULL,
+ server_id integer NOT NULL,
+ server_name text NOT NULL,
+ occurred_at timestamp,
+ victim_pid integer,
+ participant_count integer,
+ deadlock_hash text,
+ lock_modes text,
+ resources text,
+ victim_statement text,
+ graph_text text
+);
+CREATE INDEX IF NOT EXISTS idx_pg_deadlocks_time ON pg_deadlocks(server_id, collection_time);
+
+CREATE TABLE IF NOT EXISTS pg_xmin_horizon (
+ collection_id bigint NOT NULL,
+ collection_time timestamp NOT NULL,
+ server_id integer NOT NULL,
+ server_name text NOT NULL,
+ source text,
+ xmin_age bigint,
+ holder text,
+ detail text,
+ is_winner boolean
+);
+CREATE INDEX IF NOT EXISTS idx_pg_xmin_horizon_time ON pg_xmin_horizon(server_id, collection_time);
+
+CREATE TABLE IF NOT EXISTS pg_replication_slot_stats (
+ collection_id bigint NOT NULL,
+ collection_time timestamp NOT NULL,
+ server_id integer NOT NULL,
+ server_name text NOT NULL,
+ slot_name text,
+ slot_type text,
+ plugin text,
+ database_name text,
+ is_active boolean,
+ active_pid bigint,
+ is_temporary boolean,
+ two_phase boolean,
+ wal_status text,
+ safe_wal_size_bytes bigint,
+ retained_wal_bytes bigint,
+ xmin_age bigint,
+ catalog_xmin_age bigint,
+ inactive_since timestamp,
+ invalidation_reason text,
+ conflicting boolean
+);
+CREATE INDEX IF NOT EXISTS idx_pg_replication_slot_stats_time ON pg_replication_slot_stats(server_id, collection_time);
+
+CREATE TABLE IF NOT EXISTS pg_autovacuum_stats (
+ collection_id bigint NOT NULL,
+ collection_time timestamp NOT NULL,
+ server_id integer NOT NULL,
+ server_name text NOT NULL,
+ database_name text,
+ schema_name text,
+ table_name text,
+ live_tuples bigint,
+ dead_tuples bigint,
+ mods_since_analyze bigint,
+ inserts_since_vacuum bigint,
+ vacuum_threshold bigint,
+ insert_vacuum_threshold bigint,
+ analyze_threshold bigint,
+ autovacuum_disabled boolean,
+ total_bytes bigint,
+ last_vacuum timestamp,
+ last_autovacuum timestamp,
+ last_analyze timestamp,
+ last_autoanalyze timestamp,
+ vacuum_count bigint,
+ autovacuum_count bigint,
+ analyze_count bigint,
+ autoanalyze_count bigint
+);
+CREATE INDEX IF NOT EXISTS idx_pg_autovacuum_stats_time ON pg_autovacuum_stats(server_id, collection_time);
+
+CREATE TABLE IF NOT EXISTS pg_io_stats (
+ collection_id bigint NOT NULL,
+ collection_time timestamp NOT NULL,
+ server_id integer NOT NULL,
+ server_name text NOT NULL,
+ backend_type text,
+ object_type text,
+ context text,
+ reads bigint,
+ read_time_ms double precision,
+ writes bigint,
+ write_time_ms double precision,
+ writebacks bigint,
+ writeback_time_ms double precision,
+ extends bigint,
+ extend_time_ms double precision,
+ op_bytes bigint,
+ hits bigint,
+ evictions bigint,
+ reuses bigint,
+ fsyncs bigint,
+ fsync_time_ms double precision,
+ stats_reset timestamp,
+ read_bytes numeric(28,0),
+ write_bytes numeric(28,0),
+ extend_bytes numeric(28,0)
+);
+CREATE INDEX IF NOT EXISTS idx_pg_io_stats_time ON pg_io_stats(server_id, collection_time);
+
+CREATE TABLE IF NOT EXISTS pg_blocking_edges (
+ collection_id bigint NOT NULL,
+ collection_time timestamp NOT NULL,
+ server_id integer NOT NULL,
+ server_name text NOT NULL,
+ blocked_backend_id bigint,
+ blocked_pid integer,
+ blocking_backend_id bigint,
+ blocking_pid integer,
+ database_name text,
+ blocked_username text,
+ blocked_application_name text,
+ blocked_client_addr text,
+ blocked_state text,
+ blocked_wait_event_type text,
+ blocked_wait_event text,
+ blocked_query text,
+ blocked_xact_duration_ms bigint,
+ blocked_query_duration_ms bigint,
+ blocking_username text,
+ blocking_application_name text,
+ blocking_client_addr text,
+ blocking_state text,
+ blocking_wait_event_type text,
+ blocking_wait_event text,
+ blocking_query text,
+ blocking_xact_duration_ms bigint,
+ blocking_query_duration_ms bigint,
+ blocked_pid_count integer,
+ blocking_is_idle_in_transaction boolean,
+ query_text_may_be_truncated boolean
+);
+CREATE INDEX IF NOT EXISTS idx_pg_blocking_edges_time ON pg_blocking_edges(server_id, collection_time);
+
+CREATE TABLE IF NOT EXISTS pg_database_stats (
+ collection_id bigint NOT NULL,
+ collection_time timestamp NOT NULL,
+ server_id integer NOT NULL,
+ server_name text NOT NULL,
+ database_name text,
+ xact_commit bigint,
+ xact_rollback bigint,
+ blks_read bigint,
+ blks_hit bigint,
+ temp_files bigint,
+ temp_bytes bigint,
+ deadlocks bigint,
+ stats_reset timestamp
+);
+CREATE INDEX IF NOT EXISTS idx_pg_database_stats_time ON pg_database_stats(server_id, collection_time);
+
+CREATE TABLE IF NOT EXISTS pg_index_usage_stats (
+ collection_id bigint NOT NULL,
+ collection_time timestamp NOT NULL,
+ server_id integer NOT NULL,
+ server_name text NOT NULL,
+ database_name text,
+ schema_name text,
+ table_name text,
+ index_name text,
+ index_scans bigint,
+ tuples_read bigint,
+ tuples_fetched bigint,
+ blocks_read bigint,
+ blocks_hit bigint,
+ index_bytes bigint,
+ table_bytes bigint,
+ is_unique boolean,
+ is_primary_key boolean,
+ is_valid boolean,
+ is_ready boolean,
+ is_replica_identity boolean,
+ is_partial boolean,
+ is_expression boolean,
+ supports_constraint boolean,
+ index_method text,
+ column_count integer,
+ index_definition text,
+ last_scan timestamp,
+ stats_reset timestamp
+);
+CREATE INDEX IF NOT EXISTS idx_pg_index_usage_stats_time ON pg_index_usage_stats(server_id, collection_time);
+
+CREATE TABLE IF NOT EXISTS pg_table_bloat_stats (
+ collection_id bigint NOT NULL,
+ collection_time timestamp NOT NULL,
+ server_id integer NOT NULL,
+ server_name text NOT NULL,
+ database_name text,
+ schema_name text,
+ table_name text,
+ heap_bytes bigint,
+ heap_pages bigint,
+ toast_bytes bigint,
+ index_bytes bigint,
+ live_tuples bigint,
+ dead_tuples bigint,
+ mods_since_analyze bigint,
+ last_analyzed timestamp,
+ estimated_tuple_bytes double precision,
+ estimated_heap_pages bigint,
+ fillfactor integer,
+ bloat_bytes_estimate bigint,
+ bloat_pct_estimate numeric(5,2),
+ estimate_unavailable boolean,
+ alignment_bytes integer,
+ pgstattuple_available boolean
+);
+CREATE INDEX IF NOT EXISTS idx_pg_table_bloat_stats_time ON pg_table_bloat_stats(server_id, collection_time);
+
+CREATE TABLE IF NOT EXISTS pg_session_states (
+ collection_id bigint NOT NULL,
+ collection_time timestamp NOT NULL,
+ server_id integer NOT NULL,
+ server_name text NOT NULL,
+ backend_id bigint,
+ pid integer,
+ database_name text,
+ username text,
+ application_name text,
+ client_addr text,
+ backend_type text,
+ state text,
+ wait_event_type text,
+ wait_event text,
+ command_tag text,
+ query_id bigint,
+ state_duration_ms bigint,
+ xact_duration_ms bigint,
+ query_duration_ms bigint,
+ backend_duration_ms bigint,
+ xmin_age bigint,
+ xid_age bigint,
+ horizon_age bigint,
+ is_idle_in_transaction boolean,
+ is_horizon_holder boolean,
+ state_is_redacted boolean,
+ total_sessions integer,
+ active_sessions integer,
+ idle_in_transaction_sessions integer,
+ reportable_sessions integer
+);
+CREATE INDEX IF NOT EXISTS idx_pg_session_states_time ON pg_session_states(server_id, collection_time);
+
+CREATE TABLE IF NOT EXISTS pg_plan_capture_readiness (
+ collection_id bigint NOT NULL,
+ collection_time timestamp NOT NULL,
+ server_id integer NOT NULL,
+ server_name text NOT NULL,
+ facet text,
+ is_satisfied boolean,
+ observed text,
+ detail text
+);
+CREATE INDEX IF NOT EXISTS idx_pg_plan_capture_readiness_time ON pg_plan_capture_readiness(server_id, collection_time);
+
+CREATE TABLE IF NOT EXISTS pg_write_stats (
+ collection_id bigint NOT NULL,
+ collection_time timestamp NOT NULL,
+ server_id integer NOT NULL,
+ server_name text NOT NULL,
+ num_timed bigint,
+ num_requested bigint,
+ num_done bigint,
+ restartpoints_timed bigint,
+ restartpoints_req bigint,
+ restartpoints_done bigint,
+ checkpoint_write_time_ms double precision,
+ checkpoint_sync_time_ms double precision,
+ buffers_written_checkpoint bigint,
+ slru_written bigint,
+ checkpointer_stats_reset timestamp,
+ buffers_clean bigint,
+ maxwritten_clean bigint,
+ buffers_alloc bigint,
+ buffers_backend bigint,
+ buffers_backend_fsync bigint,
+ bgwriter_stats_reset timestamp,
+ wal_records bigint,
+ wal_fpi bigint,
+ wal_bytes numeric(38,0),
+ wal_buffers_full bigint,
+ wal_write bigint,
+ wal_sync bigint,
+ wal_write_time_ms double precision,
+ wal_sync_time_ms double precision,
+ wal_stats_reset timestamp
+);
+CREATE INDEX IF NOT EXISTS idx_pg_write_stats_time ON pg_write_stats(server_id, collection_time);
+
+CREATE TABLE IF NOT EXISTS pg_extension_availability (
+ collection_id bigint NOT NULL,
+ collection_time timestamp NOT NULL,
+ server_id integer NOT NULL,
+ server_name text NOT NULL,
+ database_name text,
+ extension_name text,
+ state text,
+ installed_version text,
+ default_version text,
+ is_monitoring_relevant boolean,
+ comment text
+);
+CREATE INDEX IF NOT EXISTS idx_pg_extension_availability_time ON pg_extension_availability(server_id, collection_time);
+
+CREATE TABLE IF NOT EXISTS pg_lock_stats (
+ collection_id bigint NOT NULL,
+ collection_time timestamp NOT NULL,
+ server_id integer NOT NULL,
+ server_name text NOT NULL,
+ database_name text,
+ lock_type text,
+ mode text,
+ granted boolean,
+ relation_oid bigint,
+ relation_name text,
+ backend_count bigint,
+ oldest_wait_ms double precision
+);
+CREATE INDEX IF NOT EXISTS idx_pg_lock_stats_time ON pg_lock_stats(server_id, collection_time);
+
+CREATE TABLE IF NOT EXISTS pg_wait_sampling (
+ collection_id bigint NOT NULL,
+ collection_time timestamp NOT NULL,
+ server_id integer NOT NULL,
+ server_name text NOT NULL,
+ event_type text,
+ event text,
+ query_id bigint,
+ sample_count bigint,
+ profile_period_ms integer,
+ backend_count integer
+);
+CREATE INDEX IF NOT EXISTS idx_pg_wait_sampling_time ON pg_wait_sampling(server_id, collection_time);
+
+CREATE TABLE IF NOT EXISTS pg_kernel_stats (
+ collection_id bigint NOT NULL,
+ collection_time timestamp NOT NULL,
+ server_id integer NOT NULL,
+ server_name text NOT NULL,
+ database_name text,
+ query_id bigint,
+ exec_user_time_ms double precision,
+ exec_system_time_ms double precision,
+ plan_cpu_time_ms double precision,
+ exec_read_bytes bigint,
+ exec_write_bytes bigint,
+ minor_faults bigint,
+ major_faults bigint,
+ stats_since timestamp
+);
+CREATE INDEX IF NOT EXISTS idx_pg_kernel_stats_time ON pg_kernel_stats(server_id, collection_time);
+
+CREATE TABLE IF NOT EXISTS pg_predicate_stats (
+ collection_id bigint NOT NULL,
+ collection_time timestamp NOT NULL,
+ server_id integer NOT NULL,
+ server_name text NOT NULL,
+ database_name text,
+ schema_name text,
+ table_name text,
+ column_name text,
+ operator text,
+ query_id bigint,
+ sample_count bigint,
+ rows_evaluated bigint,
+ rows_filtered bigint,
+ worst_estimate_error_ratio double precision,
+ sample_rate double precision
+);
+CREATE INDEX IF NOT EXISTS idx_pg_predicate_stats_time ON pg_predicate_stats(server_id, collection_time);
+
+CREATE TABLE IF NOT EXISTS pg_plan_capture (
+ collection_id bigint NOT NULL,
+ collection_time timestamp NOT NULL,
+ server_id integer NOT NULL,
+ server_name text NOT NULL,
+ query_id bigint,
+ plan_hash text,
+ duration_ms double precision,
+ node_count integer,
+ top_node_type text,
+ plan_json text
+);
+CREATE INDEX IF NOT EXISTS idx_pg_plan_capture_time ON pg_plan_capture(server_id, collection_time);
+
+CREATE TABLE IF NOT EXISTS pg_column_stats (
+ collection_id bigint NOT NULL,
+ collection_time timestamp NOT NULL,
+ server_id integer NOT NULL,
+ server_name text NOT NULL,
+ database_name text,
+ schema_name text,
+ table_name text,
+ column_name text,
+ n_distinct double precision,
+ null_frac double precision,
+ avg_width integer,
+ correlation double precision,
+ top_value_frequency double precision,
+ common_value_count integer
+);
+CREATE INDEX IF NOT EXISTS idx_pg_column_stats_time ON pg_column_stats(server_id, collection_time);
+
+CREATE TABLE IF NOT EXISTS pg_replication_stats (
+ collection_id bigint NOT NULL,
+ collection_time timestamp NOT NULL,
+ server_id integer NOT NULL,
+ server_name text NOT NULL,
+ application_name text,
+ client_addr text,
+ state text,
+ sync_state text,
+ sync_priority integer,
+ sent_bytes_behind bigint,
+ write_bytes_behind bigint,
+ flush_bytes_behind bigint,
+ replay_bytes_behind bigint,
+ write_lag_ms double precision,
+ flush_lag_ms double precision,
+ replay_lag_ms double precision,
+ backend_start timestamp
+);
+CREATE INDEX IF NOT EXISTS idx_pg_replication_stats_time ON pg_replication_stats(server_id, collection_time);
+
+CREATE TABLE IF NOT EXISTS pg_buffer_usage (
+ collection_id bigint NOT NULL,
+ collection_time timestamp NOT NULL,
+ server_id integer NOT NULL,
+ server_name text NOT NULL,
+ database_name text,
+ relation_name text,
+ relation_kind text,
+ buffers bigint,
+ dirty_buffers bigint,
+ avg_usage_count double precision,
+ pool_buffers_total bigint,
+ pool_buffers_used bigint
+);
+CREATE INDEX IF NOT EXISTS idx_pg_buffer_usage_time ON pg_buffer_usage(server_id, collection_time);
+
+CREATE TABLE IF NOT EXISTS pg_index_bloat (
+ collection_id bigint NOT NULL,
+ collection_time timestamp NOT NULL,
+ server_id integer NOT NULL,
+ server_name text NOT NULL,
+ database_name text,
+ schema_name text,
+ table_name text,
+ index_name text,
+ index_bytes bigint,
+ tree_level integer,
+ internal_pages bigint,
+ leaf_pages bigint,
+ empty_pages bigint,
+ deleted_pages bigint,
+ avg_leaf_density double precision,
+ leaf_fragmentation double precision,
+ skipped_reason text
+);
+CREATE INDEX IF NOT EXISTS idx_pg_index_bloat_time ON pg_index_bloat(server_id, collection_time);
+INSERT INTO darling_schema_version (version, name, applied_at) VALUES (1, 'collector-tables', now()::timestamp);
+-- ===BATCH=== V2 server-registry-and-collection-log
+
+CREATE TABLE IF NOT EXISTS servers (
+ server_id integer NOT NULL PRIMARY KEY,
+ server_name text NOT NULL,
+ display_name text,
+ is_enabled boolean NOT NULL DEFAULT TRUE,
+ sql_engine_edition integer,
+ sql_major_version integer,
+ created_date timestamp,
+ modified_date timestamp
+);
+
+CREATE TABLE IF NOT EXISTS collection_log (
+ log_id bigint NOT NULL,
+ server_id integer NOT NULL,
+ server_name text,
+ collector_name text NOT NULL,
+ collection_time timestamp NOT NULL,
+ duration_ms integer,
+ status text NOT NULL,
+ error_message text,
+ rows_collected integer,
+ sql_duration_ms integer,
+ duckdb_duration_ms integer
+);
+
+CREATE INDEX IF NOT EXISTS idx_collection_log_time ON collection_log(server_id, collection_time);
+INSERT INTO darling_schema_version (version, name, applied_at) VALUES (2, 'server-registry-and-collection-log', now()::timestamp);
+-- ===BATCH=== V3 alerting-stores
+
+CREATE TABLE IF NOT EXISTS config_alert_log (
+ alert_time timestamp NOT NULL,
+ server_id integer NOT NULL,
+ server_name text NOT NULL,
+ metric_name text NOT NULL,
+ current_value double precision NOT NULL,
+ threshold_value double precision NOT NULL,
+ alert_sent boolean NOT NULL DEFAULT FALSE,
+ notification_type text NOT NULL DEFAULT 'tray',
+ send_error text,
+ dismissed boolean NOT NULL DEFAULT FALSE,
+ muted boolean NOT NULL DEFAULT FALSE,
+ detail_text text,
+ context_json text
+);
+
+CREATE INDEX IF NOT EXISTS idx_config_alert_log_time ON config_alert_log(server_id, metric_name, alert_time);
+
+CREATE TABLE IF NOT EXISTS config_edge_trigger_watermarks (
+ server_id integer NOT NULL,
+ metric_name text NOT NULL,
+ watermark integer NOT NULL,
+ watermark_time timestamp,
+ updated_at timestamp NOT NULL,
+ PRIMARY KEY (server_id, metric_name)
+);
+
+CREATE TABLE IF NOT EXISTS config_mute_rules (
+ id text NOT NULL PRIMARY KEY,
+ enabled boolean NOT NULL DEFAULT TRUE,
+ created_at_utc timestamp NOT NULL,
+ expires_at_utc timestamp,
+ reason text,
+ server_name text,
+ metric_name text,
+ database_pattern text,
+ query_text_pattern text,
+ wait_type_pattern text,
+ job_name_pattern text
+);
+INSERT INTO darling_schema_version (version, name, applied_at) VALUES (3, 'alerting-stores', now()::timestamp);
+-- ===BATCH=== V4 analysis-tables
+
+CREATE TABLE IF NOT EXISTS analysis_findings (
+ finding_id bigint NOT NULL,
+ analysis_time timestamp NOT NULL,
+ server_id integer NOT NULL,
+ server_name text NOT NULL,
+ database_name text,
+ time_range_start timestamp,
+ time_range_end timestamp,
+ severity double precision NOT NULL,
+ confidence double precision NOT NULL,
+ category text NOT NULL,
+ story_path text NOT NULL,
+ story_path_hash text NOT NULL,
+ story_text text NOT NULL,
+ root_fact_key text NOT NULL,
+ root_fact_value double precision,
+ leaf_fact_key text,
+ leaf_fact_value double precision,
+ fact_count integer NOT NULL,
+ incident_id text,
+ remediation_action_json text
+);
+
+CREATE INDEX IF NOT EXISTS idx_analysis_findings_time ON analysis_findings(server_id, analysis_time);
+CREATE INDEX IF NOT EXISTS idx_analysis_findings_hash ON analysis_findings(story_path_hash);
+
+CREATE TABLE IF NOT EXISTS analysis_muted (
+ mute_id bigint NOT NULL PRIMARY KEY,
+ server_id integer,
+ database_name text,
+ story_path_hash text NOT NULL,
+ story_path text NOT NULL,
+ muted_date timestamp NOT NULL,
+ reason text
+);
+
+CREATE INDEX IF NOT EXISTS idx_analysis_muted_hash ON analysis_muted(story_path_hash);
+
+CREATE OR REPLACE VIEW v_wait_stats AS SELECT * FROM wait_stats;
+CREATE OR REPLACE VIEW v_query_stats AS SELECT * FROM query_stats;
+CREATE OR REPLACE VIEW v_query_store_stats AS SELECT * FROM query_store_stats;
+CREATE OR REPLACE VIEW v_cpu_utilization_stats AS SELECT * FROM cpu_utilization_stats;
+CREATE OR REPLACE VIEW v_memory_grant_stats AS SELECT * FROM memory_grant_stats;
+CREATE OR REPLACE VIEW v_memory_stats AS SELECT * FROM memory_stats;
+CREATE OR REPLACE VIEW v_perfmon_stats AS SELECT * FROM perfmon_stats;
+CREATE OR REPLACE VIEW v_session_stats AS SELECT * FROM session_stats;
+CREATE OR REPLACE VIEW v_file_io_stats AS SELECT * FROM file_io_stats;
+CREATE OR REPLACE VIEW v_blocked_process_reports AS SELECT * FROM blocked_process_reports;
+CREATE OR REPLACE VIEW v_deadlocks AS SELECT * FROM deadlocks;
+CREATE OR REPLACE VIEW v_dmv_blocking_snapshots AS SELECT * FROM dmv_blocking_snapshots;
+CREATE OR REPLACE VIEW v_index_object_stats AS SELECT * FROM index_object_stats;
+CREATE OR REPLACE VIEW v_database_size_stats AS SELECT * FROM database_size_stats;
+CREATE OR REPLACE VIEW v_tempdb_stats AS SELECT * FROM tempdb_stats;
+CREATE OR REPLACE VIEW v_query_snapshots AS SELECT * FROM query_snapshots;
+CREATE OR REPLACE VIEW v_database_config AS SELECT * FROM database_config;
+INSERT INTO darling_schema_version (version, name, applied_at) VALUES (4, 'analysis-tables', now()::timestamp);
+-- ===BATCH=== V5 viewer-passthrough-views
+
+CREATE OR REPLACE VIEW v_running_jobs AS SELECT * FROM running_jobs;
+CREATE OR REPLACE VIEW v_server_config AS SELECT * FROM server_config;
+CREATE OR REPLACE VIEW v_database_scoped_config AS SELECT * FROM database_scoped_config;
+CREATE OR REPLACE VIEW v_trace_flags AS SELECT * FROM trace_flags;
+CREATE OR REPLACE VIEW v_collection_log AS SELECT * FROM collection_log;
+INSERT INTO darling_schema_version (version, name, applied_at) VALUES (5, 'viewer-passthrough-views', now()::timestamp);
+-- ===BATCH=== V6 memory-tab-passthrough-views
+
+CREATE OR REPLACE VIEW v_memory_clerks AS SELECT * FROM memory_clerks;
+CREATE OR REPLACE VIEW v_memory_pressure_events AS SELECT * FROM memory_pressure_events;
+INSERT INTO darling_schema_version (version, name, applied_at) VALUES (6, 'memory-tab-passthrough-views', now()::timestamp);
+-- ===BATCH=== V7 viewer-plan-capture-columns
+
+ALTER TABLE procedure_stats ADD COLUMN IF NOT EXISTS query_plan_xml text;
+ALTER TABLE blocked_process_reports ADD COLUMN IF NOT EXISTS blocked_query_plan_xml text;
+ALTER TABLE blocked_process_reports ADD COLUMN IF NOT EXISTS blocking_query_plan_xml text;
+ALTER TABLE deadlocks ADD COLUMN IF NOT EXISTS victim_query_plan_xml text;
+INSERT INTO darling_schema_version (version, name, applied_at) VALUES (7, 'viewer-plan-capture-columns', now()::timestamp);
+-- ===BATCH=== V8 schema-split-collect-config
+/* V8: split public into collect/config (Darling security hardening, #1262).
+ Table NAMES are unchanged; search_path = collect, config, public resolves the bare
+ references every SQL site already uses, so no query is re-qualified. */
+
+CREATE SCHEMA IF NOT EXISTS collect AUTHORIZATION darling;
+CREATE SCHEMA IF NOT EXISTS config AUTHORIZATION darling;
+
+/* collect: all collector tables (from the catalog) + registry/metadata + the V4-V6 views */
+ALTER TABLE IF EXISTS public.wait_stats SET SCHEMA collect;
+ALTER TABLE IF EXISTS public.latch_stats SET SCHEMA collect;
+ALTER TABLE IF EXISTS public.spinlock_stats SET SCHEMA collect;
+ALTER TABLE IF EXISTS public.cpu_scheduler_stats SET SCHEMA collect;
+ALTER TABLE IF EXISTS public.plan_cache_stats SET SCHEMA collect;
+ALTER TABLE IF EXISTS public.tempdb_stats SET SCHEMA collect;
+ALTER TABLE IF EXISTS public.memory_grant_stats SET SCHEMA collect;
+ALTER TABLE IF EXISTS public.cpu_utilization_stats SET SCHEMA collect;
+ALTER TABLE IF EXISTS public.memory_stats SET SCHEMA collect;
+ALTER TABLE IF EXISTS public.memory_clerks SET SCHEMA collect;
+ALTER TABLE IF EXISTS public.memory_pressure_events SET SCHEMA collect;
+ALTER TABLE IF EXISTS public.file_io_stats SET SCHEMA collect;
+ALTER TABLE IF EXISTS public.server_properties SET SCHEMA collect;
+ALTER TABLE IF EXISTS public.server_config SET SCHEMA collect;
+ALTER TABLE IF EXISTS public.database_config SET SCHEMA collect;
+ALTER TABLE IF EXISTS public.database_states SET SCHEMA collect;
+ALTER TABLE IF EXISTS public.trace_flags SET SCHEMA collect;
+ALTER TABLE IF EXISTS public.database_scoped_config SET SCHEMA collect;
+ALTER TABLE IF EXISTS public.query_store_health SET SCHEMA collect;
+ALTER TABLE IF EXISTS public.session_stats SET SCHEMA collect;
+ALTER TABLE IF EXISTS public.session_summary_stats SET SCHEMA collect;
+ALTER TABLE IF EXISTS public.waiting_tasks SET SCHEMA collect;
+ALTER TABLE IF EXISTS public.procedure_stats SET SCHEMA collect;
+ALTER TABLE IF EXISTS public.running_jobs SET SCHEMA collect;
+ALTER TABLE IF EXISTS public.perfmon_stats SET SCHEMA collect;
+ALTER TABLE IF EXISTS public.dmv_blocking_snapshots SET SCHEMA collect;
+ALTER TABLE IF EXISTS public.database_size_stats SET SCHEMA collect;
+ALTER TABLE IF EXISTS public.index_object_stats SET SCHEMA collect;
+ALTER TABLE IF EXISTS public.query_stats SET SCHEMA collect;
+ALTER TABLE IF EXISTS public.query_snapshots SET SCHEMA collect;
+ALTER TABLE IF EXISTS public.query_store_stats SET SCHEMA collect;
+ALTER TABLE IF EXISTS public.deadlocks SET SCHEMA collect;
+ALTER TABLE IF EXISTS public.blocked_process_reports SET SCHEMA collect;
+ALTER TABLE IF EXISTS public.long_query_completions SET SCHEMA collect;
+ALTER TABLE IF EXISTS public.system_health_events SET SCHEMA collect;
+ALTER TABLE IF EXISTS public.default_trace_events SET SCHEMA collect;
+ALTER TABLE IF EXISTS public.job_history SET SCHEMA collect;
+ALTER TABLE IF EXISTS public.agent_status SET SCHEMA collect;
+ALTER TABLE IF EXISTS public.ag_replica_states SET SCHEMA collect;
+ALTER TABLE IF EXISTS public.ag_database_replica_states SET SCHEMA collect;
+ALTER TABLE IF EXISTS public.plan_correction SET SCHEMA collect;
+ALTER TABLE IF EXISTS public.pvs_stats SET SCHEMA collect;
+ALTER TABLE IF EXISTS public.pg_wait_stats SET SCHEMA collect;
+ALTER TABLE IF EXISTS public.pg_statement_stats SET SCHEMA collect;
+ALTER TABLE IF EXISTS public.pg_wraparound_stats SET SCHEMA collect;
+ALTER TABLE IF EXISTS public.pg_server_config SET SCHEMA collect;
+ALTER TABLE IF EXISTS public.pg_deadlocks SET SCHEMA collect;
+ALTER TABLE IF EXISTS public.pg_xmin_horizon SET SCHEMA collect;
+ALTER TABLE IF EXISTS public.pg_replication_slot_stats SET SCHEMA collect;
+ALTER TABLE IF EXISTS public.pg_autovacuum_stats SET SCHEMA collect;
+ALTER TABLE IF EXISTS public.pg_io_stats SET SCHEMA collect;
+ALTER TABLE IF EXISTS public.pg_blocking_edges SET SCHEMA collect;
+ALTER TABLE IF EXISTS public.pg_database_stats SET SCHEMA collect;
+ALTER TABLE IF EXISTS public.pg_index_usage_stats SET SCHEMA collect;
+ALTER TABLE IF EXISTS public.pg_table_bloat_stats SET SCHEMA collect;
+ALTER TABLE IF EXISTS public.pg_session_states SET SCHEMA collect;
+ALTER TABLE IF EXISTS public.pg_plan_capture_readiness SET SCHEMA collect;
+ALTER TABLE IF EXISTS public.pg_write_stats SET SCHEMA collect;
+ALTER TABLE IF EXISTS public.pg_extension_availability SET SCHEMA collect;
+ALTER TABLE IF EXISTS public.pg_lock_stats SET SCHEMA collect;
+ALTER TABLE IF EXISTS public.pg_wait_sampling SET SCHEMA collect;
+ALTER TABLE IF EXISTS public.pg_kernel_stats SET SCHEMA collect;
+ALTER TABLE IF EXISTS public.pg_predicate_stats SET SCHEMA collect;
+ALTER TABLE IF EXISTS public.pg_plan_capture SET SCHEMA collect;
+ALTER TABLE IF EXISTS public.pg_column_stats SET SCHEMA collect;
+ALTER TABLE IF EXISTS public.pg_replication_stats SET SCHEMA collect;
+ALTER TABLE IF EXISTS public.pg_buffer_usage SET SCHEMA collect;
+ALTER TABLE IF EXISTS public.pg_index_bloat SET SCHEMA collect;
+ALTER TABLE IF EXISTS public.servers SET SCHEMA collect;
+ALTER TABLE IF EXISTS public.collection_log SET SCHEMA collect;
+ALTER TABLE IF EXISTS public.analysis_findings SET SCHEMA collect;
+ALTER TABLE IF EXISTS public.darling_schema_version SET SCHEMA collect;
+ALTER VIEW IF EXISTS public.v_wait_stats SET SCHEMA collect;
+ALTER VIEW IF EXISTS public.v_query_stats SET SCHEMA collect;
+ALTER VIEW IF EXISTS public.v_query_store_stats SET SCHEMA collect;
+ALTER VIEW IF EXISTS public.v_cpu_utilization_stats SET SCHEMA collect;
+ALTER VIEW IF EXISTS public.v_memory_grant_stats SET SCHEMA collect;
+ALTER VIEW IF EXISTS public.v_memory_stats SET SCHEMA collect;
+ALTER VIEW IF EXISTS public.v_perfmon_stats SET SCHEMA collect;
+ALTER VIEW IF EXISTS public.v_session_stats SET SCHEMA collect;
+ALTER VIEW IF EXISTS public.v_file_io_stats SET SCHEMA collect;
+ALTER VIEW IF EXISTS public.v_blocked_process_reports SET SCHEMA collect;
+ALTER VIEW IF EXISTS public.v_deadlocks SET SCHEMA collect;
+ALTER VIEW IF EXISTS public.v_dmv_blocking_snapshots SET SCHEMA collect;
+ALTER VIEW IF EXISTS public.v_index_object_stats SET SCHEMA collect;
+ALTER VIEW IF EXISTS public.v_database_size_stats SET SCHEMA collect;
+ALTER VIEW IF EXISTS public.v_tempdb_stats SET SCHEMA collect;
+ALTER VIEW IF EXISTS public.v_query_snapshots SET SCHEMA collect;
+ALTER VIEW IF EXISTS public.v_database_config SET SCHEMA collect;
+ALTER VIEW IF EXISTS public.v_running_jobs SET SCHEMA collect;
+ALTER VIEW IF EXISTS public.v_server_config SET SCHEMA collect;
+ALTER VIEW IF EXISTS public.v_database_scoped_config SET SCHEMA collect;
+ALTER VIEW IF EXISTS public.v_trace_flags SET SCHEMA collect;
+ALTER VIEW IF EXISTS public.v_collection_log SET SCHEMA collect;
+ALTER VIEW IF EXISTS public.v_memory_clerks SET SCHEMA collect;
+ALTER VIEW IF EXISTS public.v_memory_pressure_events SET SCHEMA collect;
+
+/* config: the operator-writable coordination + analysis-mute tables */
+ALTER TABLE IF EXISTS public.config_alert_log SET SCHEMA config;
+ALTER TABLE IF EXISTS public.config_edge_trigger_watermarks SET SCHEMA config;
+ALTER TABLE IF EXISTS public.config_mute_rules SET SCHEMA config;
+ALTER TABLE IF EXISTS public.analysis_muted SET SCHEMA config;
+INSERT INTO darling_schema_version (version, name, applied_at) VALUES (8, 'schema-split-collect-config', now()::timestamp);
+-- ===BATCH=== V9 server-inventory-cost-fields
+
+ALTER TABLE server_properties ADD COLUMN IF NOT EXISTS sqlserver_start_time timestamp;
+ALTER TABLE server_properties ADD COLUMN IF NOT EXISTS host_os_version text;
+ALTER TABLE server_properties ADD COLUMN IF NOT EXISTS ag_replica_role text;
+ALTER TABLE servers ADD COLUMN IF NOT EXISTS monthly_cost_usd numeric;
+INSERT INTO darling_schema_version (version, name, applied_at) VALUES (9, 'server-inventory-cost-fields', now()::timestamp);
+-- ===BATCH=== V10 latch-spinlock-collectors
+/* V10: latch_stats + spinlock_stats collectors (Dashboard->Darling parity, #1262).
+ Generated from the collector definitions so the tables match the fresh V1 shape. */
+
+CREATE TABLE IF NOT EXISTS latch_stats (
+ collection_id bigint NOT NULL,
+ collection_time timestamp NOT NULL,
+ server_id integer NOT NULL,
+ server_name text NOT NULL,
+ latch_class text,
+ waiting_requests_count bigint,
+ wait_time_ms bigint,
+ max_wait_time_ms bigint,
+ delta_waiting_requests_count bigint,
+ delta_wait_time_ms bigint,
+ delta_max_wait_time_ms bigint
+);
+CREATE INDEX IF NOT EXISTS idx_latch_stats_time ON latch_stats(server_id, collection_time);
+CREATE OR REPLACE VIEW v_latch_stats AS SELECT * FROM latch_stats;
+
+CREATE TABLE IF NOT EXISTS spinlock_stats (
+ collection_id bigint NOT NULL,
+ collection_time timestamp NOT NULL,
+ server_id integer NOT NULL,
+ server_name text NOT NULL,
+ spinlock_name text,
+ collisions bigint,
+ spins bigint,
+ spins_per_collision double precision,
+ sleep_time bigint,
+ backoffs bigint,
+ delta_collisions bigint,
+ delta_spins bigint,
+ delta_sleep_time bigint,
+ delta_backoffs bigint
+);
+CREATE INDEX IF NOT EXISTS idx_spinlock_stats_time ON spinlock_stats(server_id, collection_time);
+CREATE OR REPLACE VIEW v_spinlock_stats AS SELECT * FROM spinlock_stats;
+INSERT INTO darling_schema_version (version, name, applied_at) VALUES (10, 'latch-spinlock-collectors', now()::timestamp);
+-- ===BATCH=== V11 cpu-scheduler-plan-cache-collectors
+/* V11: cpu_scheduler_stats + plan_cache_stats collectors (Dashboard->Darling parity, #1262).
+ Generated from the collector definitions so the tables match the fresh V1 shape. */
+
+CREATE TABLE IF NOT EXISTS cpu_scheduler_stats (
+ collection_id bigint NOT NULL,
+ collection_time timestamp NOT NULL,
+ server_id integer NOT NULL,
+ server_name text NOT NULL,
+ max_workers_count integer,
+ scheduler_count integer,
+ cpu_count integer,
+ total_runnable_tasks_count integer,
+ total_work_queue_count bigint,
+ total_current_workers_count integer,
+ avg_runnable_tasks_count numeric(38,2),
+ total_active_request_count integer,
+ total_queued_request_count integer,
+ total_blocked_task_count integer,
+ total_active_parallel_thread_count bigint,
+ runnable_request_count integer,
+ total_request_count integer,
+ runnable_percent numeric(38,2),
+ worker_thread_exhaustion_warning boolean,
+ runnable_tasks_warning boolean,
+ blocked_tasks_warning boolean,
+ queued_requests_warning boolean,
+ total_physical_memory_kb bigint,
+ available_physical_memory_kb bigint,
+ system_memory_state_desc text,
+ physical_memory_pressure_warning boolean,
+ total_node_count integer,
+ nodes_online_count integer,
+ offline_cpu_count integer,
+ offline_cpu_warning boolean
+);
+CREATE INDEX IF NOT EXISTS idx_cpu_scheduler_stats_time ON cpu_scheduler_stats(server_id, collection_time);
+CREATE OR REPLACE VIEW v_cpu_scheduler_stats AS SELECT * FROM cpu_scheduler_stats;
+
+CREATE TABLE IF NOT EXISTS plan_cache_stats (
+ collection_id bigint NOT NULL,
+ collection_time timestamp NOT NULL,
+ server_id integer NOT NULL,
+ server_name text NOT NULL,
+ cacheobjtype text,
+ objtype text,
+ total_plans integer,
+ total_size_mb integer,
+ single_use_plans integer,
+ single_use_size_mb integer,
+ multi_use_plans integer,
+ multi_use_size_mb integer,
+ avg_use_count numeric(38,2),
+ avg_size_kb integer,
+ oldest_plan_create_time timestamp
+);
+CREATE INDEX IF NOT EXISTS idx_plan_cache_stats_time ON plan_cache_stats(server_id, collection_time);
+CREATE OR REPLACE VIEW v_plan_cache_stats AS SELECT * FROM plan_cache_stats;
+INSERT INTO darling_schema_version (version, name, applied_at) VALUES (11, 'cpu-scheduler-plan-cache-collectors', now()::timestamp);
+-- ===BATCH=== V12 session-summary-collector
+/* V12: session_summary_stats collector (Dashboard->Darling connection-leak / idle parity, #1262).
+ Generated from the collector definition so the table matches the fresh V1 shape. */
+
+CREATE TABLE IF NOT EXISTS session_summary_stats (
+ collection_id bigint NOT NULL,
+ collection_time timestamp NOT NULL,
+ server_id integer NOT NULL,
+ server_name text NOT NULL,
+ total_sessions integer,
+ running_sessions integer,
+ sleeping_sessions integer,
+ background_sessions integer,
+ dormant_sessions integer,
+ idle_sessions_over_30min integer,
+ sessions_waiting_for_memory integer,
+ databases_with_connections integer,
+ top_application_name text,
+ top_application_connections integer,
+ top_host_name text,
+ top_host_connections integer
+);
+CREATE INDEX IF NOT EXISTS idx_session_summary_stats_time ON session_summary_stats(server_id, collection_time);
+CREATE OR REPLACE VIEW v_session_summary_stats AS SELECT * FROM session_summary_stats;
+INSERT INTO darling_schema_version (version, name, applied_at) VALUES (12, 'session-summary-collector', now()::timestamp);
+-- ===BATCH=== V13 system-health-events-collector
+/* V13: system_health_events collector (Dashboard->Darling health-parser capture parity, #1262).
+ Generated from the collector definition so the table matches the fresh V1 shape. */
+
+CREATE TABLE IF NOT EXISTS system_health_events (
+ system_health_event_id bigint NOT NULL,
+ collection_time timestamp NOT NULL,
+ server_id integer NOT NULL,
+ server_name text NOT NULL,
+ event_time timestamp,
+ event_type text,
+ event_xml text
+);
+CREATE INDEX IF NOT EXISTS idx_system_health_events_time ON system_health_events(server_id, collection_time);
+CREATE OR REPLACE VIEW v_system_health_events AS SELECT * FROM system_health_events;
+INSERT INTO darling_schema_version (version, name, applied_at) VALUES (13, 'system-health-events-collector', now()::timestamp);
+-- ===BATCH=== V14 refresh-passthrough-views
+/* V14: refresh every v_* passthrough view's pinned SELECT * column list (#1262).
+ Postgres freezes SELECT * at CREATE, so an upgraded store's views omit columns
+ ADDed later (V7 plan columns, V9 inventory); CREATE OR REPLACE re-expands them.
+ Append-only ADD COLUMNs => the refresh only adds columns at the end (always legal). */
+
+CREATE OR REPLACE VIEW v_wait_stats AS SELECT * FROM wait_stats;
+CREATE OR REPLACE VIEW v_query_store_stats AS SELECT * FROM query_store_stats;
+CREATE OR REPLACE VIEW v_cpu_utilization_stats AS SELECT * FROM cpu_utilization_stats;
+CREATE OR REPLACE VIEW v_memory_grant_stats AS SELECT * FROM memory_grant_stats;
+CREATE OR REPLACE VIEW v_memory_stats AS SELECT * FROM memory_stats;
+CREATE OR REPLACE VIEW v_perfmon_stats AS SELECT * FROM perfmon_stats;
+CREATE OR REPLACE VIEW v_session_stats AS SELECT * FROM session_stats;
+CREATE OR REPLACE VIEW v_file_io_stats AS SELECT * FROM file_io_stats;
+CREATE OR REPLACE VIEW v_blocked_process_reports AS SELECT * FROM blocked_process_reports;
+CREATE OR REPLACE VIEW v_deadlocks AS SELECT * FROM deadlocks;
+CREATE OR REPLACE VIEW v_dmv_blocking_snapshots AS SELECT * FROM dmv_blocking_snapshots;
+CREATE OR REPLACE VIEW v_index_object_stats AS SELECT * FROM index_object_stats;
+CREATE OR REPLACE VIEW v_database_size_stats AS SELECT * FROM database_size_stats;
+CREATE OR REPLACE VIEW v_tempdb_stats AS SELECT * FROM tempdb_stats;
+CREATE OR REPLACE VIEW v_query_snapshots AS SELECT * FROM query_snapshots;
+CREATE OR REPLACE VIEW v_database_config AS SELECT * FROM database_config;
+CREATE OR REPLACE VIEW v_running_jobs AS SELECT * FROM running_jobs;
+CREATE OR REPLACE VIEW v_server_config AS SELECT * FROM server_config;
+CREATE OR REPLACE VIEW v_database_scoped_config AS SELECT * FROM database_scoped_config;
+CREATE OR REPLACE VIEW v_trace_flags AS SELECT * FROM trace_flags;
+CREATE OR REPLACE VIEW v_collection_log AS SELECT * FROM collection_log;
+CREATE OR REPLACE VIEW v_memory_clerks AS SELECT * FROM memory_clerks;
+CREATE OR REPLACE VIEW v_memory_pressure_events AS SELECT * FROM memory_pressure_events;
+CREATE OR REPLACE VIEW v_latch_stats AS SELECT * FROM latch_stats;
+CREATE OR REPLACE VIEW v_spinlock_stats AS SELECT * FROM spinlock_stats;
+CREATE OR REPLACE VIEW v_cpu_scheduler_stats AS SELECT * FROM cpu_scheduler_stats;
+CREATE OR REPLACE VIEW v_plan_cache_stats AS SELECT * FROM plan_cache_stats;
+CREATE OR REPLACE VIEW v_session_summary_stats AS SELECT * FROM session_summary_stats;
+CREATE OR REPLACE VIEW v_system_health_events AS SELECT * FROM system_health_events;
+CREATE OR REPLACE VIEW v_pvs_stats AS SELECT * FROM pvs_stats;
+CREATE OR REPLACE VIEW v_query_store_health AS SELECT * FROM query_store_health;
+INSERT INTO darling_schema_version (version, name, applied_at) VALUES (14, 'refresh-passthrough-views', now()::timestamp);
+-- ===BATCH=== V15 index-metadata-columns
+
+ALTER TABLE index_object_stats ADD COLUMN IF NOT EXISTS key_columns text;
+ALTER TABLE index_object_stats ADD COLUMN IF NOT EXISTS included_columns text;
+ALTER TABLE index_object_stats ADD COLUMN IF NOT EXISTS filter_definition text;
+ALTER TABLE index_object_stats ADD COLUMN IF NOT EXISTS is_unique_constraint boolean;
+ALTER TABLE index_object_stats ADD COLUMN IF NOT EXISTS is_foreign_key boolean;
+ALTER TABLE index_object_stats ADD COLUMN IF NOT EXISTS is_foreign_key_reference boolean;
+ALTER TABLE index_object_stats ADD COLUMN IF NOT EXISTS is_disabled boolean;
+ALTER TABLE index_object_stats ADD COLUMN IF NOT EXISTS data_compression_desc text;
+ALTER TABLE index_object_stats ADD COLUMN IF NOT EXISTS optimize_for_sequential_key boolean;
+ALTER TABLE index_object_stats ADD COLUMN IF NOT EXISTS fill_factor smallint;
+ALTER TABLE index_object_stats ADD COLUMN IF NOT EXISTS is_padded boolean;
+ALTER TABLE index_object_stats ADD COLUMN IF NOT EXISTS allow_page_locks boolean;
+ALTER TABLE index_object_stats ADD COLUMN IF NOT EXISTS allow_row_locks boolean;
+ALTER TABLE index_object_stats ADD COLUMN IF NOT EXISTS is_indexed_view boolean;
+
+CREATE OR REPLACE VIEW v_index_object_stats AS SELECT * FROM index_object_stats;
+INSERT INTO darling_schema_version (version, name, applied_at) VALUES (15, 'index-metadata-columns', now()::timestamp);
+-- ===BATCH=== V16 server-utc-offset
+
+ALTER TABLE server_properties ADD COLUMN IF NOT EXISTS utc_offset_minutes integer;
+INSERT INTO darling_schema_version (version, name, applied_at) VALUES (16, 'server-utc-offset', now()::timestamp);
+-- ===BATCH=== V17 config-control-plane
+
+/* V17: store<->service control plane (Stage 1). EVERY object is schema-qualified config.* —
+ the migrate session's search_path resolves bare names to collect (wrong schema/ACL). */
+
+/* --- A. Config plane: the Viewer writes desired state, the service reads + honors it. --- */
+
+/* 1. config_monitored_servers — the desired-state twin of the collect.servers observed registry.
+ server_id is THIS ROW'S identity and this table owns it: the service reads it here rather than
+ recomputing it, so a stored id keeps working when the fields below no longer produce it (#2218).
+ It is minted from the storage identity host[:database][:RO] — the same value the collectors
+ stamp, which is why it JOINs collected data and why no existing store needs migrating — but that
+ is now the ALLOCATION rule, not a definition anything re-derives. is_enabled drives collection;
+ the connection fields reconstruct a MonitoredServer for the service's connect path. */
+CREATE TABLE IF NOT EXISTS config.config_monitored_servers (
+ server_id integer NOT NULL PRIMARY KEY,
+ name text NOT NULL,
+ host text NOT NULL,
+ database text,
+ auth text NOT NULL DEFAULT 'integrated',
+ username text,
+ encrypted_password text,
+ encrypt_mode text NOT NULL DEFAULT 'Mandatory',
+ trust_server_certificate boolean NOT NULL DEFAULT FALSE,
+ read_only_intent boolean NOT NULL DEFAULT FALSE,
+ multi_subnet_failover boolean NOT NULL DEFAULT FALSE,
+ excluded_databases text[] NOT NULL DEFAULT '{}'::text[],
+ monthly_cost_usd numeric NOT NULL DEFAULT 0,
+ capture_plans boolean,
+ is_enabled boolean NOT NULL DEFAULT TRUE,
+ created_at timestamp NOT NULL DEFAULT (now() AT TIME ZONE 'UTC'),
+ modified_at timestamp NOT NULL DEFAULT (now() AT TIME ZONE 'UTC')
+);
+
+/* 2. config_alert_settings — single row (id=1), one column per AlertsConfig field + the analysis
+ cadence knobs (analysis_enabled / interval / notifications_enabled / notify_severity). */
+CREATE TABLE IF NOT EXISTS config.config_alert_settings (
+ id smallint NOT NULL PRIMARY KEY DEFAULT 1 CHECK (id = 1),
+ enabled boolean NOT NULL DEFAULT TRUE,
+ cpu_enabled boolean NOT NULL DEFAULT TRUE,
+ cpu_threshold_percent integer NOT NULL DEFAULT 80,
+ cpu_mode text NOT NULL DEFAULT 'total',
+ blocking_enabled boolean NOT NULL DEFAULT TRUE,
+ blocking_count_threshold integer NOT NULL DEFAULT 1,
+ deadlock_enabled boolean NOT NULL DEFAULT TRUE,
+ deadlock_count_threshold integer NOT NULL DEFAULT 1,
+ poison_wait_enabled boolean NOT NULL DEFAULT TRUE,
+ poison_wait_threshold_ms integer NOT NULL DEFAULT 500,
+ long_running_query_enabled boolean NOT NULL DEFAULT TRUE,
+ long_running_query_threshold_minutes integer NOT NULL DEFAULT 30,
+ tempdb_space_enabled boolean NOT NULL DEFAULT TRUE,
+ tempdb_space_threshold_percent integer NOT NULL DEFAULT 80,
+ low_disk_enabled boolean NOT NULL DEFAULT TRUE,
+ low_disk_threshold_percent integer NOT NULL DEFAULT 10,
+ low_disk_threshold_gb integer NOT NULL DEFAULT 5,
+ long_running_job_enabled boolean NOT NULL DEFAULT TRUE,
+ long_running_job_multiplier integer NOT NULL DEFAULT 3,
+ failed_job_enabled boolean NOT NULL DEFAULT TRUE,
+ failed_job_lookback_minutes integer NOT NULL DEFAULT 60,
+ cooldown_minutes integer NOT NULL DEFAULT 5,
+ excluded_databases text[] NOT NULL DEFAULT '{}'::text[],
+ analysis_enabled boolean NOT NULL DEFAULT TRUE,
+ analysis_interval_minutes integer NOT NULL DEFAULT 30,
+ analysis_notifications_enabled boolean NOT NULL DEFAULT TRUE,
+ analysis_notify_severity double precision NOT NULL DEFAULT 1.5,
+ modified_at timestamp NOT NULL DEFAULT (now() AT TIME ZONE 'UTC')
+);
+
+/* 3. config_notification — single row: SMTP + webhook delivery. Non-secret fields plus the SMTP
+ DPAPI blob (smtp_encrypted_password); webhook URLs/proxies carry as darling.json holds them. */
+CREATE TABLE IF NOT EXISTS config.config_notification (
+ id smallint NOT NULL PRIMARY KEY DEFAULT 1 CHECK (id = 1),
+ smtp_host text NOT NULL DEFAULT '',
+ smtp_port integer NOT NULL DEFAULT 587,
+ smtp_use_ssl boolean NOT NULL DEFAULT TRUE,
+ smtp_username text,
+ smtp_encrypted_password text,
+ smtp_from_address text NOT NULL DEFAULT '',
+ smtp_recipients text NOT NULL DEFAULT '',
+ email_cooldown_minutes integer NOT NULL DEFAULT 15,
+ teams_url text NOT NULL DEFAULT '',
+ teams_proxy text NOT NULL DEFAULT '',
+ slack_url text NOT NULL DEFAULT '',
+ slack_proxy text NOT NULL DEFAULT '',
+ modified_at timestamp NOT NULL DEFAULT (now() AT TIME ZONE 'UTC')
+);
+
+/* 4. config_collector_schedules — SPARSE per-collector overrides layered on CollectorScheduleDefaults.
+ Absent row / NULL column = code default; server_id NULL = fleet-wide. A PRIMARY KEY cannot span a
+ nullable server_id, so two partial-unique indexes enforce one fleet-wide row + one per-server row
+ per collector. */
+CREATE TABLE IF NOT EXISTS config.config_collector_schedules (
+ server_id integer,
+ collector_name text NOT NULL,
+ frequency_minutes integer CHECK (frequency_minutes >= 0),
+ retention_days integer CHECK (retention_days >= 1),
+ enabled boolean NOT NULL DEFAULT TRUE
+);
+CREATE UNIQUE INDEX IF NOT EXISTS ux_config_collector_schedules_fleet
+ ON config.config_collector_schedules (collector_name) WHERE server_id IS NULL;
+CREATE UNIQUE INDEX IF NOT EXISTS ux_config_collector_schedules_server
+ ON config.config_collector_schedules (server_id, collector_name) WHERE server_id IS NOT NULL;
+
+/* 5. config_service — single row: the service-wide flags + the config_version reload beacon. */
+CREATE TABLE IF NOT EXISTS config.config_service (
+ id smallint NOT NULL PRIMARY KEY DEFAULT 1 CHECK (id = 1),
+ paused boolean NOT NULL DEFAULT FALSE,
+ capture_plans boolean NOT NULL DEFAULT TRUE,
+ mcp_enabled boolean NOT NULL DEFAULT FALSE,
+ mcp_port integer NOT NULL DEFAULT 5152,
+ config_version bigint NOT NULL DEFAULT 0,
+ updated_at timestamp NOT NULL DEFAULT (now() AT TIME ZONE 'UTC'),
+ updated_by text
+);
+
+/* --- B. Command plane: the Viewer enqueues, the service (Stage 2) claims/executes/reports. --- */
+
+/* 6. config_command — the imperative queue. GENERATED ALWAYS AS IDENTITY (a natural queue key that
+ needs no sequence USAGE grant for admin INSERTs, unlike serial). */
+CREATE TABLE IF NOT EXISTS config.config_command (
+ command_id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
+ created_at timestamp NOT NULL DEFAULT (now() AT TIME ZONE 'UTC'),
+ requested_by text,
+ command_type text NOT NULL,
+ target_server_id integer,
+ args_json jsonb,
+ status text NOT NULL DEFAULT 'pending',
+ claimed_at timestamp,
+ completed_at timestamp,
+ result_status text,
+ result_json jsonb,
+ service_instance text
+);
+CREATE INDEX IF NOT EXISTS idx_config_command_status ON config.config_command (status);
+
+/* --- C. The config_version reload beacon (bump triggers). --- */
+
+/* Statement-level bump on the four desired-state tables: any write increments the beacon so the
+ Viewer can never forget to signal. Targets config_service by qualified name (SECURITY INVOKER —
+ both writers, the owner during seed and admin via the Viewer, hold UPDATE on config_service). */
+CREATE OR REPLACE FUNCTION config.config_bump_version() RETURNS trigger
+LANGUAGE plpgsql AS $bump$
+BEGIN
+ UPDATE config.config_service
+ SET config_version = config_version + 1,
+ updated_at = (now() AT TIME ZONE 'UTC')
+ WHERE id = 1;
+ RETURN NULL;
+END;
+$bump$;
+
+/* Direct writes to config_service (pause/capture/mcp) self-bump the beacon without recursion: the
+ BEFORE-UPDATE trigger increments NEW.config_version only when the writer did not already change it
+ (so the config_bump_version UPDATE above, which sets config_version explicitly, is not doubled). */
+CREATE OR REPLACE FUNCTION config.config_service_bump() RETURNS trigger
+LANGUAGE plpgsql AS $svc$
+BEGIN
+ IF NEW.config_version = OLD.config_version THEN
+ NEW.config_version := OLD.config_version + 1;
+ END IF;
+ NEW.updated_at := (now() AT TIME ZONE 'UTC');
+ RETURN NEW;
+END;
+$svc$;
+
+DROP TRIGGER IF EXISTS trg_bump_monitored_servers ON config.config_monitored_servers;
+CREATE TRIGGER trg_bump_monitored_servers
+ AFTER INSERT OR UPDATE OR DELETE ON config.config_monitored_servers
+ FOR EACH STATEMENT EXECUTE FUNCTION config.config_bump_version();
+
+DROP TRIGGER IF EXISTS trg_bump_alert_settings ON config.config_alert_settings;
+CREATE TRIGGER trg_bump_alert_settings
+ AFTER INSERT OR UPDATE OR DELETE ON config.config_alert_settings
+ FOR EACH STATEMENT EXECUTE FUNCTION config.config_bump_version();
+
+DROP TRIGGER IF EXISTS trg_bump_notification ON config.config_notification;
+CREATE TRIGGER trg_bump_notification
+ AFTER INSERT OR UPDATE OR DELETE ON config.config_notification
+ FOR EACH STATEMENT EXECUTE FUNCTION config.config_bump_version();
+
+DROP TRIGGER IF EXISTS trg_bump_collector_schedules ON config.config_collector_schedules;
+CREATE TRIGGER trg_bump_collector_schedules
+ AFTER INSERT OR UPDATE OR DELETE ON config.config_collector_schedules
+ FOR EACH STATEMENT EXECUTE FUNCTION config.config_bump_version();
+
+DROP TRIGGER IF EXISTS trg_service_self_bump ON config.config_service;
+CREATE TRIGGER trg_service_self_bump
+ BEFORE UPDATE ON config.config_service
+ FOR EACH ROW EXECUTE FUNCTION config.config_service_bump();
+INSERT INTO darling_schema_version (version, name, applied_at) VALUES (17, 'config-control-plane', now()::timestamp);
+-- ===BATCH=== V18 alert-delivery-mode
+
+ALTER TABLE config.config_alert_settings ADD COLUMN IF NOT EXISTS delivery_mode text NOT NULL DEFAULT 'Summary';
+ALTER TABLE config.config_alert_settings ADD COLUMN IF NOT EXISTS per_event_max integer NOT NULL DEFAULT 5;
+ALTER TABLE config.config_monitored_servers ADD COLUMN IF NOT EXISTS alert_delivery_mode_override text;
+INSERT INTO darling_schema_version (version, name, applied_at) VALUES (18, 'alert-delivery-mode', now()::timestamp);
+-- ===BATCH=== V19 analysis-state-marker
+
+CREATE TABLE IF NOT EXISTS collect.analysis_state (
+ server_id integer NOT NULL PRIMARY KEY,
+ insufficient_data boolean NOT NULL DEFAULT FALSE,
+ message text,
+ analysis_time timestamp NOT NULL
+);
+INSERT INTO darling_schema_version (version, name, applied_at) VALUES (19, 'analysis-state-marker', now()::timestamp);
+-- ===BATCH=== V20 alert-tuning-knobs
+
+ALTER TABLE config.config_alert_settings ADD COLUMN IF NOT EXISTS long_running_query_max_results integer NOT NULL DEFAULT 5;
+ALTER TABLE config.config_alert_settings ADD COLUMN IF NOT EXISTS long_running_query_exclude_sp_server_diagnostics boolean NOT NULL DEFAULT TRUE;
+ALTER TABLE config.config_alert_settings ADD COLUMN IF NOT EXISTS long_running_query_exclude_wait_for boolean NOT NULL DEFAULT TRUE;
+ALTER TABLE config.config_alert_settings ADD COLUMN IF NOT EXISTS long_running_query_exclude_backups boolean NOT NULL DEFAULT TRUE;
+ALTER TABLE config.config_alert_settings ADD COLUMN IF NOT EXISTS long_running_query_exclude_misc_waits boolean NOT NULL DEFAULT TRUE;
+ALTER TABLE config.config_alert_settings ADD COLUMN IF NOT EXISTS long_running_query_exclude_cdc boolean NOT NULL DEFAULT TRUE;
+ALTER TABLE config.config_alert_settings ADD COLUMN IF NOT EXISTS notify_connection_changes boolean NOT NULL DEFAULT TRUE;
+INSERT INTO darling_schema_version (version, name, applied_at) VALUES (20, 'alert-tuning-knobs', now()::timestamp);
+-- ===BATCH=== V21 default-trace-events-collector
+
+CREATE TABLE IF NOT EXISTS collect.default_trace_events (
+ default_trace_event_id bigint NOT NULL,
+ collection_time timestamp NOT NULL,
+ server_id integer NOT NULL,
+ server_name text NOT NULL,
+ event_time timestamp,
+ event_name text,
+ event_class integer,
+ spid integer,
+ database_name text,
+ database_id integer,
+ login_name text,
+ host_name text,
+ application_name text,
+ object_name text,
+ filename text,
+ integer_data bigint,
+ integer_data_2 bigint,
+ text_data text,
+ session_login_name text,
+ error_number integer,
+ severity integer,
+ state integer,
+ event_sequence bigint,
+ duration_us bigint,
+ end_time timestamp
+);
+
+CREATE INDEX IF NOT EXISTS idx_default_trace_events_time ON collect.default_trace_events(server_id, collection_time);
+INSERT INTO darling_schema_version (version, name, applied_at) VALUES (21, 'default-trace-events-collector', now()::timestamp);
+-- ===BATCH=== V22 index-object-stats-latest-index
+
+CREATE INDEX IF NOT EXISTS idx_index_object_stats_latest ON collect.index_object_stats (server_id, database_id, object_id, index_id, collection_time DESC);
+INSERT INTO darling_schema_version (version, name, applied_at) VALUES (22, 'index-object-stats-latest-index', now()::timestamp);
+-- ===BATCH=== V23 collection-log-hypertable
+
+/* V23: best-effort UPGRADE fast-path that converts collect.collection_log to a TimescaleDB hypertable + compresses
+ it, mirroring the collector hypertables. GUARDED on the extension (plain PostgreSQL skips it, keeping the table a
+ heap with batched-DELETE retention) and wrapped in EXCEPTION WHEN OTHERS so it can NEVER abort the startup-critical
+ migration. The AUTHORITATIVE conversion is TimescaleSupport.EnsureCollectionLogHypertableAsync at runtime (after
+ CREATE EXTENSION), which is the proven-live path and self-heals a fresh store this guard skipped. collection_log is
+ NOT in the collector catalog, so the runtime catalog loops never touch it. */
+DO $$
+BEGIN
+ IF EXISTS (SELECT 1 FROM pg_extension WHERE extname = 'timescaledb') THEN
+ PERFORM create_hypertable('collect.collection_log', by_range('collection_time', INTERVAL '1 days'), if_not_exists => true, migrate_data => true);
+ ALTER TABLE collect.collection_log SET (timescaledb.compress, timescaledb.compress_segmentby = 'server_id');
+ PERFORM add_compression_policy('collect.collection_log', compress_after => INTERVAL '1 days', if_not_exists => true);
+ END IF;
+EXCEPTION WHEN OTHERS THEN
+ RAISE WARNING 'V23: deferred collection_log hypertable conversion to the runtime path (%): %', SQLSTATE, SQLERRM;
+END
+$$;
+INSERT INTO darling_schema_version (version, name, applied_at) VALUES (23, 'collection-log-hypertable', now()::timestamp);
+-- ===BATCH=== V24 job-history-collector
+
+CREATE TABLE IF NOT EXISTS collect.job_history (
+ job_history_id bigint NOT NULL,
+ collection_time timestamp NOT NULL,
+ server_id integer NOT NULL,
+ server_name text NOT NULL,
+ instance_id bigint,
+ job_id text,
+ job_name text,
+ job_enabled boolean,
+ category_name text,
+ step_id integer,
+ step_name text,
+ run_status integer,
+ run_status_desc text,
+ run_datetime timestamp,
+ run_duration_seconds bigint,
+ retries_attempted integer,
+ message text
+);
+
+CREATE INDEX IF NOT EXISTS idx_job_history_time ON collect.job_history(server_id, collection_time);
+INSERT INTO darling_schema_version (version, name, applied_at) VALUES (24, 'job-history-collector', now()::timestamp);
+-- ===BATCH=== V25 agent-status-collector
+
+CREATE TABLE IF NOT EXISTS collect.agent_status (
+ collection_id bigint NOT NULL,
+ collection_time timestamp NOT NULL,
+ server_id integer NOT NULL,
+ server_name text NOT NULL,
+ agent_running boolean,
+ agent_status_desc text,
+ agent_startup_desc text,
+ next_scheduled_run timestamp
+);
+
+CREATE INDEX IF NOT EXISTS idx_agent_status_time ON collect.agent_status(server_id, collection_time);
+INSERT INTO darling_schema_version (version, name, applied_at) VALUES (25, 'agent-status-collector', now()::timestamp);
+-- ===BATCH=== V26 generic-webhook-channel
+
+ALTER TABLE config.config_notification ADD COLUMN IF NOT EXISTS generic_url text NOT NULL DEFAULT '';
+ALTER TABLE config.config_notification ADD COLUMN IF NOT EXISTS generic_headers text NOT NULL DEFAULT '';
+ALTER TABLE config.config_notification ADD COLUMN IF NOT EXISTS generic_body_template text NOT NULL DEFAULT '';
+ALTER TABLE config.config_notification ADD COLUMN IF NOT EXISTS generic_proxy text NOT NULL DEFAULT '';
+INSERT INTO darling_schema_version (version, name, applied_at) VALUES (26, 'generic-webhook-channel', now()::timestamp);
+-- ===BATCH=== V27 deadlocks-database-name
+
+ALTER TABLE deadlocks ADD COLUMN IF NOT EXISTS database_name text;
+CREATE OR REPLACE VIEW v_deadlocks AS SELECT * FROM deadlocks;
+INSERT INTO darling_schema_version (version, name, applied_at) VALUES (27, 'deadlocks-database-name', now()::timestamp);
+-- ===BATCH=== V28 query-store-replica-role
+
+ALTER TABLE query_store_stats ADD COLUMN IF NOT EXISTS replica_role text;
+CREATE OR REPLACE VIEW v_query_store_stats AS SELECT * FROM query_store_stats;
+INSERT INTO darling_schema_version (version, name, applied_at) VALUES (28, 'query-store-replica-role', now()::timestamp);
+-- ===BATCH=== V29 long-query-completions-collector
+
+CREATE TABLE IF NOT EXISTS collect.long_query_completions (
+ long_query_completion_id bigint NOT NULL,
+ collection_time timestamp NOT NULL,
+ server_id integer NOT NULL,
+ server_name text NOT NULL,
+ event_time timestamp,
+ event_type text,
+ database_id integer,
+ database_name text,
+ session_id integer,
+ client_app_name text,
+ client_pid integer,
+ nt_username text,
+ server_principal_name text,
+ query_hash text,
+ event_sequence bigint,
+ duration_microseconds bigint,
+ cpu_time_microseconds bigint,
+ physical_reads bigint,
+ logical_reads bigint,
+ writes bigint,
+ row_count bigint,
+ result text,
+ statement_text text,
+ object_name text
+);
+
+CREATE INDEX IF NOT EXISTS idx_long_query_completions_time ON collect.long_query_completions(server_id, collection_time);
+INSERT INTO darling_schema_version (version, name, applied_at) VALUES (29, 'long-query-completions-collector', now()::timestamp);
+-- ===BATCH=== V30 web-dashboard-config
+
+ALTER TABLE config.config_service ADD COLUMN IF NOT EXISTS web_enabled boolean NOT NULL DEFAULT FALSE;
+ALTER TABLE config.config_service ADD COLUMN IF NOT EXISTS web_port integer NOT NULL DEFAULT 5153;
+INSERT INTO darling_schema_version (version, name, applied_at) VALUES (30, 'web-dashboard-config', now()::timestamp);
+-- ===BATCH=== V31 custom-views-table
+
+CREATE TABLE IF NOT EXISTS config.custom_views (
+ id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
+ name text NOT NULL UNIQUE,
+ definition jsonb NOT NULL,
+ description text,
+ version integer NOT NULL DEFAULT 1,
+ created_at timestamp NOT NULL DEFAULT (now() AT TIME ZONE 'UTC'),
+ updated_at timestamp NOT NULL DEFAULT (now() AT TIME ZONE 'UTC'),
+ updated_by text
+);
+INSERT INTO darling_schema_version (version, name, applied_at) VALUES (31, 'custom-views-table', now()::timestamp);
+-- ===BATCH=== V32 server-tags
+
+CREATE TABLE IF NOT EXISTS config.server_tags (
+ id integer GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
+ name text NOT NULL,
+ parent_id integer REFERENCES config.server_tags(id) ON DELETE CASCADE,
+ sort_order integer NOT NULL DEFAULT 0,
+ created_at timestamp NOT NULL DEFAULT (now() AT TIME ZONE 'UTC')
+);
+
+CREATE UNIQUE INDEX IF NOT EXISTS ux_server_tags_parent_name
+ ON config.server_tags (COALESCE(parent_id, 0), lower(name));
+
+CREATE TABLE IF NOT EXISTS config.server_tag_map (
+ server_id integer NOT NULL,
+ tag_id integer NOT NULL REFERENCES config.server_tags(id) ON DELETE CASCADE,
+ PRIMARY KEY (server_id, tag_id)
+);
+
+CREATE INDEX IF NOT EXISTS idx_server_tag_map_tag
+ ON config.server_tag_map (tag_id);
+INSERT INTO darling_schema_version (version, name, applied_at) VALUES (32, 'server-tags', now()::timestamp);
+-- ===BATCH=== V33 connection-alert-refire
+
+ALTER TABLE config.config_alert_settings
+ ADD COLUMN IF NOT EXISTS notify_connection_down_at_startup boolean NOT NULL DEFAULT false,
+ ADD COLUMN IF NOT EXISTS connection_refire_minutes integer NOT NULL DEFAULT 0;
+INSERT INTO darling_schema_version (version, name, applied_at) VALUES (33, 'connection-alert-refire', now()::timestamp);
+-- ===BATCH=== V34 availability-group-collectors
+
+CREATE TABLE IF NOT EXISTS collect.ag_replica_states (
+ collection_id bigint NOT NULL,
+ collection_time timestamp NOT NULL,
+ server_id integer NOT NULL,
+ server_name text NOT NULL,
+ ag_name text,
+ replica_server_name text,
+ role_desc text,
+ operational_state_desc text,
+ connected_state_desc text,
+ recovery_health_desc text,
+ synchronization_health_desc text,
+ availability_mode_desc text,
+ failover_mode_desc text,
+ endpoint_url text
+);
+
+CREATE INDEX IF NOT EXISTS idx_ag_replica_states_time ON collect.ag_replica_states(server_id, collection_time);
+
+CREATE TABLE IF NOT EXISTS collect.ag_database_replica_states (
+ collection_id bigint NOT NULL,
+ collection_time timestamp NOT NULL,
+ server_id integer NOT NULL,
+ server_name text NOT NULL,
+ ag_name text,
+ database_name text,
+ replica_server_name text,
+ is_local boolean,
+ synchronization_state_desc text,
+ last_hardened_lsn text,
+ last_commit_lsn text,
+ log_send_queue_size bigint,
+ redo_queue_size bigint,
+ log_send_rate bigint,
+ redo_rate bigint,
+ is_suspended boolean,
+ suspend_reason_desc text,
+ availability_mode_desc text,
+ secondary_lag_seconds bigint
+);
+
+CREATE INDEX IF NOT EXISTS idx_ag_database_replica_states_time ON collect.ag_database_replica_states(server_id, collection_time);
+INSERT INTO darling_schema_version (version, name, applied_at) VALUES (34, 'availability-group-collectors', now()::timestamp);
+-- ===BATCH=== V35 availability-group-alerts
+
+ALTER TABLE config.config_alert_settings
+ ADD COLUMN IF NOT EXISTS notify_ag_health boolean NOT NULL DEFAULT true,
+ ADD COLUMN IF NOT EXISTS ag_lag_alert_seconds integer NOT NULL DEFAULT 300,
+ ADD COLUMN IF NOT EXISTS ag_redo_queue_alert_kb bigint NOT NULL DEFAULT 0;
+INSERT INTO darling_schema_version (version, name, applied_at) VALUES (35, 'availability-group-alerts', now()::timestamp);
+-- ===BATCH=== V36 ag-latency-columns
+
+ALTER TABLE collect.ag_database_replica_states
+ ADD COLUMN IF NOT EXISTS last_commit_time timestamp,
+ ADD COLUMN IF NOT EXISTS last_hardened_time timestamp,
+ ADD COLUMN IF NOT EXISTS last_redone_time timestamp,
+ ADD COLUMN IF NOT EXISTS last_received_time timestamp,
+ ADD COLUMN IF NOT EXISTS est_redo_completion_time_min double precision,
+ ADD COLUMN IF NOT EXISTS est_send_drain_time_min double precision;
+INSERT INTO darling_schema_version (version, name, applied_at) VALUES (36, 'ag-latency-columns', now()::timestamp);
+-- ===BATCH=== V37 ag-local-replica-and-disconnect-refire
+
+ALTER TABLE collect.ag_replica_states
+ ADD COLUMN IF NOT EXISTS is_local boolean;
+
+ALTER TABLE config.config_alert_settings
+ ADD COLUMN IF NOT EXISTS ag_disconnect_refire_minutes integer NOT NULL DEFAULT 0;
+INSERT INTO darling_schema_version (version, name, applied_at) VALUES (37, 'ag-local-replica-and-disconnect-refire', now()::timestamp);
+-- ===BATCH=== V38 query-payload-dimensions
+/* V38: hash-keyed dimension tables for query_stats / procedure_stats payloads (#1767).
+ query_text/query_plan_xml stored inline per row were 94% of a 250 GB field store;
+ a measured 1-hour window held 3,166 MB of payload across 23 MB of distinct content.
+ ZERO-REWRITE: the inline columns stay, new rows leave them NULL, readers coalesce,
+ and raw retention ages the inline copies out on its own. */
+
+CREATE TABLE IF NOT EXISTS query_text_dim (
+ digest bytea NOT NULL PRIMARY KEY,
+ query_text text NOT NULL,
+ last_seen timestamp NOT NULL
+);
+CREATE INDEX IF NOT EXISTS idx_query_text_dim_last_seen ON query_text_dim(last_seen);
+
+CREATE TABLE IF NOT EXISTS query_plan_dim (
+ digest bytea NOT NULL PRIMARY KEY,
+ query_plan_xml text NULL,
+ query_plan_gz bytea NULL,
+ last_seen timestamp NOT NULL
+);
+CREATE INDEX IF NOT EXISTS idx_query_plan_dim_last_seen ON query_plan_dim(last_seen);
+
+ALTER TABLE query_stats ADD COLUMN IF NOT EXISTS query_text_digest bytea;
+ALTER TABLE query_stats ADD COLUMN IF NOT EXISTS query_plan_digest bytea;
+ALTER TABLE procedure_stats ADD COLUMN IF NOT EXISTS query_plan_digest bytea;
+ALTER TABLE query_stats ADD COLUMN IF NOT EXISTS database_name text;
+ALTER TABLE query_stats ADD COLUMN IF NOT EXISTS query_hash text;
+ALTER TABLE query_stats ADD COLUMN IF NOT EXISTS query_plan_hash text;
+ALTER TABLE query_stats ADD COLUMN IF NOT EXISTS creation_time timestamp;
+ALTER TABLE query_stats ADD COLUMN IF NOT EXISTS last_execution_time timestamp;
+ALTER TABLE query_stats ADD COLUMN IF NOT EXISTS execution_count bigint;
+ALTER TABLE query_stats ADD COLUMN IF NOT EXISTS total_worker_time bigint;
+ALTER TABLE query_stats ADD COLUMN IF NOT EXISTS total_elapsed_time bigint;
+ALTER TABLE query_stats ADD COLUMN IF NOT EXISTS total_logical_reads bigint;
+ALTER TABLE query_stats ADD COLUMN IF NOT EXISTS total_logical_writes bigint;
+ALTER TABLE query_stats ADD COLUMN IF NOT EXISTS total_physical_reads bigint;
+ALTER TABLE query_stats ADD COLUMN IF NOT EXISTS total_clr_time bigint;
+ALTER TABLE query_stats ADD COLUMN IF NOT EXISTS total_rows bigint;
+ALTER TABLE query_stats ADD COLUMN IF NOT EXISTS total_spills bigint;
+ALTER TABLE query_stats ADD COLUMN IF NOT EXISTS min_worker_time bigint;
+ALTER TABLE query_stats ADD COLUMN IF NOT EXISTS max_worker_time bigint;
+ALTER TABLE query_stats ADD COLUMN IF NOT EXISTS min_elapsed_time bigint;
+ALTER TABLE query_stats ADD COLUMN IF NOT EXISTS max_elapsed_time bigint;
+ALTER TABLE query_stats ADD COLUMN IF NOT EXISTS min_physical_reads bigint;
+ALTER TABLE query_stats ADD COLUMN IF NOT EXISTS max_physical_reads bigint;
+ALTER TABLE query_stats ADD COLUMN IF NOT EXISTS min_rows bigint;
+ALTER TABLE query_stats ADD COLUMN IF NOT EXISTS max_rows bigint;
+ALTER TABLE query_stats ADD COLUMN IF NOT EXISTS min_dop bigint;
+ALTER TABLE query_stats ADD COLUMN IF NOT EXISTS max_dop bigint;
+ALTER TABLE query_stats ADD COLUMN IF NOT EXISTS min_grant_kb bigint;
+ALTER TABLE query_stats ADD COLUMN IF NOT EXISTS max_grant_kb bigint;
+ALTER TABLE query_stats ADD COLUMN IF NOT EXISTS min_used_grant_kb bigint;
+ALTER TABLE query_stats ADD COLUMN IF NOT EXISTS max_used_grant_kb bigint;
+ALTER TABLE query_stats ADD COLUMN IF NOT EXISTS min_ideal_grant_kb bigint;
+ALTER TABLE query_stats ADD COLUMN IF NOT EXISTS max_ideal_grant_kb bigint;
+ALTER TABLE query_stats ADD COLUMN IF NOT EXISTS min_reserved_threads bigint;
+ALTER TABLE query_stats ADD COLUMN IF NOT EXISTS max_reserved_threads bigint;
+ALTER TABLE query_stats ADD COLUMN IF NOT EXISTS min_used_threads bigint;
+ALTER TABLE query_stats ADD COLUMN IF NOT EXISTS max_used_threads bigint;
+ALTER TABLE query_stats ADD COLUMN IF NOT EXISTS min_spills bigint;
+ALTER TABLE query_stats ADD COLUMN IF NOT EXISTS max_spills bigint;
+ALTER TABLE query_stats ADD COLUMN IF NOT EXISTS query_text text;
+ALTER TABLE query_stats ADD COLUMN IF NOT EXISTS query_plan_xml text;
+ALTER TABLE query_stats ADD COLUMN IF NOT EXISTS sql_handle text;
+ALTER TABLE query_stats ADD COLUMN IF NOT EXISTS plan_handle text;
+ALTER TABLE query_stats ADD COLUMN IF NOT EXISTS delta_execution_count bigint;
+ALTER TABLE query_stats ADD COLUMN IF NOT EXISTS delta_worker_time bigint;
+ALTER TABLE query_stats ADD COLUMN IF NOT EXISTS delta_elapsed_time bigint;
+ALTER TABLE query_stats ADD COLUMN IF NOT EXISTS delta_logical_reads bigint;
+ALTER TABLE query_stats ADD COLUMN IF NOT EXISTS delta_logical_writes bigint;
+ALTER TABLE query_stats ADD COLUMN IF NOT EXISTS delta_physical_reads bigint;
+ALTER TABLE query_stats ADD COLUMN IF NOT EXISTS delta_rows bigint;
+ALTER TABLE query_stats ADD COLUMN IF NOT EXISTS delta_spills bigint;
+ALTER TABLE query_stats ADD COLUMN IF NOT EXISTS plan_generation_num bigint;
+ALTER TABLE query_stats ADD COLUMN IF NOT EXISTS sample_interval_seconds integer;
+ALTER TABLE query_stats ADD COLUMN IF NOT EXISTS host_object_name text;
+ALTER TABLE query_plan_dim ADD COLUMN IF NOT EXISTS query_plan_gz bytea;
+
+/* v_query_stats resolves each row's payload: the inline column for rows written before
+ the dimension tables existed, the dimension row for every row written since (#1767).
+ Column list generated from the collector definition so it can never go stale. */
+CREATE OR REPLACE VIEW v_query_stats AS
+SELECT
+ f.collection_id,
+ f.collection_time,
+ f.server_id,
+ f.server_name,
+ f.database_name,
+ f.query_hash,
+ f.query_plan_hash,
+ f.creation_time,
+ f.last_execution_time,
+ f.execution_count,
+ f.total_worker_time,
+ f.total_elapsed_time,
+ f.total_logical_reads,
+ f.total_logical_writes,
+ f.total_physical_reads,
+ f.total_clr_time,
+ f.total_rows,
+ f.total_spills,
+ f.min_worker_time,
+ f.max_worker_time,
+ f.min_elapsed_time,
+ f.max_elapsed_time,
+ f.min_physical_reads,
+ f.max_physical_reads,
+ f.min_rows,
+ f.max_rows,
+ f.min_dop,
+ f.max_dop,
+ f.min_grant_kb,
+ f.max_grant_kb,
+ f.min_used_grant_kb,
+ f.max_used_grant_kb,
+ f.min_ideal_grant_kb,
+ f.max_ideal_grant_kb,
+ f.min_reserved_threads,
+ f.max_reserved_threads,
+ f.min_used_threads,
+ f.max_used_threads,
+ f.min_spills,
+ f.max_spills,
+ COALESCE(f.query_text, qtd.query_text) AS query_text,
+ COALESCE(f.query_plan_xml, qpd.query_plan_xml) AS query_plan_xml,
+ f.sql_handle,
+ f.plan_handle,
+ f.delta_execution_count,
+ f.delta_worker_time,
+ f.delta_elapsed_time,
+ f.delta_logical_reads,
+ f.delta_logical_writes,
+ f.delta_physical_reads,
+ f.delta_rows,
+ f.delta_spills,
+ f.plan_generation_num,
+ f.sample_interval_seconds,
+ f.host_object_name,
+ f.query_text_digest,
+ f.query_plan_digest,
+ qpd.query_plan_gz
+FROM query_stats AS f
+LEFT JOIN query_text_dim AS qtd ON qtd.digest = f.query_text_digest
+LEFT JOIN query_plan_dim AS qpd ON qpd.digest = f.query_plan_digest
+;
+INSERT INTO darling_schema_version (version, name, applied_at) VALUES (38, 'query-payload-dimensions', now()::timestamp);
+-- ===BATCH=== V39 dim-feeding-fact-floor-indexes
+
+CREATE INDEX IF NOT EXISTS ix_query_stats_digest_floor
+ ON query_stats (collection_time)
+ WHERE query_text_digest IS NOT NULL OR query_plan_digest IS NOT NULL;
+
+CREATE INDEX IF NOT EXISTS ix_procedure_stats_digest_floor
+ ON procedure_stats (collection_time)
+ WHERE query_plan_digest IS NOT NULL;
+INSERT INTO darling_schema_version (version, name, applied_at) VALUES (39, 'dim-feeding-fact-floor-indexes', now()::timestamp);
+-- ===BATCH=== V40 blocking-wait-threshold
+
+ALTER TABLE config.config_alert_settings
+ ADD COLUMN IF NOT EXISTS blocking_wait_seconds_threshold integer NOT NULL DEFAULT 0;
+INSERT INTO darling_schema_version (version, name, applied_at) VALUES (40, 'blocking-wait-threshold', now()::timestamp);
+-- ===BATCH=== V41 query-store-interval-identity
+
+ALTER TABLE query_store_stats ADD COLUMN IF NOT EXISTS runtime_stats_interval_id bigint;
+ALTER TABLE query_store_stats ADD COLUMN IF NOT EXISTS interval_start_time_utc timestamp;
+CREATE OR REPLACE VIEW v_query_store_stats AS SELECT * FROM query_store_stats;
+INSERT INTO darling_schema_version (version, name, applied_at) VALUES (41, 'query-store-interval-identity', now()::timestamp);
+-- ===BATCH=== V42 pagerduty-webhook
+
+ALTER TABLE config.config_notification
+ ADD COLUMN IF NOT EXISTS pagerduty_routing_key text NOT NULL DEFAULT '',
+ ADD COLUMN IF NOT EXISTS pagerduty_use_eu_region boolean NOT NULL DEFAULT FALSE;
+INSERT INTO darling_schema_version (version, name, applied_at) VALUES (42, 'pagerduty-webhook', now()::timestamp);
+-- ===BATCH=== V43 pagerduty-proxy
+
+ALTER TABLE config.config_notification
+ ADD COLUMN IF NOT EXISTS pagerduty_proxy text NOT NULL DEFAULT '';
+INSERT INTO darling_schema_version (version, name, applied_at) VALUES (43, 'pagerduty-proxy', now()::timestamp);
+-- ===BATCH=== V44 collector-state
+
+CREATE TABLE IF NOT EXISTS collect.collector_state (
+ server_id integer NOT NULL,
+ collector_name text NOT NULL,
+ state_key text NOT NULL,
+ state_value text NOT NULL,
+ updated_at timestamp NOT NULL,
+ PRIMARY KEY (server_id, collector_name, state_key)
+);
+INSERT INTO darling_schema_version (version, name, applied_at) VALUES (44, 'collector-state', now()::timestamp);
+-- ===BATCH=== V46 plan-correction-collector
+
+CREATE TABLE IF NOT EXISTS collect.plan_correction (
+ collection_id bigint NOT NULL,
+ collection_time timestamp NOT NULL,
+ server_id integer NOT NULL,
+ server_name text NOT NULL,
+ database_name text,
+ force_last_good_plan_desired_state text,
+ force_last_good_plan_actual_state text,
+ force_last_good_plan_reason text,
+ create_index_actual_state text,
+ drop_index_actual_state text,
+ recommendation_name text,
+ recommendation_type text,
+ recommendation_state text,
+ recommendation_state_reason text,
+ recommendation_reason text,
+ valid_since timestamp,
+ last_refresh timestamp,
+ score integer,
+ query_id bigint,
+ query_text text,
+ regressed_plan_id bigint,
+ last_good_plan_id bigint,
+ last_good_plan_forcing_type text,
+ last_good_plan_is_forced boolean,
+ last_good_plan_force_failure_reason text,
+ regressed_plan_execution_count bigint,
+ regressed_plan_cpu_time_average_ms double precision,
+ regressed_plan_error_count bigint,
+ last_good_plan_execution_count bigint,
+ last_good_plan_cpu_time_average_ms double precision,
+ last_good_plan_error_count bigint,
+ estimated_gain_seconds double precision,
+ is_executable_action boolean,
+ is_revertable_action boolean,
+ execute_action_initiated_by text,
+ execute_action_initiated_time timestamp,
+ execute_action_start_time timestamp,
+ execute_action_duration_seconds double precision,
+ revert_action_initiated_by text,
+ revert_action_initiated_time timestamp,
+ revert_action_start_time timestamp,
+ revert_action_duration_seconds double precision,
+ implementation_script text
+);
+
+CREATE INDEX IF NOT EXISTS idx_plan_correction_time ON collect.plan_correction(server_id, collection_time);
+INSERT INTO darling_schema_version (version, name, applied_at) VALUES (46, 'plan-correction-collector', now()::timestamp);
+-- ===BATCH=== V47 pvs-stats
+
+CREATE TABLE IF NOT EXISTS collect.pvs_stats (
+ collection_id bigint NOT NULL,
+ collection_time timestamp NOT NULL,
+ server_id integer NOT NULL,
+ server_name text NOT NULL,
+ database_name text,
+ database_id integer,
+ is_accelerated_database_recovery_on boolean,
+ pvs_filegroup_id smallint,
+ persistent_version_store_size_mb numeric(19,2),
+ online_index_version_store_size_mb numeric(19,2),
+ database_data_size_mb numeric(19,2),
+ current_aborted_transaction_count bigint,
+ oldest_active_transaction_id bigint,
+ oldest_aborted_transaction_id bigint,
+ min_transaction_timestamp bigint,
+ online_index_min_transaction_timestamp bigint,
+ secondary_low_water_mark bigint,
+ offrow_version_cleaner_start_time timestamp,
+ offrow_version_cleaner_end_time timestamp,
+ aborted_version_cleaner_start_time timestamp,
+ aborted_version_cleaner_end_time timestamp,
+ pvs_off_row_page_skipped_low_water_mark bigint,
+ pvs_off_row_page_skipped_transaction_not_cleaned bigint,
+ pvs_off_row_page_skipped_oldest_active_xdesid bigint,
+ pvs_off_row_page_skipped_min_useful_xts bigint,
+ pvs_off_row_page_skipped_oldest_snapshot bigint,
+ pvs_off_row_page_skipped_oldest_aborted_xdesid bigint
+);
+
+CREATE INDEX IF NOT EXISTS idx_pvs_stats_time ON collect.pvs_stats(server_id, collection_time);
+
+CREATE OR REPLACE VIEW v_pvs_stats AS SELECT * FROM pvs_stats;
+INSERT INTO darling_schema_version (version, name, applied_at) VALUES (47, 'pvs-stats', now()::timestamp);
+-- ===BATCH=== V48 pvs-pressure-alert
+
+ALTER TABLE config.config_alert_settings
+ ADD COLUMN IF NOT EXISTS pvs_enabled boolean NOT NULL DEFAULT TRUE;
+ALTER TABLE config.config_alert_settings
+ ADD COLUMN IF NOT EXISTS pvs_threshold_percent integer NOT NULL DEFAULT 40;
+ALTER TABLE config.config_alert_settings
+ ADD COLUMN IF NOT EXISTS pvs_floor_gb integer NOT NULL DEFAULT 1;
+INSERT INTO darling_schema_version (version, name, applied_at) VALUES (48, 'pvs-pressure-alert', now()::timestamp);
+-- ===BATCH=== V49 database-state-alert
+
+CREATE TABLE IF NOT EXISTS collect.database_states (
+ collection_id bigint NOT NULL,
+ collection_time timestamp NOT NULL,
+ server_id integer NOT NULL,
+ server_name text NOT NULL,
+ database_name text,
+ database_id integer,
+ state_desc text,
+ is_in_standby boolean
+);
+
+CREATE INDEX IF NOT EXISTS idx_database_states_time ON collect.database_states(server_id, collection_time);
+
+CREATE TABLE IF NOT EXISTS config.database_state_expected (
+ server_id integer NOT NULL,
+ database_name text NOT NULL,
+ expected_state text NOT NULL,
+ is_user_override boolean NOT NULL DEFAULT false,
+ updated_at timestamp NOT NULL DEFAULT (now() AT TIME ZONE 'UTC'),
+ PRIMARY KEY (server_id, database_name)
+);
+
+ALTER TABLE config.config_alert_settings
+ ADD COLUMN IF NOT EXISTS database_state_enabled boolean NOT NULL DEFAULT true;
+INSERT INTO darling_schema_version (version, name, applied_at) VALUES (49, 'database-state-alert', now()::timestamp);
+-- ===BATCH=== V50 server-tag-colour
+
+ALTER TABLE config.server_tags
+ ADD COLUMN IF NOT EXISTS colour text;
+INSERT INTO darling_schema_version (version, name, applied_at) VALUES (50, 'server-tag-colour', now()::timestamp);
+-- ===BATCH=== V51 query-stats-host-object
+
+ALTER TABLE query_stats
+ ADD COLUMN IF NOT EXISTS host_object_name text;
+DROP VIEW IF EXISTS v_query_stats;
+
+ALTER TABLE query_plan_dim
+ ADD COLUMN IF NOT EXISTS query_plan_gz bytea;
+ALTER TABLE query_plan_dim
+ ALTER COLUMN query_plan_xml DROP NOT NULL;
+/* v_query_stats resolves each row's payload: the inline column for rows written before
+ the dimension tables existed, the dimension row for every row written since (#1767).
+ Column list generated from the collector definition so it can never go stale. */
+CREATE OR REPLACE VIEW v_query_stats AS
+SELECT
+ f.collection_id,
+ f.collection_time,
+ f.server_id,
+ f.server_name,
+ f.database_name,
+ f.query_hash,
+ f.query_plan_hash,
+ f.creation_time,
+ f.last_execution_time,
+ f.execution_count,
+ f.total_worker_time,
+ f.total_elapsed_time,
+ f.total_logical_reads,
+ f.total_logical_writes,
+ f.total_physical_reads,
+ f.total_clr_time,
+ f.total_rows,
+ f.total_spills,
+ f.min_worker_time,
+ f.max_worker_time,
+ f.min_elapsed_time,
+ f.max_elapsed_time,
+ f.min_physical_reads,
+ f.max_physical_reads,
+ f.min_rows,
+ f.max_rows,
+ f.min_dop,
+ f.max_dop,
+ f.min_grant_kb,
+ f.max_grant_kb,
+ f.min_used_grant_kb,
+ f.max_used_grant_kb,
+ f.min_ideal_grant_kb,
+ f.max_ideal_grant_kb,
+ f.min_reserved_threads,
+ f.max_reserved_threads,
+ f.min_used_threads,
+ f.max_used_threads,
+ f.min_spills,
+ f.max_spills,
+ COALESCE(f.query_text, qtd.query_text) AS query_text,
+ COALESCE(f.query_plan_xml, qpd.query_plan_xml) AS query_plan_xml,
+ f.sql_handle,
+ f.plan_handle,
+ f.delta_execution_count,
+ f.delta_worker_time,
+ f.delta_elapsed_time,
+ f.delta_logical_reads,
+ f.delta_logical_writes,
+ f.delta_physical_reads,
+ f.delta_rows,
+ f.delta_spills,
+ f.plan_generation_num,
+ f.sample_interval_seconds,
+ f.host_object_name,
+ f.query_text_digest,
+ f.query_plan_digest,
+ qpd.query_plan_gz
+FROM query_stats AS f
+LEFT JOIN query_text_dim AS qtd ON qtd.digest = f.query_text_digest
+LEFT JOIN query_plan_dim AS qpd ON qpd.digest = f.query_plan_digest
+;
+INSERT INTO darling_schema_version (version, name, applied_at) VALUES (51, 'query-stats-host-object', now()::timestamp);
+-- ===BATCH=== V52 finding-drilldown-json
+
+ALTER TABLE analysis_findings
+ ADD COLUMN IF NOT EXISTS drill_down_json text;
+INSERT INTO darling_schema_version (version, name, applied_at) VALUES (52, 'finding-drilldown-json', now()::timestamp);
+-- ===BATCH=== V53 store-self-metrics
+
+CREATE TABLE IF NOT EXISTS collect.store_metrics (
+ metric_time timestamp NOT NULL,
+ object_name text NOT NULL,
+ object_kind text NOT NULL,
+ total_bytes bigint,
+ compressed_before_bytes bigint,
+ compressed_after_bytes bigint,
+ chunk_count integer,
+ row_count bigint,
+ enabled_server_count integer
+);
+
+CREATE INDEX IF NOT EXISTS idx_store_metrics_time ON collect.store_metrics(metric_time);
+INSERT INTO darling_schema_version (version, name, applied_at) VALUES (53, 'store-self-metrics', now()::timestamp);
+-- ===BATCH=== V54 plan-dim-gzip
+
+ALTER TABLE query_plan_dim
+ ADD COLUMN IF NOT EXISTS query_plan_gz bytea;
+ALTER TABLE query_plan_dim
+ ALTER COLUMN query_plan_xml DROP NOT NULL;
+/* v_query_stats resolves each row's payload: the inline column for rows written before
+ the dimension tables existed, the dimension row for every row written since (#1767).
+ Column list generated from the collector definition so it can never go stale. */
+CREATE OR REPLACE VIEW v_query_stats AS
+SELECT
+ f.collection_id,
+ f.collection_time,
+ f.server_id,
+ f.server_name,
+ f.database_name,
+ f.query_hash,
+ f.query_plan_hash,
+ f.creation_time,
+ f.last_execution_time,
+ f.execution_count,
+ f.total_worker_time,
+ f.total_elapsed_time,
+ f.total_logical_reads,
+ f.total_logical_writes,
+ f.total_physical_reads,
+ f.total_clr_time,
+ f.total_rows,
+ f.total_spills,
+ f.min_worker_time,
+ f.max_worker_time,
+ f.min_elapsed_time,
+ f.max_elapsed_time,
+ f.min_physical_reads,
+ f.max_physical_reads,
+ f.min_rows,
+ f.max_rows,
+ f.min_dop,
+ f.max_dop,
+ f.min_grant_kb,
+ f.max_grant_kb,
+ f.min_used_grant_kb,
+ f.max_used_grant_kb,
+ f.min_ideal_grant_kb,
+ f.max_ideal_grant_kb,
+ f.min_reserved_threads,
+ f.max_reserved_threads,
+ f.min_used_threads,
+ f.max_used_threads,
+ f.min_spills,
+ f.max_spills,
+ COALESCE(f.query_text, qtd.query_text) AS query_text,
+ COALESCE(f.query_plan_xml, qpd.query_plan_xml) AS query_plan_xml,
+ f.sql_handle,
+ f.plan_handle,
+ f.delta_execution_count,
+ f.delta_worker_time,
+ f.delta_elapsed_time,
+ f.delta_logical_reads,
+ f.delta_logical_writes,
+ f.delta_physical_reads,
+ f.delta_rows,
+ f.delta_spills,
+ f.plan_generation_num,
+ f.sample_interval_seconds,
+ f.host_object_name,
+ f.query_text_digest,
+ f.query_plan_digest,
+ qpd.query_plan_gz
+FROM query_stats AS f
+LEFT JOIN query_text_dim AS qtd ON qtd.digest = f.query_text_digest
+LEFT JOIN query_plan_dim AS qpd ON qpd.digest = f.query_plan_digest
+;
+INSERT INTO darling_schema_version (version, name, applied_at) VALUES (54, 'plan-dim-gzip', now()::timestamp);
+-- ===BATCH=== V55 self-alert-knobs
+
+ALTER TABLE config.config_alert_settings
+ ADD COLUMN IF NOT EXISTS self_disk_free_warn_percent integer NOT NULL DEFAULT 10;
+ALTER TABLE config.config_alert_settings
+ ADD COLUMN IF NOT EXISTS collection_stale_minutes integer NOT NULL DEFAULT 30;
+ALTER TABLE config.config_alert_settings
+ ADD COLUMN IF NOT EXISTS collection_failure_threshold integer NOT NULL DEFAULT 10;
+ALTER TABLE config.config_alert_settings
+ ADD COLUMN IF NOT EXISTS disk_critical_free_percent integer NOT NULL DEFAULT 3;
+ALTER TABLE config.config_alert_settings
+ ADD COLUMN IF NOT EXISTS disk_critical_free_gb integer NOT NULL DEFAULT 2;
+ALTER TABLE config.config_alert_settings
+ ADD COLUMN IF NOT EXISTS analysis_notify_cooldown_minutes integer NOT NULL DEFAULT 360;
+INSERT INTO darling_schema_version (version, name, applied_at) VALUES (55, 'self-alert-knobs', now()::timestamp);
+-- ===BATCH=== V56 store-metrics-background-jobs
+
+ALTER TABLE collect.store_metrics
+ ADD COLUMN IF NOT EXISTS last_run_duration_ms bigint;
+ALTER TABLE collect.store_metrics
+ ADD COLUMN IF NOT EXISTS schedule_interval_ms bigint;
+ALTER TABLE collect.store_metrics
+ ADD COLUMN IF NOT EXISTS total_runs bigint;
+ALTER TABLE collect.store_metrics
+ ADD COLUMN IF NOT EXISTS total_failures bigint;
+INSERT INTO darling_schema_version (version, name, applied_at) VALUES (56, 'store-metrics-background-jobs', now()::timestamp);
+-- ===BATCH=== V57 store-job-cadence-knob
+
+ALTER TABLE config.config_alert_settings
+ ADD COLUMN IF NOT EXISTS store_job_cadence_warn_percent integer NOT NULL DEFAULT 25;
+INSERT INTO darling_schema_version (version, name, applied_at) VALUES (57, 'store-job-cadence-knob', now()::timestamp);
+-- ===BATCH=== V58 qs-backfill-switch
+
+ALTER TABLE config.config_service
+ ADD COLUMN IF NOT EXISTS query_store_backfill_enabled boolean NOT NULL DEFAULT TRUE;
+INSERT INTO darling_schema_version (version, name, applied_at) VALUES (58, 'qs-backfill-switch', now()::timestamp);
+-- ===BATCH=== V59 collector-memory-knobs
+
+ALTER TABLE config.config_service
+ ADD COLUMN IF NOT EXISTS query_store_text_budget_mb integer NOT NULL DEFAULT 64;
+ALTER TABLE config.config_service
+ ADD COLUMN IF NOT EXISTS max_concurrent_sweeps integer NOT NULL DEFAULT 4;
+INSERT INTO darling_schema_version (version, name, applied_at) VALUES (59, 'collector-memory-knobs', now()::timestamp);
+-- ===BATCH=== V60 database-state-edge-memory
+
+ALTER TABLE config.database_state_expected
+ ADD COLUMN IF NOT EXISTS last_alerted_state text;
+ALTER TABLE config.database_state_expected
+ ADD COLUMN IF NOT EXISTS last_alerted_at timestamp;
+INSERT INTO darling_schema_version (version, name, applied_at) VALUES (60, 'database-state-edge-memory', now()::timestamp);
+-- ===BATCH=== V61 incident-occurrence-counters
+
+CREATE TABLE IF NOT EXISTS config.incident_occurrences (
+ server_id integer NOT NULL,
+ metric_name text NOT NULL,
+ dedup_key text NOT NULL,
+ total_occurrences bigint NOT NULL,
+ observed_window_count integer NOT NULL,
+ incident_started_at timestamp NOT NULL,
+ last_observed_at timestamp NOT NULL DEFAULT (now() AT TIME ZONE 'UTC'),
+ PRIMARY KEY (server_id, metric_name, dedup_key)
+);
+INSERT INTO darling_schema_version (version, name, applied_at) VALUES (61, 'incident-occurrence-counters', now()::timestamp);
+-- ===BATCH=== V62 plan-xml-compression-knob
+
+ALTER TABLE config.config_service
+ ADD COLUMN IF NOT EXISTS plan_xml_compression text NOT NULL DEFAULT 'gzip';
+DO $$
+BEGIN
+ IF NOT EXISTS (
+ SELECT 1 FROM pg_constraint
+ WHERE conname = 'config_service_plan_xml_compression_check'
+ ) THEN
+ ALTER TABLE config.config_service
+ ADD CONSTRAINT config_service_plan_xml_compression_check
+ CHECK (plan_xml_compression IN ('gzip', 'none'));
+ END IF;
+END $$;
+INSERT INTO darling_schema_version (version, name, applied_at) VALUES (62, 'plan-xml-compression-knob', now()::timestamp);
+-- ===BATCH=== V63 pg-wait-stats
+
+CREATE TABLE IF NOT EXISTS collect.pg_wait_stats (
+ collection_id bigint NOT NULL,
+ collection_time timestamp NOT NULL,
+ server_id integer NOT NULL,
+ server_name text NOT NULL,
+ wait_type_id integer,
+ wait_event_id bigint,
+ wait_type text,
+ wait_event text,
+ waits bigint,
+ wait_time_us bigint,
+ delta_waits bigint,
+ delta_wait_time_us bigint
+);
+
+CREATE INDEX IF NOT EXISTS idx_pg_wait_stats_time
+ ON collect.pg_wait_stats(server_id, collection_time);
+INSERT INTO darling_schema_version (version, name, applied_at) VALUES (63, 'pg-wait-stats', now()::timestamp);
+-- ===BATCH=== V64 pg-statement-stats
+
+CREATE TABLE IF NOT EXISTS collect.pg_statement_stats (
+ collection_id bigint NOT NULL,
+ collection_time timestamp NOT NULL,
+ server_id integer NOT NULL,
+ server_name text NOT NULL,
+ queryid bigint,
+ database_id bigint,
+ user_id bigint,
+ toplevel boolean,
+ calls bigint,
+ total_exec_time_ms double precision,
+ min_exec_time_ms double precision,
+ max_exec_time_ms double precision,
+ mean_exec_time_ms double precision,
+ rows_returned bigint,
+ shared_blks_hit bigint,
+ shared_blks_read bigint,
+ shared_blks_dirtied bigint,
+ shared_blks_written bigint,
+ temp_blks_read bigint,
+ temp_blks_written bigint,
+ blk_read_time_ms double precision,
+ blk_write_time_ms double precision,
+ storage_blks_read bigint,
+ orcache_blks_hit bigint,
+ storage_blk_read_time_ms double precision,
+ orcache_blk_read_time_ms double precision,
+ wal_records bigint,
+ wal_fpi bigint,
+ wal_bytes bigint,
+ total_exec_peakmem_bytes bigint,
+ max_exec_peakmem_bytes bigint,
+ delta_calls bigint,
+ delta_total_exec_time_ms bigint,
+ delta_rows bigint
+);
+
+CREATE INDEX IF NOT EXISTS idx_pg_statement_stats_time
+ ON collect.pg_statement_stats(server_id, collection_time);
+INSERT INTO darling_schema_version (version, name, applied_at) VALUES (64, 'pg-statement-stats', now()::timestamp);
+-- ===BATCH=== V65 pg-wraparound-stats
+
+CREATE TABLE IF NOT EXISTS collect.pg_wraparound_stats (
+ collection_id bigint NOT NULL,
+ collection_time timestamp NOT NULL,
+ server_id integer NOT NULL,
+ server_name text NOT NULL,
+ database_name text,
+ frozen_xid_age bigint,
+ min_multixid_age bigint,
+ autovacuum_freeze_max_age bigint,
+ autovacuum_multixact_freeze_max_age bigint,
+ pct_toward_emergency_vacuum double precision,
+ pct_toward_wraparound double precision,
+ pct_toward_multixact_emergency double precision,
+ pct_toward_multixact_wraparound double precision,
+ xids_remaining bigint,
+ multixids_remaining bigint,
+ allows_connections boolean
+);
+
+CREATE INDEX IF NOT EXISTS idx_pg_wraparound_stats_time
+ ON collect.pg_wraparound_stats(server_id, collection_time);
+INSERT INTO darling_schema_version (version, name, applied_at) VALUES (65, 'pg-wraparound-stats', now()::timestamp);
+-- ===BATCH=== V66 pg-xmin-horizon
+
+CREATE TABLE IF NOT EXISTS collect.pg_xmin_horizon (
+ collection_id bigint NOT NULL,
+ collection_time timestamp NOT NULL,
+ server_id integer NOT NULL,
+ server_name text NOT NULL,
+ source text,
+ xmin_age bigint,
+ holder text,
+ detail text,
+ is_winner boolean
+);
+
+CREATE INDEX IF NOT EXISTS idx_pg_xmin_horizon_time
+ ON collect.pg_xmin_horizon(server_id, collection_time);
+INSERT INTO darling_schema_version (version, name, applied_at) VALUES (66, 'pg-xmin-horizon', now()::timestamp);
+-- ===BATCH=== V67 pg-replication-slots
+
+CREATE TABLE IF NOT EXISTS collect.pg_replication_slot_stats (
+ collection_id bigint NOT NULL,
+ collection_time timestamp NOT NULL,
+ server_id integer NOT NULL,
+ server_name text NOT NULL,
+ slot_name text,
+ slot_type text,
+ plugin text,
+ database_name text,
+ is_active boolean,
+ active_pid bigint,
+ is_temporary boolean,
+ two_phase boolean,
+ wal_status text,
+ safe_wal_size_bytes bigint,
+ retained_wal_bytes bigint,
+ xmin_age bigint,
+ catalog_xmin_age bigint,
+ inactive_since timestamp,
+ invalidation_reason text,
+ conflicting boolean
+);
+
+CREATE INDEX IF NOT EXISTS idx_pg_replication_slot_stats_time
+ ON collect.pg_replication_slot_stats(server_id, collection_time);
+INSERT INTO darling_schema_version (version, name, applied_at) VALUES (67, 'pg-replication-slots', now()::timestamp);
+-- ===BATCH=== V68 pg-autovacuum-stats
+
+CREATE TABLE IF NOT EXISTS collect.pg_autovacuum_stats (
+ collection_id bigint NOT NULL,
+ collection_time timestamp NOT NULL,
+ server_id integer NOT NULL,
+ server_name text NOT NULL,
+ database_name text,
+ schema_name text,
+ table_name text,
+ live_tuples bigint,
+ dead_tuples bigint,
+ mods_since_analyze bigint,
+ inserts_since_vacuum bigint,
+ vacuum_threshold bigint,
+ insert_vacuum_threshold bigint,
+ analyze_threshold bigint,
+ autovacuum_disabled boolean,
+ total_bytes bigint,
+ last_vacuum timestamp,
+ last_autovacuum timestamp,
+ last_analyze timestamp,
+ last_autoanalyze timestamp,
+ vacuum_count bigint,
+ autovacuum_count bigint,
+ analyze_count bigint,
+ autoanalyze_count bigint
+);
+
+CREATE INDEX IF NOT EXISTS idx_pg_autovacuum_stats_time
+ ON collect.pg_autovacuum_stats(server_id, collection_time);
+INSERT INTO darling_schema_version (version, name, applied_at) VALUES (68, 'pg-autovacuum-stats', now()::timestamp);
+-- ===BATCH=== V69 pg-io-stats
+
+CREATE TABLE IF NOT EXISTS collect.pg_io_stats (
+ collection_id bigint NOT NULL,
+ collection_time timestamp NOT NULL,
+ server_id integer NOT NULL,
+ server_name text NOT NULL,
+ backend_type text,
+ object_type text,
+ context text,
+ reads bigint,
+ read_time_ms double precision,
+ writes bigint,
+ write_time_ms double precision,
+ writebacks bigint,
+ writeback_time_ms double precision,
+ extends bigint,
+ extend_time_ms double precision,
+ op_bytes bigint,
+ hits bigint,
+ evictions bigint,
+ reuses bigint,
+ fsyncs bigint,
+ fsync_time_ms double precision,
+ stats_reset timestamp,
+ read_bytes numeric(28,0),
+ write_bytes numeric(28,0),
+ extend_bytes numeric(28,0)
+);
+
+CREATE INDEX IF NOT EXISTS idx_pg_io_stats_time
+ ON collect.pg_io_stats(server_id, collection_time);
+INSERT INTO darling_schema_version (version, name, applied_at) VALUES (69, 'pg-io-stats', now()::timestamp);
+-- ===BATCH=== V70 monitored-server-engine
+
+ALTER TABLE config.config_monitored_servers
+ ADD COLUMN IF NOT EXISTS engine text NOT NULL DEFAULT 'sqlserver';
+
+ALTER TABLE config.config_monitored_servers
+ ADD COLUMN IF NOT EXISTS port integer NOT NULL DEFAULT 0;
+INSERT INTO darling_schema_version (version, name, applied_at) VALUES (70, 'monitored-server-engine', now()::timestamp);
+-- ===BATCH=== V71 pg-blocking-edges
+
+CREATE TABLE IF NOT EXISTS collect.pg_blocking_edges (
+ collection_id bigint NOT NULL,
+ collection_time timestamp NOT NULL,
+ server_id integer NOT NULL,
+ server_name text NOT NULL,
+ blocked_backend_id bigint,
+ blocked_pid integer,
+ blocking_backend_id bigint,
+ blocking_pid integer,
+ database_name text,
+ blocked_username text,
+ blocked_application_name text,
+ blocked_client_addr text,
+ blocked_state text,
+ blocked_wait_event_type text,
+ blocked_wait_event text,
+ blocked_query text,
+ blocked_xact_duration_ms bigint,
+ blocked_query_duration_ms bigint,
+ blocking_username text,
+ blocking_application_name text,
+ blocking_client_addr text,
+ blocking_state text,
+ blocking_wait_event_type text,
+ blocking_wait_event text,
+ blocking_query text,
+ blocking_xact_duration_ms bigint,
+ blocking_query_duration_ms bigint,
+ blocked_pid_count integer,
+ blocking_is_idle_in_transaction boolean,
+ query_text_may_be_truncated boolean
+);
+
+CREATE INDEX IF NOT EXISTS idx_pg_blocking_edges_time
+ ON collect.pg_blocking_edges(server_id, collection_time);
+INSERT INTO darling_schema_version (version, name, applied_at) VALUES (71, 'pg-blocking-edges', now()::timestamp);
+-- ===BATCH=== V72 query-store-plan-map
+
+CREATE TABLE IF NOT EXISTS collect.query_store_plan_map (
+ server_id integer NOT NULL,
+ database_name text NOT NULL,
+ plan_id bigint NOT NULL,
+ digest bytea NOT NULL,
+ plan_hash text,
+ last_seen timestamp NOT NULL,
+ PRIMARY KEY (server_id, database_name, plan_id)
+);
+CREATE INDEX IF NOT EXISTS idx_query_store_plan_map_last_seen
+ ON collect.query_store_plan_map(last_seen);
+INSERT INTO darling_schema_version (version, name, applied_at) VALUES (72, 'query-store-plan-map', now()::timestamp);
+-- ===BATCH=== V73 pg-statement-text
+
+CREATE TABLE IF NOT EXISTS collect.pg_statement_text (
+ server_id integer NOT NULL,
+ queryid bigint NOT NULL,
+ query_text text NOT NULL,
+ first_seen timestamp NOT NULL,
+ last_seen timestamp NOT NULL,
+ PRIMARY KEY (server_id, queryid)
+);
+CREATE INDEX IF NOT EXISTS idx_pg_statement_text_last_seen
+ ON collect.pg_statement_text(last_seen);
+INSERT INTO darling_schema_version (version, name, applied_at) VALUES (73, 'pg-statement-text', now()::timestamp);
+-- ===BATCH=== V74 query-store-text
+CREATE TABLE IF NOT EXISTS collect.query_store_text (
+ server_id integer NOT NULL,
+ database_name text NOT NULL,
+ query_id bigint NOT NULL,
+ query_sql_text text,
+ last_seen timestamp NOT NULL,
+ PRIMARY KEY (server_id, database_name, query_id)
+);
+CREATE INDEX IF NOT EXISTS idx_query_store_text_last_seen
+ ON collect.query_store_text(last_seen);
+INSERT INTO darling_schema_version (version, name, applied_at) VALUES (74, 'query-store-text', now()::timestamp);
+-- ===BATCH=== V75 plan-content-retention-knob
+
+ALTER TABLE config.config_service
+ ADD COLUMN IF NOT EXISTS plan_content_retention_days integer NOT NULL DEFAULT 21;
+INSERT INTO darling_schema_version (version, name, applied_at) VALUES (75, 'plan-content-retention-knob', now()::timestamp);
+-- ===BATCH=== V76 query-store-health
+
+CREATE TABLE IF NOT EXISTS collect.query_store_health (
+ config_id bigint NOT NULL,
+ capture_time timestamp NOT NULL,
+ server_id integer NOT NULL,
+ server_name text NOT NULL,
+ database_name text,
+ actual_state text,
+ desired_state text,
+ readonly_reason integer,
+ current_storage_size_mb bigint,
+ max_storage_size_mb bigint,
+ size_based_cleanup_mode text,
+ stale_query_threshold_days bigint,
+ max_plans_per_query bigint,
+ interval_length_minutes bigint
+);
+
+CREATE INDEX IF NOT EXISTS idx_query_store_health_time ON collect.query_store_health(server_id, capture_time);
+
+CREATE OR REPLACE VIEW v_query_store_health AS SELECT * FROM query_store_health;
+INSERT INTO darling_schema_version (version, name, applied_at) VALUES (76, 'query-store-health', now()::timestamp);
+-- ===BATCH=== V77 activity-driven-plan-fetch
+
+ALTER TABLE collect.query_store_plan_map ALTER COLUMN digest DROP NOT NULL;
+
+ALTER TABLE collect.query_store_text ADD COLUMN IF NOT EXISTS query_hash text;
+
+DELETE FROM collector_state WHERE collector_name = 'query_store_plan_xml' AND state_key LIKE 'planwm:%';
+DELETE FROM collector_state WHERE collector_name = 'query_store_text' AND state_key LIKE 'textwm:%';
+INSERT INTO darling_schema_version (version, name, applied_at) VALUES (77, 'activity-driven-plan-fetch', now()::timestamp);
+-- ===BATCH=== V78 compose-statement-timeout
+
+ALTER TABLE config.config_service
+ ADD COLUMN IF NOT EXISTS compose_statement_timeout_seconds integer NOT NULL DEFAULT 15;
+INSERT INTO darling_schema_version (version, name, applied_at) VALUES (78, 'compose-statement-timeout', now()::timestamp);
+-- ===BATCH=== V79 file-growth-alert
+
+ALTER TABLE config.config_alert_settings
+ ADD COLUMN IF NOT EXISTS file_growth_enabled boolean NOT NULL DEFAULT false,
+ ADD COLUMN IF NOT EXISTS file_growth_rise_mb integer NOT NULL DEFAULT 10240,
+ ADD COLUMN IF NOT EXISTS file_growth_volume_percent integer NOT NULL DEFAULT 60,
+ ADD COLUMN IF NOT EXISTS file_growth_lookback_minutes integer NOT NULL DEFAULT 60;
+INSERT INTO darling_schema_version (version, name, applied_at) VALUES (79, 'file-growth-alert', now()::timestamp);
+-- ===BATCH=== V80 collection-log-fanout-rollup
+
+ALTER TABLE collect.collection_log
+ ADD COLUMN IF NOT EXISTS fanout_item_count integer,
+ ADD COLUMN IF NOT EXISTS slowest_item text,
+ ADD COLUMN IF NOT EXISTS slowest_item_ms integer;
+
+/* Postgres FREEZES a view's SELECT * column list at CREATE, so the passthrough every read goes through
+ would keep serving eleven columns forever — the V14 lesson, and the reason it exists. Appending is the
+ one alteration CREATE OR REPLACE VIEW permits, which is exactly what an ADD COLUMN produces. */
+CREATE OR REPLACE VIEW collect.v_collection_log AS SELECT * FROM collect.collection_log;
+INSERT INTO darling_schema_version (version, name, applied_at) VALUES (80, 'collection-log-fanout-rollup', now()::timestamp);
+-- ===BATCH=== V81 tempdb-max-size
+
+ALTER TABLE collect.tempdb_stats
+ ADD COLUMN IF NOT EXISTS max_size_mb numeric(18,2);
+
+/* Postgres FREEZES a view's SELECT * column list at CREATE, so the V4 passthrough the analysis fact
+ collector reads would keep serving the twelve columns it had forever — the V14 lesson, restated by V80.
+ Appending is the one alteration CREATE OR REPLACE VIEW permits, which is exactly what an ADD COLUMN
+ produces. */
+CREATE OR REPLACE VIEW collect.v_tempdb_stats AS SELECT * FROM collect.tempdb_stats;
+INSERT INTO darling_schema_version (version, name, applied_at) VALUES (81, 'tempdb-max-size', now()::timestamp);
+-- ===BATCH=== V82 server-engine-kind
+
+ALTER TABLE collect.servers
+ ADD COLUMN IF NOT EXISTS engine_kind text;
+INSERT INTO darling_schema_version (version, name, applied_at) VALUES (82, 'server-engine-kind', now()::timestamp);
+-- ===BATCH=== V83 pg-database-stats
+
+CREATE TABLE IF NOT EXISTS collect.pg_database_stats (
+ collection_id bigint NOT NULL,
+ collection_time timestamp NOT NULL,
+ server_id integer NOT NULL,
+ server_name text NOT NULL,
+ database_name text,
+ xact_commit bigint,
+ xact_rollback bigint,
+ blks_read bigint,
+ blks_hit bigint,
+ temp_files bigint,
+ temp_bytes bigint,
+ deadlocks bigint,
+ stats_reset timestamp
+);
+
+CREATE INDEX IF NOT EXISTS idx_pg_database_stats_time
+ ON collect.pg_database_stats(server_id, collection_time);
+INSERT INTO darling_schema_version (version, name, applied_at) VALUES (83, 'pg-database-stats', now()::timestamp);
+-- ===BATCH=== V84 pg-index-usage-stats
+
+CREATE TABLE IF NOT EXISTS collect.pg_index_usage_stats (
+ collection_id bigint NOT NULL,
+ collection_time timestamp NOT NULL,
+ server_id integer NOT NULL,
+ server_name text NOT NULL,
+ database_name text,
+ schema_name text,
+ table_name text,
+ index_name text,
+ index_scans bigint,
+ tuples_read bigint,
+ tuples_fetched bigint,
+ blocks_read bigint,
+ blocks_hit bigint,
+ index_bytes bigint,
+ table_bytes bigint,
+ is_unique boolean,
+ is_primary_key boolean,
+ is_valid boolean,
+ is_ready boolean,
+ is_replica_identity boolean,
+ is_partial boolean,
+ is_expression boolean,
+ supports_constraint boolean,
+ index_method text,
+ column_count integer,
+ index_definition text,
+ last_scan timestamp,
+ stats_reset timestamp
+);
+
+CREATE INDEX IF NOT EXISTS idx_pg_index_usage_stats_time
+ ON collect.pg_index_usage_stats(server_id, collection_time);
+INSERT INTO darling_schema_version (version, name, applied_at) VALUES (84, 'pg-index-usage-stats', now()::timestamp);
+-- ===BATCH=== V85 pg-table-bloat-stats
+
+CREATE TABLE IF NOT EXISTS collect.pg_table_bloat_stats (
+ collection_id bigint NOT NULL,
+ collection_time timestamp NOT NULL,
+ server_id integer NOT NULL,
+ server_name text NOT NULL,
+ database_name text,
+ schema_name text,
+ table_name text,
+ heap_bytes bigint,
+ heap_pages bigint,
+ toast_bytes bigint,
+ index_bytes bigint,
+ live_tuples bigint,
+ dead_tuples bigint,
+ mods_since_analyze bigint,
+ last_analyzed timestamp,
+ estimated_tuple_bytes double precision,
+ estimated_heap_pages bigint,
+ fillfactor integer,
+ bloat_bytes_estimate bigint,
+ bloat_pct_estimate numeric(5,2),
+ estimate_unavailable boolean,
+ alignment_bytes integer,
+ pgstattuple_available boolean
+);
+
+CREATE INDEX IF NOT EXISTS idx_pg_table_bloat_stats_time
+ ON collect.pg_table_bloat_stats(server_id, collection_time);
+INSERT INTO darling_schema_version (version, name, applied_at) VALUES (85, 'pg-table-bloat-stats', now()::timestamp);
+-- ===BATCH=== V86 pg-session-states
+
+CREATE TABLE IF NOT EXISTS collect.pg_session_states (
+ collection_id bigint NOT NULL,
+ collection_time timestamp NOT NULL,
+ server_id integer NOT NULL,
+ server_name text NOT NULL,
+ backend_id bigint,
+ pid integer,
+ database_name text,
+ username text,
+ application_name text,
+ client_addr text,
+ backend_type text,
+ state text,
+ wait_event_type text,
+ wait_event text,
+ command_tag text,
+ query_id bigint,
+ state_duration_ms bigint,
+ xact_duration_ms bigint,
+ query_duration_ms bigint,
+ backend_duration_ms bigint,
+ xmin_age bigint,
+ xid_age bigint,
+ horizon_age bigint,
+ is_idle_in_transaction boolean,
+ is_horizon_holder boolean,
+ state_is_redacted boolean,
+ total_sessions integer,
+ active_sessions integer,
+ idle_in_transaction_sessions integer,
+ reportable_sessions integer
+);
+
+CREATE INDEX IF NOT EXISTS idx_pg_session_states_time
+ ON collect.pg_session_states(server_id, collection_time);
+INSERT INTO darling_schema_version (version, name, applied_at) VALUES (86, 'pg-session-states', now()::timestamp);
+-- ===BATCH=== V87 pg-plan-capture-readiness
+
+CREATE TABLE IF NOT EXISTS collect.pg_plan_capture_readiness (
+ collection_id bigint NOT NULL,
+ collection_time timestamp NOT NULL,
+ server_id integer NOT NULL,
+ server_name text NOT NULL,
+ facet text,
+ is_satisfied boolean,
+ observed text,
+ detail text
+);
+
+CREATE INDEX IF NOT EXISTS idx_pg_plan_capture_readiness_time
+ ON collect.pg_plan_capture_readiness(server_id, collection_time);
+INSERT INTO darling_schema_version (version, name, applied_at) VALUES (87, 'pg-plan-capture-readiness', now()::timestamp);
+-- ===BATCH=== V88 pg-write-stats
+
+CREATE TABLE IF NOT EXISTS collect.pg_write_stats (
+ collection_id bigint NOT NULL,
+ collection_time timestamp NOT NULL,
+ server_id integer NOT NULL,
+ server_name text NOT NULL,
+ num_timed bigint,
+ num_requested bigint,
+ num_done bigint,
+ restartpoints_timed bigint,
+ restartpoints_req bigint,
+ restartpoints_done bigint,
+ checkpoint_write_time_ms double precision,
+ checkpoint_sync_time_ms double precision,
+ buffers_written_checkpoint bigint,
+ slru_written bigint,
+ checkpointer_stats_reset timestamp,
+ buffers_clean bigint,
+ maxwritten_clean bigint,
+ buffers_alloc bigint,
+ buffers_backend bigint,
+ buffers_backend_fsync bigint,
+ bgwriter_stats_reset timestamp,
+ wal_records bigint,
+ wal_fpi bigint,
+ wal_bytes numeric(38,0),
+ wal_buffers_full bigint,
+ wal_write bigint,
+ wal_sync bigint,
+ wal_write_time_ms double precision,
+ wal_sync_time_ms double precision,
+ wal_stats_reset timestamp
+);
+
+CREATE INDEX IF NOT EXISTS idx_pg_write_stats_time
+ ON collect.pg_write_stats(server_id, collection_time);
+INSERT INTO darling_schema_version (version, name, applied_at) VALUES (88, 'pg-write-stats', now()::timestamp);
+-- ===BATCH=== V89 pg-extension-availability
+
+CREATE TABLE IF NOT EXISTS collect.pg_extension_availability (
+ collection_id bigint NOT NULL,
+ collection_time timestamp NOT NULL,
+ server_id integer NOT NULL,
+ server_name text NOT NULL,
+ database_name text,
+ extension_name text,
+ state text,
+ installed_version text,
+ default_version text,
+ is_monitoring_relevant boolean,
+ comment text
+);
+
+CREATE INDEX IF NOT EXISTS idx_pg_extension_availability_time
+ ON collect.pg_extension_availability(server_id, collection_time);
+INSERT INTO darling_schema_version (version, name, applied_at) VALUES (89, 'pg-extension-availability', now()::timestamp);
+-- ===BATCH=== V90 pg-lock-stats
+
+CREATE TABLE IF NOT EXISTS collect.pg_lock_stats (
+ collection_id bigint NOT NULL,
+ collection_time timestamp NOT NULL,
+ server_id integer NOT NULL,
+ server_name text NOT NULL,
+ database_name text,
+ lock_type text,
+ mode text,
+ granted boolean,
+ relation_oid bigint,
+ relation_name text,
+ backend_count bigint,
+ oldest_wait_ms double precision
+);
+
+CREATE INDEX IF NOT EXISTS idx_pg_lock_stats_time
+ ON collect.pg_lock_stats(server_id, collection_time);
+INSERT INTO darling_schema_version (version, name, applied_at) VALUES (90, 'pg-lock-stats', now()::timestamp);
+-- ===BATCH=== V91 pg-column-stats
+
+CREATE TABLE IF NOT EXISTS collect.pg_column_stats (
+ collection_id bigint NOT NULL,
+ collection_time timestamp NOT NULL,
+ server_id integer NOT NULL,
+ server_name text NOT NULL,
+ database_name text,
+ schema_name text,
+ table_name text,
+ column_name text,
+ n_distinct double precision,
+ null_frac double precision,
+ avg_width integer,
+ correlation double precision,
+ top_value_frequency double precision,
+ common_value_count integer
+);
+
+CREATE INDEX IF NOT EXISTS idx_pg_column_stats_time
+ ON collect.pg_column_stats(server_id, collection_time);
+INSERT INTO darling_schema_version (version, name, applied_at) VALUES (91, 'pg-column-stats', now()::timestamp);
+-- ===BATCH=== V92 pg-replication-stats
+
+CREATE TABLE IF NOT EXISTS collect.pg_replication_stats (
+ collection_id bigint NOT NULL,
+ collection_time timestamp NOT NULL,
+ server_id integer NOT NULL,
+ server_name text NOT NULL,
+ application_name text,
+ client_addr text,
+ state text,
+ sync_state text,
+ sync_priority integer,
+ sent_bytes_behind bigint,
+ write_bytes_behind bigint,
+ flush_bytes_behind bigint,
+ replay_bytes_behind bigint,
+ write_lag_ms double precision,
+ flush_lag_ms double precision,
+ replay_lag_ms double precision,
+ backend_start timestamp
+);
+
+CREATE INDEX IF NOT EXISTS idx_pg_replication_stats_time
+ ON collect.pg_replication_stats(server_id, collection_time);
+INSERT INTO darling_schema_version (version, name, applied_at) VALUES (92, 'pg-replication-stats', now()::timestamp);
+-- ===BATCH=== V93 pg-buffer-usage
+
+CREATE TABLE IF NOT EXISTS collect.pg_buffer_usage (
+ collection_id bigint NOT NULL,
+ collection_time timestamp NOT NULL,
+ server_id integer NOT NULL,
+ server_name text NOT NULL,
+ database_name text,
+ relation_name text,
+ relation_kind text,
+ buffers bigint,
+ dirty_buffers bigint,
+ avg_usage_count double precision,
+ pool_buffers_total bigint,
+ pool_buffers_used bigint
+);
+
+CREATE INDEX IF NOT EXISTS idx_pg_buffer_usage_time
+ ON collect.pg_buffer_usage(server_id, collection_time);
+INSERT INTO darling_schema_version (version, name, applied_at) VALUES (93, 'pg-buffer-usage', now()::timestamp);
+-- ===BATCH=== V94 pg-index-bloat
+
+CREATE TABLE IF NOT EXISTS collect.pg_index_bloat (
+ collection_id bigint NOT NULL,
+ collection_time timestamp NOT NULL,
+ server_id integer NOT NULL,
+ server_name text NOT NULL,
+ database_name text,
+ schema_name text,
+ table_name text,
+ index_name text,
+ index_bytes bigint,
+ tree_level integer,
+ internal_pages bigint,
+ leaf_pages bigint,
+ empty_pages bigint,
+ deleted_pages bigint,
+ avg_leaf_density double precision,
+ leaf_fragmentation double precision,
+ skipped_reason text
+);
+
+CREATE INDEX IF NOT EXISTS idx_pg_index_bloat_time
+ ON collect.pg_index_bloat(server_id, collection_time);
+INSERT INTO darling_schema_version (version, name, applied_at) VALUES (94, 'pg-index-bloat', now()::timestamp);
+-- ===BATCH=== V95 pg-per-database-attribution
+
+ALTER TABLE collect.pg_column_stats
+ ADD COLUMN IF NOT EXISTS database_name text;
+
+ALTER TABLE collect.pg_index_bloat
+ ADD COLUMN IF NOT EXISTS database_name text;
+
+ALTER TABLE collect.pg_extension_availability
+ ADD COLUMN IF NOT EXISTS database_name text;
+INSERT INTO darling_schema_version (version, name, applied_at) VALUES (95, 'pg-per-database-attribution', now()::timestamp);
+-- ===BATCH=== V96 pg-wait-sampling
+
+CREATE TABLE IF NOT EXISTS collect.pg_wait_sampling (
+ collection_id bigint NOT NULL,
+ collection_time timestamp NOT NULL,
+ server_id integer NOT NULL,
+ server_name text NOT NULL,
+ event_type text,
+ event text,
+ query_id bigint,
+ sample_count bigint,
+ profile_period_ms integer,
+ backend_count integer
+);
+
+CREATE INDEX IF NOT EXISTS idx_pg_wait_sampling_time
+ ON collect.pg_wait_sampling(server_id, collection_time);
+INSERT INTO darling_schema_version (version, name, applied_at) VALUES (96, 'pg-wait-sampling', now()::timestamp);
+-- ===BATCH=== V97 pg-kernel-stats
+
+CREATE TABLE IF NOT EXISTS collect.pg_kernel_stats (
+ collection_id bigint NOT NULL,
+ collection_time timestamp NOT NULL,
+ server_id integer NOT NULL,
+ server_name text NOT NULL,
+ database_name text,
+ query_id bigint,
+ exec_user_time_ms double precision,
+ exec_system_time_ms double precision,
+ plan_cpu_time_ms double precision,
+ exec_read_bytes bigint,
+ exec_write_bytes bigint,
+ minor_faults bigint,
+ major_faults bigint,
+ stats_since timestamp
+);
+
+CREATE INDEX IF NOT EXISTS idx_pg_kernel_stats_time
+ ON collect.pg_kernel_stats(server_id, collection_time);
+INSERT INTO darling_schema_version (version, name, applied_at) VALUES (97, 'pg-kernel-stats', now()::timestamp);
+-- ===BATCH=== V98 pg-predicate-stats
+
+CREATE TABLE IF NOT EXISTS collect.pg_predicate_stats (
+ collection_id bigint NOT NULL,
+ collection_time timestamp NOT NULL,
+ server_id integer NOT NULL,
+ server_name text NOT NULL,
+ database_name text,
+ schema_name text,
+ table_name text,
+ column_name text,
+ operator text,
+ query_id bigint,
+ sample_count bigint,
+ rows_evaluated bigint,
+ rows_filtered bigint,
+ worst_estimate_error_ratio double precision,
+ sample_rate double precision
+);
+
+CREATE INDEX IF NOT EXISTS idx_pg_predicate_stats_time
+ ON collect.pg_predicate_stats(server_id, collection_time);
+INSERT INTO darling_schema_version (version, name, applied_at) VALUES (98, 'pg-predicate-stats', now()::timestamp);
+-- ===BATCH=== V99 pg-plan-capture
+
+CREATE TABLE IF NOT EXISTS collect.pg_plan_capture (
+ collection_id bigint NOT NULL,
+ collection_time timestamp NOT NULL,
+ server_id integer NOT NULL,
+ server_name text NOT NULL,
+ query_id bigint,
+ plan_hash text,
+ duration_ms double precision,
+ node_count integer,
+ top_node_type text,
+ plan_json text
+);
+
+CREATE INDEX IF NOT EXISTS idx_pg_plan_capture_time
+ ON collect.pg_plan_capture(server_id, collection_time);
+INSERT INTO darling_schema_version (version, name, applied_at) VALUES (99, 'pg-plan-capture', now()::timestamp);
+-- ===BATCH=== V100 pg-major-version
+
+ALTER TABLE collect.servers
+ ADD COLUMN IF NOT EXISTS postgres_major_version integer;
+INSERT INTO darling_schema_version (version, name, applied_at) VALUES (100, 'pg-major-version', now()::timestamp);
+-- ===BATCH=== V101 pg18-io-bytes
+
+ALTER TABLE collect.pg_io_stats
+ ADD COLUMN IF NOT EXISTS read_bytes numeric(28,0),
+ ADD COLUMN IF NOT EXISTS write_bytes numeric(28,0),
+ ADD COLUMN IF NOT EXISTS extend_bytes numeric(28,0);
+INSERT INTO darling_schema_version (version, name, applied_at) VALUES (101, 'pg18-io-bytes', now()::timestamp);
+-- ===BATCH=== V102 pg-server-config
+
+CREATE TABLE IF NOT EXISTS collect.pg_server_config (
+ collection_id bigint NOT NULL,
+ collection_time timestamp NOT NULL,
+ server_id integer NOT NULL,
+ server_name text NOT NULL,
+ name text,
+ setting text,
+ unit text,
+ category text,
+ context text,
+ vartype text,
+ source text,
+ boot_val text,
+ reset_val text,
+ sourcefile text,
+ sourceline integer,
+ pending_restart boolean,
+ short_desc text
+);
+
+CREATE INDEX IF NOT EXISTS idx_pg_server_config_time
+ ON collect.pg_server_config(server_id, collection_time);
+INSERT INTO darling_schema_version (version, name, applied_at) VALUES (102, 'pg-server-config', now()::timestamp);
+-- ===BATCH=== V103 pg-deadlocks
+
+CREATE TABLE IF NOT EXISTS collect.pg_deadlocks (
+ collection_id bigint NOT NULL,
+ collection_time timestamp NOT NULL,
+ server_id integer NOT NULL,
+ server_name text NOT NULL,
+ occurred_at timestamp,
+ victim_pid integer,
+ participant_count integer,
+ deadlock_hash text,
+ lock_modes text,
+ resources text,
+ victim_statement text,
+ graph_text text
+);
+
+CREATE INDEX IF NOT EXISTS idx_pg_deadlocks_time
+ ON collect.pg_deadlocks(server_id, collection_time);
+INSERT INTO darling_schema_version (version, name, applied_at) VALUES (103, 'pg-deadlocks', now()::timestamp);
+-- ===BATCH=== V104 pg-deadlock-identity-index
+
+CREATE INDEX IF NOT EXISTS idx_pg_deadlocks_identity
+ ON collect.pg_deadlocks(server_id, deadlock_hash);
+INSERT INTO darling_schema_version (version, name, applied_at) VALUES (104, 'pg-deadlock-identity-index', now()::timestamp);
diff --git a/Darling/Darling.Tests/MigrationUpgradeLadderLiveTests.cs b/Darling/Darling.Tests/MigrationUpgradeLadderLiveTests.cs
index e458ba667b..5b8f3a999a 100644
--- a/Darling/Darling.Tests/MigrationUpgradeLadderLiveTests.cs
+++ b/Darling/Darling.Tests/MigrationUpgradeLadderLiveTests.cs
@@ -208,12 +208,14 @@ climb started from. */
/// release cut", and both the 3.4.0 and 3.5.0 cuts shipped without it, leaving the only tested upgrade
/// population a 3.3.0 store while every real user was upgrading from 3.5.0.
///
- /// Derived from CHANGELOG.md's newest released heading rather than from a pinned version constant,
- /// because a constant is the same kind of thing that decayed: it would need remembering at exactly the
- /// moment the fixture needed remembering. The release cut moves the heading, and this fails until the
- /// fixture beside it exists. No live PostgreSQL needed, so it runs on every build rather than only where
- /// DARLING_TEST_PG is set — the climb tests are gated, and a gate is a poor place for the check that says
- /// the gated thing is testing the right store.
+ /// Derived from CHANGELOG.md's newest SHIPPED release — the newest release heading that is NOT the
+ /// in-development version in Directory.Build.props — rather than from a pinned constant that would decay
+ /// the same way. It is deliberately the release users upgrade FROM, not the one being cut: a fixture of
+ /// the version being cut is already at the top of the ladder and would climb nothing, failing the
+ /// applied>0 assertion above. The release cut adds the new heading, and this fails until the previous
+ /// release's fixture exists beside it. No live PostgreSQL needed, so it runs on every build rather than
+ /// only where DARLING_TEST_PG is set — the climb tests are gated, and a gate is a poor place for the
+ /// check that says the gated thing is testing the right store.
///
[Fact]
public void TheMostRecentRelease_HasALadderFixture()
@@ -222,10 +224,22 @@ public void TheMostRecentRelease_HasALadderFixture()
Assert.True(root is not null, "Could not locate the repository root.");
var changelog = File.ReadAllText(Path.Combine(root!, "CHANGELOG.md"));
- var released = Regex.Match(changelog, @"^## \[(\d+\.\d+\.\d+)\]", RegexOptions.Multiline);
- Assert.True(released.Success, "CHANGELOG.md has no released version heading to derive the expected fixture from.");
- var version = released.Groups[1].Value;
+ /* The fixture we need is the most recent SHIPPED release — the store users upgrade FROM — not the
+ version currently being cut, whose changelog heading now sits at the top. A fixture of the version
+ being cut is already at the top of the ladder, so the climb theory above would apply nothing and
+ fail its applied>0 assertion. So take the newest changelog release that is NOT the in-development
+ version carried in Directory.Build.props. */
+ var props = File.ReadAllText(Path.Combine(root!, "Directory.Build.props"));
+ var current = Regex.Match(props, @"(\d+\.\d+\.\d+)");
+ Assert.True(current.Success, "Directory.Build.props has no to identify the in-development release.");
+ var inDevelopment = current.Groups[1].Value;
+
+ var version = Regex.Matches(changelog, @"^## \[(\d+\.\d+\.\d+)\]", RegexOptions.Multiline)
+ .Select(m => m.Groups[1].Value)
+ .FirstOrDefault(v => v != inDevelopment);
+ Assert.True(version is not null,
+ "CHANGELOG.md has no shipped release heading (one that is not the in-development version) to derive the expected fixture from.");
var expected = $"migration-ladder-v{version}.sql";
var path = Path.Combine(root!, FixtureDirectory.Replace('/', Path.DirectorySeparatorChar), expected);
diff --git a/Directory.Build.props b/Directory.Build.props
index 15a57a23f1..b0811256f5 100644
--- a/Directory.Build.props
+++ b/Directory.Build.props
@@ -15,7 +15,7 @@
agree with the XML read, so the ordering is enforced rather than remembered.
-->
- 3.6.0
+ 3.7.0
false
diff --git a/README.md b/README.md
index 698ca13837..f2ff883c22 100644
--- a/README.md
+++ b/README.md
@@ -214,7 +214,7 @@ Configuration is a single JSON file with no schedule knobs. See the **[Darling o
| Alerts (tray + email + webhooks) | Yes | Email + webhooks (headless) | Yes |
| Themes | Dark and light | Dark and light | Dark and light |
| Portability | Single executable | Portable service + viewer zip | Server-bound |
-| MCP server (LLM integration) | Built-in (87 tools) | On request | Built into Dashboard (66 tools) |
+| MCP server (LLM integration) | Built-in (87 tools) | On request (139 tools) | Built into Dashboard (66 tools) |
---
@@ -350,7 +350,7 @@ claude mcp add --transport http --scope user sql-monitor http://localhost:5151/
### Available Tools
-**Lite** exposes 87 tools; **Darling** exposes the analysis + data-read surface on request; the deprecated **Dashboard** exposes 66 (see [deprecated/Dashboard/README.md](deprecated/Dashboard/README.md)). Core tools are shared.
+**Lite** exposes 87 tools; **Darling** exposes 139 (the analysis + data-read surface plus its write tools) on request; the deprecated **Dashboard** exposes 66 (see [deprecated/Dashboard/README.md](deprecated/Dashboard/README.md)). Core tools are shared.
| Category | Tools |
|---|---|