Chosen database states alert once, not forever — edge-trigger OFFLINE/RESTORING (#2166) - #2182
Chosen database states alert once, not forever — edge-trigger OFFLINE/RESTORING (#2166)#2182erikdarlingdata wants to merge 4 commits into
Conversation
OFFLINE / RESTORING / RECOVERING / STANDBY become edge-triggered: alert on the transition, then quiet until the state changes. The integrity states keep re-firing on the cooldown — nobody parks a database in SUSPECT, so there the repetition is the signal, and a test pins that it must never go quiet. The edge memory is PERSISTED (V60: last_alerted_state / last_alerted_at on config.database_state_expected, the table that already holds this alert's per-database config). In-memory would have been worse than the behavior it replaces: every service restart would re-announce every deliberately-parked database, which is precisely the reporter's complaint amplified. Deliberately not stored in config_edge_trigger_watermarks despite that being the existing edge-memory table: it is keyed (server_id, metric_name) and metric_name feeds alert history and mute matching, so per-database use would mean smuggling a compound key into a label column. Darling only. The engine change is inert for Lite by construction: with no persisted memory the adapter reports empty, every deviation reads as new, and Lite behaves exactly as before. Lite's no-op is documented as such rather than faking an in-memory cache, which would go quiet and then re-fire on restart — the worst of both. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
| using var command = new NpgsqlCommand(@" | ||
| INSERT INTO config.database_state_expected (server_id, database_name, expected_state, is_user_override, updated_at, last_alerted_state, last_alerted_at) | ||
| VALUES ($1, $2, $3, false, (now() AT TIME ZONE 'UTC'), $3, (now() AT TIME ZONE 'UTC')) | ||
| ON CONFLICT (server_id, database_name) DO UPDATE SET | ||
| last_alerted_state = EXCLUDED.last_alerted_state, | ||
| last_alerted_at = EXCLUDED.last_alerted_at", connection); |
There was a problem hiding this comment.
This upsert breaks the "integrity states must keep re-firing" guarantee for the exact case the PR calls out as the composition property to protect.
expected_state is NOT NULL (V49), and this INSERT sets it to $3 = effectiveState — the state that was just alerted, reused for both expected_state and last_alerted_state. That's fine when a row already exists (the ON CONFLICT branch only touches last_alerted_state/last_alerted_at), but it's wrong for a brand-new row.
Concrete sequence:
- A database with no
config.database_state_expectedrow (never seen deviating before) goes SUSPECT.SeedDatabaseStateExpectedSqldeliberately skips seeding SUSPECT/RECOVERY_PENDING/EMERGENCY on first observation ("onboarding a server mid-outage must not learn the bad state as expected... stays pending (no row)"). DatabaseStateDeviationsSql'sexpected_state IS NULLbranch matches (two-sample rule), the alert fires at Critical with the "no baseline yet" message.SaveDatabaseStateAlertedAsyncnow INSERTs a fresh row withexpected_state = 'SUSPECT'.- Next cycle:
e.expected_stateis no longer NULL, andl.eff IS DISTINCT FROM e.expected_stateis false (both are'SUSPECT') — the database drops out of the deviation query entirely. No more alerts, ever, even though it's still corrupted.
This directly contradicts DatabaseState_IntegrityState_StillRepeats_EvenWhenAlreadyAnnounced (and the PR's own stated intent that SUSPECT/RECOVERY_PENDING/EMERGENCY must keep nagging on cooldown) — it's just not caught because that test stubs IAlertStateStore and never exercises this SQL against real Postgres.
Since expected_state is NOT NULL, this write path needs to either leave the pending/critical case alone (only stamp last_alerted_state/last_alerted_at, e.g. via a plain UPDATE ... WHERE EXISTS, doing nothing when no row exists yet) or explicitly avoid baselining critical states the same way the seed SQL does.
| /// <summary> | ||
| /// V60 — restart-surviving edge memory for the database-state alert (#2166). Two nullable columns on | ||
| /// <c>config.database_state_expected</c>, which is already keyed per (server, database) and already | ||
| /// exists for this alert, so the memory lives beside the config it belongs with rather than in a new | ||
| /// table or smuggled into <c>config_edge_trigger_watermarks</c>' metric_name (that column feeds alert | ||
| /// history and mute matching; a compound key hidden in a label is a trap). | ||
| /// | ||
| /// <para>Why it must persist: the reporter's case is a database deliberately parked OFFLINE for a | ||
| /// month. Edge-triggering on in-memory state would re-fire every parked database on every service | ||
| /// restart — worse than the cooldown-repeat it replaces. NULL means never alerted, so an upgraded | ||
| /// store's first evaluation fires once per deviating database and then goes quiet.</para> | ||
| /// </summary> | ||
| private const string V60Sql = @" |
There was a problem hiding this comment.
Doc-comment/field mismatch: this V60 /// <summary> block is inserted directly after V59's existing /// <summary> block (lines ~1142-1153, "V59 — the two collector memory knobs...") with no field declaration between them. C# attaches an uninterrupted run of /// lines to whatever member follows, so both summaries merge onto V60Sql below, and V59Sql (declared right after V60Sql) ends up with no doc comment at all. Worth reordering — e.g. put V60's comment+const after V59's comment+const — so each constant keeps its own doc comment, matching how V57/V58/V59 are laid out elsewhere in this file.
|
Reviewed. Solid design overall (edge-triggering only the 'chosen' states, persisting to survive restarts, keeping the integrity states on cooldown) and the engine-level test coverage for
No Lite/Darling parity issues — the Lite no-op ( |
The V60 rung's doc + const landed between V59's <summary> and V59Sql, so NoMemberCarriesTwoStackedSummaryBlocks tripped: V60Sql carried two summaries and V59Sql was left undocumented. Reordered so each rung's doc sits on its own const. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
| public async Task SaveDatabaseStateAlertedAsync(string serverKey, string databaseName, string effectiveState) | ||
| { | ||
| try | ||
| { | ||
| await using var connection = await _postgres.OpenConnectionAsync(); | ||
| using var command = new NpgsqlCommand(@" | ||
| INSERT INTO config.database_state_expected (server_id, database_name, expected_state, is_user_override, updated_at, last_alerted_state, last_alerted_at) | ||
| VALUES ($1, $2, $3, false, (now() AT TIME ZONE 'UTC'), $3, (now() AT TIME ZONE 'UTC')) | ||
| ON CONFLICT (server_id, database_name) DO UPDATE SET | ||
| last_alerted_state = EXCLUDED.last_alerted_state, | ||
| last_alerted_at = EXCLUDED.last_alerted_at", connection); | ||
| command.Parameters.AddWithValue(ParseServerKey(serverKey)); | ||
| command.Parameters.AddWithValue(databaseName); | ||
| command.Parameters.AddWithValue(effectiveState); | ||
| await command.ExecuteNonQueryAsync(); | ||
| } | ||
| catch (Exception ex) | ||
| { | ||
| /* Same posture as the watermark writes: a failed stamp costs a duplicate alert next cycle, | ||
| never a missed one, so it logs and continues rather than failing the sweep. */ | ||
| _logger?.LogWarning("Could not record the alerted database state for {Database}: {Message}", databaseName, ex.Message); | ||
| } | ||
| } |
There was a problem hiding this comment.
Correctness: this upsert can permanently silence — and falsely "resolve" — a SUSPECT/RECOVERY_PENDING/EMERGENCY database, exactly the case #2166 says must never go quiet.
Walk through a database first observed already critical (no config.database_state_expected row yet — the "pending" path):
SeedDatabaseStateExpectedSql(DarlingAlertReadAdapter.cs) deliberately skips baselining SUSPECT/RECOVERY_PENDING/EMERGENCY (... NOT IN ('SUSPECT','RECOVERY_PENDING','EMERGENCY')), specifically so the database stays "pending" and keeps alerting until it recovers or an operator sets an override.- Two critical samples in a row →
DatabaseStateDeviationsSql's pending branch (e.expected_state IS NULL AND l.eff IN (...) AND p.eff IN (...)) fires the alert. AlertEngine.CheckDatabaseStateAsynccalls this method after delivery witheffectiveState = "SUSPECT"(say). Because no row exists yet, theINSERTbranch runs — andexpected_stateisNOT NULL, so it gets set to$3, i.e. the current critical state itself.- Next cycle: the row now exists with
expected_state = 'SUSPECT'. The pending branch no longer matches (expected_state IS NULLis false), and the general branch'sl.eff IS DISTINCT FROM e.expected_stateis also false (SUSPECT == SUSPECT). The database silently drops out ofcurrentinCheckDatabaseStateAsync. - Because it drops out of
currentwhile still inactive, the recovery block (~AlertEngine.cs L1325-1339) treats it as recovered and fires "Database State Resolved" — even though the database is still SUSPECT. - It will never alert again unless it later transitions to a different critical state (SeedDatabaseStateExpectedSql never overwrites the row,
ON CONFLICT DO NOTHING).
So a real corruption's very first alert both permanently silences the alert and tells the operator it "resolved" — worse than the pre-PR behavior (which just kept re-firing forever), and the opposite of what the PR sets out to guarantee for integrity states.
The doc comment's justification doesn't hold up either: "the baseline column takes the current state in that case, which the seed logic would have written anyway on the next healthy observation" — but the seed logic explicitly refuses to write a baseline for these three states while they remain critical, so this INSERT does something the seed logic never would while the outage is ongoing.
None of the new tests catch this because the in-memory test harness's fake adapter/state-store are decoupled — nothing exercises the real interaction between this INSERT and DatabaseStateDeviationsSql against the same store.
Possible fix: only persist last_alerted_state for the states that actually consult it (DatabaseStateTokens.RepeatsAreNoise), or make this an UPDATE ... WHERE EXISTS so a not-yet-baselined critical database never gets an accidental baseline written.
| /* Stamped AFTER delivery so a failed fire is retried next cycle rather than silenced. | ||
| Written for every state, not just the edge-triggered ones, so that flipping a state's | ||
| classification later has correct history to work from. */ | ||
| await _stateStore.SaveDatabaseStateAlertedAsync(key, dbName, db.StateDesc); |
There was a problem hiding this comment.
Correctness: recovery (below, ~L1325-1339) never clears the persisted last_alerted_state, so the same "chosen" state recurring in a later, independent episode goes silent forever.
That block clears the in-memory active set and the cooldown entry when a database returns to its expected state, but nothing calls back into _stateStore to clear the persisted last_alerted_state/last_alerted_at this line writes (PgAlertStateStore.cs L172-194).
Concretely: a database is parked OFFLINE → alerts once, last_alerted_state persisted as 'OFFLINE'. Operator brings it back ONLINE → the recovery block fires the "resolved" notice, but the persisted last_alerted_state column is untouched. Weeks later the same database is deliberately parked OFFLINE again (a second, unrelated soft-delete cycle — exactly the workflow described in the PR). DarlingAlertReadAdapter's deviation query still returns last_alerted_state = 'OFFLINE' from the first episode, so alreadyAnnounced (L1262-1264 above) is true and the second parking never alerts at all.
The "composition property" test added here (DatabaseState_ChosenState_ChangingToADifferentState_FiresAgain) only proves re-firing across a different subsequent state (OFFLINE → SUSPECT); nothing pins the same-state-after-recovery case, which is the one most likely to recur in practice for a repeated soft-delete/park workflow on the same database.
Worth clearing the persisted state on recovery (a _stateStore call in the block below), plus a test that pins: park → alert → recover → re-park with the same state → alerts again.
|
Reviewed the diff. Overall the edge-trigger design and the persisted-vs-in-memory reasoning in the PR description are sound, and Lite parity (documented no-op) is correctly inert. Left two inline comments on what I believe are real correctness regressions in the persisted-memory interaction, both missed by the new unit tests because the in-memory test harness's fake adapter/state-store never exercise the real SQL round-trip together:
Both are edge-memory lifecycle gaps rather than problems with the overall approach — I think both are fixable by (a) only ever persisting No Lite/Darling parity issues beyond what's already called out (Lite deliberately inert), and no SQL-injection or secret-handling concerns — all queries are parameterized. |
…2166) Two correctness bugs the review caught, both worse than the repetition #2166 set out to fix. The alerted-state stamp was an upsert. INSERT has to supply expected_state (NOT NULL) and the only value available is the state being alerted ON, so a database first observed SUSPECT got SUSPECT written as its accepted baseline: it stopped deviating, dropped out of the deviation query, was read as RECOVERED (firing a false resolution on a still-corrupt database), and never alerted again. The seed logic refuses to baseline SUSPECT/RECOVERY_PENDING/ EMERGENCY precisely so those stay pending; this write was doing behind its back what it declines to do in front. Now UPDATE-only. Nothing is lost: a database with no row was first seen in an integrity state, and integrity states are never edge-suppressed, so the memory is never consulted for them. Recovery cleared the in-memory cooldown but never the PERSISTED last_alerted_state, making the memory permanent. Park a database OFFLINE (alerts), restore it (resolves), park it again weeks later - the stale memory still read OFFLINE, the repeat was judged already-announced, and the second parking was swallowed for good. That repeat park/restore cycle is the workflow the alert exists for. Added ClearDatabaseStateAlertedAsync across the interface, both stores and every stub, called on the falling edge - including when suppressed, since suppression governs what operators are told, never whether the engine keeps accurate state. The review also named why the tests missed both: the stub state store counted calls while the adapter supplied states, so no test crossed the round trip the edge trigger actually depends on. The stub now models what the store HOLDS, and the new episode test feeds that memory back in as LastAlertedState - park, recover, re-park in the same state - so it fails if either direction is missing. A source pin keeps the stamp an UPDATE. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
Both findings confirmed and fixed in 5e2a74b. Both were real, and the first one was worse than what this PR set out to fix — thank you. The upsert. Walked the pending path and it lands exactly as described: no row → INSERT → Worth stating why UPDATE-only loses nothing, since "skip the no-row case" sounds lossy: a row is absent only when a database was first observed in an integrity state, and The permanent memory. Also correct, and the OFFLINE → restore → OFFLINE-again sequence is the exact workflow the issue describes, so this would have bitten the reporter specifically. Added On "none of the new tests catch this." That was the most useful part of the review, because it named the structural reason rather than just the miss: the stub state store counted calls while the adapter supplied states, so nothing crossed the round trip the edge trigger actually depends on. Verified: Alerting, Darling.Tests, Lite and Lite.Tests all build clean (the interface addition needed the Lite no-op and three stubs). |
| edge only ever triggers once per database. Cleared even when suppressed — suppression | ||
| governs whether operators are TOLD about a transition, never whether the engine keeps | ||
| accurate state, and leaving a stale memory behind would swallow the next real episode. */ | ||
| await _stateStore.ClearDatabaseStateAlertedAsync(key, dbName); |
There was a problem hiding this comment.
This clear — the thing that makes the persisted last_alerted_state restart-safe against the exact bug the 3rd commit just fixed — is itself only reachable through _activeDatabaseStateAlerts (line 140), which is in-memory only and resets to empty on every process restart.
ClearDatabaseStateAlertedAsync runs for active.Where(d => !current.ContainsKey(d)) — i.e. only for databases this process instance previously observed as deviating. Walk through a restart landing between an alert and its recovery:
- DB parks OFFLINE. Fires, persists
last_alerted_state = 'OFFLINE'.active = {db}. - Service restarts (deploy/patch/crash) —
activeresets to{}. The persisted value is untouched (that's supposed to be the point). - Before the next eval cycle observes the database as still-deviating, it recovers to ONLINE.
- First post-restart eval: the deviations query no longer returns this db (back to expected), so it's never in
current— andactiveis empty, so it's never inrecoveredeither. This line never runs.last_alerted_statestays'OFFLINE'forever. - Weeks later the DB is parked OFFLINE again — the exact repeat-park workflow [FEATURE] Database State alert: per-state notify mode - fire OFFLINE/RESTORING once instead of re-firing every cooldown #2166 exists for.
LastAlertedState('OFFLINE') still equalsStateDesc('OFFLINE'),alreadyAnnouncedis true, and the second parking is silently swallowed. Nobody is ever told.
This is narrower than the bug the 3rd commit fixed (needs a restart in the gap between alert and recovery, not just any recovery), but it's the same failure mode and it undercuts the PR's stated restart-survival goal. It's also a departure from how every other _active* flag in this file is documented to behave (lines 87-89: "In-memory only... the restart protection is the persisted watermarks... not these"). For those alerts, losing active on restart risks only a missed/duplicate resolution notification, because the persisted watermark that actually gates re-firing is a monotonic count/time that naturally supersedes stale values. Here the persisted value is a sticky state string whose only path to being cleared is this ephemeral set, so that invariant no longer holds for this alert.
DatabaseState_RepeatEpisode_OfTheSameState_FiresAgainAfterRecovery doesn't catch this because it drives the whole episode (alert → recover → re-park) through one AlertEngine/active instance — it never simulates a restart landing between the alert and the recovery.
A fix probably needs the clear to not depend on process-local state — e.g. have the adapter/store also identify databases with a non-null last_alerted_state whose current effective state now equals expected_state, so the memory can be cleared from what the store actually holds rather than from what this process happened to observe.
ReviewWent through the three commits (edge-trigger the chosen database states, fix the migration-doc reorder, and the author's own self-caught fixes for baseline-poisoning + falling-edge reset). Overall the design is sound and well-tested for the scenarios it targets — the UPDATE-only fix for One correctness gap remains (left as an inline comment on Other notes, none blocking:
|
#2166) Third finding, and correct: the clear I added in the previous commit hangs off the engine's in-memory active set, which empties on every restart. A service restart landing between an alert and the recovery therefore left last_alerted_state sticky forever - the database was never in active to be noticed as recovered - so the next parking of that database read as already-announced and was swallowed silently. Narrower than the bug it fixed (needs the restart to land in that specific gap) but the same failure mode, and it undercut the restart-survival the whole change is for. The reviewer also named why this alert cannot follow the convention the other _active* flags document. For those, losing the set on restart risks only a missed resolution notice, because the persisted thing that gates re-firing is a monotonic count or time that stale values naturally supersede. Here the persisted thing is a sticky state string whose only path to being cleared WAS that ephemeral set, so the invariant did not carry over. Fixed by deriving the clear from the store: a new maintenance statement running beside the existing seed and prune, on the same connection, clearing the memory for any database the store now shows back at its expected state. It cannot have the gap because it asks rather than remembers. The engine's clear stays as the immediate path - a recovery inside one process should not wait for the next cycle - and the store sweep is what owns the invariant. Details worth stating: it reads the same EFFECTIVE state the deviation query does (standby resolves to STANDBY, not the raw state_desc), so a log-shipping secondary clears correctly; it clears the (ignore) sentinel too, since a silenced database should not keep a memory that outlives the silence; and one sample at expected is enough where deviation needs two, because clearing is the safe direction and a flap cannot exploit it - a flap never survives the two-sample deviation test to alert in the first place. Tested live rather than in the harness, deliberately: the restart gap is invisible to any test that drives the engine, since it is defined by there being no process state at all. The new gated-live test seeds five databases covering recovered, still-deviating, deviating-differently, standby-effective-state and (ignore), runs the real statement against a real store, and asserts a second sweep changes zero rows - it runs on every evaluation of every server. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
Confirmed and fixed in 5be13e7. Third real one, and the diagnosis of why was the valuable part. You're right that this alert can't follow the convention documented at lines 87-89. For the other Fixed by taking your suggestion literally: the clear is now derived from the store. I kept the engine-side clear as well, and want to be explicit that this is two layers rather than accidental duplication: the engine clear is the immediate path, so a recovery inside one process doesn't wait for the next cycle's sweep, and the store sweep is what owns the invariant. If they ever disagree, the store wins by construction. Three details that took some thought:
On testing: you were right that the harness test can't reach this, and I don't think any harness test can — the restart gap is defined by there being no process state, so anything driving the engine proves only the path a running process takes. So it's a gated-live test ( Worth noting these three statements had no test coverage at all before this, so that's a small net improvement beyond the fix. |
| DatabaseStateTokens.RepeatsAreNoise(db.StateDesc) | ||
| && string.Equals(db.LastAlertedState, db.StateDesc, StringComparison.OrdinalIgnoreCase); | ||
|
|
||
| if (!suppressed && !alreadyAnnounced && CooldownElapsed(_lastDatabaseStateAlert, cooldownKey, now, alertCooldown)) |
There was a problem hiding this comment.
Worth double-checking the "composition property" this PR pins (OFFLINE → SUSPECT must fire again at Critical): alreadyAnnounced correctly goes false on a state change, but the fire is still gated by CooldownElapsed(_lastDatabaseStateAlert, cooldownKey, now, alertCooldown), and cooldownKey (DatabaseStateCooldownKey) is keyed per database, not per state.
So if a database goes OFFLINE (alerts, stamps _lastDatabaseStateAlert[cooldownKey] = now) and then flips to SUSPECT within the same cooldown window, the transition-to-SUSPECT alert will be silently suppressed by the still-running cooldown from the OFFLINE alert — even though alreadyAnnounced is false for SUSPECT. That's exactly the "going quiet must not mean going blind" scenario the PR calls out as the important safety property, and none of the new tests exercise it because they all start from a fresh harness where the cooldown key has never been stamped (so CooldownElapsed trivially returns true).
This isn't introduced by this PR (the cooldown key was already shared across states pre-#2166), but the new edge-trigger feature raises the stakes: previously every deviation re-fired every cooldown regardless of state, so a same-cooldown-window SUSPECT would eventually re-announce on the next tick anyway. Now that OFFLINE goes quiet indefinitely, a fast OFFLINE→SUSPECT flip landing inside one cooldown window means the SUSPECT transition can be missed for a full cooldown period. Given SUSPECT is the integrity/safety-net case the PR explicitly designed to never go quiet, might be worth confirming this is acceptable (cooldown windows are typically much shorter than an outage) or keying the cooldown/announced-state check by state as well as database.
|
Reviewed the diff (C#/Postgres, no T-SQL touched here). Overall this is solid — the design rationale in the commits/PR body is clear, and it's evident the branch already went through a few rounds of self-review (the UPSERT→UPDATE-only fix and the store-derived clear are both good catches on prior iterations). Things I verified and didn't find issues with:
One correctness edge case worth a look, left as an inline comment on Minor/non-blocking style nit: No security or missing-index concerns; the new SQL is fully parameterized and scoped by |
What changes
The Database State alert re-fired for as long as a database stayed deviated. @gotqn's case: a soft-delete workflow parks a database OFFLINE for a month, which produced hundreds of identical alerts for one intended action.
Now OFFLINE / RESTORING / RECOVERING / STANDBY are edge-triggered — alert on the transition, stay quiet until the state changes. SUSPECT / RECOVERY_PENDING / EMERGENCY are untouched and keep re-firing on the cooldown.
The split is the whole idea: those first states are usually ones somebody chose, and repetition tells the operator nothing new. Nobody parks a database in SUSPECT, so there the repetition is the signal — there's a test pinning that an already-announced integrity state must keep firing, because the failure mode worth guarding is a real corruption quietly stopping its nagging.
Why the memory is persisted, not in-memory
This is the part that decided the design. The case is a database parked for weeks. In-memory edge state would go quiet, then re-announce every parked database on the next service restart — worse than the cooldown-repeat it replaces, and it would look like a regression to exactly the person who asked for this.
So V60 adds
last_alerted_state/last_alerted_attoconfig.database_state_expected— the table that already holds this alert's per-database config, already keyed(server_id, database_name).Rejected alternative:
config_edge_trigger_watermarksalready does restart-surviving edge memory, but it's keyed(server_id, metric_name)andmetric_namefeeds alert history and mute-rule matching. Per-database use would mean smuggling a compound key into a label column — fine today, a trap later.The composition property @gotqn identified
Pinned by test: a database announced OFFLINE that turns SUSPECT fires again, at Critical. Going quiet for a parked state must not mean going blind, and the new state doesn't inherit the old one's quiet treatment.
Scope
Darling only, matching where the issue was filed. The engine change is inert for Lite by construction: with no persisted memory the adapter reports empty,
current != emptyis always true, and Lite behaves exactly as before. Lite'sSaveDatabaseStateAlertedAsyncis a documented no-op rather than a fake in-memory cache — a cache would go quiet and then re-fire on restart, which is the worst of both. Lite parity follows separately, same as #2167 → #2176.Behavior-change warning
This alters a default. Anyone relying on the repeat-nag to track deliberate offlines will see it go quiet after the first alert — called out in the changelog in those terms.
Testing
Four engine pins: an already-announced chosen state stays silent with no cooldown involved; a first observation fires and records the state (recording is what makes the silence survive a restart); an already-announced SUSPECT still fires at Critical; and OFFLINE→SUSPECT fires again at Critical. Plus the full V60 ladder — rung identity, probe sentinel, gate arm mapping a fully-migrated store to 60 and a V59 store to 59, and the suite's ladder-tip pins moved. Alerting, Darling service, Viewer, Lite, and both test projects build clean.
Addresses #2166 (Darling half).