Skip to content

Release 3.6.0 - #2669

Merged
erikdarlingdata merged 607 commits into
mainfrom
dev
Aug 27, 2026
Merged

erikdarlingdata merged 607 commits into
mainfrom
dev

Conversation

@erikdarlingdata

Copy link
Copy Markdown
Owner

3.6.0 → main. check-version-bump verifies 3.6.0 > 3.5.0 (deprecated/Dashboard/Dashboard.csproj).

Merge this NOT squashed — the release build and history depend on the individual commits.

Scope since 3.5.0

194 merges. Store schema v79 → v104 (25 rungs; full upgrade verified end to end against a real v3.5.0 store — climbed to v104, matched the constant, re-ran idempotent).

Headlines: PostgreSQL parity (configuration + change history #2658, deadlock capture #2661, all four trend reads #2663, PG18 support #2655, major-version persistence #2653, multi-role store exposure #2665), ten previously-unreachable PostgreSQL MCP tools now registered with a reflection pin #2659, and the opt-in GCF output format (#2286, external — 29.7% fewer tokens on real payloads).

Plan capture (#2538) ships as built and tested to the fleet's limit: proven end to end self-hosted, RDS transport parse pinned against real bytes, AWS calls faked-client tested. The untested hop needs an Aurora auto_explain preload + reboot unavailable on this fleet — a fleet constraint, not a product one.

After merge

Publish the GitHub release with tag v3.6.0 to trigger the build; it stops at the SignPath approval gate. The CHANGELOG heading promotion to [3.6.0] and the migration-ladder-v3.6.0 fixture land next cycle once the ladder moves past v104 (the upgrade-path guard requires the most-recent-release fixture to sit behind current — same timing as #2572 for 3.5.0).

erikdarlingdata and others added 30 commits August 22, 2026 23:53
Give a PostgreSQL target PostgreSQL tabs on the web (#2530)
…er (#2535)

Lite's CorrelatedTimelineLanesControl is a third copy of the five-separate-
WpfPlot Overview surface that carried #2533 on the Darling viewer: SyncXAxes
matched the five lanes' X-axis LIMITS but never gave them identical pixel
geometry, so a lane whose Y ticks read six digits still starts its data area
further right than one reading a fraction. Confirmed present by reading the
shipped source, not assumed from the shared XAML.

The fix already existed in the shared PerformanceMonitor.Ui.LaneAxisAligner
that Lite already references, so this is the one call site #2534 filed as
#2535: SyncXAxes now splits into set-limits / align / refresh, calling
LaneAxisAligner.AlignLeftGutters between the two loops so every lane's X and
Y limits are final before it measures. It has to run on every refresh, not
once at Initialize, because ClearChart() -> WpfPlot.Reset() swaps in a fresh
Plot and takes any axis floor with it.

A source-parse wiring pin in Lite.Tests (not Darling.Tests, so a Lite-only
edit fires the CI filter the guard needs) asserts the control calls the
aligner inside SyncXAxes and before the refresh loop, bounded by brace
matching so Lite's AddGhostLine helper - which sits a few members later and
also calls Refresh() - can't satisfy it by accident. Confirmed red against
the pre-fix source with a throwaway net10.0 harness running the same parse
logic, since the xUnit suite itself only executes in CI.

The behavioral coverage (the gutter is independent of plot width, and
non-decreasing in plot height) is not duplicated here - those are properties
of the shared helper, already pinned by Darling.Tests/LaneAxisAlignerTests.cs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…#2548)

`get_pg_top_queries` put `queryid` on the wire as a JSON number. PostgreSQL's
`queryid` is a signed int8 derived from a hash of the post-parse-analysis tree,
so its values are spread over the whole 64-bit range and most sit past 2^53 --
where every parser that decodes JSON numbers as IEEE-754 doubles (`JSON.parse`,
`json.loads`, most agent tooling) silently rounds. Reproduced against real
`JSON.parse`: `-4185925123159566327` comes back `-4185925123159566300`.

The value was never wrong on the wire; it was unrecoverable after parsing, which
for an identity is the same thing. `queryid` is the ONLY identity a PostgreSQL
statement has, and every use of it -- `WHERE queryid = ...`, matching a row on
our screen to 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. Breaking response-shape change, taken deliberately.

`get_pg_blocking`'s `root_backend_id` had the same defect in a WORSE form, and
is fixed here rather than filed for later. The collector builds it by
concatenating the backend's start epoch with its zero-padded pid, so every value
is a 17-digit integer around 1.79e16 -- about 2x past 2^53, where adjacent
doubles are 2 apart. Half of all backend ids are 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 over 200 adjacent pids,
100 lost precision and 99 landed on an id belonging to another backend. That is
the field the tool's own description tells a reader to prefer for comparing a
root blocker across captures.

Scoped to those two. `database_id` / `user_id` are PostgreSQL oids (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
nowhere near 2^53. None has a defect to fix; none was touched.

Both response bodies are split into `BuildTopQueriesJson` /
`BuildBlockingChainsJson` so the wire shape can be asserted against the SHIPPED
serializer instead of a re-implementation that would keep passing as the code
drifted. `PgInt64IdentityWireShapeTests` pins both fields as strings, pins
`database_id` and `root_pid` as NUMBERS so "stringify everything" cannot pass,
asserts its own fixture is genuinely out of double range, and checks that four
distinct backends stay four distinct ids after a double-decoding parse. Proven
red by reverting only the two `.ToString(...)` calls: 15 checks fail.

The web needs no change -- the Query ID column already rendered the raw value,
so it simply starts showing the true digits.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…er-alignment

Give Lite's Overview lanes the same shared gutter as the Darling viewer (#2535)
The WPF viewer cannot reference the service, so the PostgreSQL reads it
needs for #2530 had to be reachable from somewhere both front ends see.
They move to PerformanceMonitor.Darling.Storage, which the service, the
viewer and the tests all already reference. Namespace change only: no
query text, no ordinal mapping and no signature is touched, and every
existing reader test still pins the same constants.

Copying them into the viewer instead 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. Two copies of a query like that diverge, and the copy that
diverges is never the one being read - the same reasoning
CollectorEngineCapability's own comment gives for keeping one copy of a
sentence both SKUs print.

Storage gains InternalsVisibleTo("Darling.Tests") because the ordinal
pins reach the internal Map*Row helpers, which followed the readers; the
service keeps its own for everything that stayed behind.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`git grep IsPostgres` across the 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 #2547 chose for the web, with
the same ids and the same grouping so the two front ends do not teach
one engine two shapes.

Placement is DERIVED from CollectorCatalog in both directions rather
than enumerated. All nine PostgreSQL collectors reach exactly one tab; a
tenth 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 - and its reader-layer scan now follows the
shared store readers the viewer NAMES, one hop outwards, so coverage
still means "the viewer reads it" without demanding a duplicate copy of
the SQL to satisfy a text match.

Both Aurora-only panels are SHOWN on stock PostgreSQL, not hidden. Each
prints CollectorEngineCapability.NotCollectedMessage - the same sentence
the MCP surface and the web print, naming the server, the engine, the
collector and the exact aurora_stat_* surface, ending "and never will".
The defect here is unexplained emptiness, not emptiness; hiding would
also make the tab strip a different shape on two PostgreSQL servers in
one fleet. For the same reason the Overview grid is built from the
CATALOG rather than from collection_log: a gated-off collector writes no
log row at all, so a log-driven grid would drop precisely the row an
operator most needs explained.

Mechanics worth knowing:

- The six TabItems CONTINUE the same TabControl at indices 19-24 rather
  than living in a second one. For a PostgreSQL server the nineteen SQL
  Server tabs are collapsed and these six shown, so both sets keep fixed
  indices and every drill-down that navigates by an index constant is
  untouched.
- Each PostgreSQL index gets its OWN dispatch arm. Falling through to
  `default:` would run the SQL Server overview lanes - four collectors
  that cannot run on this engine - at a PostgreSQL server.
- engine_kind and sql_engine_edition are selected by BOTH server reads.
  The sidebar uses ManagedServersSql on any seeded store, i.e. every real
  deployment, so a discriminator on only ServersSql would have left every
  PostgreSQL target on the SQL Server tabs 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 at all, because the tabs such
  a server gets are a default rather than a finding.
- The display projections keep the store's sentinels out of the cells:
  -1 is not a number, an untracked pg_stat_io write counter is not 0, a
  NULL recurrence is "cannot tell" and not "once", temp blocks are not
  bytes, and every timestamp goes through ViewerTimeHelper.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ility gate (#2554)

Two defects found by driving all nine get_pg_* reads against a live
PostgreSQL 16 target. Eight answered; this one returned
`42702: column reference "queryid" is ambiguous`.

**Defect 1 — the SQL did not parse, on any engine.** #2219 added
`LEFT JOIN collect.pg_statement_text AS t` so the read could carry statement
text. That put `t.queryid` in scope beside `differenced.queryid` and made the
unqualified references ambiguous. 42702 is a PARSE-time error, so the read
threw on every call from #2219 onward — on Aurora, the one engine it exists
for, as much as anywhere — and a store with zero rows failed exactly the way a
full one did.

The one-word fix in the report does NOT work, and that was measured rather
than assumed: qualifying only the `GROUP BY` still fails on the select-list
`queryid`, which is the reference PostgreSQL names first. Both are qualified.

**Nothing caught it because nothing executed it.** About a dozen tests assert
things about this query's TEXT and all of them passed throughout; a substring
assertion cannot resolve a name, only a server can. So the deliverable is not
the qualifier but `DarlingPgReadSqlParsesLiveTests`, which PREPAREs EVERY
shipped PostgreSQL read against a real PostgreSQL. PREPARE runs the whole front
end and stops before execution, so it needs no fixture and no rows — which is
exactly why it catches a defect that zero rows was never a defence against. The
reads are discovered by reflection, not listed, so a new one is covered the day
it lands; the discovered COUNT is asserted too, so a filter that stopped
matching cannot turn the guard into a test that passes by finding no work.
All 12 shipped read constants across the 9 readers now parse.

**Defect 2 — the capability gate was downstream of the throw.** It sat inside
`if (rows.Count == 0)`, so it only spoke when the query SUCCEEDED and returned
nothing. On stock PostgreSQL, where pg_statement_stats can never run at all,
the parse error surfaced as a raw SQL error where the sibling get_pg_wait_stats
gives "does not run on that engine ... and never will". The gate is now
consulted on the throw path.

Deliberately NOT moved ahead of the read. DarlingEngineCapability's contract is
explicit that every call site asks AFTER its read came back empty, so a server
whose registry row says one engine while its collected rows say another still
gets its DATA rather than an explanation of why it cannot have any. Asking
first trades this defect for that one across every read; on the throw path
there is no data to prefer, so the same reasoning points the other way.

Guarded without being vacuous. Once defect 1 is fixed the read no longer
throws, so an assertion on the ordinary empty path would pass with or without
the gate fix. The throw is therefore INDUCED — delta_calls at bigint extremes
makes the read's `CAST(SUM(...) AS bigint)` overflow at runtime, deterministic
and with no DDL against the shared store — and the discriminating half is the
Aurora case: the same fault on an engine that CAN collect must still read as an
error, or the fix would just be an exception swallower.

Sibling sweep: all nine PostgreSQL tools have exactly one catch block and only
this one consults the gate there, but with defect 1 fixed no other read can
throw, so the other eight are recorded rather than changed.

Three text pins that asserted the bare `GROUP BY queryid, database_id` are
updated to the qualified form, and say which form is correct rather than merely
that a GROUP BY exists.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
get_pg_top_queries: make it parse, and stop rounding the PostgreSQL int8 identities (#2554, #2548)
All three claude[bot] findings were real. Two of them are the same
defect - a field the shared reader goes to the trouble of returning that
no viewer column shows - so the fix is an invariant rather than two
columns, per the rule that the second instance of something is a sign
you are fixing the wrong level.

EveryFieldTheSharedReadersReturn_ReachesAColumn_OrIsFoldedWithAReason
reflects over the ten reader row records against their display classes:
every field must reach a column, or appear in FoldedFields naming the
column that carries the fact and why that shape reads better. Derived
both ways - a new reader field turns it red until something happens, and
an exemption for a field that no longer exists is deleted the moment the
reader stops returning it. Proved red by deleting exactly the two
columns review found, each naming itself.

It found three more on its first run, which is the point:
PctTowardMultixactWraparound had no column at all (the multixact side
has its own shutdown ceiling and a subtransaction-heavy workload reaches
it first, with nothing on the XID columns to say so), and the two
freeze_max_age settings were renamed rather than shown.

The findings themselves:

- Replication gains a Conflicting column, folded into the invalidated
  row highlight. A logical slot whose needed rows were vacuumed away by a
  recovery conflict is a different failure from invalidation-by-WAL-size,
  with a different cause (hot_standby_feedback, not
  max_slot_wal_keep_size), so it is its own column rather than folded
  into the invalidation reason. It was visible on the web and invisible
  on the desktop.
- I/O gains ExtendTimeMs plus the three derivations the MCP tool already
  computed - avg read ms, a per-combination hit ratio, and each row's
  share of the window's read time (which is how the grid's order was
  decided, made legible). ContextMeaning moved to DarlingPgIoReader,
  beside the query that produces the value it explains, so the MCP
  surface and the viewer print one copy; it rides as the Context cell's
  tooltip because it is a paragraph.
- The csproj comment's triple apostrophe, from my own shell escaping.

Also filled while the pin was pointing at them: the autovacuum grid's
ANALYZE half (a table can be vacuumed on schedule and still give the
planner stale row counts), live tuples and the dead-tuple SHARE, the
manual-vs-automatic run split, and autovacuum_count - zero being the
classic wraparound route, since relfrozenxid never advances on a table
autovacuum has never processed. Statements gain the database OID (half
the read's grain), one cache-hit ratio over both Aurora cache tiers, and
temp blocks READ beside written.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The merge itself was clean, but it carried a SEMANTIC conflict a text
merge cannot see: PgInt64IdentityWireShapeTests arrived on dev naming
DarlingPgStatementReader and DarlingPgBlockingReader, which this branch
had already moved to PerformanceMonitor.Darling.Storage. One using.

Nothing about #2548's fix changes here — queryid was already rendered as
a string in the viewer's statement projection, for the same reason it is
now a string on the wire: a PostgreSQL int8 queryid routinely exceeds
2^53, and the one field whose entire purpose is joining back to
pg_stat_statements must not go through anything that rounds.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The three note assignments indexed All[0..2], which couples them to the
registry's STRIP order - a list whose whole job is to be reorderable.
The first reorder would have put the Vacuum note above the Activity
grids, silently and correctly-compiling. NoteFor(id) instead, returning
empty for an id the registry does not carry, because a missing note must
never take a tab down with it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
IsPostgres is only true for a token DescribeEngineKind recognises, so
inside that branch EngineDescription always has words - the local
function offering 'an engine the store has not recorded' was describing
a state this code path excludes, which is the kind of defensive branch
that later reads as a real case somebody should handle.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two small ones the prose already promised and the markup did not deliver.

The Query Shapes tab gave the per-database counters TWICE the height of
the statement grid they exist to explain, which is backwards: they are
the follow-up question (did this spill?), not a second list of equal
standing. Swapped.

And an unrecognised engine token reaches the badge raw so an operator
can search their store for it; raw did not have to mean untrimmed, and
a hand-edited row with surrounding whitespace would have rendered it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
**The CI red is mine, and the guard that caught it did its job.**
DarlingPgReadSqlParsesLiveTests discovers the shipped PostgreSQL reads by
reflection, filtered on a HARDCODED namespace string — and this branch
moved the readers, so the filter matched nothing and its own anti-vacuity
floor failed the build saying exactly that: "the reflection filter has
stopped matching, so this test is no longer checking anything". That
assertion is the reason a silent pass was not what happened instead.

The filter now takes the namespace from a reader TYPE it has to resolve
anyway, so it follows the readers wherever they live. Verified the fix
rather than assuming it: on macOS that test SKIPS before reaching its own
count (no DARLING_TEST_PG), so "it passed" proves nothing — a throwaway
host invoked the shipped private ShippedReadSql() directly and it
discovers 12 constants across the nine readers, which is the number its
own doc comment names. Proved red by restoring the literal: 0.

**Two review findings, both real.**

Bytes() chose the display unit against the UNROUNDED quotient and then
formatted with N1, so one byte short of a megabyte rendered "1024.0 KB" —
a number expressed in its own next unit, which reads as a typo rather
than a size, on every column built over bytes. Re-checked after rounding.

WPF applies the LAST matching DataTrigger, and IsInvalidated/IsInactive
are not exclusive: an invalidated replication slot is usually also
inactive, because its subscriber has already gone. Declaring inactive
last painted the informational amber over the red on exactly the rows
that needed the red. Swapped — and asserted as a RULE over every row
style on these tabs rather than fixed on the one grid review found, since
the same ordering pattern is one edit away from recurring: each flag
carries a severity and a milder one may not follow a severer one. The
Overview grid's pair is mutually exclusive today and is ordered that way
anyway, so one rule covers the tab set instead of a rule and an
exception.

Both proved red by reverting only the thing under test.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Give a PostgreSQL target PostgreSQL tabs in the WPF viewer (#2530)
…y row (#2552)

WarnAboutFileOnlyServersAsync compared server_ids and names and nothing
else, then returned early once every file server was registered. So for a
server the store already had, every per-server setting in darling.json was
dead text: host, database, auth, username, encryptMode,
trustServerCertificate, excludedDatabases, port, engine, the display name.
Read, parsed, validated, logged as loaded, and then not used.

The field report is the loop that makes it expensive: a PostgreSQL target
refused a self-signed certificate, the operator set
"trustServerCertificate": true, restarted, and got a byte-identical error
because the store row still said false. #2252/#2254's warning made it
worse by teaching "adding a server to the file does not register it",
which invites the wrong inference about the servers it already knows.

The store still wins -- store-authoritative is deliberate and none of it
changed. The defect was that the disagreement was invisible.

The comparison runs through the same folds the connect path and the
collectors apply, so it cannot warn about a difference that does not
exist: encryptMode through the connection builder's fail-closed fold,
engine through TargetEngine, a blank database through the engine's
implicit default, PostgreSQL port 0 as 5432, excludedDatabases as the
NOT IN set the collectors splice, monthlyCostUsd numerically. Fields that
are inert on the target are not compared at all, gated on the STORE's
engine because that is what the service connects with.

No credential is compared or printed, by construction: encrypted_password
is not in the SELECT list. It must not be compared anyway -- a file entry
legitimately carries an env:/file: reference or a dev plaintext password
while the store row carries a DPAPI blob, which is the supported shape the
read-time backfill exists to serve.

The remedy names the Viewer's Manage Servers window and rules OUT
add_servers, which skips an already-monitored server as a duplicate before
it validates anything. The --test-connection caveat is appended only when
a connection-relevant field drifted.

darling.sample.json's servers block gains the seed-once paragraph the
web.network / mcp.network blocks have always had.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…2546)

The miss vocabulary had three words and none of them fitted a setup step
somebody can change: `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, and `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
- the SQLSTATE 42P01 case says CREATE EXTENSION in so many words - and the
hourly query_store_health collector records actual_state per database. No
read reported any of it. Two Query Store reads were guessing in prose
("Query Store may not be enabled on target databases"), which is equally
true of a server where it IS enabled and the window simply reached past
what the raw tier retains.

CollectorRuntimePrecondition is the shared vocabulary: the `precondition`
status word and the two message shapes, with the remedy text FRAMED rather
than authored - a second copy of prose whose whole value is being accurate
about one server's answer is the copy that drifts. The noun phrase comes
from CollectorEngineCapability.CapturePathByCollector rather than a second
table. DarlingRuntimePrecondition and McpRuntimePrecondition are the two
store halves and hold no vocabulary of their own, so the SKUs are
byte-identical here by construction rather than by pinning.

Evaluated at READ time, not gate time, and that is the whole point. An
AppliesTo gate is decided once when the connection is made, so it would go
on reporting a precondition after somebody satisfied it - the operator does
what the message asked and nothing changes. This re-derives on every call.
No sweep change and no IL-guard change: the capability derivation is
untouched and this asks a different question of the store, not a new
question of the gates.

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. Nothing in the type system enforces that order
and a `??` chain is one cut-and-paste from reversing it, so a source scan
pins it across both trees, along with SKU parity and a non-vacuity floor.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
An in-place 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
DarlingInstallDirectoryReport walks top-level DIRECTORIES, so a stale DLL
in the root, in viewer\ or in runtimes\ was structurally invisible to it.

The measurement came first, because the issue's own decider was whether
this has ever actually happened. Diffing the file lists of consecutive
release zips: the Lite package dropped 44 shipped files across twelve
consecutive releases - 43 in ONE step, where a target-framework move
stranded every runtimes\*\lib\net8.0\ assembly including two copies of
Microsoft.Data.SqlClient.dll - while the Darling package has dropped none
in the four steps it has existed for. Rare, bursty, tied to a packaging
change, and when it happens it lands forty files at a time in a .NET
probing directory, where a stale assembly is a candidate for LOADING.

The authority is a manifest DERIVED from the payload, never a list
anybody maintains. After each successful copy the script records the
files that copy laid down; the next upgrade diffs its own payload against
it. So every path it can name provably came out of one of our own zips,
which disposes of the whole false-positive class the obvious approach
has: darling.json, the DPAPI blobs, the .bak-* config copies, the
_rollback_manual_* backups and pg-runtime were never in a payload, so
they are never in the manifest. That is a property of where the list
comes from rather than a list of exceptions someone keeps current -
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
turns naming into deleting and is OFF for the first release: the check
reports either way, and a delete inside a monitoring host's install
directory should spend a few deploys showing operators its answer first.
Files reported and not removed stay nominated in the next manifest, so
the report is not a one-shot that forgets.

Directories emptied by their own removal are pruned with NON-RECURSIVE
deletes, deepest first. 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 today, since
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 right after the copy 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. No C# twin of the rule: nothing in
the service participates in this convention, so a second implementation
would be a copy that drifts while its test keeps passing.

upgrade-darling.ps1 also gains a parse-and-AST pin, 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.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…erived

Review finding on #2556: ReadRegisteredServersForComparisonAsync reads
every config_monitored_servers row with no is_enabled filter, so a server
an operator paused from the Viewer was warned about with a claim that is
not true of it -- "the registry is what the service uses" says nothing
useful about a server nothing is connecting to.

The suggested fix, filtering the read to is_enabled = TRUE, is the one
option that must not be taken: that read also feeds ServersOnlyInFile,
whose question is whether a file entry is REGISTERED, and a paused server
is. Filtering there would report it as never-monitored and advise
re-adding it -- the #2158 defect this method was already fixed once to
avoid. Dropping it from the drift pass silently is rejected more narrowly:
#2552 is a defect about silence being expensive, and the drift is exactly
what the operator walks back into when they re-enable.

So the row carries its enablement (a new RegisteredServer record) and the
drift report splits into two lines -- the same two-line shape cause A
already uses for "never monitored" versus "deliberately removed". The
Information line for a paused server drops the claim, the remedy and the
--test-connection caveat.

Also, the category rather than the instance. Field labels are now the
darling.json keys spelled exactly (displayName -> name), which lets a new
pin DERIVE the covered set from both ends: MonitoredServer's
[JsonPropertyName] properties on one side, and CompareServerSettings' own
output when driven with two entries differing in every field on the other
-- twice, because two fields are engine-gated. 16 keys, 14 compared, 2
excluded, and only the credential is excluded. A per-server setting added
to darling.json later is compared or turns the pin red naming itself, so
it cannot go back to being silently dead text.

Proven red by reverting each: dropping monthlyCostUsd from the comparison,
and adding a new per-server key nothing compares.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Doc comment only, no behaviour change.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
claude[bot] caught that `actual_state = 'ERROR'` fell through to the OFF
wording. IsCollecting already excluded it correctly, but the bucketing
right after only split READ_ONLY out, so an ERROR database was told "Query
Store is not enabled ... turn it on with ALTER DATABASE SET QUERY_STORE =
ON" - which is exactly the mistake the READ_ONLY branch exists to avoid,
one state over. ERROR means Query Store WAS configured and stopped after an
internal failure, so desired_state is typically still READ_WRITE, the
suggested ALTER is already in effect, and re-running it silently no-ops
while the actual repair goes unmentioned.

Worse, with a mixed snapshot the sentence named the wrong database: the
ERROR row was swept into the `off` bucket, so a scope holding BrokenDb
(ERROR) and OffDb (OFF) produced "Query Store is not enabled on the
database this read covers (OffDb)" and dropped the broken one entirely.

There are three not-collecting states and each has its own remedy, so each
now gets its own sentence, ordered by how badly the OFF wording would
mislead. ERROR points at sys.sp_query_store_consistency_check first, then
SET QUERY_STORE CLEAR or an OFF/ON cycle, and says both discard the data
already held.

Two tests, both proven red against the previous code: the ERROR sentence
itself (including that it must NOT say "Turn it on with ALTER"), and that
ERROR outranks READ_ONLY when a scope holds both.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The parse pin added in the previous commit 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; the third byte of a UTF-8 em dash (E2 80 94) becomes U+201D in
CP1252, which PowerShell honours as a CLOSING DOUBLE QUOTE. So every em
dash #2528 put inside a double-quoted message terminated its string early,
the rest of the line became code, and the script failed to parse before
doing anything at all. Reproduced byte for byte on this machine, 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 happen to sit in COMMENTS, where a
mis-decoded character is never parsed. That is not a property anybody can
maintain by eye, and it is one edit from being false.

Fixed by making all three shipped scripts ASCII-only rather than by adding
a byte-order mark. A BOM works and then decays: it is one careless save
from being gone, and its absence is invisible. "No byte above 127" holds
under every code page, does not care about the BOM, and is a rule anyone
can check - including the extracted-function probes in Darling.Tests,
which write lifted PowerShell to a temp file and run it under
powershell.exe, where a non-ASCII byte would fail in a way that looks
nothing like its cause.

Both pins proved red by planting a single em dash back into one
double-quoted string: the ASCII check names the line, and the file then
fails to parse through the same CP1252 path 5.1 uses. The parse pin now
covers all four Windows-side scripts rather than only the one this issue
touches.

Never shipped in a release - #2525 is in the same unreleased section.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two review findings on #2556, both correct, and one of them generalizes to
a field the review did not name.

encryptMode: SQL Server really does have three behaviours (three
SqlConnectionEncryptOption values), but BuildPostgresConnectionString
branches on OPTIONAL alone -- Strict and Mandatory take the same SslMode
arm. So on a PostgreSQL target they are ONE connection, and reporting them
as drift with AffectsConnection=true (pulling in the --test-connection
caveat) reported a connection difference that cannot exist. The comparison
collapses them there. Display and comparison are split for this one field
so the message still prints what each side actually says rather than a
value neither holds.

trustServerCertificate: on a PostgreSQL target whose STORED mode is
Optional, SslMode.Prefer is chosen without ever reading the flag, so it is
inert. Gated on the store's mode, since the store's is the one in force.
It stays live on SQL Server's Optional, where SqlClient still validates
the certificate if the server negotiates encryption.

database, and -- the generalization -- excludedDatabases: PostgreSQL
matches the startup packet's database against pg_database.datname byte for
byte, so ReportingDB and reportingdb are two databases there and folding
case MISSES a real difference. The exclusion list is live on BOTH engines:
the SQL Server collectors splice DatabaseExclusionFilter against d.name,
and PostgresTargetProvider.BuildDatabaseListPlan splices the identical
filter against datname to choose the per-database fan-out, where NOT IN is
case-sensitive. Both are now engine-gated; SQL Server keeps the
case-insensitive comparison ServerDefinitionEquals assumes, with the
collation limit stated.

The first two would have been false positives, the last two false
negatives -- the silent direction this whole change is about.

Each proven red by reverting it alone. The exclusion set's INNER comparer
needed the test rebuilt: the outer comparison catches a case difference
either way, so the first attempt passed with and without the fix. What
only the comparer decides is whether a store excluding both Scratch and
scratch is RENDERED as excluding one, and asserting the rendered value is
what makes it bite.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…2529)

Review finding: the rootedness test ran after ConvertTo-DarlingManifestPath
had already stripped leading separators, so it could never fire. The bot
was right that it was not exploitable - the same stripping degrades an
absolute path into an ordinary subdirectory name before Combine sees it,
so the classic "rooted second argument wins" Path.Combine pitfall does not
apply, and Remove-DarlingStaleFiles' own containment check backstops it
either way.

It is still worth fixing rather than deleting, because a check that cannot
fire is a comment wearing a guard's clothes, and the behaviour it was
meant to produce is the better one. Asked of the RAW input,
'\\fileserver\share\x.dll' is now REFUSED and reported, instead of being
quietly reinterpreted as a relative 'server\share\x.dll' that could name a
real file inside the install root.

Three cases added to the shared table - UNC, root-relative backslash,
root-relative forward slash - and the reordering proved red by moving the
test back after normalization and watching the UNC path sail through.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…econdition-miss

A runtime-precondition miss vocabulary, evaluated at read time (#2546)
Two collectors, two reads, two schema rungs, and a Storage tab on both
front ends. They ship together because both add rungs and would collide
on StorageVersion.SchemaVersion otherwise.

#2541 - pg_index_usage_stats (v84, daily, per database, writers only).
The scan counts are the easy half; the half that makes it shippable is
that "unused" is not "droppable", so every row carries whether the index
backs a primary key, a constraint or the replica identity, whether it is
partial, an expression index or INVALID, and its full definition. The
read also reports scans over the STORED window, not just the server's
lifetime counter - an index with nine million lifetime scans and none in
ninety days is dead weight today, and no live query can see that.

#2542 - pg_table_bloat_stats (v85, hourly, per database, writers only).
The statistics-based estimate ships over pgstattuple on a measured
margin (44 ms vs 860 ms over 2,001 tables), and the estimate is
SUPPRESSED rather than captioned when its inputs cannot be trusted:
stale statistics were measured 81 percentage points wrong on two
byte-identical tables, and a pg_monitor-only role sees zero pg_stats
rows and makes the estimator return a confident 88.59% for a table
whose true bloat is 0.50%.

Version floors measured against live PostgreSQL 13, 14, 15, 16, 17 and
18 rather than read from documentation. Exactly one exists:
last_idx_scan is PG16+, proven red by running the gated-on form against
13/14/15.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
erikdarlingdata and others added 29 commits August 26, 2026 21:28
)

A store's tables come from one of two texts depending on when it was created: a
fresh store builds them from V1's generated schema, walked from the collector
catalog, while an existing store has whatever its rungs built. PgSchemaGeneratorTests
enforces that the two say the same thing column for column, and adding the columns
to PayloadColumns moved the generated side only.

Both places are now updated and neither is redundant. The CREATE is IF NOT EXISTS
and never re-runs on a store that already has the table, so the existing population
needs V101's ALTER; the ALTER is ADD COLUMN IF NOT EXISTS and is a no-op on the
fresh store that just created them. Dropping either would leave one population
permanently without the columns -- the invisible, permanent divergence that test
exists to catch.

Verified locally rather than by another CI round: the generator's CREATE TABLE and
the rung's are byte-identical after the normalisation the test applies.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Collect and serve PostgreSQL 18's measured I/O byte totals (#2655)
Version drift is silent from one server. 17 gutted pg_stat_bgwriter and 18 removed
pg_stat_io's op_bytes, and in both cases a collector that guards the difference
correctly and a read that quietly returns NULL look identical unless two majors are
present at once. #2653 and #2655 were both found and verified that way, and the rig
could not do it -- PG_MAJOR replaces the target rather than adding one.

`docker compose --profile multiversion up -d` adds a plain PostgreSQL 18 on 55418,
alongside the existing 17. Opt-in so the default stays a two-container rig.

Deliberately the plain image, not the extension build: the PGDG extension packages
do not track a new major immediately, so pinning to them would make the whole rig
fail to build on the day a major ships -- exactly when this target is most useful.
It exercises the core catalog views, which is where version drift lives.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Add an opt-in PostgreSQL 18 target to the verification rig
…register ten MCP tools that shipped unreachable (#2659)

pg_settings was never collected. SQL Server answers this three ways and
PostgreSQL had no answer at all, so neither "what is work_mem set to here" nor
"what changed last Tuesday" could be answered -- and the second is the kind that
cannot be recovered afterwards at any price, because a configuration history
nobody recorded is not sitting on the server waiting to be read.

V102 stores the snapshot hourly, 365 days, and stores every column without
filtering. source is what separates the server's configuration from the
collector's own session -- pg_settings is a per-backend view -- and dropping a
session-scoped row at collection time would make that evidence unrecoverable, so
both reads filter instead, where it can be explained. Reporting one as a CHANGE
would be worse than showing it: the collector reconnects, application_name moves,
and the read would announce a change nobody made.

get_pg_server_config reports what somebody actually chose, non-default first.
Defaultness comes from PostgreSQL's own `source`, NOT from comparing setting to
boot_val -- the string comparison invents non-defaults on a server nobody
configured, measured on a live 17.11: data_directory_mode reads 0700 against a
boot_val of 448, the same value in octal and decimal; archive_command reads
(disabled) against an empty default; commit_timestamp_buffers reads 32 against 0
because 0 means auto-tune. That defect was in the first version of this read and
only running it found it.

get_pg_server_config_changes reports value changes between snapshots. A setting
APPEARING is deliberately not a change: LAG returns NULL for the first snapshot
of every setting, so without the guard the first collection would manufacture
several hundred changes, and would again for every extension whose GUCs appear
when its library loads.

Both name pending_restart loudly -- the file edited and reloaded while the
running server is still on the old value, disagreeing with no symptom until a
restart months later changes behaviour during someone else's incident.

#2659, found while wiring the above: six [McpServerToolType] classes -- ten
shipped PostgreSQL reads -- were never registered with the MCP host. They were
implemented, documented, dispatched by the web dashboard and counted in the
census, and tools/list answered 116 where the census claimed 126. Nothing failed:
the inventory pin checks NAMES, which exist either way, and the tab pin exists to
stop a read shipping reachable only through MCP, which is the exact inverse of
this. Registering them here rather than separately because two of the ten are
this change's own tools, and shipping a feature into a class agents cannot reach
is shipping nothing. A reflection-derived pin now asserts every tool class is
registered, so it fails when a class is added rather than when an agent next
reaches for the tool.

Verified end to end against the rig through the real service: store at v102,
tools/list 116 -> 128 with 25 PostgreSQL reads matching the census exactly, and
after ALTER SYSTEM on the target the changes read reported exactly one change
(work_mem 4096 -> 8192) out of 415 settings while shared_buffers correctly showed
pending_restart.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Eight failures, every one a pin doing its job, and together they are the
checklist for adding a PostgreSQL collector.

The viewer half was the real work. EveryPostgresCollector_IsShownOnExactlyOnePostgresTab
and EveryCollectorTable_HasAViewerReader_OrIsAllowListed both refused a collector
the WPF viewer never shows -- the allow-list is a shrink-only ratchet, so the
answer is a panel, not an entry. The settings grid sits under the extension axis
on the Overview tab because it is the same kind of fact one layer in: extensions
say what this server CAN do, settings say what it was told to do. It is the one
panel on that tab NOT scoped to the toolbar window, because a configuration is
the state now and an hours filter returns nothing for a server whose hourly
collector last ran just outside it -- which reads as "no configuration" rather
than "widen the window".

The rest were counts a new collector legitimately moves: the pg rung list, the
catalog count, the PostgreSQL tab count (7 -> 8, prose included), and the
CapturePathByCollector noun phrase without which the capability message falls
back to generic phrasing. The web tab used format: "datetime", which the renderer
does not know -- an unknown format falls through to raw text, so the column still
renders and the defect is invisible on inspection. It is "time".

CiClusterWorkerSizingTests corrected a factual error in V102's own doc comment
rather than just a number. I had written that pg_server_config is not a
hypertable, arguing from its shape -- a snapshot of something a person changes,
not a series of measurements. TimescaleSupport.HypertableTables is
CollectorCatalog.All, so membership follows from being a collector and is not a
per-table choice, and CI sizes its cluster's workers off that count. The doc now
says so, and both workflows move to 70/81.

Every pin re-validated locally against the product assemblies before pushing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Both statements carried a SESSION_SCOPED token substituted at call time, which
meant the constants were not SQL. DarlingPgReadSqlParsesLiveTests runs parse
analysis on every shipped PostgreSQL read against a real server and both failed
with 42703 column "session_scoped" does not exist.

The deduplication was not worth it. A read whose text only becomes valid after a
string replacement cannot be checked by anything -- not parse analysis, not a
reader, not a person. The excluded sources are now spelled out inline in both
statements, and PgServerConfigTests asserts each contains NOT IN ( plus the named
constant, so the two cannot drift now that they are written twice.

Verified by running PREPARE for both statements against a real PostgreSQL with
their actual parameter signatures -- the same thing CI does -- rather than by
rebuilding and hoping.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Collect PostgreSQL configuration and what changed in it (#2658), and register ten MCP tools that shipped unreachable (#2659)
We had pg_stat_database.deadlocks -- a number that goes up -- and nothing else.
PostgreSQL writes a complete report to its log: the wait graph, every participant,
and each one's full statement text. In one respect that beats the SQL Server
deadlock graph, which names the victim's statement and often leaves the other side
as a handle.

It needs NOTHING configured on the target, which is what separates it from plan
capture. auto_explain needs a preload and a restart, which on a managed fleet is a
parameter-group change nobody can make; a deadlock report is unconditional, and
log_lock_waits governs ordinary lock waits rather than this. The only precondition
is reading the log, which pg_plan_capture already established and
pg_plan_capture_readiness already reports on -- so this works on the Aurora fleet
today.

Two things in the extraction were measured against a real 17.11 rather than
assumed, and both are the kind that fail silently:

  %Q writes the query id with NO separator before the severity -- the captured
  line reads `[1549] 322048460535975151ERROR:  deadlock detected`. A pattern
  requiring whitespace there matches nothing, in the way that looks like "this
  server has no deadlocks".

  The DETAIL block runs to the next line carrying a log prefix, and a
  participant's statement is arbitrary user SQL that can contain newlines, each
  arriving tab-indented. A line-count or blank-line rule truncates multi-line SQL
  to its first line, and the row still looks fine.

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 text so
the store dedupes. The hash is over the graph rather than (timestamp, victim_pid):
the graph is what distinguishes two reports, and hashing the thing itself needs no
argument about how unlikely a collision is. Participants are counted from the wait
EDGES, not the statement headers, because the server omits a header when it could
not recover the text and a participant with no statement is still in the cycle.

Verified end to end against a real deadlock on the rig: the collector stored it
with the same hash the parser produced offline, the reads returned the victim, the
graph and both participants' SQL, and after a SECOND induced deadlock the store
held three rows that the read correctly collapsed to two deadlocks -- one with
times_seen=2 from the overlapping window, and the repeat as its own row because
the process IDs differed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…eachable (#2661)

Three CI failures, all mine.

PgSchemaGeneratorTests requires a collector's rung to be exactly what the
generator emits, and the generator emits one index per collector -- so the extra
dedupe index inside V103 was not a drafting nit the pin should tolerate. A fresh
store builds from the generated schema and would silently not have had it. V103 is
now byte-identical to the generated table and index, and V104 adds the index on
its own, which gives BOTH populations what was actually wanted.

get_pg_deadlock_detail was reachable only through MCP, which the POSTGRES_TABS pin
exists to refuse -- and the fix is better than an exemption. The read took a
mandatory hash, so it could only ever be a drill-down; it now takes an OPTIONAL
one and answers with the most recent graphs without it, which is the shape the SQL
Server "Deadlock Graphs" panel already has. A reader no longer has to call the
summary first just to see a graph, and the tab gets a panel with no drill-down
plumbing. DISTINCT ON (deadlock_hash) for the same reason every read here groups:
the overlapping window means the newest rows are often one report several times.

The viewer schema sentinel was simply missing -- my own checklist item, and the
one that would have made the connect-time gate refuse a perfectly current store.
Both rungs get an arm: V103 a table sentinel, V104 an index one, because a store
stopped between them has the table and not the index and that is a real
interrupted-upgrade state.

Verified against the live rig: both statements PREPARE with their real parameter
signatures, and the optional-hash read returns two distinct graphs with no hash and
one with.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Collect PostgreSQL deadlock reports from the server log (#2661)
Fourteen trend reads shipped and none worked on a PostgreSQL target, so every
PostgreSQL answer described one window and none answered "is this getting worse".
This was a READ gap, not a collection gap: the rig held over a thousand snapshots
each of pg_io_stats and pg_database_stats after two days, and nothing differenced
any of it.

get_pg_wait_trend follows one wait event over time; 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.

Three decisions worth stating, two of them corrected by running it:

A counter going backwards is a RESET, not negative work. pg_wait_sampling_reset_profile()
and a server restart both zero the profile, and the second happens without anyone
deciding to. GREATEST(delta, 0) would report a QUIET interval across a restart --
the one reading that is definitely wrong, because the server was not idle. The
interval takes the new value whole and says so per point. This follows
DarlingPgWaitSamplingReader rather than inventing a second rule.

The query trend reads the delta_* columns the collector already wrote rather than
differencing the cumulative ones again. They are not equivalent: the collector's
delta spans the interval it OBSERVED, a LAG here spans the gap in the STORED data,
and whenever a snapshot is missing only the collector's is about the server.

The automatic wait choice skips the CPU class. My first version picked whatever
had the most samples, which on the rig was `Running` -- pg_wait_sampling's name for
the backend NOT waiting, growing 1,534 samples against 194 for the next event. A
wait trend defaulting to the not-waiting state answers the opposite of the question
asked. It stays askable by name, because CPU time is a real signal; it is just
never the automatic answer. Only running it showed this.

Both subjects are optional and the read says which it chose, so a caller never
mistakes an automatic pick for an answer about the event or query they had in
mind -- and it makes both reachable as web panels without drill-down plumbing.

Verified end to end through the real service: the wait trend returned 12 hourly
points with per-second normalisation, the query trend 353 points correctly
reporting a null mean for the intervals with no calls, and after the CPU fix the
default moved from `Running` to `transactionid` while `Running` still answers when
named.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ngs (#2663)

The panel landed in SERVER_TABS. Both registries have an `io` tab and SERVER_TABS
comes first in the file, so anchoring on the id matched the SQL Server one -- the
same trap that put four panels on the wrong registry earlier in this work.

The reachability pin caught it, which is what it is for: a get_pg_* read on a SQL
Server tab is not merely misplaced, it is unreachable from the registry a
PostgreSQL server actually renders. Anchored inside the POSTGRES_TABS region this
time rather than on a tab id that exists in both.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Set DARLING_OUTPUT_FORMAT=gcf to have the MCP server return Graph Compact
Format instead of JSON. A single call-tool filter (registered once via
WithRequestFilters) re-encodes each tool's JSON result as a GCF generic
wire, factoring the repeated field names of the record arrays these tools
return (blocking pairs, wait stats, alerts, config, ...) into one header
and dropping the indentation — roughly halving the token cost of a result.

Covers every tool with no per-tool changes. Conservative per result: used
only when the GCF wire is both smaller than the JSON (never-grow) and a
stable round-trip of it, comparing number-exactly (int64 preserved, not
float-rounded); on any parse/encode/mismatch the JSON is kept, so a result
is never grown, dropped, or garbled. Default JSON output is unchanged.
BlackwellSystems.Gcf is zero-dependency, pinned exact.
- The fail-safe now compares the decoded wire against the parsed input model
  (ValuesEqual), not the wire against itself, so a value that survived only as
  a rounded double is caught and the result falls back to JSON. Key order is
  compared order-insensitively (header-order normalization is semantically
  equal for JSON objects).
- FromJson declines any non-integer number a double cannot hold exactly
  (checked via TryGetDecimal); 33.5 / 0.25 still encode, a high-precision
  decimal or a uint64 past Int64 stays JSON.
- Transform returns a new CallToolResult (carrying StructuredContent / IsError
  / Meta) instead of mutating the tool's result; drop the unused System.Linq.
- README: savings are payload-shaped, document the - (null) / ~ (absent)
  sentinels, and scope the losslessness claim to what round-trips.
- Revert the unrelated deprecated/Dashboard version bump.
NumbersEqual compared a long against an integer-valued double by widening the
long to double, which rounds identically on both sides above 2^53 and would
accept a wire that no longer carries the input value. In the mixed branch,
compare without widening: the double must be integral, in long range, and
convert back to the same long.

Add a test that decodes the wire and asserts it reproduces the parsed input's
values (the property the runtime enforces), rather than only asserting the wire
re-encodes to itself.
The representability check used (decimal)d != exact, but a decimal cast keeps only
15 significant digits, so a 16-digit shortest-round-trip double such as
0.5029000043869019 (an exactly representable float8 value) was wrongly declined,
sending real payloads back to JSON for no reason. It also failed open on a token
that overflows double (1e400 -> Infinity), which the round-trip fail-safe cannot
catch (Infinity == Infinity). NumberSurvivesAsDouble now declines non-finite
doubles and accepts a value when its shortest round-trip form reproduces the token
or the in-range decimal is unchanged. Tests pin 0.5029000043869019 as accepted and
1e400 as declined.
…utput

Since the MCP tools serialize compact (WriteIndented=false), the sample payloads
now build compact too, so the size assertions measure GCF against what the server
actually emits rather than the pretty-printed JSON it stopped producing. GCF still
wins on the uniform record shapes (Blocking 6757->2633; an integer proxy of the
decline payloads is 710->308, so a declined result is the precision guard, not the
never-grow guard). Class comment updated to state the win against compact JSON.
… role

The roles already existed with distinct grants; only the generated pg_hba line was
single-valued. NormalizeNetworkRoles returns an ordered, de-duplicated list, the
plan carries it, and the managed block gets one hostssl line per role -- so
tightening allowFrom still narrows all of them, which a hand-added line outside the
markers would not do.

Verification compares the live rule COUNT to the number of roles rather than
checking one is present: a rule that applied for one role and not the other would
otherwise leave those clients locked out with the exposure looking healthy.

Tests and the --configure-network prompts still to come.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Mechanical: decision.Role -> decision.Roles?[0]. These assert on a single-role
exposure, which is still the default and still the shape they were written for.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
#2665)

Finishes the role list and fixes a defect in the verification the WIP added.

VerifyPgHbaAsync counted ROWS matching any admitted role and compared that to
the number of roles. Rows do not answer the question: two rules naming the SAME
role satisfy the count, and that is the expected shape on an upgraded box,
because #2665's documented workaround is a hand-added second hostssl line
outside the markers that ReconcilePgHba deliberately preserves. Such a file
with a stale viewer line and no live admin rule counted 2 of 2 and logged
"reconciled" while every admin client was locked out - precisely the failure the
check exists to catch. It now counts DISTINCT role names via unnest, which
reaches Roles.Count only when every admitted role really has a live rule.

NormalizeNetworkRoles: a value of only separators (",", "+") no longer reads as
the viewer default. It is not the blank case - the operator wrote something and
none of it names a role - so it fails closed like a typo instead of exposing a
store off a malformed field. NormalizeNetworkRole also guards the empty list
before indexing it; it cannot happen today, but it is the one caller that would
index, and every other caller already checks.

--configure-network offers both at the prompt (default still viewer) and keys
its admin warning on the NORMALIZED value, so the warning that matters most -
the one for an operator who typed "admin,viewer" while thinking about the viewer
half - does not go silent. --print-viewer-connection prints one paste-ready
string per admitted role, each labelled with the seat it authenticates as, with
the shared certificate emitted once and every STDERR warning still ahead of the
first STDOUT byte. --export-viewer-config writes ONE folder holding ONE live
credential, so where both roles are admitted it picks the least-privileged seat
and says so rather than choosing silently.

DarlingWorker's D7 pivot warning now says the role "admits 'admin'" rather than
"is 'admin'", which would read as wrong to the operator who configured a list
and invite them to dismiss it.

Also strips the UTF-8 BOMs the WIP commit added to five files; 134 of the 147
.cs files in the service carry none.

Verified: whole-solution build clean on macOS. The Windows-only suites cannot
run here, so the pure logic was run against the real build in a throwaway
net10.0 harness - 115 checks mirroring every new assertion, all green, and red
in the right places when the stable sort and the whole-value rejection were
each removed.
feat: add opt-in GCF output format for MCP tool results
…posure

Store exposure admits both roles at once (#2665)
The remaining two trends from #2663. `pg_io_stats` and `pg_database_stats` are the deepest
series the store holds - 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: bulkread is a sequential scan deliberately using a
ring buffer so it cannot evict the pool, and folding its misses in with the normal context's
understates both when the two have opposite remedies. Measured on the rig, that pair's hit
ratio is 2.5% - correct, and unreadable if it were averaged with anything else. Object types
ARE summed, which cannot distort the ratio because WAL rows report no hits.

`get_pg_database_trend` follows one database's spills, hit ratio, deadlocks and rollback
share. The hit ratio is the reason it exists: 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. On the rig a database averaging 98.78% across the window had an interval at 78.49%,
and on the PG18 target 85.28% hid a 61.52%. Only the differenced series can say so.

Three decisions that running it changed or forced:

- The subject choice is ranked on OPERATIONS, not read time. The single-window read orders
  by read_time_ms, which is the better ranking when populated - and it is usually not:
  track_io_timing is off by DEFAULT and was off on both rig targets, so every read_time_ms
  in the store is 0.0 and ranking on it resolves to picking a name alphabetically.
- Both reads now ask the target's own track_io_timing before reporting a latency.
  read_time_ms / reads over an untimed server is 0.000 ms, which reads as an impossibly
  fast disk rather than an unmeasured one - a confident wrong answer about the one number
  an operator would act on.
- Half a subject is a real request. Naming only backend_type constrains the choice and the
  context is picked within it, rather than the named half being silently dropped.

Neither table carries delta_* columns - checked against the stored schema, not assumed - so
both difference with LAG. A counter going backwards is a RESET and the interval takes the
new value whole and is flagged; GREATEST(delta, 0) would report a quiet interval across a
restart, which is the one reading that is definitely wrong. Both reset signals from the
single-window database read are carried, because neither sees every reset.

Verified on the rig, against both PostgreSQL majors it holds: all 43 shipped PostgreSQL
reads pass PREPARE parse analysis; the reset and late-arriving-series arithmetic proven
against controlled rows with the shipped text unmodified; both reads exercised end to end
through the service; and both panels proven to be fetched from POSTGRES_TABS and from no
SQL Server tab.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…#2663)

Three things, all found by reviewing and re-running what the first commit shipped.

**A fully cached server had no I/O trend at all.** The automatic subject required
reads + writes + extends > 0, so on a workload PostgreSQL is serving entirely from
shared_buffers - the healthy state - every combination has hits and no physical I/O, the
choice matched nothing, and the read answered "nothing to follow" about a server that is
perfectly observable. Measured: a six-hour window on the rig's quieter target has 0
operations and 248,293 buffer hits. Hits now QUALIFY a pair, and break the tie ahead of
the name, while operations still rank it - so the 24-hour windows still pick the pairs
that actually move I/O (bulkread with 8,824 reads on 17, checkpointer with 36,076 writes
on 18) and the six-hour one now draws the 100%-hit-ratio chart it was refusing to.
Without the hits tiebreak that window would fall through to alphabetical order, which is
the same defect ranking on an unmeasured read_time_ms would have caused.

The single-window database read's choice already had this right. The I/O one did not, and
the asymmetry is what gave it away.

**Decision pins for both new reads**, in the file #2664 established for them: the LAG-not-
delta call and why it is the opposite of the query trend's, the take-it-whole reset rule on
both series, per-second normalisation, the operations-not-read-time ranking, the pair-shaped
subject, the shared-relations NULL, the 18-vs-pre-18 byte split, and that a quiet interval is
still a point. Each was run against a deliberately broken copy of its own SQL first: a pin
that passes before and after the fix converts an open question into false confidence.

**And the pins had to stop reading the prose.** Two of them were red against CORRECT SQL,
because the comment explaining a decision quotes the string the pin forbids - the one saying
why there is no HAVING, and the one saying why the ranking is not on read_time_ms. Both
comments are load-bearing and neither should be reworded to appease a test, so every
"must not contain" now runs against the query with its block comments stripped. Fixed as a
category after hitting it twice.

Also: PgIoTrendPoint and PgDatabaseTrendPoint are sealed records rather than record structs,
on the size rule DarlingPgIoReader.PgIoRow already follows - a twenty-four-field struct is
copied whole by every LINQ pass over a thousand-point series.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…-trends

Add the PostgreSQL I/O and database-stats trends (#2663)
<Version> 3.5.0 -> 3.6.0 in the three shipping products and the deprecated
Dashboard the version-bump gate checks.

Deliberately JUST the version bump. The CHANGELOG heading stays [Unreleased] and
no migration-ladder-v3.6.0 fixture is added at the cut: the upgrade-path guard
(MigrationUpgradeLadderLiveTests) requires the most-recent-RELEASED fixture to be
the store users upgrade FROM, always behind the current ladder. Promoting the
heading to [3.6.0] now would make the release its own previous version and the
climb would apply zero rungs. The heading promotion and the v3.6.0 fixture land
next cycle once the ladder moves past v104 -- exactly how #2572 added the v3.5.0
fixture four days after 3.5.0 shipped. The GitHub release notes are authored from
the [Unreleased] section.

Schema upgrade v79 -> v104 verified end to end against a real v3.5.0 store.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@erikdarlingdata
erikdarlingdata merged commit 4bb851a into main Aug 27, 2026
10 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants