Repository navigation
Never answer one cooldown key's question with another key's history (#3456) - #3459
Conversation
merge The digest tests' Regression() factory carried a remark saying #3441 would add a member to CostRegression's positional constructor and that keeping every fixture behind one factory made that merge a one-line change here. The two lanes crossed and the line was never written, so Darling.Tests has not compiled on dev since the second of them landed. BaselineP95MsPerRun is pinned to the fixture's mean, which makes ThresholdMsPerRun resolve to the pre-#3441 bound exactly - every fixture keeps asking the routing question it was written to ask (single-server demotion, fleet-wide paging, the majority boundary) rather than silently gaining a dispersion conjunct that belongs to CollectorCostRegressionDispersionTests.
…old, instead of answering with the fleet-wide bucket (#3456) Both history stores mapped the string server key onto config_alert_log's integer server_id with int.TryParse(...) ? sid : 0, on both sides of the table. On the write side that is a tolerable collapse - the row must be recorded somewhere, and 0 is where every non-integer key lands. On the read side it was the defect: the restart seed asked 'when did THIS key last deliver?' and got MAX(alert_time) over EVERY collapsed key's rows - the fleet-wide last send of the metric - so for the whole self-alert family (cost:<id>:<collector>, the AG composites, the sentinels) the per-fingerprint cooldown #1154 built degraded back into a fleet-wide per-metric mutex, one layer down. Measured twice on one day: same-metric pairs 3.7 ms apart where the second seeded against the first's just-written row and rendered throttled despite its own last delivery being two hours old. The mapping now lives in one shared type, AlertHistoryServerIdentity, the way AlertValueParser already does for the value columns and for the same drift reason. StorageId keeps the write-side collapse unchanged; SeedScope declines (null - no seed - first notice) any key whose rows are not distinguishable by server_id alone: the unparseable keys, and the literal 0, because the bucket's rows are mixed by construction and an answer from it is exactly the cross-key history the invariant forbids. The invariant: the seed never answers one key's question with another key's history. Declining fails toward posting, the direction the delivery doctrine already names. What this deliberately does not do: restore cross-restart cooldown continuity for the self-alert family, or make #3436's roster engage for their slow recurrences - both need seed-and-stamp identity that survives the integer column, which is a schema-shaped follow-up, not a parse fix. StorageId is the one line per direction that follow-up changes. Pinned four ways: the pure mapping in AlertHistoryServerIdentityTests; the real DuckDB store's refusal (and the write-side collapse it guards) in StoreRoundTripTests; the service-level consequence - a first notice on an unparseable key attempted, never throttled by another key's send, with the integer-key seed as its control - in WebhookCooldownSeedTests; and the banked shape end to end in RepeatDeliveryBudgetTests, two self-alert siblings milliseconds apart through the real deliverer against a column-faithful store double, both posting, with the no-roster consequence asserted rather than left silent.
Review summaryReviewed the diff against Verdict: looks correct and solid. The fix is narrowly scoped, well-tested, and centralizes the identity mapping exactly where the "Two-Store Parity" guidance in What I verified:
One minor/non-blocking observation (out of scope for this PR's diff, so noting here rather than inline): 🤖 Generated with Claude Code |
…ctors that already saw it (#3467) (#3469) * Page on a plan-flip pileup from the two collectors that were already current while it ran (#3467) A parameterized statement joining a #temp table flipped to a plan doing ~35x the logical reads. Every caller shares the parameterized plan, so ten tenants inherited the flip together, the copies convoyed on PAGEIOLATCH_SH, physical reads saturated the volume, and sixteen executions died at the client's 30-second command timeout. The episode ran about ninety seconds and self-cleared when the plan churned out. The store was AHEAD of it the whole time. The 20:10:31Z active-query snapshot held three concurrent copies of the one statement - 29,124 ms / 28,343 ms / 27,703 ms elapsed, 287,049 logical reads on the leader, two suspended on page IO - next to that same statement's 54 ms observation from 3.5 minutes earlier. Nothing could page on any of it: the timely findings were correct and capped under the threshold (ANOMALY_QUERY_DURATION 0.72, ANOMALY_BATCH_REQUESTS 1.0, ANOMALY_CPU_SPIKE 0.56, against notify_severity 1.5), the finding that DID clear it keys off query_store data that had not landed yet (~28 minutes), and no alert family covers the shape - long_running_query fires at 30 minutes and the victims died at 30 seconds, poison_wait does not class PAGEIOLATCH, CPU stayed near 50%. The tool watched an outage it could describe and could not report. * The finding. SAME_STATEMENT_PILEUP: N concurrent sessions on one statement at one snapshot instant, EVERY copy past a floor of elapsed time, at least one suspended on an IO-class wait, where that same statement's own snapshots across the prior 45 minutes were sub-second. The conjunction is what makes it specific rather than any single threshold - on four production servers x 24 hours (~4,160 snapshot instants) it fired exactly once, on this incident. * Placement is the point, not an implementation detail. It is evaluated on the COLLECTION cadence - the Darling worker's per-server sweep on the 30-second alert-sweep stamp, Lite's one-minute collection loop - immediately behind the collectors that write its input, so the 20:10:31Z snapshot produces a notify-eligible finding inside one collection cycle instead of 21 minutes later at the scheduled pass. It is NOT on the 5-360 minute analysis interval, and it deliberately does not inherit the scheduled pass's 24-hour data-span gate: that gate exists because fraction-of-period fact scoring distorts on short windows, and this finding's evidence is one snapshot plus one statement's own recent history. * Query Store independence is a requirement, not a convenience. On at least one production store class the Query Store readers are deliberately disabled (#2296) - the deployment this incident happened on, where tonight's plan-count evidence had to come from outside the product. A detector keyed on query_store identity would compile, test green against a QS-enabled fixture, and ship blind on exactly the servers that needed it. So the trigger, the baseline, the statement identity and the incident fingerprint are all computed from the active-query snapshot collector alone, and the statement key is the DMV's own query_hash off sys.dm_exec_requests rather than anything Query Store hands out. SameStatementPileupSourceCensusTests holds that from source for the detector and BOTH editions' readers: no query_store identifier in code or SQL, and the only tables the trigger path may read are the permitted collector tables. PLAN_REGRESSION and the #2138 force-plan bot stay what they were - follow-on enrichment where QS happens to be readable, never the trigger. * Thresholds from fleet distributions, named per #3060, each derivation stated on its constant in the unit the gate compares. Three sessions, because n>=2 same-statement groups number 484 in four server-days and are full of ordinary paired workloads while n>=3 past the elapsed floor numbered four; a two-session pileup therefore produces NO finding at any duration, which is the issue's "must not notify" held one step stronger than asked. Ten seconds, because the victims died at the client's 30-second timeout and a 10 s floor pages while they are still alive (the measured pack cleared it with 2.7x headroom). One IO-waiting session, not two: page-IO waits are millisecond-scale - the incident's suspended copies showed wait_time_ms 5 - so demanding two SIMULTANEOUSLY suspended makes the gate a coin flip on sampling phase, and specificity is carried by the conjunction instead. A 45-minute sub-second baseline window with at least one observation in it, which is what turns "normally fast" into a measurement: the three fleet shapes that reach n>=3 past ten seconds and are NOT incidents fail precisely this gate or the IO one - a sync job at n=5 with no IO wait and no prior observation, a permission rebuild whose priors were already at 11.9 s, a nightly catalog scan with no baseline at all. * Severity prices magnitude, not presence (#3462), in the unit the gate compares (#3463): sessions x the pack's minimum elapsed seconds, over 40 session-seconds per severity point, capped at the band ceiling. The measured incident - 3 x 27.703 s = 83.109 session-seconds - scores 2.0777 before the cap, 0.578 severity points above the 1.5 gate, and would still clear it with two of its three sessions discounted to the floor. The minimum firing shape (3 x 10 s = 30 session-seconds) scores 0.75: a persisted, drill-downable, visible finding that does not page. Notify-eligibility begins at exactly 60 session-seconds - three concurrent copies each past two-thirds of the timeout that killed the measured victims. * Recurrence folds instead of surprising. The measured statement re-drew its regressed plan later the same evening after fully self-clearing, so the finding gets the incident-fingerprint treatment every alert family already has: the IncidentId recipe extended with the statement identity, which keeps episode two on episode one's incident trail as an occurrence (first_seen / last_seen / occurrences, the drill-down an operator wants when deciding whether the plan deserves forcing) and gives #2138 a trigger token that is stable across episodes, plan churn, restarts and QS resets. The story path carries the statement identity too, so muting or cooling down one statement's pileup cannot answer another statement's question (#3459's rule). * Both editions, one detector. Lite's active-query snapshots feed the same seam - one shared collector definition, one table shape - so the predicate, the thresholds, the severity arithmetic, the fingerprint and the noise filters live once in PerformanceMonitor.Analysis as a pure function of snapshot rows, and each edition contributes only its window reader (Npgsql over query_snapshots, DuckDB over v_query_snapshots) and its sweep call site. * It adds no delivery semantics. The finding rides the existing analysis-finding machinery end to end - mute registry, two-phase materialize-enrich-persist, the (story_path_hash, incident_id) occurrence folding, notify_severity, the incident-keyed cooldown and its #2054 worsening rule - which also means a recurrence inside the 6-hour cooldown folds silently rather than re-paging, because a band-saturating severity cannot worsen. It IS a new delivery call site on both SKUs, which is a different thing and is accounted for as one: each sits under ShouldNotifyAnalysisFindings (master AND family), each carries its own #3465 census entry, and AlertMasterSwitchSurfaceTests pins both in both directions, with the predicate's call count raised from two to three. The README sentence that makes alerts_enabled a promise widened from "scheduled-analysis finding notifications" to "analysis finding notifications" - the narrower wording described a promise smaller than the one the gates keep, and an operator could reasonably have read a non-scheduled finding as escaping the switch, which is the exact confusion #3464 was about. * One read-side change, in both stores, for the same reason. The latest-batch read excludes pileup rows from DEFINING MAX(analysis_time) and overlays the ones newer than it. #2448's contract is that the newest batch is one indivisible statement about a server - the complete output of one pass - and a per-sweep row claiming the MAX would replace the last complete analysis with a single-row one, making a server read HEALTHIER mid-incident. That is #2448's own misreading arriving through the write cadence instead of a torn batch. The overlay is self-limiting (visible until the next scheduled pass completes, then in the windowed reads and its incident trail), and the epoch fallback keeps a pileup visible where analysis is disabled and no scheduled batch exists. No migration rung is claimed and none is needed: the finding persists through analysis_findings' existing columns, reads query_snapshots' existing columns, and keeps its one piece of cross-sweep state (the last-evaluated snapshot instant) in memory, where a restart costs at most one repeated evaluation that folds onto the same incident id. Tests: the 20:10:31Z incident reconstructed verbatim from the store's own rows (notify-eligible severity, statement identity, session and elapsed evidence, the drill-down's sessions and baseline, and the frozen value-stated advice quoting the measured wait type); the 20:07:01Z pre-incident snapshot must not fire; a busy-but-healthy eight-session parallel workload on DIFFERENT statements must not fire; a long-running single session must not fire; two sessions must not fire at 12 s, 29 s or 600 s; the fleet's three other deep groups must not fire, each failing a different gate; no-IO, stale-snapshot, noise-shape, one-deep-copy-among-fast-siblings and duplicate-session shapes must not fire; episode two folds onto episode one's incident id and mute key while the fingerprint still separates statements, databases and servers; the severity table is pinned at six points with the notify boundary expressed in session-seconds; and QS independence is pinned both behaviorally (the row type is the contract) and structurally (the source census over three files). * State the pileup detector's onset blind spot, and stop the dedup stamp from outrunning the persist (#3467) Review of the pileup rung surfaced one behavior worth fixing and one worth stating out loud instead of fixing. The statement first, because it is the bigger admission. The detector's baseline gate reads the statement's prior in-flight observations - and the collector can make the episode itself one of them. If the first sample of an episode lands while the sessions are between the sub-second ceiling and the elapsed floor (1-10 s in), that instant correctly fires nothing, and its rows then sit in the 45-minute lookback as multi-second priors: every later instant of the same episode fails the sub-second ceiling against them, and the episode never fires at all. It fails quiet - a missed page, never a false one - and the exposure is sampling phase: a 9-second landing band in a 60-second collection cycle, roughly 9-in-60 on the measured 90-second shape. This is accepted, deliberately: both repairs are gate changes (onset-exclusion, or pricing on the qualifying subset past the floor), each with edges of its own, and neither ships ahead of a measured miss to calibrate against - the recurrence watch, which folds episodes onto a statement-scoped incident id, is the instrument that would produce one. The detector's remarks now carry the full paragraph, and the poisoned sequence is pinned as accepted behavior: instant one at ~5 s (no finding, the floor), instant two past the floor (still no finding, the ceiling reads instant one's rows), and the counterfactual - the identical second instant without the onset sample - firing. The every-session floor's fresh-arrival dilution gets the same treatment beside its comment: the qualifying-subset alternative is named, the reason it waits (the fleet calibration was measured under this predicate) is stated, and the same reopen shape covers it. The fix: both call sites advanced the per-instant dedup stamp BEFORE InsertFindingsAsync, so a transient persist fault marked a firing instant "done" and lost its finding for good if the episode cleared before the next collector write. The stamp now advances only once the instant's outcome is durable - evaluated clean, fully muted, or persisted - and a fault leaves it behind so the next sweep re-derives the same instant, where occurrence folding absorbs the re-persist and the incident-keyed cooldown absorbs the re-notify. The ordering is pinned from source in both files. The polish, each item its own small honesty: * The census's permitted-tables set pre-cleared waiting_tasks, which made "adding it later is a deliberate census change" false - the read could have arrived without touching a census line. The permission is removed: adding the read now means widening the set in the same change, so the dependency lands as a recorded decision. * The worker's doc quoted the README's pre-#3467 contract sentence ("scheduled-analysis finding notifications"); the quote now matches the widened sentence this lane shipped. * Both finding stores justified the epoch COALESCE with a deployment that cannot exist (analysis disabled writes no pileup rows either - the sweep rides the same gate); the comments now name the real anchors: a young install whose pileup fired before the data-span gate let a first scheduled batch exist, and analysis toggled off after pileup rows landed. * PgPileupSnapshotReader's catch now uses the directory's own AnalysisShutdown.IsExpectedAbandon filter (#2299) instead of a bare OperationCanceledException carve-out, so shutdown residue propagates quietly and a cancellation that nobody requested keeps its log line. Out-of-band verification (the test projects target net10.0-windows): the detector harness replays 102 checks and the census harness 93, both clean, including the onset pin and the stamp-ordering pin. * Re-pin the dashboard-shape read to the carved batch anchor it now ships (#3467) CI's only failure on 601ab8a (1 of 9,449) was FindingStoreSql_CarriesTheDashboardShape: its GetLatestFindingsSql pin still asserted the pre-#3467 single-line anchor, "SELECT MAX(analysis_time) FROM analysis_findings WHERE server_id = $1" — a string that no longer exists, because the carve-out deliberately rewrote it: the MAX now excludes SAME_STATEMENT_PILEUP rows from DEFINING the batch (a per-sweep pileup row claiming the MAX would replace the last complete analysis pass and read the server healthier mid-incident), and the subquery spans two lines. The re-pin asserts MORE than the old pin, not less: both fragments as they actually appear in the const — the MAX anchor line and the WHERE line carrying the exclusion predicate. The full carve-out contract (overlay arm, epoch anchor, Lite twin parity) already belongs to SameStatementPileupSourceCensusTests, which normalizes source whitespace and passed on the same run; this pin's job stays what it was — the dashboard shape of the Darling store's own consts — now stated against the SQL that ships. Why the lane's out-of-band verification missed it: the detector and census harnesses replay the NEW tests (102 + 93 checks, clean); this pin lives in the pre-existing suite that only executes on Windows CI. A Contains pin between two compile-time strings is fully verifiable by grep, which is how this fix was checked: each fragment appears twice in GetLatestFindingsSql (anchor arm and COALESCE arm), and the stale single-line form appears nowhere outside DELETE cleanup statements.
Fixes the delivery-correctness half of #3456 — the throttle-instead-of-deliver mechanism and the first-notice suppression. The roster half of that issue's acceptance (a self-alert burst folding into carrier + roster) needs cooldown identity that survives the integer
server_idcolumn, which is a schema-shaped follow-up argued below and now filed as #3458; it is deliberately not attempted here.Both history stores mapped the string server key onto
config_alert_log's integerserver_idwithint.TryParse(...) ? sid : 0, on both sides of the table. On the write side that is a tolerable collapse: the row must be recorded somewhere, the column is an integer, and 0 is where every non-integer key lands. On the read side it was the defect. The restart seed asked "when did THIS key last deliver?" and got backMAX(alert_time)over EVERY collapsed key's rows — the fleet-wide last successful send of the metric — because the whole self-alert family's keys are non-integers by construction (cost:<id>:<collector>, the Availability Group composites, the fixed sentinels) and every one of their rows sits in the same bucket. The per-fingerprint cooldown #1154 built therefore degraded, for exactly this family, back into the fleet-wide per-metric mutex #1154 removed — one layer down, where the alert log's own rows could not show it until #3446's dispositions made the shape legible.Measured live, twice, identically (the issue's banked reproductions): two servers'
Collector Cost Regressionfires for the same collector land 3.7 ms apart. The first seeds against the fleet-wide last send — 60m07s prior, outside the window — and delivers. The second seeds 3.7 ms later against the first's just-written row and rendersthrottled, despite its own last delivery being 2h00m13s prior. The 60m07s figure equals the fleet-wideMAX(alert_time)for the metric to the second, which is what identified the seed as the mechanism. Retroactively this is also the pre-#3446 exactly-one-winner bursts: every burst member after the first seeded against the winner's row.In severity order, per the issue: a first notice could be suppressed by another server's send — an unannounced incident, the class #1154 exists to prevent and the line
RepeatDeliveryBudget's own remarks say must not be crossed; #3436's roster never engaged for this family, because the throttle returns before the budget evaluates; and thethrottleddisposition inverted its meaning, reporting re-tell suppression of an incident it was actually suppressing on other incidents' sends.The change
The mapping now lives in one shared type,
AlertHistoryServerIdentity, used by both stores — the same arrangementAlertValueParseralready gives the value columns, and for the same reason: two stores answering an identity question with separately-maintained parses is the drift risk, and this question is the one the issue turned on.StorageIdis the write side, unchanged in behavior: an integer key stores its own id, everything else collapses to 0. Centralized so the rows the bucket absorbs are demonstrably the same ones the seed refuses to read, and so the follow-up changes one line per direction.SeedScopeis the read side and the fix: it declines (null— no seed) any key whose rows are not distinguishable byserver_idalone. That is the unparseable keys, and also the literal key "0": the bucket's rows are mixed by construction, so an answer from it is cross-key history even for a caller whose key genuinely is 0. The bucket is write-only. A declined seed reads as a first notice and posts — the direction the delivery doctrine already names ("fails toward posting"): the cost of failing this way is a post; the cost of the old way was an unannounced incident.The invariant, pinned: the seed never answers one key's question with another key's history. Even the asking key's OWN rows are unrecoverable from the bucket — nothing in a collapsed row says which key wrote it — so the honest answer for the whole family is "no answer", which is the pre-seed (#1145) behavior: the in-memory stamp governs within the process lifetime.
PgAlertHistoryStore.ReadMaxAlertTimeAsyncserves all three seed reads, so one early return fixes the webhook seed (the measured defect), the email seed (same mechanism, same fix), andGetLastAlertTimeAsync. Lite'sDuckDbAlertHistoryStorecarried the same parse in all three of its reads — the twin the Pg store's class doc claims query-for-query parity with — and takes the same change, which keeps that claim true.Other callers of these reads, checked:
GetLastAlertTimeAsyncfeeds the analysis notification cooldown and the #2716 Tier-0 predictor seeds. Every one of those callers passes an integer key exclusively — Lite resolvesf.ServerId.ToString(), Darling resolvesfinding.ServerIdandsnapshot.ServerKey, which is the storage-name hash rendered as a string — so their behavior is unchanged. The only populations whose reads change are unparseable keys (previously: garbage answer from the bucket; now: no answer) and the literal "0" (previously: the bucket; now: no answer), both of which resolve toward posting.Parse-to-0 fallbacks in the delivery/history path, named and left alone: both stores'
RecordAlertAsync(the write-side collapse, now spelledStorageIdand kept deliberately — a row must be recorded, and the seed no longer reads the bucket); Lite'sEmailAlertService.SendFindingAlertAsync(collapsesalert.ServerIdto anintparameter; its only producers pass integer finding ids, so the fallback is dead in practice and it feeds a send path, not a seed); and the deprecated Dashboard'sJsonAlertHistoryStore, which compares the string key directly and never had this defect.DarlingWorker'sresolveServerOverridealready falls back tonullrather than 0 — the pattern this change extends to the seeds.What this deliberately does not do
The issue's acceptance has three clauses, and this ships two: a first-notice self-alert is never throttled by another server's send, and the banked pairs' shape re-runs green (both pinned below). The third — a multi-server self-alert burst delivering one carrier with the siblings folded onto its roster — is not met, and cannot be met by any seed that declines: with no per-key history, an evicted or post-restart key reads as a first notice, and a first notice is exempt from folding by #1154's rule, correctly. So the hourly multi-server pairs that identified this defect now deliver two full cards rather than one throttled and one delivered. Nothing is lost — the old build lost announcements, this one spends posts — but #3436's fold stays structurally unreachable for the self-alert family, and the family keeps no cooldown continuity across a restart.
Restoring both needs seed-and-stamp identity that survives the integer column. The two candidate shapes are weighed in #3458: hashing the string key into the column on both sides is two lines but mints ids outside the registered population — a hash of an arbitrary composite can land ON a registered server's id and interleave the family's rows into that server's per-server reads and dismiss keys, a new and silent defect class — while a rung persisting the string key alongside the column carries identity instead of inferring it and changes no reader. The rung shape is the recommendation. No rung is claimed here, because this change needs no schema and a rung ships with the change that needs it.
Tests
AlertHistoryServerIdentityTestspins the pure mapping: integer keys answer the same id on both sides (including negative hashes), the self-alert shapes write to the bucket and read nothing, the literal "0" writes to the bucket and still reads nothing, and the invariant is stated as a property — the seed either declines or answers with the write side's own non-zero id — so a future edit that lets the halves drift fails by name.StoreRoundTripTests.SeedReads_ForUnparseableServerKeys_DeclineRatherThanReadTheCollapsedBucketruns the real DuckDB store, like its siblings, because the refusal is a line of SQL scoping plus a guard and a text assertion would pass against any code containing the right words. It pins the write-side collapse first — the refusal only means something if the rows really land at 0 — then the seam the defect lived in: a same-metric sibling key's seed reads null against a fresh bucket row, as does the writing key's own, as does "0"'s, for webhook, email, and unfiltered reads. The control records under "7" and asserts "7" still seeds while "8" does not, so the #1145/#981 restart guarantee is measured, not assumed.WebhookCooldownSeedTestsgains the service-level half, following that file's dead-URL discipline (a suppressed post never touches the network; an attempted one fails observably): a first notice on an unparseable key comes backFailed— attempted — with the seed consulted, neverThrottledby another key's fresh send; the integer-key control is one fixture where the only difference betweenThrottledandFailedis whose row the store holds. The store double models the integer column through the shared mapping rather than keying on the caller's string, which is the distinction the whole issue turns on: a string-keyed fake keeps every key's history separate and the defect cannot reproduce against it.RepeatDeliveryBudgetTests.OnTheWire_TwoSelfAlertSiblingsMillisecondsApart_TheSecondIsNotThrottledOffTheFirstsSendis the banked shape end to end: two same-metric self-alerts on different unparseable keys through the real deliverer, real cooldown, and a column-faithful store double whoseRecordAlertAsynccollapses exactly as the real stores do — so the first delivery writes the row the second's seed would have read. Both post; both history rows say webhook-delivered; both rows demonstrably landed in the bucket. On the pre-fix seed the second call reads the first's just-written row and comes back throttled, verified out of band against the built assembly, so the fixture discriminates the mechanism rather than passing by shape. The deferred half is asserted rather than left silent: neither card carries a roster, because both siblings are first notices — the pin says nothing is dropped, and deliberately does not say the burst folds.Tests cannot run on this lane's platform (
net10.0-windowstest projects); every touched assembly builds clean, and the mapping, the composed cooldown behavior (both siblings post; integer controls throttle and pass correctly), and the pre-fix reproduction were executed out of band against the builtPerformanceMonitor.Notificationsassembly.A separate preparatory commit makes the one-line change
CollectorCostDigestTests' factory reserved for the #3441 merge — the #3441 and #3448 lanes crossed andDarling.Testshas not compiled ondevsince the second of them landed.BaselineP95MsPerRunis pinned to each fixture's mean soThresholdMsPerRunresolves to the pre-#3441 bound exactly and every digest fixture keeps asking the routing question it was written to ask.The branch also carries the one-line cure for #3457 — dev's Darling.Tests compile break from the #3441×#3448 cross-merge (the digest factory passing nine positional arguments to a CostRegression that #3441 gave ten): the named argument the factory's own comment reserved, ordered first on the branch so every commit after it builds against a green dev. ci-sentinel filed that issue from the red run; this is its fix.