Plan: Platform Stabilization Phase 2 — Concurrency & Atomicity Hardening
On this page
- Status
- Context
- Scope
- Design
- D1. Idempotency atomic-claim state machine (P0.1 — Step 3)
- D2. Outbox FOR UPDATE SKIP LOCKED (P0.2 — Step 4)
- D3. Inbox three-state retry semantics (P0.3 — Step 5)
- D4. Atomic blob+DB+event upload (P1 — Step 6)
- D5. Exchange send: outbox-driven worker (P1 — Step 7)
- D6. Placement state-read inside tx + foster-home FOR UPDATE (P1 — Step 8)
- D7. BFF silent-degradation cleanup (P1 — Step 9)
- D8. P2 cluster — bundle (#281 — Step 10)
- D9. Encryption mode configuration-layer fail-closed (#273 — Step 11)
- D10. P3 cleanup bundle (#282 — Step 12)
- Steps
- Step 1: Plan adoc + nav.adoc Active + CHANGELOG entry
- Step 2: Minimal test infrastructure inline
- Step 3: P0.1 — Idempotency atomic-claim state machine
- Step 4: P0.2 — Outbox FOR UPDATE SKIP LOCKED
- Step 5: P0.3 — Inbox three-state retry semantics
- Step 6: P1.1 — Atomic blob+DB+event upload
- Step 7: P1.2 — Exchange send outbox-driven worker
- Step 8: P1.3 — Placement state-read inside tx + foster-home FOR UPDATE
- Step 9: P1.4 — BFF silent-degradation cleanup
- Step 10: P2 cluster — magic-byte + CAPTCHA-guard + outbox publishes + CORS warn (#281)
- Step 11: Encryption mode configuration-layer fail-closed (#273)
- Step 12: P3 cleanup bundle (#282)
- Step 13: Plan completion audit + archive
- Files Touched
- Verification
- Documentation Updates
- Open questions
- After this plan lands
- Errata
Status
| Step | Description | Status |
|---|---|---|
1 |
Convert plan to AsciiDoc + nav.adoc Active entry + CHANGELOG entry. Epic &21 already filed; step issues #273–#282 already filed and linked. No code changes. |
Done (pre-ADR-030) — this MR |
2 |
Minimal test infrastructure inline: |
Done (pre-ADR-030) — verification + sequencing doc shipped; consolidated helpers from test-framework-hardening Phase A.1/A.2 cover the spec; 4 base injectors deferred to their consuming §D-section step per §Errata table; |
3 |
P0.1: Idempotency atomic-claim state machine. Claim BEFORE handler runs (processing → succeeded → failed); concurrent loser polls-and-replays winner (5s ceiling) or returns 409 with Retry-After. Migration adds |
Done (pre-ADR-030) |
4 |
P0.2: Outbox |
Done (pre-ADR-030) |
5 |
P0.3: Inbox processed-state retry semantics. Three-state machine (claimed-not-processed, succeeded, failed-at-cap). Migration adds |
Done (pre-ADR-030) |
6 |
P1.1: Atomic blob+DB+event upload across 4 sites (contact_attachments, court_orders, ICPC, report_attachments). Post-commit blob write with compensating cleanup; new |
Done (pre-ADR-030) |
7 |
P1.2: Exchange send → outbox-driven worker pattern. New |
Done (pre-ADR-030) |
8 |
P1.3: Placement state-read inside tx + foster-home |
Done (pre-ADR-030) |
9 |
P1.4: BFF silent-degradation cleanup. |
Done (pre-ADR-030) |
10 |
P2 cluster (#281): 4 sub-items, all shipped together. D8.1: |
Done (pre-ADR-030) |
11 |
273 — encryption mode (configuration-layer fail-closed). Option A picked: default |
Done (pre-ADR-030) |
12 |
P3 cleanup bundle (#282): D10.2 dep-dupe documentation (jsonwebtoken 9.x and convert_case 0.6/0.8 dupes documented in |
Done (pre-ADR-030) |
13 |
Plan completion audit + archive. Audit subagent verified all 12 prior steps' shipped artifacts against repo reality (MR list, issue closures, CHANGELOG entries, file-touch coverage, endpoint counts, test counts, §Errata completeness). Two findings resolved before archive flip: backfilled Step 2 CHANGELOG entry (verification-only MR was originally omitted); flipped Status row 13 + moved plan from nav.adoc Active → archive.adoc Infrastructure & DevOps; added platform-stabilization-2 row to |
Done (pre-ADR-030) — this MR |
Epic: &21
Issues: #273 (encryption mode), #274 (P0.1 idempotency), #275 (P0.2 outbox), #276 (P0.3 inbox), #277 (P1.1 attachments), #278 (P1.2 exchange send), #279 (P1.3 placement), #280 (P1.4 BFF degradation), #281 (P2 cluster), #282 (P3 cleanup)
Sibling epic: &22 Test Framework Hardening — separate plan; consolidates Step 2’s inline helpers + extends to multi-replica devstack, JWT mutation, state-machine matrix, etc.
Branch prefix: feat/platform-stab2- / fix/platform-stab2- / chore/platform-stab2-
*Milestone: 2026 Q3 — Platform Reliability
Context
The original Platform Stabilization plan (epic &20, closed 2026-05-03) closed structural gaps:
-
event_outboxandevent_inboxtables exist -
OutboxWorkeris spawned in 7 stateful services -
idempotency_responsesis Postgres-backed (not in-memory DashMap) -
DLX exchange (
craig.dlx) is declared -
/livez/readyz/healthzare split -
xtask reconcileenumerates 7 cross-service references
Within hours of that epic closing, an external reviewer (2026-05-03) caught three P0 race conditions that pass-1 audits had marked "verified shipped" earlier the same day. The methodology gap, captured in the two-pass platform-invariant audit:
A "verified shipped" structural check (table exists, function returns Result, worker is spawned) does NOT verify SEMANTIC concurrency correctness. The reviewer’s findings showed the SELECT/INSERT/UPDATE patterns were structurally correct but the locking, claim ordering, and retry semantics were wrong.
A 5-agent audit confirmed all 3 P0 findings, plus 4 P1 findings, plus a P2 cluster, plus a P3 cluster. This plan closes those gaps.
A second-order finding from the same review: the existing test framework structurally cannot catch these races (sequential single-fire, single-replica devstack, no fault injection, no property-based testing). That gap is addressed by the sibling Test Framework Hardening epic (&22), with Step 2 of this plan building the minimum inline test infrastructure to verify Steps 3–9 without depending on epic &22 landing first.
Scope
In scope:
-
All 3 P0 race conditions (concurrent claim ordering for idempotency / outbox / inbox)
-
All 4 P1 durability gaps (attachments / exchange-send / placement / BFF degradation)
-
P2 cluster: magic-byte upload validation + RFC 5987 Content-Disposition; CAPTCHA production-guard; move advisory
publisher.publishcallsites into the outbox; CORS production-guard warning -
#273 encryption-mode configuration-layer fail-closed
-
P3 cleanup cluster
-
Minimal inline test infrastructure (Step 2) to verify the above:
concurrent_firehelper + cipher/DB/RMQ/object-store fault injectors
Out of scope (filed separately as epic &22, separate plan):
-
Multi-replica devstack profile (
cargo xtask dev start --replicas N) — Step 4 (outbox) uses single-process simulation of 2× OutboxWorker against same DB; the proper multi-replica devstack belongs to epic &22 Phase A.3 -
Typed test-client DTOs replacing
serde_json::Valueacross the 8 typed clients — long-running migration; epic &22 Phase A.8 -
State-machine matrix tests across all 16 documented state machines — epic &22 Phase B
-
JWT mutation library — epic &22 Phase A.5
-
Magic-byte upload-spoofing fixtures (test side) — epic &22 Phase A.6 (this plan’s Step 10 P2 cluster adds the magic-byte VALIDATION; epic &22 adds the spoofing TEST FIXTURES)
-
/livez vs /readyz semantic-differentiation tests — epic &22 Phase B
-
Cross-service reconciliation orphan-injection test — epic &22 Phase B
-
New domain features (any of the standing-backlog items)
-
AWS deployment thread (#163 / #172 / #199 — owned by external team)
Design
D1. Idempotency atomic-claim state machine (P0.1 — Step 3)
Today crates/craig-api/src/idempotency.rs does check-then-act without a claim: the middleware queries idempotency_responses for a hit, runs the handler on miss, then INSERT … ON CONFLICT DO NOTHING after the handler returns. Two same-key concurrent POSTs both miss the cache, both run the handler (both side-effects land), and only the first INSERT wins. The second caller silently observes its work was duplicated.
Current buggy shape (lines 133-258):
// idempotency.rs:133 — check
async fn check_cache(pool, cache_key, body_fp) -> ... {
let row: Option<CachedRow> = sqlx::query_as(
r#"SELECT body_fingerprint, status, headers, body, body_too_large
FROM idempotency_responses
WHERE cache_key = $1 AND expires_at > now()"#,
)...
// miss → returns Ok(None), handler runs unguarded
}
// idempotency.rs:238 — act, AFTER the handler
sqlx::query(
r#"INSERT INTO idempotency_responses
(cache_key, method, path, user_sub, body_fingerprint,
status, headers, body, body_too_large)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
ON CONFLICT (cache_key) DO NOTHING"#,
)
The race is invisible to a structural check (the table exists, the constraint is correct, the INSERT clause is correct) and only surfaces under concurrent load — which is exactly what idempotency is supposed to prevent.
Fix shape: claim-before-handle with a status state machine
Introduce three states on every cache row, claimed at the top of the middleware before the handler executes:
| State | Set when | Reaction on second arrival |
|---|---|---|
|
claim INSERT succeeds; handler about to run |
wait/poll |
|
handler returned a response that was persisted |
replay |
|
handler errored OR claim was abandoned past TTL |
re-claim allowed |
The first POST does the claim INSERT and observes 1 row. The second POST does the same INSERT, observes 0 rows (conflict), reads the existing row, and branches on status.
Migration (additive — backwards-compatible with prior data)
-- services/<svc>/migrations/<ts>_idempotency_atomic_claim.sql
-- Adds the claim-state machine to idempotency_responses.
-- Additive: existing rows from Step 10 are back-populated as 'succeeded'
-- because the only way they exist on this table is the legacy code path
-- which only INSERTed on a successful response.
ALTER TABLE idempotency_responses
ADD COLUMN status TEXT NOT NULL DEFAULT 'succeeded'
CHECK (status IN ('processing', 'succeeded', 'failed')),
ADD COLUMN started_at TIMESTAMPTZ NOT NULL DEFAULT now(),
ADD COLUMN finished_at TIMESTAMPTZ,
-- Per-row claim TTL: a 'processing' row older than this may be
-- recovered (the original requester died mid-handler). 30s ceiling
-- bounds loser-poll wait + claim-recovery without bouncing to 409.
ADD COLUMN claim_expires_at TIMESTAMPTZ NOT NULL
DEFAULT (now() + INTERVAL '30 seconds'),
-- Loosen NOT NULL constraints on response columns so we can persist
-- the row at claim time before the handler has produced a response.
ALTER COLUMN status_code DROP NOT NULL,
ALTER COLUMN headers DROP NOT NULL,
ALTER COLUMN body_fingerprint DROP NOT NULL;
-- body, body_too_large already nullable.
CREATE INDEX idx_idempotency_responses_processing
ON idempotency_responses (claim_expires_at)
WHERE status = 'processing';
The default 'succeeded' on the new status column means existing rows remain replayable without backfill. New rows are written with 'processing' explicitly, and the legacy post-handler INSERT is replaced by an UPDATE that flips the row to 'succeeded' (or 'failed').
Rollback compatibility: the new columns are additive; the previous binary can still read the table and treat any row as a hit (because status is 'succeeded' for legacy rows and the new code never serves a non-succeeded row to the legacy code path before this migration runs).
New function signatures
/// Outcome of attempting to claim a cache slot.
enum ClaimOutcome {
/// We are the winner; run the handler then call `finalize_*`.
Claimed,
/// Another request reached the slot first. Carries the row.
Existing(CachedRow),
}
async fn try_claim(
pool: &PgPool,
cache_key: &str,
method: &str,
path: &str,
user_sub: uuid::Uuid,
body_fp: &str,
) -> Result<ClaimOutcome, sqlx::Error>;
/// Finalize a winning claim with a successful handler response.
async fn finalize_succeeded(
pool: &PgPool,
cache_key: &str,
status: u16,
headers: serde_json::Value,
body_bytes: Bytes,
) -> Result<Bytes, sqlx::Error>;
/// Finalize a winning claim where the handler errored. Marks 'failed'
/// so a retry from the *same* idempotency-key may re-claim.
async fn finalize_failed(
pool: &PgPool,
cache_key: &str,
error_detail: &str,
) -> Result<(), sqlx::Error>;
/// Loser-side wait: poll the slot until status flips to 'succeeded' or
/// the per-request ceiling expires. Returns the replay response on
/// success, or None on ceiling timeout (caller emits 409 Retry-After).
async fn await_winner(
pool: &PgPool,
cache_key: &str,
body_fp: &str,
ceiling: Duration,
) -> Result<Option<Response>, sqlx::Error>;
try_claim issues the claim INSERT:
INSERT INTO idempotency_responses
(cache_key, method, path, user_sub, body_fingerprint, status, started_at)
VALUES ($1, $2, $3, $4, $5, 'processing', now())
ON CONFLICT (cache_key) DO NOTHING
RETURNING cache_key
If RETURNING yields 1 row → Claimed. If 0 rows → SELECT the existing row (re-using the existing CachedRow struct, extended with status, started_at, claim_expires_at) and return Existing(row).
Sequence — three paths
Path A — winner:
-
POST arrives, body buffered, fingerprint computed, key derived
-
try_claim→Claimed -
Handler runs against the rebuilt request
-
Response body buffered (1 MiB cap;
body_too_largesentinel still applies) -
finalize_succeededUPDATEs the row:status='succeeded',finished_at=now(),status_code,headers,body,body_too_large -
Live response returned to caller
Path B — loser, replays winner:
-
POST arrives, body buffered, fingerprint computed, key derived
-
try_claim→Existing(row)withrow.status = 'processing' -
Body fingerprint mismatch → 422 (RFC 9530 conflict; same as today)
-
await_winnerpolls every 100ms (initial) backing off to 500ms; ceiling 5s-
Each poll re-reads
(status, finished_at, status_code, headers, body, body_too_large, body_fingerprint) -
If
status = 'succeeded'and fingerprints match → build replay Response (existing path) withx-idempotency-replay: true -
If
status = 'failed'→ caller may re-execute (return None; outer middleware re-runstry_claimonce more, racing to set'processing'again under the same key)
-
-
Returns the replay Response
Path C — loser, ceiling exceeded:
-
Same as Path B through step 4, but no flip in 5s
-
await_winnerreturnsOk(None) -
Middleware emits 409 Conflict with
Retry-After: 5and a Problem Details body of typehttps://docs.craig/problems/idempotency-in-flight -
Caller is expected to retry; the next retry either replays the (by-then-finished) winner, or, if the winner died and the row is past
claim_expires_at, recovers the slot by re-INSERTing — see Path D
Path D — claim recovery (winner died):
-
Loser finds
Existing(row)withstatus = 'processing'andnow() > claim_expires_at -
Loser issues
UPDATE … SET status='failed', finished_at=now() WHERE cache_key=$1 AND status='processing' AND claim_expires_at < now() -
If the UPDATE returned 1 row, loser issues a fresh
try_claim(now the existing row’s status is'failed', so the new claim must be an UPDATE-not-INSERT — see "Failed-row re-claim SQL" below) -
Hands off to Path A as the new winner
Failed-row re-claim SQL (used by Path D and by retry-after-handler-error):
UPDATE idempotency_responses
SET status = 'processing',
started_at = now(),
claim_expires_at = now() + INTERVAL '30 seconds',
finished_at = NULL,
status_code = NULL,
headers = NULL,
body = NULL,
body_too_large = false,
body_fingerprint = $2
WHERE cache_key = $1
AND status IN ('failed')
RETURNING cache_key
The combination of INSERT … ON CONFLICT DO NOTHING (fresh slot) and this conditional UPDATE (failed slot) makes the re-claim atomic — either the caller observes itself as winner, or it observes a still-processing peer and falls back to Path B.
Error mapping
| Condition | ApiError | Status |
|---|---|---|
|
|
500 |
Loser body-fingerprint mismatch |
(existing) 422 problem+json |
422 |
|
(new) |
409 |
|
log-and-return-live-response (matches existing fallback at line 363) |
live status |
|
log-and-return-handler-error |
handler status |
Affected callsites
The middleware lives in crates/craig-api/src/idempotency.rs and is wrapped around every API service router via from_fn_with_state(idempotency_middleware). The 7 services that mount it are listed in services/<svc>/src/main.rs (grep idempotency_middleware). Adoption in this step is in one place (the middleware itself); per-handler code is unchanged.
| Layer | Touched |
|---|---|
|
Replace |
|
Additive ALTER per service (cases, exchange, financial, placement, reporting, rules, security). |
|
No change — middleware wiring stays the same. |
Test description (depends on Step 2’s concurrent_fire)
A failing-test-first integration test in crates/craig-api/tests/idempotency_atomic_claim.rs:
-
Bring up an embedded Postgres + the cases router behind a side-effect-counting handler that increments a
Mutex<u32>and returns 201 with the count -
Build N=5 identical POSTs with the same
Idempotency-Keyand same body -
concurrent_fire(5, builder)— fires all 5 in flight before any completes -
Assert: counter == 1 (handler ran exactly once)
-
Assert: 5 responses all have
status_code=201, identical body, exactly one withx-idempotency-replay=falseand four withx-idempotency-replay=true -
Assert:
idempotency_responsesrow hasstatus='succeeded',finished_at IS NOT NULL
Plus Path-D claim-recovery test and Path-C ceiling-409 test.
D2. Outbox FOR UPDATE SKIP LOCKED (P0.2 — Step 4)
Today crates/craig-mq/src/outbox.rs::OutboxWorker::drain_once SELECTs unpublished rows without row-level locking, then UPDATEs each row’s published_at after a successful publish. Two replicas of OutboxWorker running against the same Postgres both SELECT the same batch (line 96-101), both publish to RabbitMQ, and both UPDATE — every event is delivered twice.
Current buggy shape (crates/craig-mq/src/outbox.rs:96-101):
let rows: Vec<(Uuid, serde_json::Value)> = sqlx::query_as(
r#"SELECT id, envelope FROM event_outbox
WHERE published_at IS NULL
ORDER BY created_at
LIMIT $1"#,
)
.bind(BATCH_SIZE)
.fetch_all(&self.pool)
.await?;
The structural check that "only one worker is spawned per service" passes today (each service spawns one in its main.rs). The race surfaces the moment we run more than one replica of any service.
Fix shape: row-level pessimistic claim inside a tx
Wrap the SELECT + publish + UPDATE for each batch in a single Postgres transaction, with FOR UPDATE SKIP LOCKED on the SELECT. The SKIP-LOCKED clause lets the second worker observe an empty result set instead of blocking, so two workers naturally divide the pending rows between themselves.
SELECT id, envelope FROM event_outbox
WHERE published_at IS NULL
ORDER BY created_at
LIMIT $1
FOR UPDATE SKIP LOCKED
The lock must be held through the publish call and the post-publish UPDATE; releasing earlier reopens the same race because a second worker could re-claim a row whose publish is mid-flight.
Decision: FOR UPDATE SKIP LOCKED, not a claimed_by lease column
Considered alternatives:
| Option | Rejected because |
|---|---|
|
Adds two columns + an expiry sweeper for crashed claims; FUSL gets correct exclusion and automatic release on tx abort, in one line of SQL. |
Postgres advisory lock per row id |
Same effect as FUSL but bypasses the row visibility rules — harder to reason about under nested tx. |
Single-instance worker (deployment guarantee) |
Couples correctness to ops discipline; AWS rollout assumes ≥2 replicas of every service. |
Filed as a future-revisit if multi-region / multi-DB topology emerges, where FUSL’s tx-scoped lock semantic stops applying. Tracked in §Open Questions.
Tx-scope refactor
pub async fn drain_once(&self) -> Result<usize, sqlx::Error> {
let mut tx = self.pool.begin().await?;
let rows: Vec<(Uuid, serde_json::Value)> = sqlx::query_as(
r#"SELECT id, envelope FROM event_outbox
WHERE published_at IS NULL
ORDER BY created_at
LIMIT $1
FOR UPDATE SKIP LOCKED"#,
)
.bind(BATCH_SIZE)
.fetch_all(&mut *tx)
.await?;
if rows.is_empty() {
return Ok(0);
}
let mut published = 0usize;
for (id, envelope_json) in rows {
// … rehydrate envelope (existing logic) …
match self.publisher.publish(&envelope).await {
Ok(()) => {
sqlx::query(
r#"UPDATE event_outbox
SET published_at = now(),
last_error = NULL
WHERE id = $1"#,
)
.bind(id)
.execute(&mut *tx)
.await?;
published += 1;
}
Err(e) => {
let msg = e.to_string();
sqlx::query(
r#"UPDATE event_outbox
SET attempts = attempts + 1,
last_error = $2
WHERE id = $1"#,
)
.bind(id)
.bind(&msg)
.execute(&mut *tx)
.await?;
warn!(outbox_id = %id, error = %msg, "outbox publish failed");
}
}
}
tx.commit().await?;
Ok(published)
}
The attempts/last_error UPDATE on publish failure also runs inside the same tx so that both attempt-counter increment and the row-claim release land atomically. If the publish fails, tx.commit() still succeeds (the row stays unpublished, ready for retry), and the lock releases cleanly.
Error semantics — publish-failure + tx-rollback matrix
| Failure | Tx outcome | Row state on next tick |
|---|---|---|
RabbitMQ publish errors (lapin::Error) |
Inner UPDATE bumps attempts/last_error, tx commits |
Still |
Worker process crashes mid-publish |
Tx auto-aborts (Postgres releases locks) |
Still |
|
Row will be re-claimed and re-published |
At-least-once — duplicate delivery is acceptable per ADR-022; consumers dedup via inbox |
Post-publish UPDATE errors |
Tx auto-aborts; lock released |
Still |
ADR-022 already commits to at-least-once delivery semantics: the inbox pattern (D3) is the dedup boundary. The post-publish-UPDATE-fails edge case is accepted because it’s bounded (single duplicate per failure) and detected at the consumer side.
Performance considerations
-
BATCH_SIZE = 100(existing) caps per-tick lock window; tx duration is bounded by100 × publish_latency, typically < 200ms total -
POLL_INTERVAL = 1s(existing) keeps the outbox-depth p95 under the LISTEN/NOTIFY threshold (500 from ADR-022) up to ~50K events/min -
Long-running publishes (>1s) won’t cause lock contention because subsequent workers SKIP LOCKED past the held rows — they pick up younger pending rows and progress
-
No backoff change needed: an empty SELECT (all rows locked or none pending) returns 0 rows without blocking; the worker sleeps
POLL_INTERVALand retries
Test description (single test process — no multi-replica devstack needed)
A failing-test-first integration test in crates/craig-mq/tests/outbox_concurrent.rs:
-
Embedded Postgres + the
event_outboxschema -
Wrap
Publisherwith anEventCollector(existing utility) that records every published envelope -
Stage N=200 rows in
event_outboxwithpublished_at IS NULL -
Spawn 2×
OutboxWorkerinstances against the samePgPool, sharing a single in-processEventCollector -
Drive
drain_onceconcurrently (tokio::join!(w1.drain_once(), w2.drain_once())) repeated until both return 0 -
Assert:
EventCollector.count() == 200(no duplicates, no drops) -
Assert:
SELECT count(*) FROM event_outbox WHERE published_at IS NOT NULLreturns 200 -
Assert: every
published_atis non-NULL and>= created_at
Bonus: stress variant uses a tokio::time::sleep(Duration::from_millis(20)) shim inside EventCollector::publish to widen the publish window; assert the result still holds.
D3. Inbox three-state retry semantics (P0.3 — Step 5)
|
Prerequisite: #312 must land before Step 5. The |
Today crates/craig-mq/src/inbox.rs::handle_idempotently claims the envelope BEFORE the handler runs (line 36-46), via INSERT … ON CONFLICT DO NOTHING. If the handler errors, the function bubbles Err to the subscriber loop, which nacks-with-requeue. The redelivery’s INSERT returns 0 rows (the claim from the failed run is still there), the function silently returns Ok(()) at line 54, and the side-effect work is permanently lost.
Current buggy shape (crates/craig-mq/src/inbox.rs:36-65):
let claimed: Option<(uuid::Uuid,)> = sqlx::query_as(
r#"INSERT INTO event_inbox (envelope_id, source_service, event_type)
VALUES ($1, $2, $3)
ON CONFLICT (envelope_id) DO NOTHING
RETURNING envelope_id"#,
)
.bind(envelope.id)
.bind(&envelope.source_service)
.bind(&envelope.event_type)
.fetch_optional(db)
.await?;
if claimed.is_none() {
debug!(…, "duplicate event; skipping (already claimed in event_inbox)");
return Ok(());
}
handler(envelope).await?; // ← if this errors, the row stays claimed forever
sqlx::query(r#"UPDATE event_inbox SET processed_at = now() WHERE envelope_id = $1"#)
.bind(envelope_id)
.execute(db)
.await?;
The bug is the if claimed.is_none() { return Ok(()) } shortcut: it treats every second arrival as a successful duplicate, including second arrivals where the first attempt failed. The ADR-022 dedup contract is "at most once successful execution", but the code implements "at most one attempt".
Fix shape: distinguish claimed-but-not-processed from processed
Three states on every inbox row:
| State | Shape | Reaction on redelivery |
|---|---|---|
Fresh |
row missing |
INSERT (claim) and run handler |
In-flight / failed |
|
re-run handler with backoff |
Successfully processed |
|
dedup: log + return Ok |
Permanently failed |
|
publish to DLX with retry-count, return Ok |
Migration (additive ALTER)
-- services/<svc>/migrations/<ts>_inbox_retry_semantics.sql
-- Adds error-tracking + cap fields to event_inbox.
ALTER TABLE event_inbox
ADD COLUMN error_count INT NOT NULL DEFAULT 0,
ADD COLUMN last_error TEXT,
ADD COLUMN failed_at TIMESTAMPTZ,
ADD COLUMN attempt_started_at TIMESTAMPTZ;
CREATE INDEX idx_event_inbox_in_flight
ON event_inbox (received_at)
WHERE processed_at IS NULL AND failed_at IS NULL;
failed_at is set when error_count >= max_retries and we publish to the DLX. After this stamp the row is dedup-only — further redeliveries will short-circuit to dedup-log without re-running the handler or re-emitting to the DLX.
Defaults (flagged in §Open Questions)
| Knob | Default | Rationale |
|---|---|---|
|
5 |
Three-strikes-and-out feels light for a child-welfare workflow; five gives one transient-DB-blip + a retry + a deploy-window without overshooting. |
Backoff schedule |
1s, 4s, 16s, 60s, 60s (capped) with full jitter ±25% |
Exponential up to 60s ceiling; jitter prevents redelivery storms when N consumers fail simultaneously on a downstream blip. |
DLQ surface mechanism |
Direct publish to |
Reuses the existing DLX exchange; the audit consumer in craig-security already binds |
Backoff implementation site |
|
Subscriber-loop nack-with-requeue would also work but is harder to bound; in-process sleep keeps the retry-count + backoff state co-located with the dedup row. |
New function signature
pub const INBOX_MAX_RETRIES: i32 = 5;
const INBOX_BACKOFF_BASE: Duration = Duration::from_secs(1);
const INBOX_BACKOFF_CAP: Duration = Duration::from_secs(60);
pub async fn handle_idempotently<F, Fut>(
db: &PgPool,
publisher: &Publisher, // ← NEW: needed for DLX surface
envelope: EventEnvelope,
handler: F,
) -> anyhow::Result<()>
where
F: Fn(EventEnvelope) -> Fut + Send + Sync, // Fn (not FnOnce) — may run twice
Fut: std::future::Future<Output = anyhow::Result<()>> + Send;
Sequence — four paths
Path A — first delivery: INSERT claim → handler runs → UPDATE processed_at.
Path B — redelivery after success: INSERT returns 0 rows → SELECT row → processed_at IS NOT NULL → log dedup + return Ok.
Path C — redelivery after failure under cap: INSERT returns 0 rows → row has processed_at IS NULL, error_count < max_retries → compute jittered exponential backoff → sleep → re-run handler. On success: UPDATE processed_at. On Err: UPDATE error_count + 1, last_error; bubble Err.
Path D — redelivery after failure at cap: INSERT returns 0 rows → row has processed_at IS NULL, error_count >= max_retries → UPDATE failed_at = now() → publisher.publish_dlx(envelope, original_queue, error_count, last_error) → return Ok.
Distinguishing handler errors from envelope-deserialization errors
Envelope deserialization is in the subscriber loop (crates/craig-mq/src/subscriber.rs::consume_loop), upstream of handle_idempotently. That path already nacks-without-requeue, routing straight to the DLX. No retry — the bytes are unrecoverable. This stays unchanged.
handle_idempotently only sees envelopes that already deserialized; every Err returned from handler(envelope) is treated as retryable up to the cap.
DLX surface mechanism
impl Publisher {
pub async fn publish_dlx(
&self,
envelope: &EventEnvelope,
original_queue: &str,
retry_count: i32,
last_error: &str,
) -> Result<(), lapin::Error>;
}
Body: clone the envelope; mutate payload to wrap it in { "_dlx": { "retry_count": …, "last_error": …, "original_queue": … }, "original_payload": <prev payload> }; publish to DLX_EXCHANGE (craig.dlx) with routing key dlq.<original_queue> so the existing DLQ audit consumer binds and records.
Test description (depends on Step 2 fault-injection harness)
A failing-test-first integration test in crates/craig-mq/tests/inbox_retry.rs:
-
Embedded Postgres with
event_inboxschema (post-migration) -
Stand up a
FaultyHandler(Step 2 helper) that errors on attempt 1 and succeeds on attempt 2 -
Mock the publisher with
EventCollector -
Build envelope
e1 -
Call
handle_idempotently(db, &publisher, e1.clone(), &faulty_handler).await— expectErr(_); assert row state:error_count=1, processed_at IS NULL -
Call
handle_idempotently(db, &publisher, e1.clone(), &faulty_handler).await— expectOk(()); assert row state:processed_at IS NOT NULL, error_count=1 -
Assert
faulty_handlerwas invoked exactly twice
Plus Path-D DLX-surface test and Path-B post-success-dedup test.
D4. Atomic blob+DB+event upload (P1 — Step 6)
Four attachment-upload handlers write the binary to object storage before the database row + outbox event are committed. On any post-blob failure (DB insert error, unique-constraint violation, transient pool error, outbox stage error, panic between the blob write and the commit) the blob orphans in object storage with no metadata anchor and no cleanup. Garage’s quotas count toward an unbounded leak; private PII may sit in object storage with no audit pointer.
D4.1 Affected sites (current pattern)
| Site | put line | DB-write line | Wrapped in tx? |
|---|---|---|---|
|
121–124 |
128–142 |
no — direct pool insert |
|
207–210 |
212–216 |
no — |
|
485–488 |
491–500 |
no — direct pool insert |
|
110–113 |
116–140 |
partial — TX wraps |
D4.2 Fix-shape decision: post-commit blob write + compensating cleanup
Chosen pattern (default proposal, applied to all 4 sites):
-
Generate
object_keydeterministically up-front usingcraig_common::id::new_id()(UUID v7). The path remains<scope>/<parent>/<id>/<safe-name>exactly as today. -
BEGIN tx -
INSERT the metadata row with
object_status = 'pending'(see migration in §D4.4). -
publish_*the outbox event in the same tx (onlyreport_attachments.rsalready does this; the other 3 sites adopt the §D3.1 outbox pattern at the same time). -
COMMIT tx. The DB now owns a pending row anchoring the blob’s lifecycle. -
Call
object_store.put(&object_key, data)outside any tx. -
On
putsuccess:UPDATE attachments SET object_status = 'present' WHERE id = $1. Onputfailure: compensating-cleanup tx —DELETE FROM attachments WHERE id = $1and stage a.upload_failedoutbox event so consumers that already saw the.uploadedevent can react. If cleanup also fails, the row sits atobject_status = 'pending'and is reaped by the background scanner (§D4.5).
Alternative considered: pre-write blob with scopeguard or orphan-GC sweep. The pre-write-with-scopeguard variant pushes a deferred object_store.delete(&key) onto a stack that runs only if the function returns Err. It’s simpler to retrofit (no migration, no sweeper) but it’s strictly weaker than post-commit:
-
a panic between
putand thedefer-handler still orphans the blob -
a process kill (OOM, SIGKILL on rolling deploy, SIGTERM on autoscaler scale-down) between
putand the cleanup orphan —scopeguardcan’t survive process death -
there is no record-of-orphan to detect or audit
-
fundamentally treats the blob-store as the source of truth, inverting the invariant that every blob must be reachable from a metadata row
The post-commit pattern makes orphans structurally impossible: the only blobs in object storage are anchored to a pending or present row. The reverse-orphan (a pending row with no blob, after a post-commit put failure) is benign — it’s a row, it’s queryable, and it carries the timestamp + uploader for triage.
D4.3 New pattern (Rust)
let object_id = craig_common::id::new_id();
let file_size = data.len() as i64;
let object_key = format!("cases/contacts/{case_id}/{contact_id}/{object_id}/{safe_name}");
// Phase 1 — metadata + event in tx, blob NOT yet written.
let mut tx = app.db.inner().begin().await.map_err(ApiError::internal)?;
let attachment = store::contact_attachments::create_attachment(
&mut *tx,
store::contact_attachments::CreateAttachmentParams {
contact_id, attachment_type, file_name: safe_name.clone(),
content_type: content_type.clone(), file_size,
object_key: object_key.clone(),
uploaded_by: claims.sub.clone(),
worker_name: worker_name.clone(),
// object_status defaults to 'pending' in SQL
},
)
.await
.map_err(ApiError::internal)?;
events::publish_contact_attachment_uploaded(
&mut tx, attachment.id, contact_id, &attachment.file_name, attachment.file_size,
)
.await
.map_err(ApiError::internal)?;
tx.commit().await.map_err(ApiError::internal)?;
// Phase 2 — blob write + status promotion. Compensate on failure.
match object_store.put(&object_key, data).await {
Ok(()) => {
store::contact_attachments::mark_object_present(app.db.inner(), attachment.id)
.await
.map_err(ApiError::internal)?;
}
Err(blob_err) => {
tracing::warn!(
attachment_id = %attachment.id,
error = %blob_err,
"blob write failed post-commit; compensating",
);
let mut tx = app.db.inner().begin().await.map_err(ApiError::internal)?;
store::contact_attachments::delete_attachment_pending(&mut *tx, attachment.id)
.await
.map_err(ApiError::internal)?;
events::publish_contact_attachment_upload_failed(
&mut tx, attachment.id, contact_id, &format!("{blob_err}"),
)
.await
.map_err(ApiError::internal)?;
tx.commit().await.map_err(ApiError::internal)?;
return Err(ApiError::from(blob_err));
}
}
Ok(Json(attachment))
D4.4 Migration: object_status column
Per attachment table — 4 migrations, additive:
-- services/craig-cases/migrations/<TS>_attachment_object_status.sql
ALTER TABLE contact_attachments
ADD COLUMN object_status TEXT NOT NULL DEFAULT 'present'
CHECK (object_status IN ('pending', 'present', 'failed'));
ALTER TABLE contact_attachments
ALTER COLUMN object_status DROP DEFAULT;
CREATE INDEX idx_contact_attachments_pending
ON contact_attachments (created_at)
WHERE object_status = 'pending';
Mirror migrations for report_attachments, court_orders (the column there is added alongside the existing object_key NULL state — object_status distinguishes "no document yet" from "document upload pending"), and icpc_attachments.
D4.5 Error semantics + scanner
| Failure | Outcome |
|---|---|
Phase 1 fails (DB insert / event stage / commit) |
ApiError to caller, no blob written, no metadata row. |
Phase 2 |
ApiError to caller, no blob, row deleted, |
Phase 2 |
ApiError to caller, no blob, row sits at |
Phase 2 |
ApiError to caller, blob present, row at |
Process killed between Phase 1 commit and Phase 2 put |
Row at |
Process killed between Phase 2 put and status-promotion UPDATE |
Blob present, row at |
Background scanner (crates/craig-store/src/scanner.rs, new):
pub struct AttachmentScanner {
pool: PgPool,
store: Store,
grace: Duration, // default 1 hour
interval: Duration, // default 5 min
}
impl AttachmentScanner {
pub fn spawn(self) -> tokio::task::JoinHandle<()> { /* ... */ }
/// One scan pass per registered table. For each pending row older than `grace`:
/// - object_store.head(&object_key) → 200 ⇒ UPDATE object_status = 'present'
/// - head 404 ⇒ DELETE row + publish *.upload_failed
/// - head error ⇒ leave row, log, retry next pass
async fn scan_table(&self, table: &str) -> Result<ScanReport, ScanError> { /* ... */ }
}
Each service registers its attachment tables (cases registers contact_attachments + report_attachments + court_orders; exchange registers icpc_attachments).
D5. Exchange send: outbox-driven worker (P1 — Step 7)
Today services/craig-exchange/src/api/transactions.rs:56-144:
-
Line 80–93:
create_transactionwrites a pending tx row toexchange_transactionsdirectly against the pool — no surrounding tx. -
Line 96–104:
adapter.send(endpoint, &payload)makes the external HTTP call. No DB tx at all wraps this; partner-side state is now changing. -
Line 105–122 (success branch): begin tx →
update_transaction_status('success', …)→publish_exchange_sentin tx → commit. -
Line 123–139 (failure branch): begin tx →
update_transaction_status('failed', …)→publish_exchange_failedin tx → commit.
Failure modes: process killed between line 93 (row INSERT) and line 104 (adapter send) leaves a stuck pending row; killed between adapter.send and commit means partner accepted but DB shows pending; adapter timeout where partner committed leads to duplicate sends.
D5.1 Fix-shape decision: service-side outbox-driven worker
Chosen pattern: ExchangeSendWorker at services/craig-exchange/src/send_worker.rs, mirrors craig_mq::OutboxWorker’s structural shape but is exchange-specific. It drains a new `exchange_send_jobs table and invokes adapters::adapter_for(…).send(…).
Alternative considered: shared worker in craig-mq. Rejected because exchange retries are partner-specific (court-system retries idempotently within 5 min; CWCA must serialize per-case to avoid out-of-order partner state); shared worker can’t carry partner-policy without leaking exchange semantics into craig-mq. Service-side keeps observability scoped (exchange.send.attempts belongs to craig-exchange).
D5.2 New table
-- services/craig-exchange/migrations/<TS>_exchange_send_jobs.sql
CREATE TABLE exchange_send_jobs (
id UUID PRIMARY KEY DEFAULT uuidv7(),
transaction_id UUID NOT NULL REFERENCES exchange_transactions(id) ON DELETE CASCADE,
partner_id UUID NOT NULL,
correlation_id UUID NOT NULL DEFAULT uuidv7(),
payload JSONB NOT NULL,
status TEXT NOT NULL DEFAULT 'pending'
CHECK (status IN ('pending', 'in_flight', 'sent', 'failed')),
attempts INT NOT NULL DEFAULT 0,
last_error TEXT,
next_attempt_at TIMESTAMPTZ NOT NULL DEFAULT now(),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
completed_at TIMESTAMPTZ
);
CREATE INDEX idx_exchange_send_jobs_drainable
ON exchange_send_jobs (next_attempt_at)
WHERE status = 'pending';
CREATE UNIQUE INDEX idx_exchange_send_jobs_correlation
ON exchange_send_jobs (correlation_id);
correlation_id is the partner-side idempotency token: a deterministic UUID v7 generated on job stage, written into the adapter’s outbound payload as craig_correlation_id, and stored in exchange_transactions.correlation_id (additive column).
D5.3 New flow
-
Request handler: validate partner → BEGIN tx →
create_transaction(…, status='pending', correlation_id)→stage_send_job(…, transaction_id, partner_id, payload, correlation_id)→publish_exchange_send_requested→ COMMIT → respond 202 Accepted with the pending transaction row. -
Worker loop (polls every 1s):
-
claim a batch:
UPDATE exchange_send_jobs SET status = 'in_flight', attempts = attempts + 1 WHERE id IN ( SELECT id FROM exchange_send_jobs WHERE status = 'pending' AND next_attempt_at <= now() ORDER BY next_attempt_at LIMIT $1 -- max_concurrency FOR UPDATE SKIP LOCKED ) RETURNING *; -
for each claimed job, spawn a task: invoke
adapter.send(endpoint, &payload_with_correlation_id) -
on
Ok(response): tx →UPDATE exchange_send_jobs SET status='sent', completed_at=now()+update_transaction_status('success')+publish_exchange_sent→ commit -
on
Errwithattempts < MAX_ATTEMPTS: tx →UPDATE exchange_send_jobs SET status='pending', last_error=…, next_attempt_at=now()+backoff(attempts)→ commit -
on
Errwithattempts >= MAX_ATTEMPTS: tx →UPDATE exchange_send_jobs SET status='failed', last_error=…, completed_at=now()+update_transaction_status('failed')+publish_exchange_failed→ commit
-
D5.4 Worker module shape
// services/craig-exchange/src/send_worker.rs
pub const POLL_INTERVAL: Duration = Duration::from_secs(1);
pub const MAX_CONCURRENCY: usize = 8;
pub const MAX_ATTEMPTS: i32 = 6; // 1 immediate + 5 retries → ~32 min total
pub fn backoff(attempts: i32) -> Duration {
match attempts { 1 => 5, 2 => 30, 3 => 120, 4 => 300, 5 => 900, _ => 1800 }
.pipe(Duration::from_secs)
}
pub struct ExchangeSendWorker {
pool: PgPool,
publisher: Publisher,
client: reqwest::Client,
}
impl ExchangeSendWorker {
pub fn new(pool: PgPool, publisher: Publisher, client: reqwest::Client) -> Self;
pub fn spawn(self) -> tokio::task::JoinHandle<()>;
async fn drain_once(&self) -> Result<usize, SendWorkerError>;
async fn dispatch(&self, job: SendJob) -> Result<(), SendWorkerError>;
}
D5.5 Idempotency
Three layers, all required:
-
Worker-side claim atomicity:
FOR UPDATE SKIP LOCKEDensures only one worker (across instances) claims a given job. Theattemptsincrement in the same UPDATE means a crash-during-send leaves the row inin_flight; on restart, a recovery pass revertsin_flightrows older than 2 ×SEND_TIMEOUTback topending. -
Adapter-side dedup:
correlation_idis propagated into the payload as acraig_correlation_idfield; adapters that map to a partner-side idempotency key emit it automatically. -
Status-update idempotency: the
update_transaction_statusfinalize step is keyed bytransaction_idand is harmless to apply twice.
D5.6 Recovery
On service restart, two sweeps run before normal draining begins:
-
UPDATE exchange_send_jobs SET status='pending' WHERE status='in_flight' AND next_attempt_at < now() - INTERVAL '60 seconds'— reclaim jobs whose worker died mid-send. -
Existing
OutboxWorkerdrains anyevent_outboxrows that the original handler staged but never published.
D6. Placement state-read inside tx + foster-home FOR UPDATE (P1 — Step 8)
Today services/craig-placement/src/api/placements.rs:update_placement (lines 260–329):
let mut tx = app.db.begin().await.map_err(ApiError::internal)?; // line 277
let current = store::placements::get_placement(app.db.inner(), id) // line 279 — pool, NOT tx
.await
.map_err(ApiError::internal)?
.ok_or_else(|| ApiError::not_found("placement", id))?;
if let Some(ref new_status) = body.status {
transitions::validate_placement_transition(¤t.status, new_status)
.map_err(ApiError::bad_request)?; // ← validates against unlocked snapshot
}
// ...
if body.status.as_deref() == Some("ended")
&& let Some(home_id) = current.foster_home_id
{
store::foster_homes::decrement_occupancy(&mut *tx, home_id).await?; // ← no FOR UPDATE on the home
}
Two correctness defects:
-
Stale state-machine check:
get_placementreads via pool, not tx. Two concurrent requests can both validateactive → endedas legal, both UPDATE. The second silently overwrites the first. -
Lost-update on occupancy:
decrement_occupancyruns inside the tx but the home row is never locked. Concurrent placement-end requests against the same home can both decrement from N, leaving occupancy off-by-one or negative.
The create path (lines 100–125) already gets this right: get_foster_home_for_update is called inside the tx.
D6.1 Fix-shape decision
-
Move
get_placementinside the tx, using a new locked variantget_placement_for_updatethat issuesSELECT … FOR UPDATE. -
When the transition involves an occupancy change (today:
status → 'ended'decrements), callget_foster_home_for_updateinside the tx before the decrement. -
Re-validate the state-machine transition against the locked row.
D6.2 New store fn
pub async fn get_placement_for_update<'e, E>(
executor: E,
id: Uuid,
) -> Result<Option<Placement>, sqlx::Error>
where
E: sqlx::Executor<'e, Database = sqlx::Postgres>,
{
sqlx::query_as::<_, Placement>("SELECT * FROM placements WHERE id = $1 FOR UPDATE")
.bind(id)
.fetch_optional(executor)
.await
}
Verified existence: get_foster_home_for_update already exists at services/craig-placement/src/store/foster_homes.rs:86-97. Reused as-is.
D6.3 New handler shape
let mut tx = app.db.begin().await.map_err(ApiError::internal)?;
let current = store::placements::get_placement_for_update(&mut *tx, id)
.await
.map_err(ApiError::internal)?
.ok_or_else(|| ApiError::not_found("placement", id))?;
if let Some(ref new_status) = body.status {
transitions::validate_placement_transition(¤t.status, new_status)
.map_err(ApiError::bad_request)?;
}
if body.status.as_deref() == Some("ended")
&& let Some(home_id) = current.foster_home_id
{
let _home = store::foster_homes::get_foster_home_for_update(&mut *tx, home_id)
.await
.map_err(ApiError::internal)?
.ok_or_else(|| ApiError::not_found("foster home", home_id))?;
}
let placement = store::placements::update_placement(&mut *tx, id, /*...*/)
.await
.map_err(ApiError::internal)?
.ok_or_else(|| ApiError::not_found("placement", id))?;
if body.status.as_deref() == Some("ended")
&& let Some(home_id) = current.foster_home_id
{
store::foster_homes::decrement_occupancy(&mut *tx, home_id)
.await
.map_err(ApiError::internal)?;
}
if body.status.as_deref() == Some("ended") {
let end_reason = placement.end_reason.as_deref().unwrap_or("unspecified");
events::publish_placement_ended(&mut tx, placement.id, end_reason)
.await
.map_err(ApiError::internal)?;
}
tx.commit().await.map_err(ApiError::internal)?;
D6.4 Lock ordering
Always placement row first, foster home row second. The create path locks foster_home first because no placement row yet exists; there’s no cycle because update locks placement first and the create path doesn’t lock placement.
D6.5 Test coverage
Concurrent transition guard (concurrent_fire(2, |i| update_placement(id, status='ended')) — exactly one returns 200, the other 409); concurrent occupancy decrement on different placements same home (final occupancy = initial − 2); mixed concurrent updates; lock contention smoke; transition rollback on event-publish failure.
D7. BFF silent-degradation cleanup (P1 — Step 9)
Today services/craig-web/src/routes/mod.rs:51-70:
pub async fn fetch_page<T: serde::de::DeserializeOwned + Default>(
api: &ApiClient,
base_url: &str,
path: &str,
token: &str,
) -> PageResponse<T> {
match api.get(base_url, path, token).await {
Ok(v) => match serde_json::from_value(v) {
Ok(page) => page,
Err(e) => {
tracing::warn!(path, error = %e, "API response deserialization failed");
PageResponse::default() // ← silent: empty page, 200 to user
}
},
Err(e) => {
tracing::warn!(path, error = %e, "API list request failed");
PageResponse::default() // ← silent: empty page, 200 to user
}
}
}
PageResponse::default() is { data: vec![], page: 0, per_page: 0, total: 0 }. When the upstream API returns 500 / times out / returns garbage JSON, the user sees an empty list and a 200 status — indistinguishable from "this user really has no records." The pattern recurs across the BFF: ~101 unwrap_or_default() occurrences across 26 files.
D7.1 Fix-shape decision
-
fetch_page<T>returnsResult<PageResponse<T>, ApiError>instead of swallowing. -
Introduce a route-level error template + flash-banner pattern at
services/craig-web/src/routes/error.rs(new) for "main view failed to load". -
Audit each
unwrap_or_default()callsite; classify as legit-fallback (decorative / optional) vs silent-degradation (primary data); fix the silent ones. -
ApiError → HTTP status mapping: upstream 5xx → 502 Bad Gateway; upstream 4xx pass through with original status; 401 → redirect /login.
D7.2 New fetch_page
pub async fn fetch_page<T: serde::de::DeserializeOwned>(
api: &ApiClient, base_url: &str, path: &str, token: &str,
) -> Result<PageResponse<T>, ApiError> {
let v = api.get(base_url, path, token).await
.map_err(|e| ApiError::upstream(path, e))?;
serde_json::from_value(v)
.map_err(|e| ApiError::upstream_deserialize(path, e))
}
pub async fn fetch_page_or_empty<T: serde::de::DeserializeOwned + Default>(
api: &ApiClient, base_url: &str, path: &str, token: &str,
) -> PageResponse<T> {
fetch_page(api, base_url, path, token).await.unwrap_or_else(|e| {
tracing::warn!(path, error = %e, "fetch_page_or_empty: returning default");
PageResponse::default()
})
}
D7.3 ApiError variants + HTTP status mapping
| Variant | BFF HTTP status | When |
|---|---|---|
|
5xx → 502; 4xx pass-through; 401 → /login redirect |
Upstream returned an HTTP error. |
|
502 |
Upstream returned 200 but JSON didn’t match the BFF’s view struct. |
|
502 |
Connection refused, timeout, DNS fail. |
|
302 → |
Token expired or refresh failed. |
|
403 |
Token valid but lacks required role/scope. |
D7.4 Error template + flash banner
New template services/craig-web/templates/error/upstream_failure.html. New render_upstream_failure(…) helper at services/craig-web/src/routes/error.rs. Sets a flash banner via set_flash(cookies, &state.cookie_key, "error", &err.user_facing_message()) and renders the template with correlation_id for ops-side tracing.
D7.5 Partial-failure pattern
Many handlers tokio::join! 2-3 upstream calls. Pattern:
-
Primary resource (the entity the URL points at) is fatal-on-error: render error template.
-
Secondary resources (sub-tables, related counts) are partial-degradation-acceptable: render the page with primary, render secondary section as "(failed to load)" plus a flash banner.
Encoded by callers choosing fetch_page (fatal) vs fetch_page_or_empty (degraded with banner).
D7.6 Audit checklist for ~101 callsites
| Category | Definition | Examples | Fix |
|---|---|---|---|
Primary list view |
Main content of page is paginated list; empty-on-error is data loss. |
|
Switch to |
Primary detail view, joined sub-list |
Sub-list rendered as table on detail page. |
|
Use |
Sidebar widget / count chip |
Decorative count or short list shown alongside primary content. |
Dashboard counts, nav badge counts, security audit "recent activity" widget |
Keep |
Lookup cache miss |
Optional name resolution (UUID → display name). |
|
Keep current behavior; ensure |
Form-submit redirect path |
POST handler reads upstream after write to render redirect target’s success view. |
|
Already pattern-correct; leave alone. |
Per-route audit of ~26 files with line counts — full table in plan; samples include routes/cases/list.rs (2 occurrences, primary list), routes/exchange.rs (10 occurrences, mixed), routes/dashboard.rs (1 occurrence, sidebar — keep), routes/security/partners.rs (6 occurrences, primary list), etc.
D7.7 Test coverage (uses Step 2 fault-injector)
Primary list 502 surfacing; primary list deserialization failure (mock returns garbage JSON); joined sub-list partial degradation (partner-API ok, agreements-API 500); sidebar tolerance (one widget API fails, primary content still renders); auth-failure path (401 → 302); parametrized smoke for every audit-list-view route.
D8. P2 cluster — bundle (#281 — Step 10)
Four ops/correctness items. Each sub-item is a self-contained commit; sequencing within the cluster is unconstrained.
D8.1. Magic-byte upload validation + RFC 5987 Content-Disposition
Today crates/craig-store/src/validation.rs:37-52 performs declared-MIME allowlist only. PE binary uploaded as application/pdf passes. Filename sanitization at validation.rs:58-88 strips path separators + NUL but doesn’t RFC 5987 percent-encode for Content-Disposition.
New signatures:
pub fn validate_upload(
content_type: &str,
body: &[u8],
validation: &UploadValidation<'_>,
) -> Result<(), StoreError>;
pub fn content_disposition_attachment(filename: &str) -> String;
validate_upload takes the body slice and after the allowlist check inspects infer::get(&body[..body.len().min(256)]). Match rules: infer returns None (unknown signature, e.g. plain text/CSV) → allow if declared MIME is text/plain or text/csv, reject otherwise. infer returns kind whose canonical MIME equals content_type → allow. infer returns kind whose MIME differs → reject with StoreError::SignatureMismatch { declared, detected }.
Add infer = "0.16" (~50KB pure Rust) to workspace deps.
content_disposition_attachment(filename) per RFC 6266 §5: emit attachment; filename="<ascii_fallback>"; filename*=UTF-8''<percent-encoded>. ASCII fallback replaces non-ASCII + " + \ with _.
Test: parametric over (declared, body-prefix, expected) tuples. Filename round-trip test: "résumé/foo.pdf" → ASCII fallback contains no /, filename*=UTF-8'' field percent-encodes the é.
D8.2. CAPTCHA production-guard validation
Today services/craig-intake/src/config.rs:244-246: default_captcha_secret returns "disabled". captcha.rs:21 sentinels enabled = secret != "disabled". Production deployment that fails to set CRAIG_INTAKE__CAPTCHA_SECRET runs with CAPTCHA off.
New field on IntakeSettings:
#[serde(default)]
pub require_captcha: bool,
Extend IntakeSettings::validate():
if self.require_captcha && self.captcha_secret == "disabled" {
return Err(
"require_captcha=true but captcha_secret is 'disabled' — \
set CRAIG_INTAKE__CAPTCHA_SECRET to a real secret"
.into(),
);
}
Default-shape rationale (parallel to D9 framing): chose default = false. CAPTCHA is partner-config (Cloudflare Turnstile keys arrive per-deployment), not data-correctness. Production manifests set REQUIRE_CAPTCHA=true explicitly.
Test: require_captcha_true_with_disabled_secret_fails_validate.
D8.3. Move advisory publishes into outbox
Two callsites bypass the outbox:
Callsite 1: services/craig-security/src/main.rs:230-248 (DLQ threshold-exceeded alert). Surrounding handle_dead_letter already opens a tx for dead_letter_audit. Refactor: reorder so threshold query runs inside same tx, then craig_mq::stage_event(&mut tx, &alert).await? per breach, then commit.
Callsite 2: services/craig-rules/src/api.rs:259-263 + services/craig-rules/src/engine.rs:229-240 (rules cache invalidation). Refactor: lift the tx to the handler.
let mut tx = app.db.inner().begin().await.map_err(ApiError::internal)?;
let set = store::rule_sets::create_in_tx(&mut tx, /* params */).await?;
let envelope = EventEnvelope::new(
"craig-rules",
"rules.cache_invalidated",
serde_json::json!({"instance_id": engine.instance_id()}),
);
craig_mq::stage_event(&mut tx, &envelope).await
.map_err(ApiError::internal)?;
tx.commit().await.map_err(ApiError::internal)?;
engine.insert_decision(set.name.clone(), decision).await;
Test: RabbitMQ-down simulator (Step 2 helper) → call POST /v1/rules/sets → assert 200 + event_outbox row with published_at IS NULL for rules.cache_invalidated. Same shape for DLQ alert.
D8.4. CORS production-guard for craig-intake
Today services/craig-intake/src/config.rs:238-240: default_cors_origins returns "*". By design — public intake accepts submissions from any partner embed origin. But no warning if a deployment unintentionally inherits the default in a non-public-intake context.
Fix: in IntakeSettings::validate(), append:
if self.cors_origins.trim() == "*" {
tracing::warn!(
"CORS origins set to wildcard '*' — acceptable for public intake \
(forms embedded on partner sites), but verify this matches the \
deployment intent."
);
}
No bail. Production runbooks add a grep on the warn line as a deploy-readiness check.
Test: cors_wildcard_emits_warning using tracing-test.
D9. Encryption mode configuration-layer fail-closed (#273 — Step 11)
ADR-020 made the runtime path fail-closed. The configuration layer is still optional-by-default (services/craig-cases/src/main.rs:73-83: missing CRAIG_FIELD_ENCRYPTION_KEY → info!("encryption disabled") → service runs, encryption.rs:27 writes plaintext).
Decision pending (Option A vs Option B). This subsection documents both; the choice is one env-var-default and one CHANGELOG entry’s worth of difference.
D9.1. Option A — default required (fail-closed-by-default)
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum EncryptionMode {
#[default]
Required,
Optional,
}
// in ServiceSettings:
#[serde(default)]
pub encryption_mode: EncryptionMode,
Migration burden: docker-compose.yml every craig-cases-using stanza gets CRAIG_CASES__ENCRYPTION_MODE: optional; CI manifests inherit; tests inject optional or generate a test key (prefer the test-key path so the encrypt/decrypt round-trip is exercised); production deploy manifests inherit the default — no change required there.
CHANGELOG: breaking-change-with-migration entry.
D9.2. Option B — default optional (zero migration burden)
Same enum, #[default] on Optional. Production manifests must explicitly set CRAIG_CASES__ENCRYPTION_MODE=required. Devstack/CI/tests inherit optional default.
CHANGELOG: non-breaking entry.
D9.3. Implementation common to both
let encryptor = match (
settings.encryption_mode,
FieldEncryptor::from_env("CRAIG_FIELD_ENCRYPTION_KEY"),
) {
(EncryptionMode::Required, Ok(enc)) => {
info!("field encryption enabled (mode=required)");
Some(enc)
}
(EncryptionMode::Required, Err(e)) => {
anyhow::bail!(
"encryption_mode=required but CRAIG_FIELD_ENCRYPTION_KEY load failed: {e}"
);
}
(EncryptionMode::Optional, Ok(enc)) => {
info!("field encryption enabled (mode=optional)");
Some(enc)
}
(EncryptionMode::Optional, Err(_)) => {
info!("field encryption disabled (mode=optional, no key set)");
None
}
};
Defense-in-depth on the runtime path: even with the boot guard, tighten encrypt_field to take the mode and reject misalignment:
pub fn encrypt_field(
encryptor: Option<&FieldEncryptor>,
mode: EncryptionMode,
value: &str,
) -> Result<String, ApiError> {
match (encryptor, mode) {
(Some(enc), _) => enc.encrypt_str(value).map_err(...),
(None, EncryptionMode::Required) => Err(ApiError::internal(
"encryption_mode=required but no encryptor available — startup invariant violated",
)),
(None, EncryptionMode::Optional) => Ok(value.to_string()),
}
}
The mode reaches handlers via AppState. 5 callsites in services/craig-cases/src/api/persons.rs get the threaded mode argument.
Tests: mode_required_missing_key_bails_at_boot; mode_optional_missing_key_boots_with_none_encryptor; encrypt_field_required_with_no_encryptor_returns_500; mode_required_with_key_round_trip.
D10. P3 cleanup bundle (#282 — Step 12)
Seven low-risk cleanups grouped into a single MR. Each sub-item is ~10-30 lines of diff.
D10.1. Large-file decomposition (7 files >500 LOC)
| File | LOC | Decomposition |
|---|---|---|
|
893 |
|
|
859 |
|
|
709 |
|
|
672 |
|
|
634 |
|
|
595 |
|
|
541 |
|
Reviewer-tractability bound: ~400 LOC per resulting file. Each submodule re-exports its public handlers via pub use from mod.rs — pure internal reorganization, no callsite churn outside the directory.
Sequencing: lands before D10.7 (string-typed enum boundaries) so the enum-replacement diffs are scoped to the smaller post-split files.
D10.2. Dependency dupes
Cargo.toml:58 workspace pins zen-engine = "0.54"; xtask/Cargo.toml:24 pins zen-engine = { version = "0.35" }. Fix: upgrade xtask to 0.54. Audit xtask’s zen-engine usage with Grep zen_engine xtask/. If APIs are forward-compatible, the upgrade is a one-line bump.
jsonwebtoken 9.3 vs 10.3 dupe via reqsign-azure-storage and reqsign-google transitives. Fix path 1: bump reqsign deps to a version using jsonwebtoken 10. Fix path 2: workspace-level [patch.crates-io] override pinning jsonwebtoken to 10. Fix path 3: defer with documented exception if reqsign 9 → 10 cut introduces breaking JWT-claim changes.
Verification: cargo tree -d shows no duplicates.
D10.3. HSTS header
Add to crates/craig-api/src/lib.rs::apply_global_layers (lines 184-195):
.layer(SetResponseHeaderLayer::overriding(
HeaderName::from_static("strict-transport-security"),
HeaderValue::from_static("max-age=31536000; includeSubDomains"),
))
max-age=31536000 is one year, deploy.gov-recommended floor. includeSubDomains is safe — every craig service domain is TLS-only via the ingress. Document why preload is omitted (deployer-controlled assertion).
D10.4. Magic numbers → constants
Sample magic numbers: 60 RPM rate-limit defaults at services/craig-intake/src/api/api_key_lookup.rs:76 and services/craig-security/src/store/partners.rs:38; 30s timeouts inline at services/craig-intake/src/api/service_token.rs:99.
Create crates/craig-common/src/constants.rs:
use std::time::Duration;
pub const DEFAULT_PARTNER_RATE_LIMIT_RPM: u32 = 60;
pub const TOKEN_REFRESH_LEAD_TIME: Duration = Duration::from_secs(30);
// ... add as audit progresses
Update callsites to import. The partners.rs:38 SQL default migrates to a new migration that drops the literal 60 and lets the application layer enforce the default before INSERT.
D10.5. Stale comment + .expect() hygiene
Stale comment: services/craig-cases/src/api/reports.rs:163-164 references long-completed Step 4/Step 6. Replace with permanent-posture comment about Keycloak service-account JWT trust.
.expect() hygiene: 4 non-actionable expects in test-lib + telemetry. Audit list under #282; cap MR scope to those four.
D10.6. Swagger-public explanatory comment
crates/craig-api/src/lib.rs:116-118:
if let Some(doc) = api_doc {
// Swagger UI + OpenAPI spec are intentionally public (Kerckhoffs):
// every protected endpoint still requires a valid Bearer JWT through
// the auth_middleware on /v1. Discoverable schemas do not weaken auth.
// Decision: 2026-05-03 (per platform-stabilization-2 D10.6).
router = router.merge(SwaggerUi::new("/swagger-ui").url("/api-doc/openapi.json", doc));
}
D10.7. String-typed enum boundaries
| File:Line | Current | Enum |
|---|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
Pattern: every enum derives [derive(Deserialize, Serialize, ToSchema)] with [serde(rename_all = "snake_case")]. Wire shape unchanged (still snake_case JSON string); type system constrains accepted values at the DTO boundary.
Discriminate enum candidates: "are accepted values fixed by CRAIG, or supplied by partner config?" Fixed → enum; partner-supplied → String + reference-table validation.
Test: regression test per enum that POSTs an invalid string for the field, asserts 400.
Steps
Step 1: Plan adoc + nav.adoc Active + CHANGELOG entry
Files:
-
docs/modules/ROOT/pages/plans/platform-stabilization-2.adoc(new — content of this entire plan) -
docs/modules/ROOT/nav.adoc(Active section: add platform-stabilization-2 xref) -
CHANGELOG.adoc(entry under== Unreleased) -
Delete
docs/modules/ROOT/pages/plans/test-framework-hardening.adocfrom branch (the .adoc for epic &22 is filed in a separate later MR per user direction; epic + step issues #283-#291 stay on GitLab)
Implementation: mechanical write-out of this plan’s content. No code, no migration, no tests.
Verification:
-
glab issue list --label="P0-critical,P1-high"— confirms the 13 P0/P1 step issues exist (#274-#280, #283-#290) and are linked to the right epic -
glab epic view 21— confirms epic body lists this plan -
Antora docs render
platform-stabilization-2.adocwithout errors (verifiable via local Antora build orcargo xtask api-docs) -
nav.adoc Active section visibly shows platform-stabilization-2
-
Pre-push gate passes — no code touched
Step 2: Minimal test infrastructure inline
|
Sequencing reset: test-framework-hardening (epic &22, archived MR !215) shipped FIRST and supersedes the original Step 2 scope. The plan as drafted assumed Step 2 would author the helpers from scratch; in practice, the consolidated helpers already exist:
Step 2 therefore reduces to a gap-fill MR: enumerate each fault injector this plan’s Steps 3–9 actually call for ( The original "Files" list and "Implementation" prose below still describe the substantive work; only the part that’s already shipped (the trait + wrappers + concurrent helper) can be skipped. |
Files (post-supersession):
-
crates/craig-test-lib/src/concurrent.rs— already shipped by test-framework-hardening Phase A.1; no work needed -
crates/craig-test-lib/src/fault/(extend in place — base injectorscipher,rabbit_down,object_store_error,publish_in_tx_failure) -
crates/craig-test-lib/src/lib.rs(re-exports for the new fault submodules) -
crates/craig-test-lib/Cargo.toml(any deps the injectors need) -
.claude/docs/testing.md(extend the existing "Failure-path testing helpers" section if base injector wiring needs documentation) -
crates/craig-test-lib/tests/fault_injection_base.rs(smoke test for each new base injector)
Implementation:
concurrent_fire:
/// Spawn N concurrent tokio tasks, await all, collect results in spawn order.
///
/// Used by tests that need to fire multiple parallel calls against the same
/// endpoint or store function and assert exactly-once / no-double behavior.
pub async fn concurrent_fire<F, R, Fut>(n: usize, builder: F) -> Vec<R>
where
F: Fn(usize) -> Fut + Send + Sync,
Fut: std::future::Future<Output = R> + Send + 'static,
R: Send + 'static,
{
let mut handles = Vec::with_capacity(n);
for i in 0..n {
let fut = builder(i);
handles.push(tokio::spawn(fut));
}
let mut results = Vec::with_capacity(n);
for h in handles {
results.push(h.await.expect("concurrent_fire task panicked"));
}
results
}
Fault injectors (each as a thin wrapper struct with internal AtomicBool / AtomicUsize state):
-
CipherErrorInjector: wrapsFieldEncryptor, exposesinject_error_on_next_n(n). Calls toencrypt_str/decrypt_strfail with a syntheticcraig_crypto::Errorfor the nextninvocations. -
DbErrorInjector: wrapsPgPool/ sqlxExecutor. Hasinject_error_after(n)— the nextnqueries succeed; queryn+1returnssqlx::Error::PoolTimedOut(or a configurable variant). -
RabbitDownInjector: wrapsPublisher, exposesinject_unavailable_for(duration). Calls topublish/publish_in_tx/publish_dlxreturnlapin::Error::IOError(io::Error::new(io::ErrorKind::ConnectionRefused, …))for the duration. -
ObjectStoreErrorInjector: wrapsStore, exposesinject_put_failure_for_keys(prefix). Calls toputwhose key starts withprefixreturnStoreError::ObjectStore(…). Optionallyinject_head_failure_for_keys(prefix)for the scanner test. -
PublishInTxFailureInjector: wrapscraig_mq::stage_event, exposesfail_for_event_type(event_type). Calls staging an envelope whoseevent_typematches returnsqlx::Error::Decode(…)(mimicking a serialization failure inside the tx).
Each injector exposes:
-
new(inner)constructor -
a builder-style
with_<scenario>(…)for one-shot test setup -
Dropimpl that asserts no remaining injected-but-not-consumed faults (catches tests that set up injection but the codepath never reached it — false-positive-prevention)
Verification:
-
cargo nextest run -p craig-test-lib --test concurrent— 1 smoke test whereconcurrent_fire(10, |i| async move { i })returnsvec -
cargo nextest run -p craig-test-lib --test fault_injection— 1 smoke test per injector exercising the inject-then-call flow -
cargo clippy -p craig-test-lib --tests --locked — -D warnings— clean -
.claude/docs/testing.mdshows new section
Cross-reference: the two-pass platform-invariant audit's storage-shape-vs-semantics lesson; epic &22 Phase A consolidates these.
Step 3: P0.1 — Idempotency atomic-claim state machine
Files: crates/craig-api/src/idempotency.rs,
services/{craig-cases,craig-exchange,craig-financial,craig-placement,craig-reporting,craig-rules,craig-security}/migrations/<ts>_idempotency_atomic_claim.sql,
crates/craig-api/tests/idempotency_atomic_claim.rs
Cross-reference: §D1.
-
Generate matching migration files in all 7 stateful services using §D1’s ALTER TABLE block. Use
sqlx migrate addto mint timestamp. -
In
crates/craig-api/src/idempotency.rs:-
Extend
CachedRowwithstatus: String,started_at,claim_expires_at,finished_at; mark response columnsOption<…> -
Add
try_claim,await_winner,finalize_succeeded,finalize_failed -
Replace
idempotency_middlewarebody to drive the §D1 state machine -
Add
idempotency_in_flight_response()helper returning 409 + Problem Details +Retry-After: 5
-
-
Map every new error path through
ApiErrorper §D1 table. -
Test (
tests/idempotency_atomic_claim.rs): three async tests usingconcurrent_firefrom Step 2 — winner-only, claim-recovery, ceiling-409.
Step 4: P0.2 — Outbox FOR UPDATE SKIP LOCKED
Files: crates/craig-mq/src/outbox.rs, crates/craig-mq/tests/outbox_concurrent.rs
Cross-reference: §D2.
-
Wrap
drain_oncebody inlet mut tx = self.pool.begin().await?; -
Append
FOR UPDATE SKIP LOCKEDto SELECT; switch all DB ops to&mut *tx -
Move both UPDATE branches inside the loop and inside the tx
-
Add
tx.commit().await?;at end; early-returnOk(0)when rows empty -
No migration. No callsite changes.
-
Test (
tests/outbox_concurrent.rs): embedded Postgres, 200 staged rows, twoOutboxWorkerinstances against samePgPool,tokio::join!drain loop, assertEventCollector.count() == 200. Bonus stress variant withtokio::time::sleep(Duration::from_millis(20))shim insideEventCollector::publish.
Step 5: P0.3 — Inbox three-state retry semantics
Files: crates/craig-mq/src/inbox.rs, crates/craig-mq/src/publisher.rs, crates/craig-mq/src/lib.rs,
services/{craig-cases,craig-exchange,craig-financial,craig-reporting,craig-rules,craig-security}/migrations/<ts>_inbox_retry_semantics.sql,
services/<svc>/src/subscribers/*.rs (call-site signature updates),
crates/craig-mq/tests/inbox_retry.rs
Cross-reference: §D3.
-
Generate matching migrations in 6 consumer services (placement is publisher-only).
-
Add
Publisher::publish_dlxper §D3. -
In
crates/craig-mq/src/inbox.rs:-
Add
INBOX_MAX_RETRIES = 5,INBOX_BACKOFF_BASE = 1s,INBOX_BACKOFF_CAP = 60sconstants -
Change
handler: FfromFnOncetoFn -
Add
publisher: &Publisherandqueue_name: &strarguments -
Implement four-path state machine per §D3
-
fn backoff_for(error_count) → Durationwith jitter; unit-test bounds + monotonicity
-
-
Update every
handle_idempotentlycall site to pass the new arguments. Grep for callsites inservices/{cases,exchange,financial,reporting,rules,security}/src/subscribers/*.rs. -
Test (
tests/inbox_retry.rs): under-cap retry (usesFaultyHandlerfrom Step 2), DLX surface at cap, post-success dedup, backoff helper bounds.
Step 6: P1.1 — Atomic blob+DB+event upload
Files: 4 attachment-upload api/handlers, 4 store/*_attachments.rs modules, 4 migrations, crates/craig-store/src/scanner.rs (new), crates/craig-store/src/lib.rs (export), services/craig-{cases,exchange}/src/main.rs (spawn scanner), 4 new tests/api/*_atomic.rs files.
Cross-reference: §D4.
-
Apply §D4.4 migrations (4 total). Verify backfill: existing rows go to
present. Drop column DEFAULT after backfill. -
Add
mark_object_present,delete_attachment_pendingto each store module. -
For court_orders:
set_object_pending,mark_object_present,clear_object_pending. -
Add
publish_*_upload_failedevent functions. -
Rewrite each handler to §D4.3 shape (4 sites).
-
Implement
AttachmentScannerper §D4.5. Register in each service’s main.rs. -
Tests per §D4.6 — 5 per site × 4 sites using Step 2
inject_blob_put_failureandinject_db_failure_after_n.
Step 7: P1.2 — Exchange send outbox-driven worker
Files: services/craig-exchange/src/api/transactions.rs, services/craig-exchange/src/send_worker.rs (new), services/craig-exchange/src/store/{transactions,send_jobs}.rs, services/craig-exchange/src/events.rs, services/craig-exchange/migrations/<TS>_exchange_send_jobs.sql, services/craig-exchange/migrations/<TS>_exchange_transactions_correlation_id.sql, services/craig-exchange/src/main.rs, services/craig-exchange/src/adapters/standard.rs, services/craig-exchange/tests/{send_worker,api/transactions_outbox}.rs.
Cross-reference: §D5.
-
Apply §D5.2 migrations + correlation_id ALTER.
-
Add
store/send_jobs.rs:stage_send_job,claim_pending(FUSL SQL),finalize_sent,finalize_retry,finalize_failed,recover_in_flight. All idempotent onjob_id. -
Implement
ExchangeSendWorkerper §D5.4. Loop:recover_in_flightonce at spawn, thendrain_onceeveryPOLL_INTERVAL. Failure-class predicate: 4xx non-retryable, 5xx + transport retryable. -
Refactor
send_exchangehandler per §D5.3. -
Spawn worker from main.rs after OutboxWorker.
-
Update
adapters/standard.rs::transform_outboundto insertcraig_correlation_idfrom staged job. -
Tests per §D5.7 (6 cases) using Step 2 fault injectors.
Step 8: P1.3 — Placement state-read inside tx + foster-home FOR UPDATE
Files: services/craig-placement/src/api/placements.rs, services/craig-placement/src/store/placements.rs, services/craig-placement/tests/api/placements_concurrent.rs.
Cross-reference: §D6.
-
Add
get_placement_for_updatetostore/placements.rsper §D6.2 — adjacent to existingget_placement(line 73). -
Rewrite
update_placement(api/placements.rs:260-329) to §D6.3 shape. -
Audit no other handler reads-then-writes placement outside tx:
Grep "get_placement(app.db.inner()"onservices/craig-placement/src/api/**. -
Tests per §D6.5 — 5 cases using Step 2
concurrent_fireandfail_publish_in_tx_on.
Step 9: P1.4 — BFF silent-degradation cleanup
Files: services/craig-web/src/routes/mod.rs, services/craig-web/src/routes/error.rs (new), services/craig-web/templates/error/upstream_failure.html (new), services/craig-web/templates/_partials/upstream_partial_failure_banner.html (new), crates/craig-common/src/error.rs, ~26 files under services/craig-web/src/routes/*/.rs containing unwrap_or_default (per §D7.6 audit), services/craig-web/tests/upstream_failures.rs (new).
Cross-reference: §D7.
-
Extend
ApiErrorwith §D7.3 variants. Addbff_status(),user_facing_message(). -
Rewrite
fetch_pageper §D7.2. Addfetch_page_or_empty. Update all callers (type system forces this). -
Implement
routes/error.rsper §D7.4. -
Walk §D7.6 audit table. Per category:
-
Primary list view →
?-propagation; route-level mapper callsrender_upstream_failure -
Joined sub-list →
tokio::join!withfetch_page(primary) +fetch_page_or_empty(secondary); set partial-failure flag in template context -
Sidebar → keep
fetch_page_or_empty; add// silent fallback: <reason>comment + ensuretracing::warn! -
Auth-failure → propagate
ApiError::SessionExpired; route-level mapper redirects
-
-
Tests per §D7.7 using mocked-upstream fixture (wiremock-style).
Step 10: P2 cluster — magic-byte + CAPTCHA-guard + outbox publishes + CORS warn (#281)
Files: crates/craig-store/src/validation.rs, crates/craig-store/Cargo.toml, Cargo.toml (workspace deps for infer, urlencoding), services/craig-intake/src/config.rs, services/craig-security/src/main.rs, services/craig-rules/src/api.rs, services/craig-rules/src/engine.rs, every download handler emitting Content-Disposition.
Cross-reference: §D8.
Implement D8.1-D8.4. Recommended commit order:
-
D8.1 (validate_upload signature change is largest blast radius — land first)
-
D8.4 (one-line warn in validate())
-
D8.2 (require_captcha field + validate() check)
-
D8.3 (DLQ alert + rules cache invalidation outbox migration; depends on
craig_mq::stage_eventalready established by Step 8 of the prior plan)
Step 11: Encryption mode configuration-layer fail-closed (#273)
Files: crates/craig-common/src/settings.rs, services/craig-cases/src/main.rs, services/craig-cases/src/api/encryption.rs, services/craig-cases/src/api/persons.rs (5 callsites), crates/craig-api/src/lib.rs (AppState), docker-compose.yml and CI manifests (Option A only), CHANGELOG.adoc.
Cross-reference: §D9.
Decision-pending: Option A vs Option B. Get sign-off before merging.
-
Add
EncryptionModeenum + field toServiceSettings(option-agnostic shape). -
Rewrite
services/craig-cases/src/main.rs:73-83to four-arm match from §D9.3. -
Thread
encryption_modethroughAppStateto handlers. -
Update
encrypt_field/decrypt_fieldsignatures + 5 callsites. -
Tests:
mode_required_missing_key_bails_at_boot,mode_optional_missing_key_boots_with_none_encryptor,encrypt_field_required_with_no_encryptor_returns_500,mode_required_with_key_round_trip. -
(Option A only) update
docker-compose.yml+ CI manifests + test harnesses. -
CHANGELOG entry.
Step 12: P3 cleanup bundle (#282)
Files: per §D10 sub-items — large-file decomp directories, Cargo.toml/xtask/Cargo.toml/crates/craig-store/Cargo.toml, crates/craig-api/src/lib.rs, crates/craig-common/src/constants.rs (new) + crates/craig-common/src/lib.rs, several callsite files for magic-numbers + comments + expects, crates/craig-reference/src/lib.rs for new enums.
Cross-reference: §D10.
Recommended commit sequence:
-
D10.3 (HSTS — 4-line additive change)
-
D10.6 (Swagger comment — one comment line)
-
D10.5 (stale comment + 4 expects)
-
D10.4 (magic numbers + new constants module + migration)
-
D10.2 (dep dedupe; verify with
cargo tree -d) -
D10.1 (large-file decomp; mechanical, large diff — land before D10.7)
-
D10.7 (string-typed enum boundaries)
Test descriptions: D10.1 pure-mechanical (no new tests; existing handler tests pass post-split). D10.2 verifies via cargo tree -d. D10.3 adds integration assertion in global-headers smoke test. D10.7 adds one negative test per enum (POST invalid string → 400).
Step 13: Plan completion audit + archive
Files: docs/modules/ROOT/pages/plans/platform-stabilization-2.adoc (Status table → all Complete), docs/modules/ROOT/nav.adoc (move from Active to archive), docs/modules/ROOT/pages/plans/archive.adoc (new row), .claude/CLAUDE.md (Phase Status final stats), CHANGELOG.adoc (wrap-up entry).
-
Spawn
plan-completion-auditsubagent per delivery-protocol.md -
Verify all 11 prior steps complete via MR list
-
Flip Status table all-Complete
-
Move plan from Active to archive
-
Add archive.adoc row under Infrastructure & Reliability (or similar) with all step MR numbers
-
Update CLAUDE.md Phase Status if relevant
-
CHANGELOG wrap-up entry
-
Close epic &21
Files Touched
| File | Change |
|---|---|
|
New plan file (this content) |
|
Active section: add platform-stabilization-2 xref |
|
Step 1 entry under Unreleased; subsequent entries per step |
|
Step 3 — atomic-claim state machine |
|
Step 4 — FOR UPDATE SKIP LOCKED |
|
Step 5 — three-state retry + DLX surface |
|
Step 6 attachment scanner; Step 10 magic-byte validation |
|
Step 6 — atomic blob+DB+event upload |
|
Step 7 — exchange send outbox-driven worker |
|
Step 8 — placement state-read inside tx |
|
Step 9 — BFF silent-degradation cleanup |
|
Step 10 — P2 cluster fixes |
|
Step 11 — encryption mode |
7 large-file decomp directories + dep-dupe pins + HSTS layer + magic-number constants + enum boundary swaps |
Step 12 — P3 cleanup |
13 migrations across 7 services |
Steps 3 (×7), 5 (×6), 6 (×4), 7 (×2), 10 (variable) |
|
Step 2 — minimal test infra |
Verification
-
cargo nextest run --workspace --lib— all unit tests pass -
cargo xtask dev restart— devstack reloads after schema changes (Steps 3/5/6/7 add migrations) -
cargo nextest run --workspace --locked --profile integration— full integration battery passes including new concurrency / fault-injection / state-machine tests -
cargo xtask e2e— Playwright E2E suite passes -
cargo xtask validate --skip-docker— fmt + clippy + cargo-deny + JDM ruleset validation pass -
cargo xtask security— auth + injection + infrastructure phases pass; pentest CI catches no new high/critical alerts -
cargo xtask perf --profile load— k6 load profile passes SLO thresholds -
Manual: kill craig-cases between Phase 1 commit and Phase 2 put in Step 6; restart; assert scanner reaps orphan within 5 min
-
Manual: kill craig-exchange mid-send in Step 7; restart; assert no stuck
in_flightjobs after 60s -
Manual: stop craig-cases (
docker compose stop craig-cases); GET BFF list pages; assert 502 with error template; restart; assert 200 with list -
cargo tree -dafter Step 12 D10.2 — no zen-engine or jsonwebtoken duplicates
Documentation Updates
-
.claude/docs/services.md— outbox/inbox/idempotency semantic notes; newexchange_send_jobstable; newobject_statuscolumns -
.claude/docs/security.md— encryption-mode posture (after Step 11 decision); CORS production-guard; magic-byte upload validation -
.claude/docs/testing.md—concurrent_firehelper + fault-injection harness usage docs (Step 2);Failure-path testing helperssection -
CHANGELOG.adoc— entry per step -
docs/modules/ROOT/pages/architecture.md— concurrency-invariants section -
docs/modules/ROOT/pages/adrs/adr-022-event-durability-and-idempotency.adoc— addendum documenting semantic concurrency requirements (atomic claim, FUSL, processed-state retry) -
docs/modules/ROOT/pages/plans/archive.adoc— row added on Step 13
Open questions
-
#273 encryption-mode default —
required(fail-closed-by-default, breaking change for devstack/CI) vsoptional(zero migration, ops must remember to flip in prod). Recommendation:required. Pending user decision. -
Idempotency loser-side semantics — when the loser observes the winner’s
processingrow, do we poll-and-replay (keeps clients waiting) or 409 immediately (clients must retry)? Concurrency-correct either way; UX difference. Default proposal: poll-and-replay with a 5s ceiling, 409 after. -
Outbox locking style —
FOR UPDATE SKIP LOCKED(simpler, Postgres-native) vsclaimed_by/claimed_atcolumns (more portable, allows lease-expiration recovery). Default proposal:FOR UPDATE SKIP LOCKEDwithLIMIT N; revisit if multi-region / multi-DB topology emerges. -
Inbox max retries + backoff — what’s the cap before DLQ surface? Default proposal: 5 attempts, exponential backoff capped at 60s, then DLQ.
-
ExchangeSendWorker location — service-side vs craig-mq shared. Default proposal: service-side at
services/craig-exchange/src/send_worker.rs. -
State-machine matrix testing crate (deferred to epic &22 Phase A.4) —
proptestvs hand-rolled. Default proposal: hand-rolled.
After this plan lands
-
All 7 platform invariants are SEMANTICALLY verified, not just structurally
-
The two-pass platform-invariant audit's storage-shape-vs-semantics lesson is encoded in code + tests, not just the memory file
-
ADR-022 has an addendum documenting the concurrency-semantics requirements
-
Sibling epic &22 (Test Framework Hardening) — its plan adoc gets written next as a separate MR; Phase A consolidates Step 2’s inline helpers + extends to multi-replica devstack, JWT mutation, state-machine matrix, etc.
-
i18n (#148) and feature work resume
CRAIG can credibly claim platform-invariant correctness for the federally-protected child welfare data it stores.
Errata
Implementation deltas vs. the original plan, captured for reviewer clarity:
-
Step 2 — minimal test infrastructure inline became a pure verification + sequencing-doc MR. The original Step 2 spec called for authoring
concurrent_fire+ 5 base fault injectors (cipher, db, rabbit_down, object_store, publish_in_tx). Test Framework Hardening (epic &22, archived MR !215) shipped FIRST and provided richer surfaces:Original Step 2 spec Shipped today Source concurrent_fire(n, builder)concurrent_fire_collect,concurrent_fire_collect_ordered,concurrent_fire_synchronized,concurrent_fire_with_barrier,concurrent_fire_until_first_success,concurrent_fire_first_n(deprecatedconcurrent_firealias retained)crates/craig-test-lib/src/concurrent.rsFaultInjectortrait +ScenarioGuard+AttemptSame shape, public API
crates/craig-test-lib/src/fault/mod.rsGeneric wrapper injectors (latency, flap, backpressure)
All 3 shipped
crates/craig-test-lib/src/fault/{latency,flap,backpressure}.rsCipherErrorInjectorDeferred — pairs with §D11 (encryption fail-closed)
Step 11
DbErrorInjector(generic PgPool wrap)Rejected by design — replaced with named store-trait faulty impls (
FaultyAttachmentStore,FaultyOutboxStore, etc.). Generic DB-error wrapping conflates unrelated failure modes; named store traits make each step’s failure semantics testable in isolation.Steps 4/5/6/7/8 ship the trait + faulty impl pairs
RabbitDownInjectorDeferred — pairs with §D2 (outbox
FOR UPDATE SKIP LOCKED) and §D3 (inbox three-state)Steps 4 / 5
ObjectStoreErrorInjectorDeferred — pairs with §D4 (atomic blob+DB+event upload)
Step 6
PublishInTxFailureInjectorDeferred — pairs with §D2 (outbox)
Step 4
Each base injector ships with its first consuming step rather than alone — the failing-test-first discipline (§D2.0) requires the injector and the production-seam refactor that consumes it to land in the same MR. Step 2 therefore reduces to: confirm the consolidated helpers cover the spec (done), document each base injector’s pairing (above table), retain the deferred
concurrent_firealias for forward-compatibility (already done in Phase A.1). No new code in this MR; ships acrates/craig-test-lib/tests/platform_stab_2_step2_helpers_present.rssmoke test pinning the public API surface so a future refactor can’t silently delete what platform-stab-2 Steps 3–9 expect to find. -
Step 3 — column rename and deferred sub-paths. The §D1 ALTER block in this plan was authored against an aspirational schema where the existing HTTP-status column was already named
status_code; the Step 10 migration that actually shipped usedstatus(SMALLINT). To make room for the new TEXTstatuscarrying the state machine, the §D1 migration20260505151229_idempotency_atomic_claim.sqlfirst renames the legacy columnstatus→status_code, then adds the state-machine columns. The RustCachedRowstruct incrates/craig-api/src/idempotency.rsis updated to match in the same MR. Two of the four §D1 sequence paths shipped fully covered:Path Coverage today Why deferred (if applicable) A — winner runs handler exactly once
services/craig-cases/tests/api/idempotency_atomic_claim.rs::winner_runs_handler_exactly_once(concurrent_fire_collect(5), asserts handler ran once + 4 replays viax-idempotency-replay: true)n/a
B — loser replays winner
Same test (the four losers ARE the Path-B observation)
n/a
(Body-fingerprint conflict)
same_key_different_body_returns_422n/a
C — ceiling-exceeded 409
Not in this MR
Requires a controllable slow handler (>5s). The cases service has no slow-handler endpoint, and adding one solely for tests crosses the line the plan draws around production seams. Filed for follow-up alongside the test-framework expansion that adds latency-injectable production seams.
D — claim recovery (winner died mid-handler)
Not in this MR
Requires direct SQL manipulation to put a row in
processingwithclaim_expires_at < now()(simulating a dead winner). The integration test harness has no DB-direct seam; adding one is a Phase-B test-framework expansion. Code path is unit-tested via themod testsblock (recovery-row UPDATE shape) but not exercised end-to-end.The Path-A integration test is the central proof — it catches the exact race the previous shape exhibited. Path C/D are correctness-supplements; the SQL state machine itself is straightforward enough that path-coverage at the integration level isn’t load-bearing for the bug fix. Both deferrals are filed as a follow-up issue under the test-framework expansion track.
-
Step 12 — D10.1 large-file decomposition deferred to follow-up issue (#320). The §D10.1 spec called for decomposing 7 source files >500 LOC in the same MR as D10.2–D10.7. Decomp is mechanical, large-diff (estimated +/-3000 LOC of pure file moves), and orthogonal to the rest of the cleanup bundle (dep dedupe, HSTS, magic-numbers, comment hygiene, Swagger comment, enum-boundary swaps). Bundling would have made the MR a reviewer-hostile mix of "read each line of substance" + "skim file moves" — splitting along the substance/mechanical seam keeps each MR reviewer-tractable. D10.1 follow-up issue (#320) carries the file inventory and decomp acceptance criteria. The remaining D10.2–D10.7 sub-items shipped as planned in this MR.
-
Step 12 — successor plan filed for application-layer authorization. During Step 12 a two-week look-back review (2026-05-05) re-surfaced findings from the original 2026-04-21 audit that platform-stab-2 deliberately scoped out: record-level authorization (BOLA/IDOR), HKDF blind-index, JWS replay defense, partner-edge
Uuid::nil()sentinel, intake error-detail leakage, "constant-time"HashMaplookup,pub(crate)discipline, test silent-skip regression, strum enum-boundary continuation (D10.7 was partial), andcargo denyRUSTSEC ignore audit. Those items live at the application-shape layer (require domain context and federal-policy decisions); platform-stab-2 lived at the platform-correctness layer (deterministically verifiable). Filing them into platform-stab-2 would have diluted reviewer focus on the concurrency-correctness work and stretched the plan past its 12-step ceiling. New plan:docs/modules/ROOT/pages/plans/application-authz-and-pii-hardening.adoc(Draft / Pre-shaping). Tracking: epic &23, filed alongside this MR. Plan stub + nav.adoc Planned entry + epic ship in this Step 12 MR so the successor is on the runway when platform-stab-2 archives in Step 13.