Skip to content

Deliver the sweep's memory: the daily rollup ceiling and get_sweep_reports (#3466, lane 4 of 4) - #3473

Merged
erikdarlingdata merged 7 commits into
devfrom
feat/3466-sweep-delivery
Sep 16, 2026
Merged

erikdarlingdata merged 7 commits into
devfrom
feat/3466-sweep-delivery

Conversation

@erikdarlingdata

Copy link
Copy Markdown
Owner

Deliver the sweep's memory: the daily rollup ceiling and get_sweep_reports (#3466, lane 4 of 4)

Closes #3466.

This is the last of the four strictly-sequenced lanes the sizing ruling ordered, and the one that
closes the issue: delivery — the daily channel rollup the cadence ruling bounds, plus the
get_sweep_reports MCP surface the non-goals section designed the whole feature around. With it, the
Fleet Sweep Reports feature is complete at the CORE + DELIVERY tier the owner scoped.

What the four lanes built, together

  • Lane 1 — the state store (V123). The missing primitive the spec named: sweep-over-sweep state as
    data. Four collect tables — the immutable run documents with the NOT-NULL instrument-liveness pair
    and the mute header, the normalized per-server verdicts every later lane joins relationally, the
    would-have-paged ledger rows, and the watch-item worklist — written in one throwing transaction, so a
    sweep is one indivisible statement about the fleet or it is nothing.
  • Lane 2 — the engine (V124). The scheduled, stateful evaluation on the operator's cadence
    (fleet_sweep group: enabled + interval, 15–1440 minutes, default hourly): per-server verdicts off
    the SAME daily-summary scoring every day surface bands with, the diff against sweep N−1's persisted
    rows, watch items through the hysteresis state machine (two consecutive sightings to open, two quiet
    sweeps to close, last-seen stamps moving on SIGHTINGS only), instrument liveness judged before
    anything else is believed, restart awareness as a gate (the measured purge-before-collection-loop
    settle window), and the would-have-paged ledger derived honestly from the sweep's own scoring under
    master-off — deliberately not an alert-engine replay. The engine makes no delivery call anywhere.
  • Lane 3 — the web feed. The /api/sweeps read surface and the Fleet Sweeps dashboard page:
    caller-configurable spans (default the last hour, per the ruling) validated through the shared MCP
    window authority, the FleetSweepPresentation builders as the ONE wire shape, the mute header on
    every sweep with the ledger present-and-empty under mute and absent under alerts-on, and a sweep that
    could not prove its instruments rendering LOUDLY — quiet-is-not-clean applied to CSS.
  • Lane 4 — this PR. The two delivery surfaces, described below.

Also riding this lane: the #3471 review's retention finding, fixed at its own commit — the two unsliced fleet-sweep child purges (verdicts, would-have-paged) dispatched through the drain loop's default batchSize: 1, whose "a full batch means more may remain" contract inverts for a whole-statement DELETE: any purge day with real work ran each statement twice, once for the rows and once to observe none. They now dispatch single-shot (a cap no real row count reaches), the sliced siblings keep their batch size, and a source pin holds the shape.

The daily rollup

One channel post per day, a ceiling and not a default, exactly as the delivery ruling ordered —
built on the collector-cost digest's machinery reused whole: the daily-interval dedup, the
empty-sends-nothing gate, the declared INFO arm (#3443's precedent, pinned so the next
fall-through sweep cannot promote it to WARNING), the shared mute seam, the same funnel. The interval
is also the covered span, one number, so what a post claims to cover and how often one can arrive
cannot drift apart.

What it says: the trailing day's band transitions (chronological, parsed from the persisted sweep
documents — the engine's report is the authority on what each sweep said), watch items opened and
closed (subjects resolved from the span's own verdict names; the fleet sentinel named as the fleet; a
server no verdict named stated as its bare id, never invented), instrument-liveness incidents in the
quiet-is-not-clean vocabulary, unreadable documents counted — an unreadable day must not read as a
quiet one — and, whenever any covered sweep ran under the alert master switch, the would-have-paged
ledger those sweeps carried, counted and named per family and server: what the silence cost, rendered
explicitly.

