Plan: Battery Isolation — Detection Standing-Alert Race + Contention Strata
On this page
- Status
- Context
- Decisions
- Design
- U1 — detection race fix (MR 1,
Closes #1172) - U2 — scheduler off + narrowed group (MR 2,
Relates to #1167) - U3 — template-clone measurement (MR 3,
Relates to #1167) - U4 — financial owned-payment fixtures (MR 4,
Relates to #1167) - U5 — e2e tail ordering (MR 5,
Relates to #1167) - U6 — measurement + close-out (MR 6,
Closes #1167)
- U1 — detection race fix (MR 1,
- Measurement results (U6)
- Rejected options
- Risks
- Verification
- Documentation Updates
|
Revision history. v1 was internally reviewed (10 findings folded) and externally
REJECTED 2026-07-27 (~25 findings: 5 blockers, ~12 high-severity). This v2 folds every
finding; the reviewer confirmed the valid v1 core, retained here: the partial unique
index, explicit-arbiter |
Status
| Unit | Description | Status |
|---|---|---|
U0 |
GitLab choreography (#1172 filed/claimed/epic-attached/related; #1167 label →
|
Done (2026-07-27) — #1172 id 195977730; plan+nav first commit on
|
U1 |
Detection race fix: lock-first migration, required- |
Done (2026-07-27) — migration 20260727100000 + |
U2 |
Devstack scheduler disable ( |
Done (2026-07-27) — 8-member group (3 detection + volley + 4 alerts listing)
verified via |
U3 |
Template-clone 55006 serialization: measure truly-overlapping clones; contingent
fix issue + protocol change only on confirmation. MR |
Done (2026-07-27) — EXONERATED, no fix filed (D6’s parallelize branch): 3 runs ×
8 barrier-overlapped clones of the 33-migration cases template = 0×55006,
per-clone 108–135 ms, total ≤ 252 ms (grace-step laddering would read ~5 s
steps). Structural confirmation: |
U4 |
Financial |
Done (2026-07-27) — |
U5 |
E2E tail ordering (auth-cookies dependencies incl. conditional screenshots) |
Done (2026-07-27) — |
U6 |
Paired measurement protocol + close-out: results recorded here, Status → Done,
archive move per ADR-030. The FINAL MR, |
Done (2026-07-27) — 6 samples (B,A ×3 alternating), 0 failures both cohorts, 0 strict-dominance regressions over 4694 common tests, Σ median deltas −46.6 s, warm battery wall ≈18% faster; acceptance PASS with the two single-sample outlier windows annotated (see Measurement results (U6)) |
Epic: &73
Issues: #1167 (program), #1172 (detection race; Closes target of U1), #1171
(related, not absorbed), a contingent template-clone fix issue filed only if U3’s
measurement confirms
Branch: feature/1172-detection-standing-alert (U1); one branch per unit thereafter
MR-per-issue exception (per gitlab-issue-mr-standards): #1167 is a measurement
program whose units are independently shippable and independently green; U2–U5 each
ship Relates to #1167 and U6 closes it after the acceptance measurement — recorded
on #1167 itself.
Context
The epic &73 perf audit (#1165 evidence trail) surfaced a standing flake class in the
battery: services/craig-security detection tests intermittently observed duplicate
unacknowledged alerts for one rule — a direct violation of the #1156 sustained-breach
invariant ("one standing alert per rule; acknowledging re-arms"). Root cause:
run_detection_scan is check-then-insert (store/alerts.rs EXISTS probe, then a
plain INSERT), every scan is global over all enabled rules, and the devstack service
runs a live 60 s scheduler (detection.rs DEFAULT_INTERVAL_SECS) — so any two
overlapping scans (test-invoked or scheduled) can both pass the EXISTS check and both
insert. #1172 tracks the fix; #1167 tracks the program: after the correctness fix,
measure the battery’s remaining contention strata (template-clone DDL serialization,
event fan-out volume, financial fixture sharing, e2e tail ordering) and remediate only
what the data confirms.
The external stop-ship review of v1 reshaped the rollout (migration locking and timeout posture, deploy posture, measurement obligations) — its dispositions are inline in Decisions.
Decisions
| # | Fork | Choice | Why / review disposition |
|---|---|---|---|
D1 |
Migration locking |
|
Blocker 1: UPDATE-then-index upgrades locks and deadlocks against live
acknowledgements (the ack path holds RowExclusive). Lock-first is deterministic.
Blocker 2: migrations inherit the pool’s 30 s statement timeout on upgrades, but a
prior migration’s session-scoped |
D2 |
Deploy posture |
Single-replica stop-the-world is the SUPPORTED path pre-1.0. The rolling-deploy
hazard is RECORDED as unsupported: an old replica’s bare INSERT gets 23505, which
aborts its whole scan (500 + later rules skipped). A future rolling deploy requires
a compat phase (bare |
Blocker 3. The v1 "worst case one crashed boot" claim was wrong and is retracted; devstack does not auto-restart on migration failure. |
D3 |
Scheduler in devstack |
DISABLE: |
v1’s retention rationale rested on a false premise (the sustained-breach test manually invokes every transition; its comment merely tolerates scheduler wins). Serialization can’t isolate the scheduler; the index fixes correctness but not the contention/latency of overlapping global scans. A production scan lease is NOT built here (recorded on #1172 as residual for a future multi-replica deployment). |
D4 |
Detection nextest group |
KEEP, but narrowed to the state-mutating tests only (post-cleanup-fix), charter-commented as tertiary behind the index (U1) and the scheduler disable (U2). |
External finding: the v1 filter serialized 17 tests including pure input-validation/authz/EXPLAIN tests that mutate nothing. |
D5 |
Exchange group |
DROPPED. |
External finding: the pair is designed cross-test-safe (each row invalid on one
axis; |
D6 |
Template-clone protocol (#1162) |
MEASURE before trusting: truly-overlapping |
Blocker 4: v1 dismissed the DDL class without measuring the replacement protocol. |
D7 |
Event-heavy isolation |
NOT rejected — measured in U6 as its own stratum (the |
External finding: #1169 fixed correlation, not volume. |
D8 |
Financial adjustment race |
Owned-payment fixtures for |
External finding: shared first-pending-payment row; the test’s own comment admits a mid-flight external approval breaks its oracle. Test-only; rides as U4. |
D9 |
Migration auto-ack vs event contract |
Maintenance repairs are EXEMPT from |
External finding demanded an explicit decision. |
D10 |
|
Becomes required |
External finding: |
D11 |
sync-override |
Refresh BOTH the reason AND retire-when for |
External finding: the old retire-when could silently erase the groups. |
D12 |
U2 membership check |
|
External finding. |
D13 |
ci profile rationale |
Corrected: ci runs |
External finding. |
Design
U1 — detection race fix (MR 1, Closes #1172)
Migration services/craig-security/migrations/<ts>_standing_alert_unique.sql
-- #1172: one standing (unacked) alert per rule — #1156's contract,
-- DB-enforced. LOCK FIRST (deadlock-free vs live acknowledgements: the
-- ack path holds RowExclusive; upgrading after row updates deadlocks),
-- accepting a bounded write outage. SET LOCAL makes the timeout posture
-- identical on clean installs (where an earlier migration's
-- session-scoped SET statement_timeout = 0 leaks) and upgrades (pool
-- default 30 s). Deploy posture: single-replica stop-the-world
-- (pre-1.0). Rolling deploys are UNSUPPORTED until a compat phase ships
-- (bare ON CONFLICT DO NOTHING needs no arbiter index and would go
-- fleet-wide first); an old replica racing this migration gets 23505 =
-- its scan 500s. Maintenance auto-acks are EXEMPT from
-- alert.acknowledged events (D9): the actor string is the audit record.
SET LOCAL statement_timeout = 0;
SET LOCAL lock_timeout = '10s';
LOCK TABLE security_alerts IN SHARE MODE;
UPDATE security_alerts a
SET acknowledged = true,
acknowledged_by = 'migration:standing-alert-dedupe (#1172)',
acknowledged_at = now()
WHERE NOT a.acknowledged AND a.rule_id IS NOT NULL
AND EXISTS (SELECT 1 FROM security_alerts b
WHERE b.rule_id = a.rule_id AND NOT b.acknowledged
AND (b.created_at, b.id) < (a.created_at, a.id));
CREATE UNIQUE INDEX uq_security_alerts_standing_rule
ON security_alerts (rule_id) WHERE NOT acknowledged;
Grown-table posture (recorded): the dedupe self-join has no rule_id index behind it
and the plain unique build scans the heap under the table lock — acceptable at current
cardinality (devstack: dozens of rows; no deployed instance is large). A preflight
SELECT count(*) figure goes in the MR description. -- no-transaction + staged
CONCURRENTLY construction is the acknowledged alternative if a grown table ever
appears.
Code
-
store/alerts.rs::create_alert— params takerule_id: Uuid(D10);ON CONFLICT (rule_id) WHERE NOT acknowledged DO NOTHING(explicit arbiter)
fetch_optional→Option<SecurityAlert>; the executor generic stays. -
detection.rs::run_detection_scan— the per-rule breach branch moves to apub(crate)helper (fire_alert_for_rule) that owns the transaction:create_alertreturningNone→tx.rollback()+Ok(None)(nothing staged);Some→ stagesecurity.alert.created+ commit. The scancontinue`s on `Nonewith a debug log; webhook dispatch and the response push happen onSomeonly. The "racy by design" comment is rewritten (false post-index). -
Endpoint name in all docs/tests:
POST /v1/security/detection/run.
Tests (five workstreams; devstack-bound tests #[ignore = "requires devstack"])
-
Constraint
tests/constraints/standing_alert_partial_unique.rs: raw-INSERT a freshdetection_rulesrow (FK; unique name,enabled = false, threshold 999999, valid CHECK values), two same-rule unacked INSERTs → 23505 naming the index; ack the first → re-insert succeeds (re-arm). Cleanup runs before the asserts (alerts then rule) so a failing assert doesn’t leak rows. Deterministically red pre-migration.@axis: sad. -
Backfill
tests/constraints/standing_alert_dedupe_backfill.rs: clone the security template (craig_test_lib::template_db), DROP the new index to recreate the pre-head shape, seed one rule + three unacked alerts with distinct(created_at, id)including a timestamp tie, plus one NULL-rule unacked alert; run the migration file’s statements verbatim in one transaction → oldest survives unacked, later dupes acked with the attribution actor + timestamps set, tie broken by id, NULL-rule row untouched.@axis: sad. -
Store-level deterministic conflict — an in-crate devstack-bound unit test (the helper is
pub(crate); the battery runs lib targets with--run-ignored=all): N=4 concurrentfire_alert_for_rulecalls against one rule on a template clone viaconcurrent_fire_with_barrier— exactly oneSome, all losersNone, and exactly ONE stagedsecurity.alert.createdoutbox row for the rule (losing branches must not stage events). Deterministically exercisesfetch_optional/None/rollback — the endpoint volley can’t.@axis: conc. -
Endpoint volley
tests/api/detection_scan_concurrent.rs: 8 ×POST /v1/security/detection/runviaconcurrent_fire_synchronized, fresh threshold-0 ENABLED rule; all 200; per response, thealertsarray filtered by this rule_name (NOT the globalalerts_createdcounter — it counts unrelated rules) sums to ≤ 1 across the volley; exactly one standing unacked alert in the DB by rule_name. Division of labor: with the U2 scheduler disable this volley becomes deterministic about actors; workstream 3 remains the deterministic branch pin. Rule cleanup at the end.@axis: conc. -
Hygiene, existing tests: (a)
acknowledge_alertadopts the query-by-own-rule pattern (the scan response may not contain the alert if another actor created it); (b) panic-safe teardown for the enabled-rule fixtures intests/api/detection.rs+tests/api/alerts.rshelpers viaTestHarness::register_cleanup(the #1164 drop-teardown — delete the test’s alerts then its rule), ending the ~9-enabled-rules-per-battery leak that makes every subsequent scan slower.
Docs (mandatory)
data-model-security.adoc — add the two detection tables + the FK + the new index
(the page currently claims seven tables and no FKs). state-machines.adoc — the
one-standing-alert/re-arm invariant. CHANGELOG Fixed (incl. the D9 exemption
attributed auto-ack). #1172 closed with its comment right after THIS merge.
U2 — scheduler off + narrowed group (MR 2, Relates to #1167)
-
docker-compose.yml craig-security env:
CRAIG_SECURITY__DETECTION_INTERVAL_SECS: "0"with a comment (D3; production default unchanged). -
.config/nextest.toml:security-detection-scan = { max-threads = 1 }with the tertiary-defense charter comment; overrides on default/integration/ci-integration filtering the STATE-MUTATING tests only — exact list finalized post-U1 (scan-invoking + ack-invoking + the two new concurrency tests),package(craig-security) and kind(test) and (…). NO exchange group (D5). -
Membership check per D12; sync-override reason + retire-when refresh (D11);
testing-reference.adoc§ nextest updated AND its stale junit/timeout/thread facts repaired. CHANGELOG.
U3 — template-clone measurement (MR 3, Relates to #1167)
Instrumented measurement (throwaway harness, not shipped): N=8 truly-overlapping clones of ONE pre-built template (barrier-synchronized across OS threads, template sized ≈ the cases one) — record per-clone wall + whether 55006 retries fired. If overlapping clones serialize in ~5 s grace steps → file the fix issue + implement the protocol fix (leading candidate: a per-template pg advisory lock around the clone), red-proof = the measurement rerun. If clones genuinely parallelize, record the exonerating measurement on #1167 and close the question. Either way the result is RECORDED (Blocker 4).
U4 — financial owned-payment fixtures (MR 4, Relates to #1167)
approve_adjustment_concurrent.rs + adjustments.rs helper: each test creates its
OWN pending payment (existing builders/endpoints) instead of selecting the shared
first-pending row; delete the two-instant-oracle caveat comment once the fixture is
owned. @axis tags unchanged. No product code.
U5 — e2e tail ordering (MR 5, Relates to #1167)
-
auth-cookies.dependencies: the seven prior projects PLUS conditionallyscreenshotswhenSCREENSHOTS=1(config already builds that project conditionally) — mirror the conditional in the dependency list. Both trades in the comment (skip semantics;--project auth-cookiesnow pulls dependencies). -
Durable regression test: extend the config-introspection guard (
tests/e2e/specs/config-coverage.spec.ts) to assert auth-cookies' resolved dependency set under BOTH default andSCREENSHOTS=1. -
Verification: auth-cookies executes last in a full
cargo xtask e2e. CHANGELOG.
U6 — measurement + close-out (MR 6, Closes #1167)
This unit IS the disposition of review Blocker 5 (#1167 must not close before its acceptance measurement): the close-out has a real delivery vehicle, and closure follows the measured acceptance criterion rather than preceding it.
-
State control: full devstack reseed before EVERY sample; template pre-warmed once before each cohort’s first sample; token cache deleted before each cohort.
-
Design: paired alternating order (B,A,B,A,B,A — before = pre-U2-merge worktree build, after = HEAD) on an idle host, ≥3 pairs; every junit retained under a unique name.
-
Statistics declared up front: per-test p95 + max deltas and a slow-test table (every test whose max shifted > 2 s), plus suite wall — NOT wall alone. Acceptance: "no test’s tail regressed > 2 s and no new failures", not a wall-time delta smaller than run-to-run spread.
-
Strata: (a) idle baseline; (b) the event-heavy question (D7) via per-test p95 of the dead_letter/security event suites; (c) any U3 cross-check.
-
Any mid-protocol red: root-caused per testing-discipline; contaminated samples annotated, never silently dropped.
-
Close-out IN THIS MR: this plan’s Status → Done, archive move + index/xref repair per ADR-030, measurement tables recorded here; #1167 closing comment; epic &73 tick. CLAUDE.md status only when the epic itself closes.
Measurement results (U6)
Executed 2026-07-27 on an idle host per the protocol. Cohorts: B = the pre-U2-merge
worktree @ 863a3a0d (U1 merged; scheduler-on devstack, shared fixtures); A = HEAD
(U1+U2+U4 merged; the U5 branch’s e2e-only diff was present in the tree and is
nextest-inert by construction — recorded as the protocol’s one deviation). Per sample:
dev reload from the cohort tree → dev reseed (full volume wipe) → token-cache purge
→ the battery’s exact nextest invocation → junit retained
(test-results/u6-measure/{B,A}{1..3}.xml). Samples alternated B,A,B,A,B,A.
Headline
| Metric | Cohort B | Cohort A |
|---|---|---|
Failures across 3 samples |
0 |
0 |
Warm battery wall (samples 2+3; sample 1 pairs include compile) |
58.5 s / 63.7 s |
50.3 s / 50.0 s (paired −14.0% / −21.5%; combined ≈18% faster) |
Strict-dominance regressions (min(A) > max(B) by > 0.5 s), 4694 common tests |
— |
0 |
Strict-dominance improvements (max(A) < min(B) by > 0.5 s) |
— |
0 |
Σ per-test MEDIAN deltas (A − B) |
— |
−46.6 s |
Tests with median improved / regressed > 0.2 s |
— |
49 / 17 |
New/removed tests between cohorts: 0/0 (U1’s tests are in both; U4 changed fixtures, not test names).
The face-value > 2 s tail table, annotated
Nine tests exceeded the declared "max shifted > 2 s" bar (worst: authz_audit_emission +5.9 s; the six cases boot_verify tests +3.4–3.5 s;
retention_archive::ledger_row_and_event_are_atomic +2.7 s; the U1 in-crate conflict
test +2.5 s). Per the annotate-don’t-drop rule, all nine are NON-regressions:
-
Every one has OVERLAPPING per-sample ranges across cohorts (hence zero strict-dominance regressions — a robustness instrument computed POST HOC and disclosed as such; the declared statistics were p95/max + this table) — the exceedances come from exactly two windows: sample A1 (first HEAD sample;
boot_verifyfamily at 9.9 s in A1 vs 0.9–4.2 s in A2/A3, against B samples of 2.7–6.5 s) and one A3 co-tenancy spike (the conflict
rules-archive pair at ~4 s in A3 only, ~1.2 s elsewhere in BOTH cohorts). -
No mechanism: U2 (compose env + nextest groups) and U4 (financial test fixtures) touch neither craig-bootstrap nor the cases boot-verify paths.
Twenty tests improved > 2 s at max, CONSISTENTLY: the whole craig-mq family (−4.4 to −10.7 s — outbox/inbox/retention), the craig-cli suite (−2.7 s each), the intake evil corpus (33.5 → 26.5 s), persons_search, financial retention. Mechanism: the U2 scheduler disable removed a 60 s global scan taxing the shared Postgres under every battery.
D7 — the event-heavy stratum decision
No volume-driven signal: dlq_threshold_exceeded_alert_fires p95 16.25 → 15.73 s
(its tail is the test’s own 30 s-budget design), financial retention improved 2.5 s,
the archive-engine bookkeeping pair drifted ~+1 s inside overlapping ranges.
Decision: NO event-heavy serialization group — the data does not support one; the
question closes with this measurement.
Rejected options
-
v1 D1 — trust the #1162 template-clone protocol without measurement: rejected by the external review (Blocker 4); the 55006 grace-step serialization mechanism matches the audit’s latency texture and must be measured (U3).
-
v1 D4 — keep the devstack scheduler because the sustained-breach test needs it: the premise was false (the test drives every transition manually); replaced by the U2 disable.
-
v1 exchange nextest group: serialized tests explicitly designed to overlap safely; dropped (D5).
-
UPDATE-then-CREATE-INDEX migration without an explicit lock: deadlocks against live acknowledgements; replaced by lock-first (D1).
-
alerts_createdglobal counter as the volley oracle: counts unrelated rules' alerts; replaced by per-response filtering on the test’s own rule_name. -
Rolling-deploy compat phase built now: pre-1.0 single-replica posture makes it dead code today; recorded as the recipe instead (D2).
-
Auto-ack
security.alert.acknowledgedevents from the migration: would fabricate operator actions; the attributed actor string is the audit record (D9).
Risks
| Risk | Mitigation |
|---|---|
Migration write-outage window (SHARE lock) |
|
Old-replica 23505 on an unsupported rolling deploy |
D2 documented posture + compat-phase recipe in the migration header. |
U3 measurement inconclusive |
Both outcomes are recorded results; the fix issue only files on confirmation. |
Narrowed group filter drifts |
Charter comment; the D12 membership check is re-runnable. |
Measurement still noisy |
Paired design + per-test tails + declared thresholds; annotate-don’t-drop. |
Scheduler-off devstack hides scheduler bugs |
The sustained-breach test drives scans manually (its design); production default untouched; recorded on #1172 as residual for a future scan lease if multi-replica contention materializes. |
Verification
-
Per unit:
cargo fmt --all+cargo clippy --workspace --all-targets --locked — -D warnings+ targetedcargo nextest run(with devstack up for `#[ignore]`d tests) + the red-case proof named in each unit. -
The pre-push battery (full
cargo xtask validate+ e2e) per unit. -
U6’s paired measurement protocol is the program-level acceptance gate.
Documentation Updates
-
data-model-security.adoc— detection tables + FK +uq_security_alerts_standing_rule(U1) -
state-machines.adoc— one-standing-alert/re-arm invariant (U1) -
testing-reference.adoc§ nextest — groups + stale-fact repair (U2) -
.claude/sync-overrides.toml— cfg-nextest reason + retire-when (U2) -
CHANGELOG.adoc— per-unit entries under== Unreleased -
Archive move + measurement tables in this plan (U6)