diff --git a/CHANGELOG.md b/CHANGELOG.md index a11231857..5a4e219e3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Added +- **A collector's PostgreSQL extension dependency is declared rather than described** ([#3187]) - `Darling/README.md`'s permissions paragraph named the collectors that need an extension installed, and nothing derived that list - a commit in #3184 named **four of the six** and all eight checks passed on it, corrected only because a reviewer read the paragraph. An enumeration in prose is only better than a count if something breaks when it is wrong. `ICollectorSchemaInfo.RequiredPgExtensions` now carries the extension each collector cannot run without plus what installing it costs - `CreateExtension` for a statement, `SharedPreloadLibraries` for the ones needing a server restart first - and the paragraph is pinned to it in BOTH directions, so a dependency the product has and the documentation omits fails the build, and so does a paragraph claiming one nothing declares. **The nearby list that looks like the authority is not it**: `PgExtensionAvailabilityCollector`'s roster carries eight entries because it exists so ABSENCE is reportable, three of which no collector reads, and it deliberately omits `pg_wait_sampling`, which one does. Two axes stay derived rather than declared twice - per-database is `RunsPerDatabase`, and `pg_stat_kcache` sitting on `pg_stat_statements` is a property of that extension. A second pin closes the case the README pin cannot see, where a new collector reads an extension and never declares it: a PostgreSQL definition whose query text touches an extension-owned object has to declare it, and the verification rig has to be able to load and create everything declared. The paragraph's three word-numerals over the same set are deleted rather than pinned, the #3072 alternative. **Two corrections found by reading rather than by a pin**: `pg_stat_kcache` and `pg_qualstats` need `shared_preload_libraries` too - which the MCP tools already told operators and the rig's own preload line confirms - so following the paragraph meant creating the extension and watching the collector store nothing. And the paragraph explained `pg_extension_availability`'s exclusion of `pg_wait_sampling` by saying preload-only modules never appear in `pg_available_extensions`, which is false for that one case: the rig's `seed.sql` creates it with `CREATE EXTENSION` and the collector reads a function only that statement creates. The exclusion now carries the set-membership reason instead - reportable-absence and collector-dependency are different sets - which is the same reason the new type states. +- **A refresh-ceiling staleness finding, reported separately from where the reading sits against its slot** ([#3182]) - a live run above a constant recorded as a maximum means the constant is wrong, which is a different fact with a different remedy from the slot being exceeded, and it is reported from every band including the routine one. Rate-limited by a high-water mark per constant. `OtherHourlyRefreshObservedCeilingSeconds` gets a live per-view feed for the first time, and the guard's derivation gets the upper bound the hour imposes on it, so a re-derivation that does not fit is red at the constant. +- **A TimescaleDB compression run is watched against the clearance its own minute of the hour has before the next continuous-aggregate refresh starts** ([#3112]) - a daily chunk-close run that overruns into a refresh is reported instead of reading as 15% of its hourly cadence; both the refresh window's watch line and the new compression one are derived over a single named lead-time fraction. +- **Remediation credential seam and journal actor** ([#2138] phase 1) - monitored servers can carry a second, per-server, opt-in remediation credential (`remediation_username` / `remediation_encrypted_password`, V113) so an operator-initiated action never travels on the read-only monitoring credential. A server without one has no remediation surface at all rather than a disabled one. `collect.plan_force_actions` gains `actor`, and the force-plan bot's pending-review read now filters on it — the bot's self-review can only ever take back its own forces, not an operator's. Adds the shared evict-then-observe state machine (observation window: whichever comes first of 25 executions or 30 minutes) and the named-reason degrade when targeted plan-cache eviction is unavailable, either because the engine edition lacks `DBCC FREEPROCCACHE` or because the remediation credential lacks `ALTER SERVER STATE`. No write path to a monitored server ships in this change. +- **A collector definition can now record what it measured on its own `collection_log` row** ([#3161]) - `CollectorContext.Measure` takes a labelled count and never a verdict, so the read derives the conclusion fresh on every call instead of quoting one frozen at collect time. Both SKUs compute their run note from the runner's half and the definition's half through one shared composer, and neither can read one without the other. `blocked_process_report` is the first consumer: it reports how many events it read and how many it dropped, which separates a quiet server from one whose every captured report failed to parse. +- **Every alerting-side store read that fails now records how long it ran before it faulted** ([#3099]) - in its log line and on `get_collection_health`'s `alert_read_health` block as `last_failure_elapsed_ms`. The count said which condition went blind; the elapsed says whose deadline ended it, because a read that gives up at its own command deadline was cut off by the service while the statement was still running on the store, while one that fails far below the bound carries a fault the store returned — and the exception text cannot separate those two, since a client-side deadline renders as a torn stream with no SQLSTATE exactly like a dropped connection. This population writes no `collection_log` row by design, so it previously recorded no elapsed time anywhere and could not be classified at all. - **Every `` target in the repository is now resolved, and the ones that resolve to nothing are inventoried** ([#3086]) - nothing validated a cref, because no project sets `GenerateDocumentationFile`, so the compiler never resolved one and a rename left the reference aimed at nothing with every gate green. `DocCommentHygieneTests` grows a third rule: **7,075 crefs across all 19 solution projects**, each reduced to its dot-separated names and required to be an identifier the repository's C# code actually spells, with comments and string literals removed by `CSharpSourceWalker.StripCommentsAndStrings`. **31 targets do not resolve and are carried at set equality in three labelled kinds, none of them fixed here.** `MARKER` (12 targets, 13 sites) is the find: those crefs are written `cref="!:Something"`, and `!:` is the ID string the compiler EMITS for a reference it could not resolve - a cref carrying any `:` prefix is treated as an already-resolved ID and passed through unchecked, so all 13 compile silently, emit a dead reference, and **are invisible to `CS1574` whether or not `GenerateDocumentationFile` is ever enabled**; every one names a symbol this repository really declares, and `ComposeSpec.cs:60` carries one two lines below a cref that resolves. `DANGLING` (7) is the class the issue describes, four of them a partial-class FILE name written where a symbol belongs. `OUTSIDE` (12) is this resolver's stated boundary, not a defect. The flag stays off and stays the parked decision from [#3025], now priced: a command-line-only run of it reports **71 `CS1574` occurrences on 70 lines** alongside 44,738 `CS1591` and 1,200 `CS1573`. The two mechanisms do not subsume each other - the compiler sees 63 lines this resolver cannot, for three reasons each asserted against a live site rather than described (member names resolved repo-wide rather than against the declaring type, namespace imports ignored, parameter lists discarded), and the resolver sees 13 the compiler cannot. Population floors are **per solution project, derived from `PerformanceMonitor.sln`** rather than written down, because `Darling` alone carries 4,344 of the 7,029 crefs and a global total would be cleared by a sweep that had lost `PerformanceMonitor.Collectors` entirely; `files scanned`, `doc blocks` and `crefs extracted` are assertions distinct from `dangling == 0`, and a sweep reading only SOME of a project's files - which clears every floor - is caught by counting the census against the disk. Two of twenty-two mutations survived and both were real gaps, now closed. The tree is scanned **once** per test run behind a \`Lazy\` - ten \`[Fact]\`s plus every row of an eighteen-row \`[Theory]\` needed the same answer and were each re-walking every \`.cs\` file, 28 scans of an unchanged tree, and collapsing them took the local harness from 34.2s to 3.4s for the same 90 tests - while every pin that drives an ARRANGED population still builds its own census, because routing those through the shared one would replace each of their claims with the tree's own answer. The extraction anchor and the string-literal blanking are both findings from RUNNING the guard rather than reading it: the first version reported two offenders it had written in its own prose, and a third out of its own test fixtures. - **`TsqlConventionGuardTests` enforces the checkable part of `CONTRIBUTING.md`'s T-SQL Style list, which nothing had enforced: PR #3078 introduced `COUNT(*)` and `COUNT(DISTINCT …)` into a new collector query and passed the Linux build, the PostgreSQL tests, every whole-tree guard, the command-deadline family, `review` and `verify`** ([#3081]) - Four bullets are checked — `COUNT_BIG()` over `COUNT()`, `ROWCOUNT_BIG()` over `@@ROWCOUNT`, block comments over `--`, no tabs, and data types spelled in lowercase — over string literals that open a T-SQL statement across the shared libraries and both SKUs' app trees. The remaining five bullets, and the abbreviation half of the data-type bullet, are recorded with the reason each is out of scope and carry a fixture apiece whose expected finding set is empty; the disposition map is compared for equality against the bullets parsed out of `CONTRIBUTING.md`, so a bullet added or renamed forces a scope decision rather than going unguarded. Nine pre-existing violations are fixed with it, so the guard lands with no waiver list: two `COUNT(*)` in `DatabaseSizeStatsCollector` and one inside `sp_executesql` in Lite's FinOps recommendations, a `--` comment in `DarlingServerConnector`'s T-SQL probe (its PostgreSQL twin keeps its own, correctly), and five data types spelled in capitals. Writing it turned up T-SQL nothing was examining at all: Darling's Extended Events provisioning DDL — `CREATE`/`ALTER`/`DROP EVENT SESSION`, sent to a monitored server on every session repair — carried none of the tokens the population test keys on, so it is now in scope, and the T-SQL the marker set still cannot reach is enumerated at set equality rather than assumed empty. - **A collector stalled mid-read now gets a server-wide wait sample taken out of band** ([#2880]) - collectors run strictly sequentially per server, so a collector stalled in its result-set drain holds the sequence `waiting_tasks`, `dmv_blocking_snapshot` and `query_snapshots` would run in: measured on one real stall, nothing was observed for four minutes, `waiting_tasks` ran 2.3 s after it cleared and returned zero rows, and about 20 collectors then completed inside 20 seconds as the backlog drained. **The instrument stopped sampling exactly when the thing being measured happened.** When a server-scoped collector that declares a `PerItemWallClockBudget` is a quarter of the way through it and has delivered under 1 MB/s, one sample of `sys.dm_os_waiting_tasks` and `sys.dm_os_schedulers` is now taken over a second connection, stored in **V112's `collect.collector_stall_probes`**, and never retried. **The firing condition deliberately ignores terminal silence**, which is the whole point: `drain_ms - last_read_ms` was **0-3 ms on 8 of 8** abandoned runs, so the failure mode is *streaming slowly* and a watchdog gated on the reader having gone quiet would never fire on the real defect - it is recorded on the row instead. Both halves of the condition are derived two-sided from the measured populations, because they **overlap on throughput** (successful runs reach 0.15 MB/s, below the abandoned 0.21-0.24 MB/s median) and what separates them is that a slow successful run is a *short* one: the elapsed floor sits an order of magnitude above a healthy 2.5 s run and half a budget below the 120 s bound, the 1 MB/s floor ~4x above the abandoned band and ~11x below the successful medians. Bounded at **10 s** over connect and query together, and `Decide` **refuses to arm** a collector whose budget cannot accommodate that, so "the probe cannot outlive the stall it explains" is arithmetic rather than a comment. Pool impact is **+1 concurrent connection per monitored server for at most 10 s, at most once per collector run**, held by a per-server single-flight gate so two collectors stalling together buy one probe rather than two. **The probe is never awaited by the read it observes** - that is why the sample gets its own row rather than a column family on the stalled run's `collection_log` row: a run that waited for its own watchdog would carry up to 10 s into `duration_ms`, risk pushing it into the residual the phase split exists to keep small, and delay the next collector on a server whose sweep is already blown. A probe that cannot get a connection is a **stored outcome**, not a swallowed error, classified on the phase and on the token rather than the exception type - whether a fresh connection succeeds mid-stall has never been established, so `CONNECT_FAILED` answers an open question and `get_collector_stall_probes` never filters its outcome census to the successful samples. `scheduler_count` is the sample's own denominator: every live SQL Server reports at least one `VISIBLE ONLINE` scheduler, so zero waiting tasks beside a positive count is a real all-clear rather than a probe that read nothing. @@ -116,6 +122,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **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. - **Assert collection size with `Assert.Single` and `Assert.Empty` in the source-scanning test pins, clearing all 24 `xUnit2013` analyzer warnings** ([#3132]) - Assertion form only; counts of 2 or more, and the set-equality comparisons the analyzer does not flag, are unchanged. - **Lite: log verbosity becomes a setting, and per-database collection timing moves below the default** ([#3104]) — `AppLoggerAdapter` answered `IsEnabled(Debug)` from a hard-coded `>= Debug` while `AppLogger.Debug`'s write sat inside `#if DEBUG`, so the level a caller carried and the level that decided its fate were two different decisions that disagreed in the only configuration that ships. Lite builds Release, so every `LogDebug` in the app wrote nothing while the adapter reported it enabled — and lowering a line to `Debug` to quiet it would not have quieted anything visible, it would have deleted the line from every install with no setting able to restore it. Both are now one runtime minimum level: `AppLogger.IsEnabled` is the single gate, the adapter defers to it, the static entry points consult it, and `log_minimum_level` in `settings.json` sets it (`Trace` through `None`, default `Information`, documented in `Lite/config/settings.sample.json` and the README's Lite troubleshooting section). Behaviour at the default is byte-identical to before — the same 50 Debug sites stay silent, `Information` and above are untouched — but they are now reachable without a rebuild. The per-database collection timing line (`[server] collector [db] => N rows (sql:Nms, duckdb:Nms)`, one per database per collector per cycle) moves to `Debug` on the rule Darling's fault split already stated and #3102 already applied: a phase line must not be louder than the error it decomposes, and a run that succeeded has no error beside it. It returns in full at `Debug`, and the aggregate it decomposes was never only in the log — `collection_log` records `duration_ms`, `sql_duration_ms`, `duckdb_duration_ms` and `slowest_item` per run at every level. - **The fleet card's collection flag is named for what it measures** ([#3098], reported from two agents' readings) - `has_collector_errors` on `get_fleet_overview` / `/api/fleet` was derived from collection freshness and nothing else - no error count, no `collection_log` read, no window - and the card's own reason string already called it "collection stale". It is now `collection_stale`. Two agents drew four wrong conclusions from the old name in one day, each falsified by a store query and each a reasonable reading of the name: cards flagged with all 39 collectors `HEALTHY` and `failed_collector_count: 0`, the flag set by errors 15 to 26 hours outside the card's published window, and the flag clear on all 43 cards while `ERROR` rows sat inside the window those cards stated. The error axis is unchanged and still separate - `failed_collector_count` and `collector_severity` - and the card already carried `last_collection`, the value the flag is a function of, so a reader can now recompute it from the response alone. **Breaking for an MCP client keying on the old field name**; the value is recoverable from `status` or from `last_collection` against `generated_at`. Lite's `HasCollectorErrors` is unaffected: it is fed from a real error count, on a different ladder, where the name is accurate. @@ -146,6 +155,30 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **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 +- **`get_default_trace_events` returned `event_time` in the monitored server's local wall clock** ([#3198]) - every neighbouring timestamp — `collection_time`, `last_collection`, the XE `event_time` columns and the tool's own `as_of` — is UTC, so an event at 04:28 UTC on a server at UTC-4 rendered as 00:28 and read as having preceded the incident it coincided with. The read now de-skews the stored `StartTime` by the collected `server_properties.utc_offset_minutes` and both returns and windows the UTC value, matching the viewer's read of the same column and Lite's read of the same tool. +- **Composed-panel event annotations from `default_trace_events` were both selected and plotted in the monitored server's local clock** ([#3198]) - on an axis bucketed in UTC and beside four annotation sources that are UTC — two frames in one chart. Each annotation source now declares its clock frame, and the compiler de-skews the server-local ones per server. +- **Adding an Entra MFA server could fail with a Windows broker error that was recorded nowhere** ([#3196]) - `AddServerDialog`'s connection test kept `ex.Message` and never called `AppLogger`, so a failed Entra MFA sign-in left a dialog and no log entry; the reporter of #3196 could not send anything but a screenshot. The failure is now classified by which stage of the WAM broker handshake it died at - the application not supplying a window handle, the broker runtime not loading, or the broker running and refusing - and the whole inner exception chain is logged, since the driver wraps MSAL's exception which wraps the broker's and `Message` is only the outermost layer. The dialog names the resolved log directory, because that path is not guessable from a dialog that does not print it. `ServerManager`'s `SqlException` arm passes the exception rather than its message, matching the generic arm beside it - that is the path a saved Entra MFA server takes on every collection cycle. **The authentication failure itself is not fixed and cannot be from here**: `Microsoft.Data.SqlClient` forces the WAM broker on for any caller using the driver's own Entra application id, `UseWamBroker` is inert in that configuration, MSAL falls back to a browser only when the broker is unavailable rather than when an invoked broker refuses, and the broker's stated reason is redacted to `Context: (pii)` with no seam to enable it. Unverified against a live Entra-MFA tenant, which is the only place this class can be confirmed. +- **The store disk-pressure check stopped walking the whole store every five minutes to decorate an alert message** ([#3199]) - `SELECT pg_database_size(current_database())` ran on the collection loop's serial thread under `ServiceCommandDeadlines.SerialLoopSeconds` = 5 s, a bound floored on **6.2 ms measured against a 4.05 GB store**. That function stats every file in the database directory, so its cost follows the store rather than the single row it returns: on a **225 GiB** production store — 56x the fixture — thirteen samples spanned **2,090-3,745 ms**, leaving 1.3-2.4x of the ~806x the derivation claimed. **None of the thirteen crossed 5 s, and that is the finding.** Cancels for the statement ran mean **5.8/day** against a nominal ~288 iterations (**2.0%**), so the eight cancelled statements that found this were the visible tail and the other 98% cost ~2.5 s each while leaving no trace anywhere - under the deadline so no cancel, not a collector run so no `collection_log` row - which is **~11.9 min/day** of a single-threaded loop's wall time spent computing a number the store already held. `collect.store_metrics` has carried the whole-store size hourly since V53, so the check now reads the newest recorded row: **0.101 ms** cold, three buffers, `Index Scan Backward` on the existing V53 index, **no new index**. **The walk is relocated rather than eliminated** and the honest claim is about which path pays it - `StoreSelfMetrics.StoreInsertSql` runs `pg_database_size` itself, so 312 executions a day become 24, all of them under the 300 s budget #2317 sized against this same store's sizing queries; the ~31,000x is the single read's latency and never the change's overall effect. Raising the deadline was arithmetically closed rather than merely unattractive: the shared constant is pinned at `10 sites × N < 60 s`, so `N < 6`, and a private constant for this one call caps at 14 s with nothing measured saying 14 is enough. The `object_kind` literal became one const with six consumers, because a reader filtering on a kind the writer stopped writing returns **zero rows rather than erroring** and every consumer renders that as the same null a never-swept store produces. `SerialLoopStoreSizeSourceTests` pins the **category** - no command on the serial loop may run a read whose cost scales with the store - over the population `StartupCommandTimeoutTests` already owns, projected rather than re-listed so it cannot cover nine members while claiming ten: that pin counts DEADLINES, which is why it stayed green across the whole life of this defect and would go green again the moment a size-scaling read came back. **Two of the new pins could not fail and were found by mutating the instrument rather than the subject** - an `Assert.DoesNotContain` read over source whose transform had already removed the pattern it asserted absent, and a negative census whose loop body never runs on a population that correctly contains nothing - so both now share their scan with a positive control over a planted body. +- **`CompressionPhaseGuardMinutes` is a DECLARED four-minute width rather than the light-refresh ceiling rounded up** ([#3188]) - That member IS UnboundedLightRefreshSeparationMinutes, so the width decided the adjacency the twelve light refreshes ran under and their runtimes were a measurement of that adjacency - the grid's shape was a function of a measurement the shape produced, in both directions. The measurement is now checked against the width, so a light refresh past 240 s goes red instead of silently widening a band subtracted from a fixed hour. Nothing moves. +- **The two refresh-ceiling constants state one estimator and one closure rule, word for word, and name themselves PREFIX MAXIMA rather than maxima over closed populations** ([#3188]) - A read instant closes the read and not the series, so a later run of the same regime joins the population and can exceed the value - which is what took one of them from 896 s to 1,134 s in an evening with no code change. The refresh-slot Error band and both constants now state the same proposition from its two sides, and the staleness Warning stopped saying that a closed population had been overtaken. No constant value moves and no job moves. +- **`query_store`'s `sql_duration_ms` was mostly store time, under a column documented as the monitored server's** ([#3192]) - the enumerated driver's per-item stopwatch wraps the whole `readItem` closure, and for `query_store` that closure round-trips the store to decide what plan XML and statement text are already held before writing back what came off the target. One production run: 124,972 ms of `sql_duration_ms` of which 107,334 ms - **86%** - was the two store probes, against 6,494 ms of plan-plus-text target time; fleet-wide the probe is 55.4% of `plan_fetch` and 80.6% of `text_fetch`. So `get_collector_cost`'s tens of millions of ms/day of "target-side query DURATION" (**64.7 M ms/day** measured over 7 days on one production store, where #3192's ~45.8 M does not reproduce - a different store or window; the ratio is the claim, not the absolute), the `get_collection_log` projection comment stating that a collector slow because the target is slow needs work on that server, and the web grid's **"On Server"** column header were all pointing at the monitored servers for time spent in the monitoring store - the ~6.5 : 1 anti-target bias V110 declined a rollup shape over, arriving through the parent column instead. **The column is NOT re-based**, and the reason is that the past cannot follow it: `CollectorRunResult.SqlMs` also feeds `collect.collector_cost`, nine columns with no phase split, flushed hourly from an in-memory accumulator rather than aggregated from `collection_log` - so there is nothing there to subtract, no source to re-derive from, and a re-based column would leave 90 days meaning one thing and every row after meaning another under a self-alert whose baseline window is 14 days, in which a real regression is measured against an inflated baseline. Routing the probe into `store_duration_ms` is worse rather than kinder: that column is the binary COPY and nothing else, and it is the measurement `ServiceCommandDeadlines` derives the 10 s COPY deadline from ("worst of 200 runs, 1.53 s"), so absorbing a 54 s probe would widen a deadline the sweep watchdog depends on. Instead **`sql_store_ms`** derives the store share from the V110 columns already on the row, so nothing is stored, nothing vanishes, a 124,972 ms run still sums to 124,972 ms, and the attribution applies **retroactively to every row since V110**. It is a **FLOOR**, said out loud on the property and on both tool descriptions: the per-item watermark refresh is also a store read inside the same stopwatch, the enumerated branch never raises V108's measured flag so `watermark_ms` is NULL on exactly these rows, and that component is recorded nowhere - which makes `sql_duration_ms - sql_store_ms` an *upper* bound on target time. `get_collector_cost` can only carry the caveat and point at the per-run tool, and that bound is stated as the limit of what any fix could reach there. Scope checked rather than assumed: the server-scoped and per-database paths both keep every store round trip *outside* their sql stopwatch, deliberately and already pinned, so the enumerated path is the only one. Found in the same doc block: the driver's budget doc claimed the per-item budget is null for "every collector but `query_store`" in **three** places, one at the runner's own call site - four definitions declare one and two of those enumerate, so `plan_correction` arrives non-null too; corrected and pinned by deriving the count from `CollectorCatalog.All`. Proven red fourteen ways with a green control and the baseline printed, including the option-1 change applied at the cost surface, and including one pin that had to be rebuilt after its first mutation build-failed - the shared return statement passes a variable, so asserting the absence of a literal there was a check nothing could turn red. +- **A PostgreSQL target's permissions are documented as what they actually gate: one grant, two extension install kinds, and the six collectors that need one** ([#3184]) - the section claimed "One role covers every collector", which is false in the direction that fails silently. `pg_stats` filters every row through `has_column_privilege` and `pg_monitor` confers no `SELECT` on user tables, so a `pg_monitor`-only role reads `pg_stats` as EMPTY rather than as denied: `pg_column_stats` stores zero rows on every run and `pg_table_bloat_stats` suppresses the bloat estimate as `estimate_unavailable`, and because nothing is refused nothing logs a `PERMISSIONS` skip. Measured on the fleet before the change: **59,757 of 59,757** `pg_table_bloat_stats` rows carried `estimate_unavailable` across 49 targets, and `pg_column_stats` held **zero rows all-time against 553 SUCCESS runs** - a surface reporting healthy while storing nothing. `GRANT pg_read_all_data` is what unblocks both, and it is PostgreSQL 14+; on 13 the fallback the section gave was `GRANT SELECT` on a schema, which is not valid PostgreSQL at all - `SELECT` is a table, column and sequence privilege - and is now the three statements `docs/postgres-first-target-runbook.md` already carried, including the `ALTER DEFAULT PRIVILEGES` without which tables created later are uncovered. **Six** collectors need something installed rather than the one the section named: `pg_statement_stats` (`pg_stat_statements`), `pg_index_bloat` (`pgstattuple`, read through its `pgstatindex` function), `pg_buffer_usage` (`pg_buffercache`), `pg_kernel_stats` (`pg_stat_kcache`), `pg_predicate_stats` (`pg_qualstats`) and `pg_wait_sampling` - and **four of those additionally need `shared_preload_libraries` and a server restart**, which is the half an operator has to schedule rather than run, while `pgstattuple` and `pg_qualstats` are per database. `pg_extension_availability` reports which install would light up the five that are true extensions and deliberately not `pg_wait_sampling`, which was taken off that roster in #2564: the roster is a reportable-absence list rather than a list of what collectors depend on, and those are different sets. Index bloat is unaffected by any grant - `pg_index_bloat` is measured rather than estimated at every permission level, and an index past its per-index ceiling is reported unmeasured rather than estimated, because #2561 rejected porting the ioguix statistics-based estimator on the grounds that under exactly these permissions the exact function works and the estimator is blind. +- **The hourly refresh band separates the aggregates whose runs are long enough to collide, inside the minutes it already had** ([#3185]) - the light band stepped every member by one minute, which is sized by member COUNT and says nothing about member DURATION: a one-minute step guarantees distinct starts at any length the band can hold and guarantees nothing at all about overlap. `query_store_stats_hourly` and `query_store_stats_corrected_hourly` landed at `:02` and `:04` where the previous grid held them fifteen minutes apart, and two ~265 s aggregations overlapped for essentially their whole runs at **5.4x** the per-output-group cost with cardinality flat to 1.4%. Widening the step is not available and that is now searched rather than argued - `WidestFeasibleLightRefreshStepMinutes` returns the shipped step, because eleven gaps at two minutes drop the heaviest refresh's watch line to 500 s against an 896 s recorded ceiling. So the members that can consume a wider gap take every fourth position of the band and the ones that cannot fill the rest: the band spans the same eleven minutes and the guard, the heaviest refresh's window and the compression band do not move. Membership is read from each view's own GROUP BY rather than listed - a per-statement group key means cost grows with the monitored workload's distinct statement population, which two same-source measurements isolate with volume held constant - so an aggregate registered tomorrow is classified when it is registered, and every grouping term in every registered definition has to appear on one of the two classified lists or the build is red. `LogLightRefreshSpacingBreach` reports a run that outran the gap its class was given, keyed on the constant it falsified, for the case no build-time rule can see: a view that is slow for a reason its group key does not show. +- **The refresh-slot log-level test's routine-band comment states what the case establishes, instead of narrating a repaired failure** ([#3181]) - it said the case failed because the recorded ceiling logged Warning; on the #3178 grid the ceiling bands `InsideSlot` and logs Debug. +- **The TimescaleDB hourly refresh grid is re-derived so contention between refresh policies cannot depend on where a view sits in a list** ([#3174], [#3168], [#3166]) - every hourly policy now starts on its own minute of the hour, and the compression phase grid, its guard band and the heaviest refresh's window are each derived from a measurement or from the collector catalog rather than from a uniform step. +- **`HeaviestHourlyRefreshObservedCeilingSeconds` is re-derived from a per-run census, 594 s becomes 896 s, and the watch-line ordering the #3044 signal rests on inverts** ([#3166]) - the constant was the maximum of a 16-run record the comment itself described as half census and half sample, and it named the read that would settle that: `timescaledb_information.job_history`, every run since the boundary rather than sampled. That read is done. **57 succeeded runs after the `13:44:23` boundary, 194 s to 896 s, median 418.3 s, total 27799 s**, against **304 runs at or before it, median 1081.7 s, maximum 13300.7 s** - read at one row per run at `2026-09-08 01:37Z`, with zero post-boundary failures and no run missing a finish time, so the `succeeded` filter removes nothing from the span. The read reproduces the published record digit for digit where they overlap, which is what says it is the same method: the nine boundary-day values and the seven "days after" values come back exactly, and the sampled seven turn out to be seven consecutive runs of one day with fifteen more either side that the sample never saw. The population is republished in full so the estimator stays recomputable rather than trusted. **The estimator is unchanged and the decision was re-taken rather than inherited**: at 16 readings the 95th percentile *was* the maximum by nearest rank, and at 57 it is 830 s, 66 s below it, so the pin written to expire expired - the maximum stands on the half that never referenced the sample size, and the expiring clause goes with the argument it guarded. **Three supporting arguments expired with the sample and none was re-argued to fit.** The mean is no longer exactly stateable (27799 s over 57 is 487.7017…), so the prose carries the exact total, which is a stronger pin - a bump to a middle-ranked reading moves neither the range nor the median and the total is what sees it. The excluded 864 s run is now **0.96x of the maximum**, inside the post-boundary range rather than 1.45x past it, so its duration-based disqualification is dead and the exclusion rests on the positional rule it always claimed. And the three quoted self-metrics readings are no longer disjoint from the population and **cannot be**: the snapshot reads this job's last run and a census contains every run, so disjointness was a property of the sample rather than of the rule; it is inverted to **containment**, which fails toward the more useful label (a quoted reading with no census run behind it means the two reads disagree about what the job did), while the rule about which SOURCE may set the constant stays where it was already load-bearing, against the shipped SQL of all three reads. Occupancy moves from tenths of a minute to seconds (896 of 900), because a tenth of a minute is exact only while the ceiling is a multiple of six seconds and demanding that a measurement be divisible makes an honest figure unstateable rather than catching a drift. **The consequence is stated and not settled.** Clearance is `RefreshPhaseSlotSeconds` minus the ceiling: **306 s before, 4 s now**. The compression grid's own clearance survives - the nearest phase minute is 1,320 s past the heaviest slot's start and the discriminating minute is still excluded - so nothing overruns a slot. What breaks is the ordering: `RefreshSlotWarningSeconds` is 750 and 896 sits **146 s above it**, so `ClassifyRefreshSlotHeadroom` bands the grid's own sizing figure `ApproachingSlot`, and **7 of the 57 readings** are already at or past the line where the 16-run record had none. `RefreshSlotWarningSeconds`' own summary pre-registered exactly this. **Six test methods are therefore red and every condition is left byte-identical** - two failure messages were re-pointed because "the doc comment claims otherwise" stopped being why they matter, and every one of the six carries a note naming the condition and the decision that owns the remedy. Restoring the five-sixths line above 896 s needs a slot of at least **1,076 s** - 18 minutes, which 60 does not divide, so the grid would gain slots of unequal width or lose one of its four; moving the fraction or making the refresh cheaper are the other two routes, and choosing among them is a scheduling decision (#3035, #3044, #3107) rather than something a re-derived measurement may take. **This is a trend rather than a calibration**: the daily maximum went 594 s (boundary-day tail, 9 runs) to 778 s (22 runs) to 896 s (24 runs), and the census found a 896.1 s run the issue did not have, so the runtimes #3166 reported were not the ceiling either. No schema change and no version bump. +- **Darling: the TimescaleDB job-execution-logging GUC now heals on existing stores instead of only fresh ones** ([#3175]) - `timescaledb.enable_job_execution_logging` is what makes `timescaledb_information.job_history` record one row per background-job run, and #1681 put it in the **v1** `postgresql.conf` block - the one block `EnsureConfAppended` cannot heal, because it skips a block whose marker it finds and v1's marker is present on every cluster that already exists. So the setting only ever reached a fresh initdb, and every older store has had `job_history` empty the whole time. That is worse than a plainly missing setting: **a maximum over an empty `job_history` returns zero rows, which reads as "no run exceeded the line" rather than "this instrument is off"** - an absence that reads as health. Measured on two field stores running the same binary: one carried all ten markers, no GUC line, an effective `off` with `source = default` and ONE history row for 110 jobs; the other carried the line and 39,020 rows. The setting now has its own **v11 marker** and is **moved** out of v1 rather than duplicated, so the repository no longer asserts it in the block that cannot deliver it. Existing clusters gain it on their next service-owned start - the append runs before `pg_ctl start`, so it is live on that very start - and **there is no history to recover**: logging starts then, which the log line states rather than implies. **Not widened, and the harm is measured**: re-appending the shared v1 block would clobber `shared_preload_libraries`, which is list-valued and takes the last assignment as a REPLACEMENT - a conf carrying an operator's `'timescaledb,pg_stat_statements'` came back up serving `'timescaledb'` alone. `get_store_metrics` also now reports that GUC's **effective value, source and sourcefile** on every response, in four states rather than a bool, because that tool's own description redirects a maximum question to `job_history` and had no way to say whether the route was live - and it reads the effective value rather than the marker because an `ALTER SYSTEM` in `postgresql.auto.conf` beats the append, measured. Three new pins close the category rather than the instance: the v1 block's content is **frozen** (adding a setting there now fails on the commit that types it - the pin whose absence let this through), no marker may be a **substring** of another (`EnsureConfAppended` asks with `Contains`), and a heal must re-apply **no** v1 setting. +- **`pg_index_bloat`'s per-index ceiling and cycle budget are now sized against a MEASURED block rate, and it came in at half the assumed one** ([#3164]) - `PessimisticBlocksPerSecond` was `2_000` with no measurement behind it; the name asserted pessimism and the value was optimistic by roughly 2x. The instrument `CycleMeasureBudgetBytes`' own summary pre-registered for this decision - a SUCCESS row's `collection_log.sql_duration_ms` ([#2997]) - now exists: a **252,940 ms** run, the first this collector has ever produced, which spent **84% of its 300 s deadline** against a pin that allows half. That row bounds the rate from above with nothing else: the gate admits an index only when the running total *through* it is still within budget, so a run cannot read more than its budget, and 262,144 blocks / 252.94 s is at most **1,036 blocks/s**. `MeasuredBlocksPerSecond` replaces the constant at **1,013**, the figure derived from the bytes that run measured. **The over-ceiling census is deliberately not restated at the new ceiling**: 43 indexes / 311 GB was measured *at a 2 GiB ceiling* and now says so, because a count welded to a ceiling it no longer describes is a partial list with a numeral on it. **`MeasureCeilingBytes` and `CycleMeasureBudgetBytes` both drop 2 GiB -> 1 GiB in lockstep, and the DECOUPLING [#3153] deferred could not have avoided that.** Its whole purpose was to let the budget fall to fit a slower rate while the ceiling stayed at 2 GiB, and at the measured rate that is unreachable by any budget: one index just under a 2 GiB ceiling is 259 s **in a single statement**, past the entire 150 s allowance before a budget is chosen, so `ceiling + budget <= allowance` has no solution. The binding constraint was never the ordering between the two figures - it was the ceiling's own deadline cost, which equal values had been hiding. The band-emptiness floor therefore survives **by construction** rather than by argument: the two stay equal, `TheCycleBudget_IsNeverBelowThePerIndexCeiling` is green unmodified, and [#3153]'s liveness guarantee (the first sub-ceiling candidate at or below the cursor is admitted unconditionally, so the cursor advances every cycle) is untouched. **The other arithmetic route was investigated rather than assumed and then declined.** Raising the command deadline is not blocked by the "120-second whole-server wall-clock budget" [#3164] worried about, because that budget does not apply here at all: 120 s is the value three SQL Server collectors chose for `PerItemWallClockBudget` ([#2673]'s opt-in per-item budget), the base default is `null`, `query_store` already ships **600 s** - longer than this deadline - and `pg_index_bloat` overrides none of it, so no wall clock bounds it. It is declined on cost instead: ~518 s would fit 2 GiB, which is over eight minutes of `pgstatindex` in one statement against a production instance and, because the deadline is a backstop rather than a bound, also eight minutes a *mis-budgeted* run spends before reporting nothing - sized to make one n=1 measurement fit, which is the move the budget's pin exists to prevent. **Cadence is deliberately untouched**: coverage is cadence-limited after [#3153] and a smaller budget lengthens the pass, so moving both at once would conflate two effects and leave neither figure meaning anything. **Cost, in the unit it is paid in**: pass length is measurable blocks over the budget, so this at most DOUBLES the pass (~75 cycles to at most ~150, at most ~150 days at the daily cadence) - "at most", because the lower ceiling also removes the 1-2 GiB indexes from the measurable set, a smaller numerator against the halved denominator. Those indexes are the permanent loss, since over-ceiling is a terminal state; **how many there are is NOT measured** and the doc says so - the store holding that target was unreachable. Their `skipped_reason` already claims permanence rather than deferral and already names `pg_index_usage_stats`, which covers them at the same cadence and retention whatever this ceiling is. **The mutation that caught nothing is the one that produced a pin.** Retargeting the new `ThePerIndexCeiling_FitsTheDeadline_OnItsOwn` from `MeasureCeilingBytes` to `CycleMeasureBudgetBytes` is ONE TOKEN and left all 34 pins green - a pin's subject is the one thing it cannot check about itself, and here the two figures are *equal*, which is exactly the value `TheCycleBudget_IsNeverBelowThePerIndexCeiling` documents as preferred. So the two pins are arithmetically indistinguishable for as long as the collector is correct, and diverge only in the decoupled state one exists to catch. `EveryDeclaredByteBound_FitsTheDeadline_CensusedFromTheType` removes the hand-typed subject entirely by reading the bounds off the type's own `public const long` fields, so the ceiling is covered whatever any single pin names and a third byte bound is covered without anyone remembering; it asserts a non-empty census **and** requires both figures by name, because a reflection filter that matches nothing reports success for having looked, and those two guards catch different mutations. **One adjacent doc claim was corrected explicitly rather than quietly rewritten**: the deadline pin said its half-deadline reserve also paid for "the tail index admitted while the running total was still just under budget ... charged for itself", which describes a gate of the form `total BEFORE this row <= budget`. The shipped gate is a window sum over `ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW`, so it INCLUDES the row under test and admits the tail index only if it fits - prefix sums are non-decreasing, the admitted set is a prefix, and the total read is bounded by the budget exactly. The wrong version made the reserve look like it covered a possible 2x read, leaving the rate error uncovered, and the same prefix property is what lets a SUCCESS row's duration bound the rate from above at all. Nine mutations, each confirmed applied by diffing the file before the run and each restored with the tree verified clean; run by compiling the actual Windows-only `Lite.Tests` file into a `net10.0` harness, since it cannot execute on macOS. +- **Alert history states which channel a row is about** ([#3169]) - `config_alert_log.alert_sent` meant "delivered" on a fired row and "no send channel applies" on a resolution row, so `false` could not be told from never-attempted and no aggregate over the column meant anything. An alert-history row now carries one factory-only delivery value: `notification_type` names the state — no channel applies, none configured, muted, consulted-but-undelivered, or the channel that delivered — and `alert_sent` is only ever a delivery outcome. The headless service no longer records a tray notification it has no way to show, which #2781/#2814 had worked around on the web dashboard alone. All three surfaces that render the status column now share one vocabulary: the two desktop grids through one description, and the web dashboard through a single state-to-label map pinned against it. Existing rows are not rewritten — a pre-change resolution row reads as "No channel", and a pre-change fired row keeps a label that claims nothing rather than being reinterpreted under the new semantics. +- **The PostgreSQL runbook lists `pg_index_bloat` in its cadence and MCP tool tables** ([#3165]) - with its first-answer time stated as a full rotation pass rather than one cycle, and its `pgstattuple` requirement and no-size-floor census beside it. The "three per-database collectors" count is replaced by the rule for deriving it, since there are seven and the sentence named a different three from the one the V95 migration comment names. +- **`pg_index_bloat` rotates which indexes it measures instead of re-measuring the largest one every cycle, so the `skipped_reason` it stamps on the rest states a deferral a later cycle actually honours** ([#3153]) - A per-database cursor in `collect.collector_state` resumes each cycle below where the last one stopped and wraps once nothing measurable remains beneath it; both work bounds and the byte accounting are computed over that window. Over-ceiling indexes now say they are recorded but never measured, and point at `pg_index_usage_stats` for their size trend. +- **The measured index-bloat read keeps the latest MEASUREMENT of each index in the window rather than the latest row** ([#3153]) - so a measurement taken earlier in a rotation pass is no longer discarded by a later cycle that only labelled the same index. `get_pg_index_bloat` reports `measured_at` and the viewer grid carries a Measured column, because under rotation a density can predate the last collection. +- **`pg_index_bloat`'s per-database rotation cursors are retired when their database stops being enumerated, so they no longer accumulate for the life of the server** ([#3153]) - They are spliced into one statement that the host reuses per database, so an unpruned list grew the statement without bound and ended at PostgreSQL's 65,535-parameter limit; the query_store prune could not serve them because its live-database source is a SQL Server snapshot. A database that disappears and returns starts its pass at the largest index. +- **`pg_index_bloat` and `pg_index_usage_stats` each state their own index population and name the other's** ([#3158]) - in the collectors and in the reads that surface the counts — the complete btree census with no size floor against the reportable subset at 64 kB. Neither query's population changed. +- **`get_collection_health`'s `output_finding` no longer explains every zero-row collector as an event collector at rest** ([#3160]) - A collector whose runs recorded a note — `query_store` on a read-replica target notes an empty enumeration on every run — now gets a finding that reports how many runs said something and defers to `last_note`, and one whose runs recorded nothing keeps the category reading with its precondition stated. Keyed on `note_count`, never on a collector-name list. Both SKUs' tool descriptions teach the third reading. +- **PostgreSQL column statistics now say WHICH cause produced an empty result** ([#3154]) - `pg_column_stats` had never stored a row on any target: 553 runs, one distinct status, zero rows in every era, a NULL note on 99 of the last 100. The collector documents two legitimate ways to return nothing — a 128-page size floor and `pg_stats`' `has_column_privilege` filter — and distinguished them in its documentation and not in its output, while both reads recited both causes in one message and selected neither. Measured across 49 Aurora targets, the answer is the privilege filter on every one: `estimate_unavailable` is TRUE on 332,611 of 332,611 table measurements, and the size floor is not the explanation because 1,263 of 1,264 qualifying tables clear it. `get_pg_column_stats` now returns the arm as a `coverage` field and both surfaces print one classifier's verdict — including on a populated result, where a login that can read four tables of twenty produces a ranking that looks complete. A collection fault, which neither legitimate cause explains, is now its own answer instead of reading as innocent; a target whose evidence collector is FAILING reports undetermined rather than "nothing to fix"; and where the evidence is simply absent — a read replica, where the size collector does not run — the read says so rather than guessing. The verdict is served from measurements already in the store, so no monitored server is touched and no schema moves: 30 ms on the busiest target. +- **`pg_write_stats` now collects the checkpointer and background-writer counters on Aurora PostgreSQL** ([#3156]) - Aurora does not implement `pg_stat_wal`, and because the three views were CROSS JOINed the resulting `0A000` discarded the whole row — so the collector stored nothing at all on an Aurora target while reporting an accepted limitation. The WAL columns are typed NULLs there and `get_pg_write_stats` explains why, pointing at `get_pg_top_queries` for the per-statement WAL volume Aurora does expose. +- **The web dashboard's composer tells a read-only seat that its account is read-only** ([#2550]) - rather than reporting a failed session probe and inviting a reload that cannot grant edit rights. - **Every locked-mode restore in CI can fail its step now, and the merge gate can finally see a lock-file mismatch** ([#3143]) - `dev`'s required checks are `build` and `Darling PostgreSQL tests`; the `Darling whole-tree guards` job that reported `NU1004` on every Dependabot PR is **not one of them**, so both open ones read `mergeStateStatus: UNSTABLE` - mergeable, with lock files that do not match the props file they ship. The question was never why the guard was red, it was why `build` was green: its restore step ran six `--locked-mode` restores in one multi-line `run:` block with no `shell:`, so it executed under `pwsh`, which does not abandon a block on a native command's non-zero exit and reports the block's exit code as the LAST command's. Five of the six could not fail the required check. Measured: on PR #3130 the `build` job's own log carries `error NU1004`, continues restoring a second later, and the step's conclusion is `success`. The same six restores run under bash exit **0** as written and **1** with `set -euo pipefail` ahead of them. That step and `nightly.yml`'s now declare `shell: bash` and open with errexit, and an `ERR` trap names `dotnet restore PerformanceMonitor.sln --force-evaluate` on failure rather than leaving NU1004's own suggestion - dropping locked mode - as the only remedy on offer. **`deprecated/Dashboard` and `deprecated/Installer.Core` are covered transitively** (verified against NuGet: a perturbed `Installer.Core` lock file fails a locked-mode restore of `Installer.Tests`), so they get no redundant line - but **`deprecated/Installer` was reached by nothing**, and pointing a restore at it reported `NU1004` from `dev` on the first CI run with no dependency change involved: `Microsoft.NET.ILLink.Tasks [10.0.10, )` against `[10.0.11, )`. It is the only project publishing single-file, so the SDK adds that reference implicitly at its OWN bundled version, and `global.json`'s `rollForward: latestPatch` let the runner install 10.0.303 against a lock file written under 10.0.302. That file cannot be right for both SDKs, and nothing had ever restored the project, which is why the drift went unseen. Fixing it means pinning the SDK exactly or dropping that lock file - decisions, not patches - so the requirement carries an exemption that is DERIVED from the coupling (pinning the SDK cancels it and makes the restore mandatory) and RATCHETED on membership (a second SDK-coupled project reds rather than joining quietly). `LockedModeRestoreCoverageTests` derives the coverage requirement from the lock files on disk and the `ProjectReference` edges, was proven red on both workflows BEFORE the edits, and tables both detectors against synthetic input in both directions - including the case a simpler rule gets wrong, a folded YAML plain scalar that reads like the safe single-command form and is not. It also pins #3143's refusal to reach green by dropping `--locked-mode` or setting `RestoreLockedMode`, since NU1004's text advises exactly that. **Dependabot's PRs are still red until someone runs the command**, deliberately: a `GITHUB_TOKEN` push triggers no workflow run, so a healed commit would carry no checks at all rather than one failing non-required one, and making it re-trigger needs a standing `contents: write` App credential this repository does not issue - with `pull_request_target` additionally running the PR's own MSBuild inside that permission. `.github/dependabot.yml` now records the command, replacing a comment claiming the updater regenerates the lock files as part of each PR, which is the claim that let this sit. - **The Darling viewer's fleet sidebar labelled a PostgreSQL target "SQL Server v0", and the MCP's `list_servers` published the same string** ([#3145]) - A monitored server's version label is now derived from its engine discriminator and that engine's own version column, so a PostgreSQL target reads "PostgreSQL 18" (or plain "PostgreSQL" before its major has been collected) and an Aurora one "Aurora PostgreSQL 16". `postgres_major_version` joins `engine_kind` on all three server-registry reads; the two duplicated SQL-Server-only version tables are replaced by one engine-aware label whose SQL Server table is private, so a version cannot be rendered without saying which engine it belongs to. The unknown-major "SQL Server v{n}" fallback is retained for genuinely unrecognised SQL Server majors, but 0 — the unset value a target whose version probe has not run leaves in the column — no longer reads as a version. - **The crude second-opinion parse in `CrossAppGuardCiGateTests` no longer loses project items to an apostrophe** ([#3140]) - `ParsedProjectXmlPaths` is the deliberately-crude text scan that MSBuild's evaluated item set has to be a superset of, and its single-quote alternation treated an apostrophe anywhere in the file - including inside an XML comment - as an attribute delimiter. It paired with the next apostrophe and every literal between them vanished from the floor, so the comparison quietly measured less than it claimed. Live on `dev`: one `` item hidden in `Lite.Tests`' own project and four (about two kilobytes of markup) in `Darling.Tests`', which carries an EVEN apostrophe count - parity was never the invariant, position relative to the item was. The read now parses the document with `XDocument`, so crude means unevaluated rather than mis-parsed, while a comment's body is still text-scanned for the documented over-read that makes a commented-out path count. The two doc paragraphs claiming this could only under-read what needs evaluation are corrected and are now held by a pin. The floor assertions also name which side lost the path, so a crude-parse failure stops presenting as an evaluated-read regression the way it did in [#3138]. @@ -3425,6 +3458,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 [#2972]: https://github.com/erikdarlingdata/PerformanceMonitor/pull/2972 [#2973]: https://github.com/erikdarlingdata/PerformanceMonitor/pull/2973 [#2880]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/2880 +[#2997]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/2997 [#3008]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/3008 [#3009]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/3009 [#3010]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/3010 @@ -3495,3 +3529,29 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 [#3143]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/3143 [#3144]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/3144 [#3145]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/3145 +[#3112]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/3112 +[#3153]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/3153 +[#3154]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/3154 +[#3156]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/3156 +[#3158]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/3158 +[#3160]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/3160 +[#3161]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/3161 +[#3164]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/3164 +[#3165]: https://github.com/erikdarlingdata/PerformanceMonitor/pull/3165 +[#3166]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/3166 +[#3168]: https://github.com/erikdarlingdata/PerformanceMonitor/pull/3168 +[#3169]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/3169 +[#3174]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/3174 +[#3175]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/3175 +[#3181]: https://github.com/erikdarlingdata/PerformanceMonitor/pull/3181 +[#3182]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/3182 +[#3184]: https://github.com/erikdarlingdata/PerformanceMonitor/pull/3184 +[#3185]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/3185 +[#3187]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/3187 +[#3188]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/3188 +[#3192]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/3192 +[#3193]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/3193 +[#3196]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/3196 +[#3198]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/3198 +[#3199]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/3199 +[#3200]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/3200