Two decisions argued rather than assumed:

  • A fully-quiet day posts NOTHING — not a one-line all-clear. The digest precedent is exact
    ("empty sends nothing"), the ruling names the failure mode (report-class content in a paging channel
    trains operators to ignore the channel), and the clincher is quiet-is-not-clean itself: an honest
    all-clear would have to carry the instrument-liveness proof behind it, and a daily post carrying
    proof-of-quiet is a report card — the exact thing the ruling bounds. The affirmative all-clear lives
    where it can afford its evidence: the web feed, every sweep, at full cadence, each document proving
    its own instruments. Channel silence is therefore backed by instrument-proved quiet on the record
    surface, never by absence. And nothing consumes the interval but a delivered post — not a quiet day,
    not an empty one, not the master gate, not a failed read (which lands on the Under store contention the alert pass fails first and silently, so the self-alert that would report it is the first thing lost #3013 swallowed-read
    counter and skips the tick) — so the first day with something to say is never delayed.
  • Catch-up semantics are deliberately narrow. Delivery is master-gated — the engine keeps sweeping
    under master-off (the muted-mode contract; the report surface is never blinded), so this apply is the
    one place the sweep feature touches a channel, and master-off means it touches nothing. A rollup only
    ever covers the trailing day: the first post after re-enable states how many of ITS covered sweeps
    ran muted (with their ledger under that statement), so the operator is told there is history to read;
    older muted days never get a channel post of their own, because a backlog recap is either unbounded
    or silently truncated, and the re-enable flood is what the operator's mute usually existed to
    prevent. The sweep documents stand in the web feed and get_sweep_reports as the permanent record.

The apply joins AlertMasterSwitchSurfaceTests' per-family census as the sixteenth firing member,
inline-gated (two of the sixteen — the collection-stopped and capture-down applies — are gated at
their verified sole caller rather than inline, which is the census's own recorded distinction); the
delivery-call table itself does not move, because FireAsync's single deliver site is unchanged.

get_sweep_reports

The narration interface, per the ruling that dropped the narrator tier: any built-in prose pass
needs an LLM from somewhere, and every shape pays — a bundled or cloud-called model collides with the
nothing-phones-home promise, and a BYO-endpoint knob is config surface for a job the architecture
already assigns elsewhere. The product publishes evidence; an agent adds judgment; this tool is the
interface between the two, and it is deliberately the SAME FleetSweepPresentation builders and store
reads the web feed serves, so an agent and a browser cannot disagree about what a sweep looks like.

One call returns the sweep timeline for the window (the shared ValidateWindow authority, refusing
rather than clamping), the newest sweep in full (verdicts with transitions pre-computed, the ledger
whenever the sweep ran muted), and the watch worklist (the web feed's own state validator, one refusal
vocabulary). sweep_id fetches one sweep — as a string, by #2548's rule: tick-scale ids do not
survive a JSON number round trip, and the tool refuses an id it cannot parse exactly rather than
silently querying a rounded neighbor. The description carries the two field contracts an agent must
read, and the tests pin them: alerts_enabled: false on a sweep means delivery was off while it
ran
(present-and-empty vs absent ledger stated), and instruments_alive: false means the sweep
could not prove its own data sources — quiet is not clean, and an empty window on such a sweep is
unreadable, not healthy.

Every census moves: the instructions' tool sentence (151 tools, 65 unique to Darling) plus the
cross-server table row and a field-contract paragraph; the CrossAppMcpToolInventoryPinTests ratchet
(Darling-only by architecture — Lite has no fleet to sweep and no sweep-state tables); the root
README's two Darling figures; llms.txt's range; and the /api/read dispatch gains the mirror beside
the dedicated /api/sweeps routes — the /api/fleet coexistence. ExcludedToolNames is untouched:
read tools are mirrored.

Store

Three new rollup reads with the THROWING posture, deliberately opposite the presentation reads: the
rollup posts nothing on an empty day by design, so a fault folded into "empty" would convert an
unreadable store into a permanently quiet channel with no artifact anywhere — the quiet-is-not-clean
misreading at the delivery end. The two new span statements (the ledger joined to its runs, and the
distinct names map that lets a channel post name servers instead of ids) are rung-pinned beside their
siblings: both bounds the caller's, no now(), and no read-side restatement of the write-side
guarantee that ledger rows only exist under master-off sweeps.

Tests and verification

  • FleetSweepRollupTests — the digest suite's disciplines: every silence pin has a firing twin
    (quiet-day, empty-day, master-gate, and none consuming the interval), the fire shape (own metric, no
    severity override, the store label, the count value), the shared mute seam, no resolution edge, and
    the shipped render re-parsed with its figures held to each other — header counts against lines
    rendered, the ledger banner against the sum of its family lines, the mute sentence against the sweep
    count it claims, and chronology against a deliberately newest-first fixture.
  • DarlingMcpFleetSweepToolsTests — the get_alert_settings sibling pattern: the one-tool surface,
    the param contract with everything optional, the string sweep_id pin, the shared as_of
    description verbatim, the refusal table proven on a dead data source, the description and
    instructions contracts, Gemini-clean advertised schema, and dispatch membership beside the untouched
    exclusion list.
  • Rung extensions pin the two new statements' dialect and bounds; Lite.Tests gains the second
    deliberate INFO pin and the ratchet entry.
  • Out-of-band on macOS (the test projects target net10.0-windows): the full solution builds with zero
    errors and no new warnings, and a 70-check harness ran the pure half plus a live TimescaleDB
    pg17 pass — real planted sweeps through the real store writer, the three new reads on the wire,
    EvaluateFleetSweepRollupAsync delivering one rollup off the real store carrying the planted mute's
    ledger and refusing to repeat inside its day, master-off delivering nothing, and get_sweep_reports
    plus its /api/read binding served off the same rows.

Deployment note

Per the sizing ruling: this feature is dogfooded in a separate environment before the production fleet
sees it — no install accompanies these merges.

…ports, so a mute's cost reaches the channel once and the record stays whole (#3466)

Lane 4 of 4, stacked on the V123 state store, the V124 engine and the web
feed - the delivery lane, and the one that closes the issue. Two halves,
both over the same rows the engine writes.

THE DAILY ROLLUP is the fleet sweep's one channel post, on the
collector-cost digest's machinery reused whole: the same daily-interval
dedup (the interval is BOTH the ceiling the owner ruled and the covered
span, one number so what a post claims to cover and how often one can
arrive cannot drift apart), the same empty-sends-nothing gate, the same
declared INFO arm (#3443's precedent, pinned in Lite.Tests so the next
fall-through sweep cannot promote it), the same shared mute seam, the same
FireAsync funnel. Attempted on the worker's hourly store-metrics tick
beside the cost evaluation; 23 of every 24 ticks cost one dictionary
lookup. It summarizes the trailing day: band transitions rendered
chronologically from the persisted documents (the engine's report is the
authority on what each sweep SAID - re-deriving from verdict rows would be
a second opinion), watch items opened and closed with subjects resolved
from the span's own verdict names (the fleet sentinel named as the fleet,
a server no verdict named stated as its bare id, never invented),
instrument-liveness incidents in the quiet-is-not-clean vocabulary,
unreadable documents COUNTED and themselves reportable - and, whenever any
covered sweep ran under the alert master switch, the would-have-paged
ledger those sweeps carried, counted and named per family and server: what
the silence cost, rendered explicitly.

