Skip to content

Release 3.5.0 - #2335

Merged
erikdarlingdata merged 452 commits into
mainfrom
dev
Aug 19, 2026
Merged

Release 3.5.0#2335
erikdarlingdata merged 452 commits into
mainfrom
dev

Conversation

@erikdarlingdata

Copy link
Copy Markdown
Owner

The 3.5.0 release PR — dev → main.

Why now: 3.4.0 stable has carried a crash-on-click in BOTH viewers' Query Store by Duration grids for twelve days (three reporters: #2114, #2181, #2331); the Lite fix missed the 3.4.0 cut by twelve hours and the Darling copy was only diagnosed this week.

What ships (highlights; CHANGELOG [3.5.0] has the full detail):

Soak evidence: the exact code in this PR (minus version stamps) ran overnight on the 42-server dogfood box: 42/42 collecting, zero service-fault errors (the only ERRORs were one target-side maintenance window, fully recovered), zero self-metrics failures, query_store_health at exact hourly cadence fleet-wide, cpu_attribution verified live, clean stops verified across three restart cycles.

After the tag: #2333 (the activity-driven plan fetch) merges into the next cycle.

🤖 Generated with Claude Code

erikdarlingdata and others added 30 commits August 11, 2026 21:03
…-profile

Refuse an install location the service account can never read (#2187)
The database-state alert baselines each database's first observed state as
its accepted normal. A database swept into monitoring mid-restore therefore
learned RESTORING as expected and deviated forever by being ONLINE: 636
alerts in 24 hours from 5 databases on the 52-server production fleet, with
no escape but an operator re-baselining by hand.

Two halves, both stores.

The seed's refusal list widens from the integrity states to include the
transient ones (RESTORING, RECOVERING) via a shared DatabaseStateTokens
constant, so a database mid-operation stays pending and silent until it
settles into a state worth learning. That governs rows that do not exist
yet, so the second half applies the same rule after the fact: an AUTO-seeded
baseline recording a state the seed would refuse is not a baseline anyone
chose, and once the database's effective state reaches ONLINE the steady
state is learned instead. Already-poisoned rows repair themselves on the
next sweep with no migration, and "reset to current" pressed mid-restore --
which records whatever it sees with no filter, and always will -- now
un-writes itself.

What the heal does NOT touch is as load-bearing as what it does, since
being wrong here means silence:

- A user override. #2166's composition contract depends on it: a database
  parked at expected OFFLINE stays quiet while parked and still alerts when
  it comes back ONLINE.
- An OFFLINE or STANDBY baseline, even though both are inferred. They are
  steady states, and leaving one is real news -- a STANDBY secondary that
  turns up truly ONLINE has stopped being a secondary, so log shipping is
  broken, and healing it would swap that alert for silence and then fire
  when the operator FIXED it. An auto-OFFLINE database brought up for an
  hour and re-parked would come back deviating forever.
- The raw state_desc. A standby secondary reports state_desc = ONLINE with
  is_in_standby set, so matching that column would re-baseline every
  log-shipping secondary and alert it forever for being STANDBY.

A NORECOVERY secondary, permanently RESTORING, stays silent as before; it
now gets there by never being baselined rather than by baselining RESTORING.

Tested watched-red across the transition matrix: onboard-during-restore then
ONLINE, permanent RESTORING secondary, operator override wins, ONLINE to
OFFLINE still fires, standby not healed, re-park stays quiet. The seed and
heal are pinned live against real Postgres, and the two Darling copies of
the SQL are pinned to share the one state list.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This read was mixing two time bases in one row. calls, total_exec_time_ms and
rows_returned came from the stored delta columns and covered the requested
window, while the block, storage/orcache and WAL figures were MAX() — the latest
LIFETIME cumulative reading, since the last pg_stat_statements_reset(), possibly
weeks earlier. Nothing in the output distinguished them, so a consumer reading
total_exec_time_ms for the last hour beside shared_blks_read since forever would
derive per-call I/O ratios that are pure nonsense. The old note admitted the
limitation, which does not help: a caller that reads the note still cannot
recover the windowed number.

Fixed at read time rather than by storing more deltas. The data is already
there, sampled per minute, so the difference is computed with a window function
instead of adding eight delta series per query shape to the store.

GREATEST(value - LAG(value), 0) is what makes that safe, and the reason a plain
last-minus-first would not do. A counter reset — an explicit reset, an eviction
and re-entry, or a major-version upgrade, since queryid is not stable across
majors — makes one interval negative, and last-minus-first reports that as a
large negative figure. Clamping each interval at zero drops exactly the reset
interval and keeps the rest, which is the same rule the stored delta machinery
already applies.

The LAG partition is the full series identity (queryid, database_id, user_id,
toplevel), matching how the stored deltas are keyed, with the roll-up to
(queryid, database_id) happening after. Differencing at the coarser grain would
interleave separate pg_stat_statements entries and produce garbage intervals.
max_exec_time_ms and max_exec_peakmem_bytes stay MAX: they are high-water marks,
not counters.

Verified against real Aurora PostgreSQL 16.11 and 17.7 (stage, read-only), not
just by text assertion — probe_validate_reader_sql.py substitutes a synthetic
VALUES table for the store table, keeps the query body byte-identical, and checks
the arithmetic. It confirms the reset case sums to 100 rather than -10, and that
a series with one sample in the window reports 0 rather than its lifetime total.
The same harness covers the autovacuum reader and proves its ranking: a table 50x
past its threshold with 50k dead tuples sorts above one 2.5x past with 500k,
which is the inversion the ratio ordering exists to fix.
Two review findings, both real.

Lite has the same orphan. The first cut declared this Darling-only on the
grounds that Lite never sets CapturePlanXml and so writes no planwm: row.
True, and irrelevant to two thirds of it: Lite's own backfill worker writes
done: and hole: per database and only ever deletes a hole it SERVICES or
expires, which a dropped database can never do. So its markers were kept
forever in Lite's DuckDB too. Ported, with the key set moved into the shared
QueryStorePerDatabaseState so a future prefix cannot be pruned on one SKU and
orphan on the other. Lite runs the planwm: statement too, against rows it
never writes: one no-op delete is what makes enabling plan capture there
later a non-event rather than a thing to remember.

The snapshot must be NEWER than the row it judges. Existing is not current:
if database_states stops collecting, its newest snapshot freezes, and every
database created after that instant is missing from it while being perfectly
alive -- presence alone would prune those live rows on every cycle forever,
paying a full plan-XML refetch each time and logging that a live database was
dropped. A snapshot cannot judge a row written after it was taken. Both
stamps are the service clock's naive UTC, so this holds whatever the two
collectors' relative cadences are, and a genuinely dropped database is still
pruned because its last write necessarily precedes any snapshot taken after
the drop.

Also from review: gate both call sites on the same AppliesTo that decides
whether database_states is collected, so #2191's Azure boundary is stated
rather than emergent; RETURNING the keys instead of a bare count, since a
wrong delete's only other symptom is a silent refetch; a prefix-overlap case
(App live, AppArchive dropped) that a starts_with anti-join would fail; a
hole: survivor, not just a casualty; the call-site gate pinned in the window
around the call rather than anywhere in the file; and a drift-guard message
that names the trap -- adding a server-scoped key to PrunableKeys to silence
it would have that key deleted every cycle.

Live tests split into QueryStoreStatePruneLivePostgresTests per the shape
LivePostgresCollectionHygieneTests asks for.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… pass (#2186)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Two defects found by probing live Aurora before building the next collector,
neither of which the build or a text assertion would have caught.

The replication-slot collector's PG17+ branch selected inactive_since bare, and
that column is `timestamp with time zone`. Npgsql 10 maps a timestamptz read to
DateTime with Kind=Utc and refuses to write a Kind=Utc DateTime into the store's
`timestamp without time zone` column, so collection would have failed at COPY
time on any Aurora 17 target holding a slot that had ever gone inactive — which
is to say on exactly the servers the collector exists for. The PG16 branch
substituted NULL::timestamp and was correctly typed all along, which is what hid
the asymmetry.

The autovacuum collector used `x::timestamp` on four timestamptz columns. That
form converts, but it renders the instant in the SESSION's TimeZone before
dropping the offset, so it agrees with UTC only while every parameter group says
UTC. Verified that all probed instances do say UTC today — which is precisely
what would keep the bug invisible until one of them didn't. Both now use
AT TIME ZONE 'UTC', the only form that is correctly typed AND
timezone-independent, pinned by test.

The bigger find: pg_autovacuum_stats now gates off standbys. Not for
permissions or availability — pg_stat_user_tables reads fine on an Aurora
replica and reports ALL ZEROS. Same cluster, same database, same 15 tables, on
17.7: the writer reported 13,654,458 dead tuples and 150,790,506 live tuples
while the reader reported 0 for n_dead_tup, n_mod_since_analyze,
n_ins_since_vacuum and n_live_tup. Those are the writer's stats-collector
numbers and they are not replicated. Ungated, a replica target returns no rows,
the activity filter reads that as "nothing has pending work", and the tool
reports perfect autovacuum health for a cluster 13 million dead tuples behind. A
confidently wrong healthy answer is worse than no answer.

The same reasoning applies more weakly to slots, so get_pg_replication_slots'
empty result now says it is per-instance and points at the writer, instead of
claiming neither WAL retention nor pinned vacuum is possible.

Verified the fixed queries execute on live 17.7 and that no store column
declared naive comes back carrying a timezone. Ladder regenerated and confirmed
byte-identical: this changes SQL and a gate, not schema.
…line

Never learn a transient state as a database's expected state (#2189)
pg_stat_io attributes I/O to a (backend_type, object, context) triple rather
than to a file, so "the database is doing 40k reads/sec" becomes "autovacuum
workers are reading relations in the vacuum context". The context dimension has
no SQL Server counterpart and is the one that changes the remedy: it separates
ordinary buffer-pool misses, where more shared_buffers or a better index helps,
from sequential scans that deliberately bypass the pool through a small ring
buffer, where neither will. High bulkread volume looking like memory pressure and
not being memory pressure is the standard misreading of this view, so the tool
says so per row.

NULL is preserved end to end and never coalesced, which drove most of the design.
PostgreSQL uses NULL for "this counter does not apply to this combination" — the
checkpointer performs no reads or hits, bulkread never extends, the normal
context has no ring buffer to reuse — and on Aurora the ENTIRE write side is NULL
because backends there do not write data files, the storage layer does. Probed
before writing any of it: on 17.7 writes/write_time/writebacks/writeback_time/
fsyncs/fsync_time all come back NULL, and on 16.11 writebacks and fsyncs do. A
zero in any of those places would claim a measurement nobody took, and a consumer
averaging write latency would divide by it. So this is the one Postgres collector
here that uses NULL rather than a -1 sentinel: -1 suits a level a consumer reads
directly, but these are cumulative counters that get differenced, and -1
differenced against a real value is a garbage interval.

The read therefore reports write_counters_tracked alongside the numbers, so a
caller can tell "no writes happened" from "writes are not measured here". Differencing
uses the same clamped positive-interval rule as the statement read.

Two things the probe settled that a guess would have got wrong. The enum values
differ between majors — 17.7 showed a walreplay context and Aurora-specific
backend types ('aurora cache receiver process', 'aurora wal replay process',
'slotsync worker') that 16.11 did not — so nothing filters on them; a whitelist
would silently drop rows. And PG18 REMOVED op_bytes, so it is substituted there
rather than left to fail with "column does not exist"; the replacement per-operation
byte counters are deliberately not added speculatively, since they measure something
different and deserve their own columns decided against a real PG18 target.

Per-minute cadence, unlike the autovacuum collector: this is cluster-wide, so one
connection and no fan-out, and it returned 25-37 rows per snapshot on the fleet —
the same order as pg_wait_stats.

Verified rather than assumed. The collector SQL executed on live stage 16.11 and
17.7 returning 37 and 25 rows with stats_reset arriving naive. The reader SQL ran
on both majors against a NULL-bearing fixture and computed every case: the reset
clamp summing to 100 instead of -10, NULL writes reporting tracked=false, the
checkpointer's real writes reporting tracked=true at 600, bulkread's NULL extends
summing to 0 while its reuses summed to 150, and an idle combination filtered out
rather than returned as zeros. All seven PG rungs diffed identical to the ladder
generator (22 columns for this one).
…R forever

The engine seam has had PostgresTargetProvider.Classify since the first commit on
this branch, but nothing in the worker consulted it — every SQLSTATE-bearing
failure fell through to the general handler, which logs ERROR and records ERROR.

That is worst for the conditions that never resolve on their own. pg_statement_stats
against a database where the extension was never created raises 42P01 on every
cycle; a source Aurora does not implement raises 0A000 on every cycle; a feature
gated off in the parameter group raises 55006 on every cycle. At a one-minute
cadence each of those is 1,440 identical errors a day, which is how a real
finding becomes noise nobody reads. These are the exact PostgreSQL analogue of
the 8189 sys.traces denial that already degrades to PERMISSIONS, and for the same
stated reason: a legitimate least-privilege or platform reality should not scream
every cycle.

The store has five statuses and none of them means "this feature is not
installed", so those cases take the non-fatal-degradation bucket and the MESSAGE
carries the truth — following the AzureDmvPermissionHint precedent, and saying
explicitly "NOT a missing grant" plus the fix (CREATE EXTENSION, or the parameter
group), because PERMISSIONS on its own would send someone hunting a GRANT that
cannot help.

Two discriminations worth calling out. A statement_timeout (57014) is an ERROR
but is NOT connection-fatal, so it does not trip the reconnect-and-reprobe path —
dropping the connection over a slow query would turn a tuning problem into a
reconnect storm. And the general handler's reconnect trigger now recognises a
Postgres connection failure (08 class, 57P0x) where before it only knew about
SqlException, so a dead socket on a PG target went unnoticed and poisoned every
subsequent collector on that server.

An unrecognized SQLSTATE stays loud, deliberately: the quiet bucket is for
conditions we have identified, not a catch-all.

Verified against the real shipped code path rather than a copy — a console
harness referencing the service project ran the full SQLSTATE truth table
through DarlingWorker.PostgresFaultOutcome and PostgresTargetProvider.Classify:
12 states mapped as intended, every emitted status one the store already
understands, the yield branch reachable for a collector that opts in, and 57014
confirmed distinct from the five connection-fatal codes.
Discover the query_store state classes instead of listing two of them. A
hand-written pair made a THIRD state class invisible to the guard, which is
the same silent-omission shape the guard exists to catch; a new *KeyPrefix
const on any of them now fails until somebody decides which list it belongs
in. Watched red by adding one.

And assert the prune's log names the keys it retired. That line comes from
the statement's RETURNING clause, so if RETURNING ever stopped yielding rows
the deletes would still happen and the log would just go quiet -- invisible
to every assertion about what the store contains, which is exactly the case
the diagnostic exists for, since a wrong delete's only other symptom is a
silent refetch. Watched red by logging the count without the keys.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…r-surfacing

Decode the loader status instead of printing a raw Win32 number (#2186)
…alf)

The memory an edge trigger needs is persisted per database, Lite's
config_database_state_expected had no such columns, and LiteAlertStateStore's two
methods were documented no-ops. So alreadyAnnounced was always false in Lite and a
database parked OFFLINE for a month still alerted every cooldown forever - the
complaint #2166 was filed about, still live in the SKU most likely to hit it.

Four pieces:

- Schema v53: last_alerted_state + last_alerted_at, nullable, plus an
  ADD COLUMN IF NOT EXISTS migration so existing stores get them. NULL means never
  announced, which is what a first observation, a fresh store and a recovered
  database all look like - all three must be free to alert.
- The two store methods write for real. Save is an UPDATE and never an upsert, the
  constraint #2166 established: an INSERT would have to invent expected_state (NOT
  NULL) from the state being alerted ON, so a database first observed SUSPECT would
  get SUSPECT as its accepted baseline, stop deviating, read as recovered while
  still corrupt, and never alert again. The seed refuses to baseline the integrity
  states for exactly that reason and this must not do it behind the seed's back.
- The deviation read returns the value, so the shared engine can use it here.
- A STORE-DERIVED recovered-clear running before the seed, which is the part worth
  reviewing. The engine also clears on the falling edge it witnesses, but that path
  is reachable only through an in-memory active set that empties on restart - so a
  restart landing between an alert and the recovery left the memory sticky forever
  and silently swallowed the next parking. Darling needed the same fix during
  #2182's review; porting the no-op without porting this would have shipped the bug
  I already fixed once. One sample at expected is enough where deviation needs two:
  clearing can only cause an extra alert, never a missed one, and a flap cannot
  exploit it because a flap never survives the two-sample test to alert at all.

Five tests against a real in-process DuckDB, no live gate: the value round-trips to
the deviation read; a recovered database has the memory cleared BY THE STORE with
nothing held in memory (the restart-gap invariant); (ignore) clears it too, since a
silenced database should not keep a memory outliving the silence; and the engine's
immediate clear forgets the announcement while leaving the baseline intact -
clearing the expected state instead would re-baseline and silence a real deviation.

Deliberately unchanged: the integrity states keep repeating on the cooldown.

One self-inflicted note: the schema comment initially used double quotes inside a
C# verbatim string and broke the literal. Caught by the compiler, and I then
scanned every file I touched for the same mistake.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Lite used NOT IN where Darling used NOT EXISTS. They are not equivalent: one
NULL anywhere in a NOT IN list makes the whole predicate NULL, so the prune
would silently stop retiring anything. Fail-safe, and therefore exactly the
kind of divergence that sits undetected for a release -- the two statements
answer the same question, so they should answer it the same way. Pinned, in
both directions, beside the two guards.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ry-2203

Lite gets the persisted alerted-state memory (#2203, the #2166 Lite half)
Prune query_store per-database state for dropped databases (#2188)
The three Tier 0 outage predictors are collected, stored and readable, and
nothing pages anyone about them. That is the biggest remaining gap for replacing
DBM, so this writes down what the work actually is instead of leaving it as "add
alerting".

The finding is that it is NOT another vertical slice. The previous seven
collectors were purely additive; the alert engine is SHARED WITH LITE and is what
SQL Server monitoring alerts through today, so one new PostgreSQL alert changes a
contract two SKUs implement, four test files reference, and the viewer's Settings
window exposes — plus a migration for the new settings columns. Highest blast
radius on the branch.

That surfaces a real architecture question rather than a coding one: does a
PostgreSQL-only signal extend the shared IAlertReadAdapter, forcing Lite to
implement three methods for an engine it cannot monitor, or sit behind a separate
adapter the engine consults only for PostgreSQL targets? The note recommends the
second, for the same reason the collector seam gates by engine instead of having
every definition claim every target — but it touches the shared brain, so it is
Erik's call, and starting the implementation before that is decided would mean
guessing at a seam and rewriting it.

The note also records the thresholds each alert should use, derived from what the
collectors already measure rather than invented: wraparound against
autovacuum_freeze_max_age instead of a raw XID count, xmin against the winning
holder's age AND persistence (the collector already attributes the cause, and the
four causes need different fixes), slots against wal_status plus whether retained
WAL is growing. Each maps to a severity the read surface already computes, so the
engine's job is threshold, edge-trigger and dedup — not re-deriving the finding.

And the trap an alert would otherwise inherit: anything reading autovacuum state
must keep the standby gate, because pg_stat_user_tables reports all zeros on an
Aurora reader.
The live compression self-heal test failed one nightly out of 4,507 tests
with Assert.DoesNotContain at line 1559, on a dev where nothing touching
Timescale or compression had landed since the previous green nightly.

WaitUntilDetectorReportsHealthyAsync returns the instant one poll finds the
job unflagged. Leg (1) then issued two further queries and asserted health
from a fresh read, so the assertion was not the observation the wait had
validated -- and the test itself documents a state that lands in that gap:
from scheduler pickup to completion, job_stats reads next_start = -infinity
with status Running, which the detector flags and is right to flag.

The helper now returns the flagged list from the poll that satisfied it, and
the assertion reads that snapshot. Settled-according-to-the-wait and
settled-according-to-the-assertion become the same observation by
construction, which is what the helper's contract already claimed.

Third appearance of this class: #1760 polled one detector arm (and the very
value the caller had just written, so it never waited at all), the follow-up
made the first observation deterministic and left the second racing. Both
times the guarantee stopped where the helper returned and the caller reached
past it.

The observability assertion keeps its own read, which cannot race:
StuckCompressionJobsSql filters on proc_name alone and returns every
compression job whatever state it is in. Flagging is the C# predicate on top.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The three Tier 0 predictors were collected, stored and readable, and silent.
Now they page. Wraparound risk, a blocked vacuum horizon and replication-slot
retention are evaluated on the alert cadence and delivered through the SAME
deliverer, history and mute rules as every SQL Server alert, so they land in the
same places and obey the same suppression rather than becoming a second
notification path nobody configured.

Option B as chosen: a separate IPostgresAlertReadAdapter consulted only for
PostgreSQL targets. Extending the shared IAlertReadAdapter would have forced
Lite — which has no PostgreSQL target and no PostgreSQL collectors — to implement
three methods that can only ever return empty, leaving permanent dead code in a
shipping SKU to satisfy a contract it has no stake in. This mirrors what
collection already does: CollectorCatalog.AppliesTo gates by engine instead of
having every definition claim every target.

B turned out cheaper than the scoping note estimated. AlertEngine was not touched
at all — the evaluator is a pure function of (rows, settings) beside it, and the
host calls it after the shared sweep behind an engine check. So Lite,
IAlertReadAdapter, IAlertEngineSettings and all four existing alert test files are
untouched, and the blast radius collapsed to new files plus one gated call site.
Failure-isolated too: a broken PostgreSQL read must not cost a server its CPU or
blocking alerts, so it cannot.

The thresholds are derived from PostgreSQL's own mechanics rather than picked,
which is what makes them defensible as constants for now. Wraparound grades
against the SERVER'S OWN autovacuum_freeze_max_age, not a fixed number: warning at
90% of it (before autovacuum force-starts its own prevention vacuum, while a
planned one is still an option) and critical at 2x. That scaling matters — 400
million transactions is critical on a stock 200-million server and completely
unremarkable on one tuned to 1.5 billion, and a constant would either never fire
for the second or constantly for the first. A missing or non-positive setting
silences instead of firing, because every derived threshold would otherwise be
zero and alert on every database forever.

xmin gates on persistence as well as age, which is the whole difference between a
chronic holder and a report that ran long — without it this fires on any slow
query, which is how an outage predictor earns a mute rule and stops being one. The
message carries the remedy for the specific cause, since the five causes are
indistinguishable by symptom and need completely different fixes. Slots fire at
any size for lost/unreserved (failures that have already happened) and grade the
inactive-plus-growing-plus-over-the-line conjunction as the disk-fill emergency,
with each part alone a warning.

Thresholds are NOT configurable yet: no new config_alert_settings columns, no
migration, no Settings-window work. Deliberate first cut, and the design note says
so plainly — the moment someone wants a different number, that is the work.

Verified against the real shipped evaluator (harness kept alongside the probes):
29 checks over the boundary values, the scaling behaviour, the silencing cases,
all five xmin remedies, and the slot grading conjunction. Every boundary asserted
on both sides.
Review note: the DoesNotContain in leg (1) can no longer fail, since it
reads the snapshot the wait already found clean. Kept for what it documents
and as a guard if the helper stops returning the satisfying poll's result;
the load-bearing check is the wait's bounded loop.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Settling the decisions before writing code, because doing it that way for
alerting is what made that implementation fast — and this one has more traps than
it looks.

The load-bearing ones. pg_blocking_pids() takes ShareLock on the lock manager
partitions per call, so calling it per row of pg_stat_activity on a
5,000-connection instance makes the monitoring query the incident; filter to
wait_event_type = 'Lock' first, which is the only population that can have
blockers, so the filter costs nothing. Store the edge list rather than a rendered
tree, since root blocker, chain depth and fan-out are all cheap over edges and
expensive to recover from a string. Capture the BLOCKER's own state and not just
its pid — a chain rooted in "idle in transaction" is an application bug and one
rooted in a long query is a tuning problem, and the pid alone does not say which,
which is the most common gap in homegrown PostgreSQL blocking monitoring.

Also written down: this is a SAMPLING collector, not a blocked-process-report
equivalent. PostgreSQL has no ring buffer and no server-side threshold that
materialises a report, so blocking shorter than the cadence is invisible. That
belongs in the collector's own docs or it will be mistaken for the SQL Server
surface it resembles.

Two gates NOT to inherit: it should run on standbys (recovery conflicts are real
blocking and pg_stat_activity reports the standby's own backends — the autovacuum
collector's IsInRecovery gate exists for a reason that does not apply here), and
it should not declare YieldsOnLockTimeout, since reading pg_stat_activity takes no
table locks and the branch could never fire.

And the trap that has already bitten twice on this branch: pg_stat_activity's
timestamps are timestamptz, so AT TIME ZONE 'UTC' or store server-computed
durations instead.
…led-snapshot-2206

Assert compression-job health from the settled snapshot (#2206)
… files (#2197)

Six independent copies of one sentence told every operator with a missing
managed-store credential to "start the service once so its first run
initializes the store". That is right for a genuine first run and a dead end
for the case that actually produces it in the field: in #2185 the service HAD
been started, its initdb had died in the Windows loader, and this message is
what sent the reporter to darling.json.

The five CLI verbs and the Viewer's managed-mode parse now share one message
that decides between the two from evidence under or beside postgres.dataDirectory
- an initialized cluster, a pg.log, or a credential file, in particular the
store's own credential, which the service writes immediately before it runs
initdb and which therefore survives the exact failure #2186 decodes. With
evidence the message says this is not a first run, quotes what it found, and
names the service log; without it the first-run advice is unchanged and gains
the one sentence it was missing for an operator who has already started the
service.

Evidence is deliberately never a machine-global signal such as the service log
directory existing: that is true of any box that has ever run the service, and
would tell somebody standing up a second store that their bootstrap had failed
- this same defect pointed somewhere new. An empty data directory an operator
pre-created is not evidence either.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
--test-connection is the deployment gate, and against a healthy Aurora cluster it
printed "SQL major version 0, Unknown (0), msdb access: yes". Every field in that
line is a SQL Server fact a Postgres target does not have: the major version and
edition are zero because nothing probed them, and HasMsdbAccess is true only
because that is its default. A PASS that reads like a misconfiguration is worse
than a FAIL on the one verb whose job is to be believed.

The probe already knew better -- ConnectPostgresAsync fills in the major, the
version_num, Aurora detection and pg_is_in_recovery -- but ProbeAsync dropped all
four on the floor, so nothing downstream could see them.

So carry them, and branch on engine. A Postgres target now reports version, writer
vs reader, Aurora vs not, and then the number that actually answers "will this
target give me what I expect": how many of the seven PostgreSQL collectors clear
the gate, naming the ones that do not.

  [PASS] aurora-reader: PostgreSQL 17 (server_version_num 170007), reader (in
         recovery), Aurora - 6 of 7 PostgreSQL collectors apply (skipped:
         pg_autovacuum_stats)

That count is computed by asking CollectorCatalog.AppliesTo the same question the
runner asks, via ConnectionProbeResult.ToTargetInfo(), rather than by keeping a
parallel list that can rot. A stock-PostgreSQL 15 reader clears three of seven,
and finding that out at pre-flight is the difference between "this is configured"
and "this will collect" -- otherwise the first symptom is an empty table someone
has to explain weeks later.

The two format sites that had each grown their own copy of this string -- the CLI
PASS line and the add_servers MCP detail -- now call one describer, so they cannot
drift; that also settles the small existing divergence in their msdb wording. The
new facts ride alongside the old ones in the test_connect result_json rather than
replacing anything, so an existing consumer keeps working, plus a ready-made
`facts` string for the Viewer dialogs when they get there.

The PostgreSQL fields are trailing optional record parameters, so every existing
construction site still compiles and still means "a SQL Server target".

Verified: solution builds clean; harnesses/probecheck (new) exercises the real
describer against the four target shapes a real fleet has -- Aurora 16/17 writer,
Aurora reader, stock PG 15 reader -- and independently recomputes each count from
the catalog gate. 7/7, 6/7, 7/7, 3/7, all matching. The xUnit assertions are in
DarlingCliCommandsTests and remain unexecuted on macOS.
…ge (#2197)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
erikdarlingdata and others added 29 commits August 15, 2026 22:19
…m 1) (#2287)

The scale test fails intermittently on diffs that cannot reach it. The
reading that reframed it: d1=689ms, d10=689ms -- BYTE-IDENTICAL. Two
independent sub-second timings of different workloads do not land on the same
millisecond by chance, so "runner jitter owns the constant factor" cannot be
right, and that reading is exactly why the assertion was left as bare
monotonicity.

The obvious alternative is already ruled out in the helper:
RunJobViaSchedulerAsync waits for last_successful_finish to ADVANCE, so each
measurement is of a genuinely new completed run, not a stale row. What is left
is that both runs did the same work -- plausibly near none, duration dominated
by fixed overhead -- which a duration alone cannot show.

So the failure message now carries chunk totals, compressed-chunk counts,
total_runs, last_run_status and last_successful_finish for BOTH passes, and
says outright that equal compressed-chunk counts mean the assertion was never
measuring the #2136 capacity model: a fixture defect, not a tolerance one.

No threshold changed. Guessing a tolerance is how an intermittent test stops
looking broken without becoming correct; the next recurrence now diagnoses
itself instead of costing a re-run.

The describe helper is best-effort and cannot throw -- an explanation that
fails would replace the failure it exists to explain, the #1902 mistake in
miniature. Two dialect bugs caught before pushing: I wrote the T-SQL
'alias = expr' form (Postgres needs 'expr AS alias', and it would have failed
only against a live server), and read total_runs as Int32 when the view is
bigint -- which the defensive catch would have swallowed, taking the whole
explanation with it.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
The shipping statement carried TOP (50000) WITH TIES ... ORDER BY
last_execution_time directly over the plan/query/text joins. A Top-N Sort
carries every output column through the sort and reads all of its input
before emitting a row, so choosing 50,000 rows materialized
query_sql_text (nvarchar(max)) for the whole qualifying set.

Neither knob that looks like it bounds this can, both measured on a
1,608-plan Azure SQL DB store: TOP (500) and TOP (50000) both cost
15.89s, and the client byte budget is flat across 4-256 MB because the
server finishes before the client sees a byte. Time-to-first-row was
15.94s as shipped against 0.84s without the sort.

The cap and the tie group now run against #pm_qs_slice, where every
column is an int or a datetime, and the wide join runs from the chosen
keys: 20.82s -> 4.81s on the same rows and the same 169.9 MB.

The shipped row set is unchanged. The ORDER BY key comes off the slice in
both forms and every join below the cap is one-row-per-slice-row, so
nothing can multiply or reorder rows. WITH TIES still completes the
boundary tie group, so #1960's derived-watermark invariant holds.

One behavioural difference, stated rather than left to be found: the
three inner joins can still drop a row whose plan Query Store evicted
mid-batch, and that now happens after the cap instead of before it, so
such a cycle ships slightly fewer rows than the cap. It cannot open a
watermark hole, because the watermark is derived from rows actually
stored.

Verified by generating both the live and backfill SQL and asserting the
row-choosing statement names no wide column and no TVF, that the cap,
tie group, ship order and self-query marker are on it, and that the batch
still returns exactly one rowset.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
… (#2290)

query_stats keys its deltas on the full dm_exec_query_stats row identity,
which includes plan_handle -- and plan_handle changes on every recompile.
So a statement whose dynamic SQL mints a new plan constantly presents a
new key on nearly every sighting, and the first sighting of a key reports
0.

Field measurement on a plan-churning readable secondary: a query Datadog
measured at 43.1% / 43.9% / 42.5% of an 8-vCPU box across three windows
read through these collectors as 18 executions and 2,824 ms over 168
hours. Instance-wide, the top-25 procedures accounted for ~49M ms against
roughly 498M core-ms available.

The worse half is that it was invisible. The counter-reset branch reports
interval = 0 precisely so a reader can tell a fabricated zero from an idle
one (#2234), but it needs the SAME key to reappear lower, and a recompile
never does -- it arrives under a new key and takes the baseline path. Same
class of harm as the 300-second gap policy #2233 replaced: it did not
merely lose data, it invented quiet.

The discriminator was already being collected: creation_time has been in
this collector's SELECT all along. The row now also carries how long ago
its plan was compiled, and when a series demonstrably began since the
previous pass its whole counter accrued inside that window, so the delta
is the full value rather than 0. No new collection, no schema change --
PayloadColumns is unchanged and the age is not stored.

Sent as an AGE rather than creation_time itself because a DMV
creation_time is in the monitored server's local time while collection
times are UTC; comparing them client-side is a timezone bug on every
server that is not UTC, so DATEDIFF is evaluated where both clocks are the
same one.

Stated rather than left to be discovered: a plan compiled AND evicted
between two passes never appears in the DMV at all, so no keying scheme
can recover it.

plan_handle was deliberately NOT dropped from the key. Parameter-sensitive
variants of one statement coexist in cache, so a statement-only key would
collide across several live rows in a single pass and each delta would be
computed against whichever row was processed last -- the multi-statement
cross-contamination #2012 fixed, reintroduced from the other direction.

The new entry point is default-implemented on the interface, so the other
forty-odd delta call sites and every existing implementer are
byte-identical. All eight of the row's counters take the rule together;
crediting only some would make one row's metrics disagree about how much
work it did.

Verified by executing the new xunit facts in a plain net10.0 harness
(Darling.Tests is net10.0-windows and cannot run on this machine): 11/11,
including the gap-policy bound, the cold start, per-pass stability across
every row of one sweep, ClearServer, per-server/per-collector isolation,
and a pre-#2235 implementer still compiling.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
* Add a watermarked query-text fetch seam, default off (#2150)

The query_store payload selects query_sql_text (nvarchar(max)) inside a
TOP ... WITH TIES ... ORDER BY last_execution_time. A Top-N Sort carries
every output column through the sort and reads all of its input before
emitting row one, so choosing the rows to ship materialized the text for
the entire qualifying set.

Measured with #2210's plan XML already gone and that one column as the
only difference, on a purpose-built Azure SQL DB store:

  1,505 rows / 12.8 MB text:  ttfr 4.67s -> 0.45s   drain  8.06s -> 0.50s
  4,037 rows /   34 MB text:  ttfr 5.02s -> 0.57s   drain 16.95s -> 1.45s

So #2210 did not finish this. It removed the larger column (195 KB average
plan against 8.5 KB of text on that store) and left the one that still
dominates. Neither knob bounds it, both measured: TOP (500) cost the same
as TOP (50000), and wall time was flat from a 4 MB to a 256 MB client
budget because the server finishes before the client sees a byte.

This commit adds only the seam and changes no behaviour. The new
FetchQueryTextSeparately flag defaults false and no host sets it yet, so
the emitted SQL is byte-identical to before -- verified against the SQL
captured from dev prior to the change, and pinned by a test that
normalizes the one column out of both forms and requires the remainder to
match exactly.

It is a flag rather than a deletion because Lite stores that text inline in
DuckDB and its grid reads it from there, so nulling the column
unconditionally would blind Lite. Gated, the placeholder keeps the
column's ORDINAL, which matters because the readers index the row by
number.

The fetch is watermarked rather than deduped per pass, a decision this
collector already made once: #1556 shipped each plan once per PASS via
ROW_NUMBER and #2164 replaced it because that form re-ships every pass
forever, and drain is 94-97% of a pass. query_id is an identity, monotonic
within a database, so a statement's text is fetched once ever -- and keying
on query_id rather than query_text_id needs no new fact-table column and
no migration, because query_id is already a stored payload column.

Deliberately simpler than the plan fetch: no candidate-window estimator,
because SUM(DATALENGTH(query_plan)) forces decompression of every plan in
the window while query_sql_text has no such cost; and no content hash,
because plan XML can be rewritten in place whereas a statement's text is
fixed for the life of its id. The refresh horizon is kept for the hazard
that does apply -- query_id is monotonic in first-seen order, not in "we
have stored it", so a Query Store reset renumbers from the start.

Verified by executing the new watermark facts in a plain net10.0 harness
(Darling.Tests is net10.0-windows): 10 facts / 17 cases, plus direct
assertions on the emitted SQL for flag-off byte-identity, ordinal
stability, the fetch's shape, and all four stall-guard inputs.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Give the new text watermark a prune verdict (#2150)

CI caught this, and the guard that caught it is the point: adding a
per-database state key prefix without classifying it is a new orphan class,
and nothing else about adding one would fail.

textwm: is keyed prefix + databaseName exactly like planwm:, so it goes in
QueryStorePerDatabaseState.PrunableKeys and both hosts drop it when the
database is dropped. Paired with its OWN collector name rather than the
plan fetch's -- the two watermarks are stored separately on purpose, and a
prefix pruned under the wrong owner silently deletes nothing, which looks
exactly like having nothing to prune.

Also updated the literal count pin in AzureForeignStatePruneTests from 3 to
4 and added the matching Contains, and named QueryStoreTextState in the
drift guard's discovered-class assertions. The reflection already found it
without being told; naming it means a rename that drops it out of the
pattern fails instead of silently shrinking the set under test.

Verified by replicating the guard's reflection locally against the built
assembly: three state classes discovered, four prefixes declared
(done:, hole:, planwm:, textwm:), every one with a verdict, count 4,
NotKeyedByDatabase still empty, and KeyFor confirmed to be exactly
prefix + databaseName -- which is what justifies PrunableKeys over the
other list.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
#2292)

The storage half of the seam merged in #2291. collect.query_store_text is
keyed (server_id, database_name, query_id), and query_id was chosen because
it is ALREADY a stored fact column -- so this rung adds a table and alters
nothing, readers get the join key for free, and no migration touches
query_store_stats.

Text is stored inline rather than as a digest into a content-addressed
dimension. QueryStorePlanMap earns that machinery because plan XML is
enormous and duplicated; Query Store has already de-duplicated text one row
per statement per database, so there is nothing to squeeze -- and inline
removes the dimension GC liveness interlock whose failure mode is silently
missing text.

The upsert overwrites the TEXT, not just the stamp. query_id is unique
within a database only until Query Store is RESET, which renumbers from the
start, so id 5 afterwards is a different statement than id 5 before. The
refresh horizon brings us back to re-read it and this is where the corrected
text lands; touching only last_seen would leave the old statement's text on
the new id forever, which reads as a plausible wrong answer rather than as
missing data.

Pruned on last_seen rather than by drop_chunks (a keyed store, not a time
series), bounded to one chunk-width of the oldest rows per call, with the
retention margin ADDED to the fact horizon so text outlives the rows that
reference it.

SHIPPED INERT. FetchQueryTextSeparately is still false, because flipping it
nulls the payload column while six reader surfaces still project query_text
straight off query_store_stats -- the flip and the reader conversion have to
land together or those surfaces silently lose text for new rows. They get
their own reviewable change.

What ships live: the fetch pass, its per-database watermark under its OWN
state owner, and the Viewer probe. The state owner matters -- the load
merges both owners, so writing the text watermark under the plan fetch's
owner would read back fine and then never be pruned, because the shared
prune set pairs textwm: with query_store_text and a prefix pruned under the
wrong owner deletes nothing.

The Viewer probe is the three-place edit: a probe column, a reader argument,
and a map parameter. Verified in lockstep at 50/50/50 with contiguous
ordinals 0..49 -- note the probe's raw "EXISTS (" count is 51 because one
column is a compound EXISTS(...) OR EXISTS(...), so counting occurrences is
a false failure. A probe that cannot SEE the newest object maps every
fully-migrated store below the required version and the Viewer refuses to
open.

Darling.Tests BUILDS on macOS (it cannot run -- needs the Windows desktop
runtime), so the whole suite is compile-verified here; the new facts run in
CI.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
…2293)

Caught on the dogfood box: startup logged 42501: permission denied for
table config_notification, then "MCP could not read the monitored-server
registry -- live plan fetch will use darling.json".

Two defects met. The least-privilege carve is correct and unchanged:
DarlingManagedRoles deliberately REVOKEs table-wide SELECT on
config_notification from BOTH viewer and mcp and re-grants only the
non-secret columns, so the SMTP password and username and the
Teams/Slack/generic/PagerDuty bearer URLs stay unreadable. The MCP host was
asking for the whole row, and a column-level denial answers for the TABLE.

The second defect is why one password cost the registry: every section of
LoadViewAsync shares ONE try/catch, so the failed notification read
discarded the four reads that had already succeeded. MCP therefore fell
back to darling.json for live plan fetches -- a silent capability loss
whose logged cause named a table MCP does not use.

Fixed by making the reader agree with the boundary rather than by widening
the grant: a caller that does not DELIVER alerts skips the notification
row, and the MCP surface references neither Smtp nor Webhooks anywhere.

Pinned three ways so they cannot drift apart again: the host passes
includeNotification: false, the MCP surface is asserted to use neither
type, and the notification SELECT is asserted to still name carved secret
columns -- so narrowing that SELECT fails the guard and tells whoever did
it that the skip became unnecessary.

Not fixed here, and worth its own change: the all-or-nothing try/catch
still means any one failing section discards the rest. The skip removes
today's trigger, not the cascade.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
…2294)

Found on the dogfood box. The service logged:

  Failed to compute baselines for io_latency: Exception while reading from stream

which points an investigation at the network. The store's own log, in a
different file, showed 267 ms earlier:

  ERROR: canceling statement due to user request

Npgsql enforces its command timeout by CANCELLING the statement, so the
server reports the cancellation and the client is left holding a torn
stream. The real cause was a query outgrowing its deadline on a store grown
to 184 GB, and establishing that took correlating two logs by timestamp --
which is not a diagnosis the next person should have to repeat.

Classified structurally (57014 query_canceled, or a TimeoutException
anywhere in the chain) rather than by message text, since the message is
the very thing that was ambiguous. The timeout arm also names the
consequence the old line left implicit: the metric has NO baseline that
pass, so its anomaly detection goes silent while the collected data looks
perfectly healthy. Both paths now report elapsed seconds, so "it nearly
made it" and "it never had a chance" are distinguishable.

The other direction is pinned too: a genuine connection fault must keep
saying so. Labelling one a timeout is the identical defect aimed the other
way, and would send the next investigation at the query instead of the
network.

Extracted as an internal predicate so the classification is testable rather
than buried in a catch. Verified by executing all seven cases -- including
the negatives -- against the built assembly.

Not changed: the timeout value itself. The right number wants measurement
of what that query actually costs on a large store, not a guess; this makes
the next occurrence say what it needs.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
…eparate fetch on (#2150) (#2297)

* Resolve Query Store text from collect.query_store_text and turn the separate fetch on (#2150)

Flips FetchQueryTextSeparately for the sweep and converts every reader that
projects query_text, in one change, because they cannot land apart: the flip
nulls the payload's inline query_sql_text, so an unconverted reader would show
blank text for newly collected rows while looking perfectly healthy.

Six blocks across five files resolve collect.query_store_text first and fall
back to the fact row's inline column, which is permanent rather than a
migration step -- rows collected before the flip carry their text inline and
nothing backfills them. Three of the queries also filter on the text (#1565's
WAITFOR self-exclusion, the resolver's IS NOT NULL), so each resolves once
inside its existing lateral or a derived table and the filter tests the
resolved value under its original name; testing the raw column would have
excluded every post-cutover row. The comparison read needed query_id projected
through its dedup CTEs (it groups by query_hash, but text is keyed by query_id)
and both arms converted, since its projection coalesces current over baseline.

FetchRowsAsync stays off: it returns rows to its caller and writes nothing, so
nulling the inline column there would lose the text rather than relocate it.
Lite is untouched -- its DuckDB store has no side table.

Verified against the live 52-server store, the only instrument that can see any
of this (no CI job executes these Postgres strings). All six shipped bodies were
extracted from source, planned with EXPLAIN (GENERIC_PLAN), then executed twice:
with the side table empty each returned a byte-identical md5 to its pre-change
form over 330 rows (1/50/50/179/50/1), proving the fallback arm and no fan-out;
with the post-cutover condition induced by shadowing the table, all rows
resolved from the side table at identical counts.

* Teach the drill-down join guard about keyed side tables (#2150)

DrillDown_AllSql_EveryFromJoinTarget_ResolvesToAV4ViewOrACollectorTable allowed
three categories -- V4 passthrough views, collector TargetTables, and the query's
own CTEs. collect.query_store_text is a fourth: a keyed side table written by a
bespoke upsert path and pruned on last_seen rather than by drop_chunks, so it is
in neither catalog, and the drill-down is the first analysis read to reference
one now that statement text has to be resolved back.

Sourced from QueryStoreTextStore.TableName rather than spelled in the test, so a
rename cannot leave the guard asserting against a table that no longer exists.
Verified the conversion introduces exactly one new FROM/JOIN target across all
of PgDrillDownCollector.AllSql, which is this one.
…2298) (#2304)

* Source the MCP host's server map from the worker's privileged load (#2298)

#2293 fixed the first denied column and the failure moved to the next one:
ReadMonitoredServersAsync selects encrypted_password, which the section-6
secret ACL deliberately SELECT-carves from mcp, so the MCP host's config
view read still failed whole with 42501 and live plan fetch fell back to
darling.json - on a seeded box, exactly the servers the file doesn't know.

Skipping columns one 42501 at a time was chasing the carve. The durable
agreement with the boundary: the MCP host performs NO config read of its
own. The worker already loads the same rows privileged (it must, or it
could not collect) and now publishes the effective server set through
MonitoredServerRegistryState - the same publish/observe seam as the #1560
control-plane knobs. The plan-fetch resolver reads it PER FETCH: before
the first publish it falls back to darling.json (the documented store-down
posture) and heals on the next resolve; a server added via add_servers or
the Viewer now reaches the resolver on the worker's next reload instead of
never (the old map was built once at host start).

Security property preserved, not weakened: the mcp DATABASE role's grants
are untouched, no MCP tool exposes the state, and a token-holder still
cannot obtain a stored credential. The carve was never about keeping
credentials out of this process (the worker holds them); it keeps them off
the MCP wire, which they remain.

The #2293 pin moves with the fix: it now asserts the host contains no
LoadViewAsync call at all and reads _registryState instead. New unit tests
pin the seam's contract (null-before-publish -> file fallback, snapshot
swap -> restart-free heal, first-wins duplicate ids); all four verified
against the real build with a net10.0 harness on this machine since the
Windows suite cannot run here.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Leave a Debug breadcrumb for the pre-publish fallback window (review note)

The permanent-failure WARN went away with the failing read; the transient
pre-publish window gets one Debug line per host start instead - it is
self-healing by design, so per-fetch logging would only be noise.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Drop the includeNotification parameter with its last caller (review follow-up)

The MCP host's #2293 skip was the only false ever passed; with its config
read removed entirely, every remaining caller is privileged and the row
is read unconditionally. The comment keeps the carve's history - it is
the reason no restricted-role caller reads this view at all now.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Say (re)start, not start, on the breadcrumb comment (review nit)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Bring the class doc forward to the #2298 fix it now pins (review nit)

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
…ns grid (#2300) (#2303)

* Cap the Index Analysis banners so they cannot evict the Recommendations grid (#2300)

The reported empty grid held 2,078 rows the whole time. Six analyzer Note
banners in an uncapped Auto row, plus the rollup grid, consumed the tab's
full height at 1908x985 - the detail grid's star row collapsed to its
header and horizontal scrollbar, so the rows had no viewport while the
count indicator (computed from the same list) said 2078 recommendation(s).

Two changes, one cause each way:
- The banner strip moves into a ScrollViewer capped at 150px. Banners are
  caveats; they must never evict the content they qualify. Visibility now
  toggles on the scroller - the element occupying the layout row.
- The detail grid gets MinHeight=120 as a backstop, so future growth in
  the rows above degrades to a shorter-but-alive grid, never a vanished one.

Layout-only; no data path touched. Verified by building the Viewer - WPF
layout is not unit-assertable here, and the repro arithmetic is in the
issue thread.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Indent the #2300 comments to their nesting level (review nit)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
…#2299) (#2307)

* Give the analysis pass a stopping token and classify shutdown residue (#2299)

* Classify the baseline-data gate's catch too, keeping its silent posture (review find)
…e collection is visible (#2296) (#2308)

* Serve a sweep_pressure verdict from get_collection_health so half-rate collection is visible (#2296)

* Mirror the sweep_pressure hint into Lite's MCP instructions row (review find)
#2309) (#2311)

* Stop the review guard converting its own lookup failures into verdicts (#2309)

* Keep a successful lookup's stderr out of LOOKUP_OUT (review find)
…l dedup key (#2302) (#2310)

* Give the generic webhook raw-JSON context tokens and the cross-channel dedup key (#2302)

* Redact remediation T-SQL from context_json like every other channel (review find)

* Validate the raw tokens against a quote-bearing stand-in context (review find)

* Nudge CI after the outage dropped this branch's workflow events
* Clear FinOps column filters on server switch (#2306)

* Clear Lite's FinOps filters on server switch too (review find)

* Guard Lite's FinOps selector against same-server repopulation clears (review find)
… 15-minute stamp (#2312) (#2314)

* Ship closed Query Store intervals every cycle, the open interval on a 15-minute stamp (#2312)

* Bump the Azure foreign-prune count pin for the fifth per-database prefix (CI find)

* Land the open-interval stamp only after the item's read and flush succeed (review find)

* Treat an out-of-range numeric stamp as one more corrupt-row include (review find)
…e case (#2235) (#2318)

* Label the lifetime extremes on the top-CPU reads and flag the provable case (#2235)

* Mirror the extremes decision table in Lite.Tests per the shared-table convention (review find)
)

* Wire the plan fetch's adaptive candidate sizing (#2312 Finding 1)

* Average over only the plans that carried XML (review find)
…ck across gaps (#2324) (#2325)

The #1944 NaN gap markers shipped first in 3.4.0 and collide with the
gradient area fill. Reproduced headlessly against ScottPlot 5.1.59: one
NaN in a FillY + ColorPositions series renders the ribbon as opaque black
polygons with straight chord edges crossing the gap - the fill path
closes its contours through the break, and its fill paint under
ColorPositions is hardcoded Colors.Black with the gradient shader
expected to paint over it, which a NaN-bearing series defeats. On a dark
theme that black buried every other series on every gapped chart; the
reporter's one healthy tab was the one whose data had no gaps, and 3.3.0
predates the markers - which is why 'previous versions' looked right.

StyleScatter now withholds the fill from any series carrying gap markers
(realYs.Count != pointCount): line-only, the break stays visible, nothing
is buried. Continuous series keep the full gradient ribbon. Both
directions pinned in ChartStyleGapFillTests so the fix cannot quietly
repeal the fill feature. StyleScatter is the only fill producer
(ChartHoverHelper only toggles what it captured), so no other path needs
the guard.

Verified with a three-way headless render harness against the shipped
ScottPlot 5.1.59 on this machine: gap+fill reproduces the reporter's
black polygons exactly (chord edges included), gapless+fill renders the
intended ribbon, gap+line-only renders a clean broken line. The Windows
suite runs the new pins in CI.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* V75: plan content gets its own retention horizon (#2316)

The dimension GC's fact-coupled horizon guarantees no live fact ever
references deleted content - and cannot bound a store younger than the
fact retention. Measured on the dogfood fleet: query_plan_dim hit 127 GB
(63% of the store) in its first 22 days at ~6 GB/day of param-sniffing
recompile churn (344k distinct XMLs/day from 5,327 shapes; the worst
shape produced 57,402 in one day), with the first coupled-GC delete
mathematically impossible before ~Oct 27 - a month after projected
disk-full. Orphan pruning existed and was healthy; compression was spent
(every row already app-gzipped); lifetime was the remaining lever.

config_service.plan_content_retention_days (V75, default 21, clamps
[7,365], 0 = disabled = old behavior byte-for-byte): the dimension cutoff
becomes the NEWER of the fact-coupled cutoff and now - (knob + 1), the
same one-day margin as the measured floor for the same hourly last_seen
refresh guard. Facts keep their full retention; a plan older than the
window renders as the missing plan every reader already handles. A knob
wider than the fact horizon is deliberately a no-op - it must not become
a way to keep XML nothing can reference.

Full rung recipe: Scripts entry + SchemaVersion 75, viewer probe
sentinel/ordinal/newest-first arm, the V74 pin file demoted to its
keeps-true-forever form (including its InvokeMap arity), and the knob
plumbed file -> seed -> store view -> ApplyToConfig -> both PurgeAsync
call sites (purge_now now receives the config it previously didn't need).

Cutoff arithmetic and clamps verified against the real build with a
net10.0 harness on this machine (10/10, including the field prediction:
on the use2 box's shape the first post-upgrade sweep can delete
immediately). The Windows suite and the live-PG job run the new pins,
migration, and probe in CI.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Move the V75 doc block above V74's, not between V74's doc and its const

The doc-hygiene guard caught the exact displaced-block trap it exists
for: inserting the V75 summary+const anchored on V74Sql landed it BETWEEN
V74's doc comment and V74's const - two summaries stacked on V75Sql and
V74Sql left undocumented. Both jobs failed on this one test and nothing
else. Verified locally with a reimplementation of the detector's rule:
no stacked openings remain, and both consts have their own doc attached.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Scope the knob to the plan dimension, and teach the map's prune about it (review catches)

Two review findings, both real:

- The dedicated horizon applied to EVERY payload dimension, so the shipped
  default would have shortened query_text_dim too - breaking 'text stays
  analyzable for the facts' full retention', half the knob's own
  justification, to reclaim ~40 MB. A pure router
  (ComputeDimTableCutoff) now sends only query_plan_dim to the dedicated
  cutoff; every other dimension keeps the fact-coupled one.

- The Query Store plan map's prune cutoff never learned the knob, so the
  dedicated dim cutoff overtook it: on the shipped default a 9-day window
  (69 days at 90d fact retention) existed where a dim row was prunable
  while the map row pointing at it survived - a live fact resolving to
  absent content, the exact silent-missing-plans failure the margin
  ordering exists to prevent, and the existing ordering pin never passed
  the knob so it kept passing. ComputeMapCutoff now folds the knob in with
  a one-day gap (map at knob, dim at knob+1 - the same stamp-skew margin
  as everywhere else, since TouchSql refreshes the map's stamp eagerly but
  the dim's hourly guard lets its stamp trail). Both cutoff components are
  strictly ordered, so the max-of-newer composition preserves the
  invariant under every knob value.

New pins: the router's per-table scoping, map-disabled-equals-old-behavior,
and the both-orders age sweep across (retention x knob) including the
shipped default, clamp edges, disabled, and wider-than-facts. All verified
against the real build via the local harness (16 pairs x full age sweeps,
zero violations) plus the dogfood scenario end to end: plan dim reclaims at
now-22, text dim untouched.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Clamp the knob at the destructive sink, and say what disabled actually does (review catches)

The knob arrives pre-clamped only when a store read succeeded and
ApplyToConfig ran. On a store-unreachable boot the worker passes
darling.json's RAW value, and a file value of 1-6 would prune plan
content below the [7,365] contract - the failure direction is data loss,
so PurgeAsync now clamps first thing, exactly like retentionDaysFor's
documented belt-and-suspenders clamp beneath it (the guarantee
RunPurgeNowAsync's doc already relies on).

Also tightened the ComputeDimensionCutoff comment: disabled takes an
early return before any dedicated value exists - the old text described
a MinValue-loses-the-comparison mechanism the code doesn't have.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Actually clamp at the destructive sink this time, and pin it (review catch, round two)

The previous commit's message described this clamp and shipped only the
comment beside it: the batch edit script applied its substitutions
in-memory and wrote the file only after ALL anchors matched, so when the
comment anchor failed its assert, the already-applied clamp edit was
silently discarded with it - and the comment half was then re-applied by
hand without noticing the loss. The reviewer caught the diff not matching
the message.

PurgeAsync now clamps planContentRetentionDays first thing, before either
cutoff computation. And because the miss survived a green build (the
clamp is unreachable by any test that can execute here), it now has a
source pin - PurgeAsyncClampsTheKnobAtTheDestructiveSink asserts the
clamp exists AND precedes both uses, and was proven to fail against the
unclamped code before this landed.

Also: [JsonPropertyName("planContentRetentionDays")] for serialization
consistency with every sibling knob (review nit).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
…#2317) (#2327)

* Give the self-metrics sweep a real timeout, and name a timeout as one (#2317)

The sweep's ~5-a-day 'Exception while reading from stream' ERRORs were
command timeouts in a network-fault costume - the #2294 misdirection one
layer over. The managed server's own log confirms it at the exact failure
timestamps: 'canceling statement due to user request' with
CONTEXT: SQL function "hypertable_local_size", and pg_database_size
cancelled alongside. All five sweep statements ran on Npgsql's default
30 seconds, which sizing a 141-object store with a 100+ GB plan dimension
outgrows under load.

- Every statement now carries SweepTimeoutSeconds = 300 (DarlingRetention's
  destructive-statement budget: an hourly sweep on its own connection can
  afford patience, and one that cannot finish in five minutes should skip
  the tick - a self-healing one-hour series gap - rather than retry into
  the same load, which is why the issue's retry-once suggestion is
  deliberately not taken).
- The worker's catch classifies through PgBaselineProvider.IsCommandTimeout
  (one definition, InternalsVisibleTo extended to the Service, not a copy
  that drifts): a timeout logs as a timeout with the budget named; a real
  fault keeps the old message.
- StoreSelfMetricsTimeoutTests pins statement-count == timeout-count so a
  sixth statement cannot ride the default back in, and pins the budget.

The issue's 19:51 burst is separate (something SIGTERMed the TimescaleDB
scheduler/launcher and two backends once - operational, evidence on the
issue) and its FilterMutedFindingsAsync single line is on the post-V75
dogfood watch.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Cap the WHOLE sweep at one budget, and name the right sizing function (review catches)

The sweep is awaited on the main loop, unlike the fire-and-track
per-server sweeps - so five sequential 300s statement timeouts could
stall per-server dispatch and the disk-pressure/compression checks for
25 minutes against a genuinely wedged store, hourly. The worker now caps
the whole sweep at one SweepTimeoutSeconds through a linked CTS
(worst-case loop block ~5 minutes, comparable to the old default's
5x30s), and the budget's own OperationCanceledException takes the
timeout-named log arm when the service token is untripped. Per-statement
timeouts stay as the belt for callers that pass no token.

Also the naming nit, in all four places it appeared: the sweep calls
hypertable_detailed_size - hypertable_local_size is its inner function
and the frame the server log names when it cancels.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
… series (#2319) (#2328)

* Collect per-database Query Store health (#2319): V76, both SKUs, both stores

database_config knows one bit (is_query_store_on) - which cannot answer
what #2312's investigation needed: is Query Store actually WORKING
(desired READ_WRITE with actual READ_ONLY after the cap hit is the
classic silent failure - readonly_reason says why), how close to the cap,
and at what interval grain. The new query_store_health collector reads
sys.database_query_store_options per database.

Shape decisions, each pinned:
- The database_scoped_config enumeration idiom verbatim (accessible
  ONLINE primaries, then [db].sys.sp_executesql per database) - the issue
  asked for the fields on database_config itself, but that collector is a
  single sys.databases scan and these fields need per-database context;
  a sibling enumerating collector preserves its execution model and
  failure isolation, and mirrors the family member that already exists.
- Deliberately NOT filtered to QS-on databases: the options view answers
  one row even when Query Store is off, so OFF is recorded as OFF and an
  absent row can only mean 'not collected'.
- Hourly, not the config family's on-load cadence: these values change
  BY THEMSELVES, and the cap-hit transition is the point.
- All columns exist on 2016+; no version gates.

Full recipe: V76 rung (body generated from PgSchemaGenerator so fresh and
upgraded stores agree byte-for-byte, asserted in PgSchemaGeneratorTests),
StorageVersion 76, viewer probe sentinel/ordinal 51/newest-first arm,
V75 + V74 pin files demoted (including both InvokeMap arities), catalog +
schedule registration, both hosts' dispatch, Lite golden schema + NOT
NULL overlay + table-count pin, CI worker sizing 52/63 -> 53/64 in both
workflows (the formula comments updated with them), Query Store sub-tab
on both apps' Configuration tab (readers byte-identical over
v_query_store_health; row classes verified identical), and
QueryStoreHealthStoreTests pinning ladder/probe/filters/quoting/honesty/
schedule/payload order. Twelve definition facts additionally executed
against the real build via a net10.0 harness on this machine.

A get_query_store_health MCP read follows as its own PR - same slicing as
pvs_stats (#1951), which shipped collector + viewer first.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Register the enumerator and its view in the three guarded lists (CI catches)

Two guards fired, both doing exactly their job:

- The empty-enumeration inventory pins (#1852, both suites) compare the
  catalog's actual database enumerators against
  CollectorHealthClassifier.ExpectsUserDatabases - query_store_health now
  enumerates user databases, so its persistently-empty note must qualify
  against whether the target HAS any. Added to
  UserDatabaseEnumeratorNames and both suites' named sets.
- The passthrough-view parity pin compares every CREATE OR REPLACE VIEW
  any migration emits against PgSchemaGenerator.AllPassthroughViews - V76
  creates v_query_store_health, so the collector joins
  PostV8ViewCollectors and the V14 refresh-all idiom can never silently
  revert the view.

Also the InitializeAsync_CreatesAllTables hand list in Lite.Tests, found
by sweeping every file that hand-names the sibling enumerator rather than
waiting for the next CI round to name it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Bump the golden oracle's table count with its new entry (CI catch)

The oracle was extended by hand (as designed) for query_store_health;
its 41-literal count is the frozen-shape half of the same pin: 42.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Gate on Query Store's existence, and decode readonly_reason as the bitmask it is (review catches)

Three findings, all real:

- sys.database_query_store_options does not exist before SQL Server 2016,
  so the ungated collector would have errored once per database per hour
  on pre-2016 targets. AppliesTo now carries QueryStoreCollector's exact
  condition (v13+ / version-unknown / either Azure flavor), so Lite and
  Darling skip identically; pinned across 2012/2014/2016/unknown/Azure,
  and the class doc's 'no version gates' claim is corrected to what it
  meant (no per-COLUMN gates within the view).

- readonly_reason is a COMBINABLE bitmask - QueryStoreCollector already
  bit-tests it - and the display switch matched exact values only, losing
  every multi-bit state. Both row copies now decode bit by bit and join,
  reporting undocumented leftover bits numerically rather than guessing.

- Two labels were written from memory and wrong: 131072 is the
  statement-count internal memory limit and 262144 the persist-backlog
  memory limit (no 'user request' reason exists). Fixed with the
  documented wording in both SKUs together.

Gate facts additionally executed against the real build (16/16 in the
local harness).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Type the two threshold columns as the bigint the DMV declares, and order the README rung table (review nits)

stale_query_threshold_days and max_plans_per_query are bigint in
sys.database_query_store_options, like every other numeric on the row -
they were collected as int, harmless at realistic values but inconsistent
and a latent OverflowException. Safe to change end-to-end (collector
types, V76 rung, Lite golden, both row classes and readers) because no
store has run V76 yet; the generated-vs-rung parity assertion follows the
generator automatically.

Also moved the V76 row in Darling/README.md's notable-rungs table to
version order (it landed between V46 and V47).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* NumericCell on Lite's six numeric Query Store columns (review parity catch)

Darling's twin tab right-aligns these six; Lite's rendered them
left-aligned - the identical row data displayed inconsistently between
the two apps. NumericCell is the established convention (~266 columns in
this same file).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Review catches: the definition-test file every collector carries, and the README collector table

Every enumerating collector pairs its Darling store pins with a Lite.Tests
definition-test file; query_store_health shipped without one, so ReadItemAsync's
9-column ordinal mapping and WritePayload's write order had no executable pin.
QueryStoreHealthCollectorDefinitionTests mirrors the database_scoped_config
sibling: enumeration exclusion splice + Azure variant, bracket doubling, the
2016+ gate, ReadItemAsync accumulation across two databases (healthy row + the
cap-hit READ_ONLY shape with DBNulls through every coalesce arm), and the
10-column payload order via RecordingCollectorRowWriter.

Root README's Lite Collectors table also learns the new collector and its count
moves 41 -> 42.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Review nit: two WritePayload comments still said INTEGER after the BIGINT widening

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
…#2319) (#2329)

* get_query_store_health: the MCP read for the new collector, both SKUs

The follow-up #2319's changelog entry promised: one browsable tool beside
get_database_scoped_config (whose latest-snapshot shape it mirrors), returning
per-database actual vs desired state with the mismatch pre-folded into
state_matches_desired, readonly_reason raw and decoded, storage vs cap with
pct_of_cap, cleanup mode/thresholds, and the runtime-stats interval length.
Darling reads through DarlingConfigHistoryReader over v_query_store_health and
also exposes the /api/read endpoint; Lite reuses LocalDataService's existing
grid read. The JSON emission is byte-identical across SKUs.

The readonly_reason bit table now lives once, in PerformanceMonitor.Common
(QueryStoreReadonlyReason): both viewers' grids and both MCP tools decode
through it. The labels were miswritten from memory once during #2319 review -
a single source is the fix, not care.

Counting tools for the instructions doc surfaced that the census sentence had
silently drifted: it said ninety tools while the server exposes one hundred,
and its shared-bucket arithmetic summed to 74 against a stated 73. It is
rewritten with digit counts (101 / 76 shared / 25 Darling-only, now including
the PostgreSQL reads in the unique enumeration), and a new pin in
CrossAppMcpToolInventoryPinTests parses the sentence against the scanned
inventory so a new tool on either side fails CI until the census moves.

Test pins: ConfigToolSurface 4 -> 5, param contract, QueryStoreHealthSql
latest-snapshot + payload-order + PG-dialect pins, advertised-schema count,
and the gated live-PG round-trip plants the cap-hit shape (desired READ_WRITE,
actual READ_ONLY, reason 65536) and asserts the folded and decoded fields.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Review nit: the Globalization using outlived the decode extraction

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Review catches: DBNull parity on the state strings, and the README tool lists

The Darling reader mapped actual_state/desired_state (and size_based_cleanup_mode)
to null on DBNull where Lite's row defaults to "" - a type-level drift that would
serialize different JSON across SKUs in the never-observed null case. The read row
now coalesces to "" like both viewers' QueryStoreHealthRow.

Root README's Available Tools Configuration row and Darling/README's Config bullet
learn get_query_store_health, and the root README's 'Lite exposes 74 tools' count
(stale before this PR - actual was 76) moves to 77.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
… ranking explains (#2320) (#2330)

* cpu_attribution on the top-CPU rankings: what fraction of the box the ranking explains

The last unshipped item from #2235's wishlist, split out as #2320.
get_top_queries_by_cpu and get_top_procedures_by_cpu (both SKUs) now return
the returned rows' summed CPU-seconds, the SQL process's measured CPU-seconds
for the same window (avg cpu_utilization % x core count x window - both
stores already collect every piece), and attributed_cpu_ratio.

Twice earned per the issue: pre-#2290 the reads explained ~10% of the box and
nothing said so, and the ratio catches impossible claims at a glance - the
Datadog comparison died when its worker_time sum divided out to 137% of the
box's available CPU-seconds. Above the process's own measured consumption the
note says to distrust the numbers; below half it explains where unattributable
CPU goes (evictions between snapshots, rows outside the top-N or filters,
zero-cost rows, non-query CPU).

The degrade rule is explicit and pinned: missing CPU series, missing core
count, or coverage under 90% of the window omits the ratio rather than
inventing one. One computation in PerformanceMonitor.Common (CpuAttribution),
decision-table tested identically in both test projects and executed against
the built assembly in a local harness (all cases pass); the denominator read
windows on collection_time with the same bounds as the rankings, so numerator
and denominator share collection gaps.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Review catches: concurrent denominator reads, one shared window on Lite, honest Result doc

The CPU aggregate and server-properties reads are independent, so both SKUs'
tools now run them under Task.WhenAll instead of paying two sequential
round-trips on every call. Lite's GetCpuWindowAggregateAsync takes the window
explicitly and the tools capture one nowUtc backing both the aggregate read
and the ratio math - Darling had that by construction, Lite sampled UtcNow
three times for one disclosure. The Result record's doc no longer claims
SqlCpuSecondsInWindow and AttributedCpuRatio are null together - the
measured-zero case deliberately reports the zero, as the tests pin.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Review catches: hoist Lite's nowUtc above the ranking read, and the %% doc artifacts

The ranking read windows on its own internal UtcNow, so capturing nowUtc after
it left the numerator and denominator skewed by the ranking query's duration.
Hoisting the capture above the read shrinks the skew to call-entry overhead -
zeroing it entirely would mean threading an instant into the shared ranking
read's signature, which sub-microsecond drift against an hours window does not
buy. The %% doc-comment artifacts (a template-escaping leftover) become %.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
…he other SKU (#2181) (#2332)

* Darling Viewer: the Query Store grid's DarkButton crash - Lite's #2114, on the other SKU

The inline View Plan button in Queries -> Query Store by Duration referenced
DarkButton, a key that IS defined in the Viewer - in MainWindow.xaml's window
resources, a scope a UserControl's templates cannot see, because StaticResource
resolves lexically at load rather than through the runtime tree. A missing key
inside a cell template stack-overflows the process the moment the grid renders
a row: uncatchable, no error dialog. It survived dogfooding because an EMPTY
grid never applies its cell template, and the dogfood fleet's Query Store data
is empty. #2181 reported it against the Darling Viewer and was closed as a
duplicate of the Lite-only fix (#2118) on a wrong premise; #2331 re-proved it
on 3.4.0 stable, which the Lite fix missed by twelve hours anyway.

The button uses default chrome now - Lite's exact fix. And the hygiene test
that let this through gets the model it should have had: per-FILE resolution
(own keys + transitively merged dictionaries + App.xaml scope), matching WPF's
actual StaticResource lookup instead of the per-app existence check that
declared MainWindow's key 'defined somewhere, good enough'. The widened scan
was proven exact before adoption: over both apps it flagged exactly this one
real crash and zero false positives.

Closes #2181

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Review nit: the pack-path doc claimed a repo-layout fallback the code does not have

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
…2243) (#2334)

Versions move to 3.5.0 in the three live csprojs plus the deprecated
Dashboard csproj the dev->main version check reads. The Unreleased section
cuts to [3.5.0] - 2026-08-19. And #2243 rides the window it was parked for:
the review prompt pointed every run at a CLAUDE.md that exists on no branch
(it is gitignored local tooling), so its first instruction was to follow a
file it cannot open - it now points at CONTRIBUTING.md and names the T-SQL
style rules inline instead of dangling a reference to a guide that is not
there.

Closes #2243

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
@erikdarlingdata
erikdarlingdata merged commit da5c5aa into main Aug 19, 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.

1 participant