A day with nothing to say posts NOTHING, and the choice is argued rather
than assumed: the digest precedent is exact, the ruling names the failure
mode (report-class content in a paging channel trains operators to ignore
the channel), and an honest all-clear would have to carry its
instrument-liveness proof - which is a report card, the exact thing the
ruling bounds. The affirmative all-clear lives where it can afford its
evidence: the web feed, every sweep, at full cadence. Nothing consumes the
interval but a delivered post - not a quiet day, not an empty one, not the
master gate, not a failed read (which lands on the #3013 swallowed-read
counter and skips the tick) - so the first day with something to say is
never delayed by the silence evaluated before it.

Delivery is master-gated, and here the gate IS the feature's delivery
contract: the engine deliberately keeps sweeping under master-off (the
muted-mode contract - the report surface is never blinded), so this apply
is the one place the sweep feature touches a channel, and master-off means
it touches nothing. The catch-up semantics are deliberately narrow and
stated so nobody widens them by accident: a rollup only ever covers the
trailing day; the first post after re-enable states how many of ITS
covered sweeps ran muted, so the operator is told there is history to
read; older muted days never get a channel post of their own, because a
backlog recap is either unbounded or silently truncated and the re-enable
flood is what the operator's mute existed to prevent - the sweep documents
stand in the web feed and get_sweep_reports as the permanent record. The
apply joins AlertMasterSwitchSurfaceTests' per-family census as an
inline-gated firing member (sixteen now; the delivery-call table itself
does not move, because FireAsync's one deliver site is unchanged).

GET_SWEEP_REPORTS completes the spec's non-goals split - the product
publishes evidence, an operator's agent adds judgment - and serves the
SAME FleetSweepPresentation builders and store reads the web feed serves,
so an agent and a browser cannot disagree about what a sweep looks like.
One call returns the timeline for the window (ValidateWindow, the shared
authority, refusing rather than clamping), the newest sweep in full with
its ledger, and the watch worklist (the web feed's own state validator, so
the two surfaces refuse in one vocabulary); sweep_id fetches one sweep,
and it is a STRING by #2548's rule - tick-scale ids do not survive a JSON
number round trip, and the tool refuses an id it cannot parse exactly
rather than querying a rounded neighbor. The description carries the two
field contracts an agent must read, pinned as such: alerts_enabled: false
means DELIVERY WAS OFF while the sweep ran, with the
present-and-empty-versus-absent ledger distinction stated; and
instruments_alive: false means the sweep could not prove its own data
sources - quiet is not clean, an empty window on such a sweep is
unreadable, not healthy.

Every census moves: the instructions' tool sentence (151 tools, 65 unique
to Darling) with the cross-server table gaining the sweep row and the
field-contract paragraph, the CrossAppMcpToolInventoryPinTests ratchet
(Darling-only by architecture - Lite has no fleet to sweep and no
sweep-state tables), the root README's two Darling figures, llms.txt's
range, and the /api/read dispatch (the tool mirror beside the dedicated
/api/sweeps routes, the /api/fleet coexistence). ExcludedToolNames is
untouched: read tools are mirrored. The store gains the rollup's three
reads - throwing, deliberately opposite the presentation posture, because
the rollup posts nothing on an empty day and a fault folded into "empty"
would convert an unreadable store into a permanently quiet channel - and
the two new span statements are rung-pinned like their siblings.

Tests: FleetSweepRollupTests (the digest suite's disciplines - every
silence pin has a firing twin, and the shipped render is re-parsed with
its figures held to each other: header counts against lines, the ledger
banner against its family lines, the mute sentence against the sweep count
it claims, chronology against a newest-first fixture),
DarlingMcpFleetSweepToolsTests (surface, params, the string sweep_id pin,
the shared as_of description verbatim, the refusal table on a dead store,
the description and instructions contracts, Gemini-clean schema, the
dispatch membership), the rung extensions, the Lite INFO pin and the
ratchet entry. Verified out-of-band on macOS: the full solution builds
clean, and a 70-check harness ran the pure half plus a live TimescaleDB
pg17 pass - real planted sweeps, the three new store reads on the wire,
EvaluateFleetSweepRollupAsync delivering one rollup off the real store
carrying the planted mute's ledger and refusing to repeat inside its day,
master-off delivering nothing, and get_sweep_reports plus its /api/read
binding served off the same rows.
on the host, describe it to the catalog, and census both from source (#3466)

Review round 1 found the lane's headline tool dark. get_sweep_reports
existed, was web-dispatched, was counted in the instructions census
(151), and was never registered with the MCP host - the registration
chain carried 54 .WithGeminiCompatibleTools<T>() lines for 55 tool
classes, so a live tools/list answered 150 where the instructions
claimed 151, and the one tool the whole feature was designed around was
unreachable over the surface it was designed for. That is #2659's exact
shape, nearly repeated: every existing name census pins tool NAMES, and
the attributes exist whether or not the class is registered. The
registration rides beside get_fleet_overview and get_ag_health as the
third cross-server read - the one with memory - over the same shared
builders the web feed serves.

The pin that owns this defect existed and did not save us, which is
worth stating plainly: McpToolTypeRegistrationTests (#2659) already
derives the registered set from the host SOURCE and the declared set
from the assembly, and it would have gone red on the CI that runs it -
but the lane's out-of-band harness (tests cannot run on macOS) did not
carry the check, so the gap reached review instead of a red build. Two
hardenings follow. The declared set now derives BOTH ways the SDK
vocabulary can express a tool - the type attribute, unioned with classes
carrying [McpServerTool] METHODS, because the registration path scans
methods and a class that lost its type attribute in a refactor would be
invisible to an attribute-only census. And the assert became
SET-EQUALITY: a registration naming a class that declares no tools
registers NOTHING silently - a stale line that reads as coverage while
delivering none - so it reds in the opposite direction. The lane harness
now carries the same check, proven red both ways against the
pre-fix source (2 failures naming DarlingMcpFleetSweepTools) before the
registration landed.

The second critical was the same defect one surface over:
get_sweep_reports had a BuildReadDispatch entry and no CatalogDescriptors
entry, and BuildCatalogNode indexes CatalogDescriptors[name] for every
dispatch key - so /api/catalog answered 500 at runtime, and the two
parity tests that pin exactly this
(CatalogDescriptors_Keys_EqualTheReadDispatchKeys and the as_of dispatch
census) were red. The descriptor's params are the dispatch binder's
ACTUAL wire keys - hours (the shared Hours binder, hours_back accepted
as the tool-spelling alias), as_of, sweep_id, watch_state - because the
catalog is the web surface's only input truth and a key it does not
advertise is simply unreachable there.

Two honesty repairs in the rollup's extraction, both under the one
unreadable counter the render states. ExtractRollupFacts read each band
census with GetInt32 inside a JsonException-only catch, so a band count
a JSON number carries but an int cannot hold escaped as
FormatException/OverflowException and killed the whole rollup tick for
up to a day - a silent outage of the exact feature that promises an
unreadable day cannot read as a quiet one. TryGetInt32 now treats the
corrupt census as unreadable and the fleet-as-of line keeps the newest
sweep whose census WAS readable - and says "newest readable covered
sweep", so the sentence matches the code's actual selection rule. And
AppendWatchEvents dropped a watch entry missing its item or server id
silently; it now returns the malformed count onto the same counter,
because a watch event the rollup cannot render is still a watch event
the operator was owed. UnreadableDocuments became UnreadableItems, and
the render sentence says what it counts: whole documents or single
entries that did not parse, one counter, stated.

New pins: the census overflow arm (the tick survives, the counter
states it, the fleet-as-of line falls back readable), the malformed
watch entry arm, and the set-equality census both ways. The out-of-band
harness grew from 70 to 80 checks - the registration census, the
catalog parity twin, BuildCatalogNode served live (the former 500), and
both unreadable arms - all green against the live TimescaleDB store.
…ore batches to confirm

Review on #3471 caught the drain-loop contract inverting for the two
fleet-sweep child purges: neither is time-sliced or row-capped, so
under the default batchSize of 1 any day with real work re-ran the
full DELETE once more just to observe zero rows. They now dispatch
at SingleShotStatement (a cap no real row count reaches, making the
one execution terminal), the sliced siblings in the same loop keep
their correct batchSize of 1, the sentinel stays out of the
row-capped drain report's cap figure, and a source pin holds the
call-site shape the compiler cannot.
@erikdarlingdata
erikdarlingdata enabled auto-merge (squash) September 16, 2026 03:57
… new sites they caught

The read-failure surface census caught the rollup's three span reads
sharing one stopwatch - a fault in the server-names read would have
recorded all three reads' elapsed as its own. Restart between awaits;
the census counts advance to the eighth evaluator site and 31
tree-wide, with the doc narrating why a swallowed rollup read is
counted (a fault folded into 'empty day' converts an unreadable store
into a permanently quiet channel). The retention drain-report pin
learns the single-shot exclusion the #3471 review response added.
@claude

claude Bot commented Sep 16, 2026

Copy link
Copy Markdown

Review

Went through the full diff (no T-SQL touched — this lane is entirely C#/Darling, so the T-SQL style checklist doesn't apply) against the CONTRIBUTING.md conventions, with a focus on correctness, Lite/Darling parity, security, and performance.

Correctness — verified, no bugs found:

  • ExtractRollupFacts/RenderFleetSweepRollup (DarlingSelfAlertEvaluator.cs): the "quiet day posts nothing" gate, the mute-header math, the ledger/family aggregation, and the unreadable-item counting (including the TryGetInt32 guard against a corrupt band census that would otherwise throw past the JsonException-only catch) all check out against their tests.
  • The #3471 retention fix (SingleShotStatement = int.MaxValue for the two unsliced child purges in DarlingRetention.cs) is correct: DrainBatchesAsync breaks on deleted < batchSize, so a real row count never reaches int.MaxValue and the loop is terminal after one execution, while the previously-taken code path (batchSize: 1 on an unsliced statement) always ran the full DELETE exactly once for real work and once more to confirm emptiness — this removes exactly that redundant second pass with no change to what gets deleted.
  • DarlingMcpFleetSweepTools.BuildDetailAsync and the web feed's DarlingFleetSweepEndpoints.BuildDetailAsync are identical in shape (verdicts always read, ledger only for alerts_enabled: false), so the MCP tool and /api/sweeps can't drift on what a sweep looks like.
  • sweep_id as a string with exact-parse-or-refuse (long.TryParse + NumberStyles.Integer) correctly avoids the JSON-double round-trip issue for tick-scale ids, and the refusal paths return before any store I/O, matching the "dead store never touched" tests.
  • Timestamp handling in the new FleetSweepStore span reads (GetSweepsBySpanForRollupAsync, GetWouldHavePagedBySpanAsync, GetSweepServerNamesBySpanAsync) correctly goes through AsNaive/DateTime.SpecifyKind(..., Unspecified) before binding, per the naive-UTC convention.

Parity: this is correctly scoped as Darling-only — no shared-library code (.Collectors/.Alerting/.Common) is touched, so the CrossAppMcpToolInventoryPinTests exclusion for get_sweep_reports is justified rather than a gap, and no Lite production code needed to change.

Security: all new reads are parameterized; no new file/process/network surface; #pragma warning disable CA1707 on the MCP tool class matches the existing snake_case convention for other tool classes.

One very minor, non-blocking observation: DarlingMcpFleetSweepTools.BuildDetailAsync passes null for the logger on GetServerVerdictsAsync/GetWouldHavePagedAsync (vs. app.Logger on the web endpoint's twin), so a child-read fault on this path degrades silently with no log trace anywhere. The code comment calls this out as a deliberate choice ("log-and-degrade with no logger here"), so this is just a heads-up rather than a requested change — an actual ILogger here would cost nothing and would make a future "why did the ledger section disappear" report easier to chase down.

No blocking issues found.

@erikdarlingdata

Copy link
Copy Markdown
Owner Author

On the one observation: agreed it costs nothing, and "why did the ledger section disappear" is a real future question — but it's not worth a CI restart this close to the nightly window. Riding the already-queued review-response PR (with #3448's severity carve-out and window-text reword): the MCP tool's child reads get the host's logger, and the deliberate-choice comment gets retired with it.

…oes not parse is evidence, not a swallow

The swallowed-read census walked past the corrected counts and found
the rollup's JsonException arm - unreadable++ with no message - which
is neither a counted read nor an exemption it could match. It is a
parse arm: the store read above it is the counted site, and the arm
converts the fault into the unreadable count the rollup itself
reports, so counting it as a read failure would double-book the one
read. The catch now carries that reason as the comment the exemption
keys on, and the evaluator's scope advances to its tenth exemption.
@claude

claude Bot commented Sep 16, 2026

Copy link
Copy Markdown

Reviewed the production diff (DarlingRetention.cs, DarlingSelfAlertEvaluator.cs, DarlingWebEndpoints.cs, DarlingWorker.cs, the new DarlingMcpFleetSweepTools.cs, DarlingMcpHostService.cs/Instructions.cs, FleetSweepStore.cs, AlertMetricClassifier.cs, AlertSeverity.cs) plus the associated tests. No blocking issues found.

Correctness

  • SingleShotStatement = int.MaxValue fix in DarlingRetention.cs correctly addresses the double-execution bug: with the drain loop's deleted < batchSize termination check, an unsliced DELETE that removed any rows under the old default batchSize: 1 would loop again to re-run the whole statement just to observe zero rows. The new sentinel (and the matching batchSize != SingleShotStatement guard on the diagnostic log, so the log doesn't print a bogus cap 2147483647) fixes this correctly, and the two purges that stay time-sliced (RunsTableName, WatchItemsTableName) correctly keep batchSize: 1.
  • ExtractRollupFacts/AppendWatchEvents degrade sensibly on malformed JSON: TryGetInt32 (not GetInt32) on the band census avoids an unhandled OverflowException escaping the JsonException-only catch, which per the accompanying comment would have killed the whole daily rollup tick silently. Good defensive read of untrusted-shaped (self-produced but still parsed-from-JSON) data.
  • GetSweepReports parses sweep_id as long from a string param specifically to avoid the JSON-number-precision round-trip issue — consistent with the precedent cited (get_pg_top_queries loses queryid precision: an int8 identity serialized as a JSON number #2548) and covered by tests for overflow/non-numeric input.
  • All new store reads (GetSweepsBySpanForRollupAsync, GetWouldHavePagedBySpanAsync, GetSweepServerNamesBySpanAsync) use parameterized Npgsql commands and the AsNaive() timestamp discipline described in CONTRIBUTING.md — no injection risk, no DateTimeKind foot-gun.

Lite/Darling parity

  • This is correctly scoped Darling-only (fleet-wide feature; Lite has no fleet). CrossAppMcpToolInventoryPinTests.cs explicitly exempts get_sweep_reports with a ratchet-style comment explaining why and how to remove the exemption if Lite ever grows fleet support. AlertSeverityTests.cs/AlertMetricClassifier.cs/AlertSeverity.cs changes are made in the shared assemblies and covered on both sides. Tool counts (150→151) are updated consistently across CHANGELOG.md, README.md, and DarlingMcpInstructions.cs.

Process note (not a defect): McpToolTypeRegistrationTests.cs shows the PR caught its own near-miss — DarlingMcpFleetSweepTools was initially wired into the web dispatch/instructions/catalog but not registered with the MCP host — and hardened the pin (now derives declared tools from [McpServerTool] methods too, and asserts set-equality instead of one-directional containment) so the same class of gap can't recur silently. Worth calling out as a good catch, not a concern.

Style: T-SQL conventions don't apply here (this lane touches only the Darling PostgreSQL/C# side); the PostgreSQL-specific rules (schema-qualified objects, naive UTC timestamps via AsNaive) are followed correctly in the new queries.

🤖 Generated with Claude Code

@claude

claude Bot commented Sep 16, 2026

Copy link
Copy Markdown

Reviewed the full diff (fc0b399..HEAD): the daily rollup in DarlingSelfAlertEvaluator.cs, the three new FleetSweepStore span reads, DarlingMcpFleetSweepTools/get_sweep_reports, the MCP registration fix, the drain-loop single-shot fix in DarlingRetention.cs, and the doc/census updates.

Correctness — no bugs found. Parameterized queries throughout (no SQL injection surface). ExtractRollupFacts is pure and its unreadable-counting is careful (JSON parse failures, non-int band counts via TryGetInt32, malformed watch entries all route to the one UnreadableItems counter rather than being silently dropped or crashing the tick). The SingleShotStatement = int.MaxValue fix for the two unsliced fleet-sweep child purges is correct: DrainBatchesAsync's deleted < batchSize terminates after one execution since no real DELETE can affect int.MaxValue rows, and the log-suppression guard (batchSize > 1 && batchSize != SingleShotStatement) correctly keeps the sentinel out of the "cap" log line.

Master-gate / mute discipline — EvaluateFleetSweepRollupAsync and ApplyFleetSweepRollupAsync both consult AlertsEnabled independently (matching the sibling digest/regression pattern), so a direct test caller of Apply can't skip the gate. The rollup posting nothing on a quiet day, and the muted-ledger present-vs-absent distinction, are well covered by FleetSweepRollupTests.

Parity — this is a Darling-only feature (fleet-wide sweep state), and that's correctly reflected: CrossAppMcpToolInventoryPinTests adds get_sweep_reports to the Darling-only ratchet with a clear architectural justification (Lite has no fleet/sweep tables), not an oversight. Census numbers (150→151 tools, 64→65 Darling-unique) are updated consistently across DarlingMcpInstructions.cs, README.md, Darling/README.md, and llms.txt.

Registration bug caught mid-PR — worth noting the second commit fixes a real defect the first commit shipped (get_sweep_reports documented/dispatched but never registered with the MCP host, and missing from CatalogDescriptors), and hardens McpToolTypeRegistrationTests to derive the declared set from both the type attribute and [McpServerTool] methods, with set-equality instead of one-directional containment. Good catch, and the hardening closes a real gap in the existing pin.

Security — no new file/network/process usage; sweep_id is parsed with long.TryParse and refused (never rounded) per the stated #2548 rule; no secrets touched.

No blocking findings from this pass.

The Lite-side census caught what the Darling-side one could not say:
a fleet-scoped read the counter records that the FleetScopedReads
constant does not name - the operator-prose inventory concatenated
into both SKUs' get_collection_health descriptions, where an
unnamed member is a nonzero instance total with a documented list
that does not name the cause. The constant now carries the
fleet-sweep rollup read, the derivation advances to seven with the
usual person-counted literal, and the Contains pins hold the name
in both directions.
Comment on lines +376 to +381
public const string GetSweepServerNamesBySpanSql = @"
SELECT DISTINCT v.server_id, v.server_name
FROM collect.fleet_sweep_server_verdicts v
JOIN collect.fleet_sweep_runs r ON r.sweep_id = v.sweep_id
WHERE r.swept_at >= $1
AND r.swept_at <= $2;";

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor determinism gap: every other span read in this file (GetWouldHavePagedBySpanSql, GetRunsBySpanSql, etc.) carries an explicit ORDER BY for exactly this reason, but this one doesn't. If a monitored server is renamed during the covered 24h span, fleet_sweep_server_verdicts can carry two different server_name values for the same server_id within the window. GetSweepServerNamesBySpanAsync builds its map with names[reader.GetInt32(0)] = reader.GetString(1);, so which name wins depends on whatever row order Postgres happens to return for the DISTINCT — not guaranteed stable across calls. The impact is cosmetic (a watch-item/ledger line in the rendered rollup could show the old or new name), but it's a real nondeterminism that the rest of this file is careful to avoid. Consider ORDER BY v.server_id, r.swept_at DESC (or similar) so the newest name for a given server consistently wins.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Real, and the file's own discipline argues your side — every sibling span read orders for exactly this reason. Cosmetic impact (which spelling of a renamed server the rollup shows), so it rides the queued review-response PR rather than a hotfix: ORDER BY so the newest name wins deterministically, alongside the #3448 pair and the MCP-tool logger from this PR's first review.

@claude

claude Bot commented Sep 16, 2026

Copy link
Copy Markdown

Review summary

This PR delivers the fourth (delivery) lane of the Fleet Sweep Reports feature: the daily channel rollup on the collector-cost digest's machinery, and the get_sweep_reports MCP tool served off the same FleetSweepPresentation builders as the web feed. The commit history shows the author already ran several self-review passes and fixed real defects along the way (missing MCP host registration, missing catalog descriptor, GetInt32 overflow killing the whole rollup tick, an unsliced-DELETE double-execution in retention, a shared stopwatch misattributing read-failure timing, and read-failure-census bookkeeping) — that rigor shows in the current state of the code.

Scope check: No T-SQL install scripts are touched, so the T-SQL style rules don't apply here. The new PostgreSQL statements in FleetSweepStore.cs correctly schema-qualify (collect.*), use naive-UTC timestamps via the existing AsNaive() helper, and are fully parameterized (no injection surface). No performance regressions: the new hourly work is a dictionary lookup on 23/24 ticks and three windowed reads (with explicit ORDER BY/bounded spans) only when the daily interval has elapsed.

Lite/Darling parity: This is correctly scoped as Darling-only by architecture (Lite has no fleet to sweep and no sweep-state tables), and that's handled the way CONTRIBUTING.md's parity section expects — a documented, reasoned entry in CrossAppMcpToolInventoryPinTests.cs's ratchet rather than silent divergence. The genuinely shared pieces (AlertSeverity, AlertMetricClassifier, the AlertReadFailureCounter fleet-scoped-reads inventory) were updated in the shared assemblies with matching pins in Lite.Tests, so nothing drifts between the two SKUs.

One finding posted inline: GetSweepServerNamesBySpanSql (FleetSweepStore.cs) is the one span read in this file with no ORDER BY. If a monitored server is renamed mid-span, the (server_id → server_name) map's winning name for that id becomes dependent on Postgres's unspecified row order for the DISTINCT, unlike every sibling read here which pins its order deliberately. Impact is cosmetic (a name in the rendered rollup text), but worth a stabilizing ORDER BY for consistency with the rest of the file.

No correctness, security, or Lite/Darling parity issues found beyond that.

@erikdarlingdata
erikdarlingdata merged commit 49a912a into dev Sep 16, 2026
8 checks passed
@erikdarlingdata
erikdarlingdata deleted the feat/3466-sweep-delivery branch September 16, 2026 04:53
erikdarlingdata added a commit that referenced this pull request Sep 16, 2026
…ed degrade, and the deterministic name (#3476)

* Carve the deliberate INFO reports out of IsWarning, so the history grids agree with the severity map (#3448 review)

* Name the census window the digest actually reads: the trailing 24 hours, not the movers' baseline (#3448 review)

* Hand get_sweep_reports' child reads the host's logger, so a degraded section leaves a trace (#3473 review)

* Order the rollup's names read so a renamed server's newest name wins deterministically (#3473 review)
erikdarlingdata added a commit that referenced this pull request Sep 17, 2026
…to-merge outruns (#3492)

* A substantive review finding becomes a failed check, not a comment auto-merge outruns

The #3470-#3473 train demonstrated the hole: claude[bot] left six review findings across
one night's PRs (one real) and auto-merge outran every one of them, because review output
was comments and the merge gate only ever read CI. The guard verified that the review
POSTED, but a posted finding and a read finding are different observations, and nothing
made a substantive finding cost anything.

Now the review prompt carries a VERDICT PROTOCOL: every run ends by submitting exactly one
formal GitHub review -- `gh pr review --request-changes` with a numbered summary when any
finding is substantive (correctness, security, data integrity, contract violation), or a
`--comment` LGTM review otherwise. Style nits, observations the review itself labels
non-blocking, and tradeoffs the code or PR already documents as deliberate are explicitly
NOT changes-requested, so the red mark keeps meaning something. Bash(gh pr review:*) joins
allowedTools; permissions were already pull-requests: write, and nothing else widens.

The guard grows the matching VERDICT ARM after its posted-something tally: fetch the PR's
reviews, filter to the review bot, take the newest by submitted_at. CHANGES_REQUESTED
fails the check, naming the review and the way out; anything else is clean. The semantics
are NEWEST-VERDICT-WINS: a re-review after new commits reviews the new diff and submits a
fresh verdict, and a clean fresh verdict clears an earlier changes-requested without
--approve -- which the review token cannot issue without an org toggle this repo does not
enable, and which nothing here needs. A human dismissing the review reads as cleared too
(DISMISSED). Staleness needs no SHA bookkeeping: the concurrency groups already cancel
both workflows on a new push, and the replacement guard waits for the replacement review
run to complete, so the newest verdict at scan time is the current head's.

The coupling moves as one commit because the guard's sentinel contract says it must:
ALWAYS POST now appears in the prompt, which flips the guard's strict arms on by itself.
Silence with the sentinel shipped stays a lost output (exit 1), and comments without the
promised verdict review are the same broken-promise class -- and exactly the pre-verdict
failure shape, findings living only in comments. Absent the sentinel (an older prompt, or
a future one that drops the protocol) the verdict arm stands down, because runs that never
promise fresh verdicts could never clear a stale changes-requested and the gate would
wedge shut. The #2309 discipline is untouched: the new lookup routes through lookup(), so
an API failure marks the review UNCONFIRMED and exits 0 -- a lookup failure is never a
verdict.

Verified with fixture JSON against the extracted step: newest CHANGES_REQUESTED fails
naming the URL; an older changes-requested superseded by a newer COMMENTED clears; a
verdict-scan API error is UNCONFIRMED exit 0; comments-without-verdict fails; no sentinel
stands down; DISMISSED clears; human reviews are filtered out; pending (null submitted_at)
reviews are skipped; both posted-nothing arms behave as before the obligation-read hoist.

This PR edits claude-review.yml, so claude-code-action will refuse to review it (guard
arm 1 warns and passes, as designed) -- a human must review it, and review stays refused
repo-wide until the next release ships dev to main, which the guard header already
documents as the cost of touching this file.

* Name the guard's check what the enforcement requires: the job id was the check-run name, and the required context was a phantom

Branch protection matches required status checks on the check-run
name, which is the job's DISPLAY name - and the guard job had none,
so it reported as its bare id 'verify' while the required context
said 'Claude review guard'. Nothing could ever satisfy it: this PR's
own first CI cycle sat all-green and BLOCKED on the phantom. The
display name is now the enforcement contract, and the comment beside
it says the two move together.

---------

Co-authored-by: Erik Darling <erikdarlingdata@gmail.com>
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