Plan: Platform Stabilization Phase 2 — Concurrency & Atomicity Hardening

On this page

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: concurrent_fire(n, builder) helper in craig-test-lib + targeted fault injectors (cipher errors, DB errors, RabbitMQ unreachable, object-store errors, publish-in-tx failure). Just enough to enable Steps 3–9 verification. Test Framework Hardening epic (&22) later consolidates + extends. Sequencing note: test-framework-hardening (epic &22, archived MR !215) shipped FIRST and now provides crates/craig-test-lib::concurrent::concurrent_fire_* (richer than the inline minimal helpers this Step originally specified) + crates/craig-test-lib::fault (the FaultInjector trait + flap/latency/backpressure wrapper injectors). Step 2 reduces to a verification + gap-fill exercise: confirm each Step-2 enumerated need is covered by the consolidated helpers, then ship inline only what’s missing — notably the 4 base injectors (CipherErrorInjector, RabbitDownInjector, ObjectStoreErrorInjector, PublishInTxFailureInjector) which require production-seam refactors that platform-stab-2’s later steps will introduce.

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; tests/platform_stab_2_step2_helpers_present.rs smoke test pins the public API surface

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 status/started_at/finished_at/claim_expires_at columns. Concurrent test uses Step 2 concurrent_fire_collect. Naming note: the existing column status (HTTP status code) was renamed to status_code in the same migration to make room for the new TEXT status column carrying the state machine. Path-A (winner-only / loser-replays) covered by services/craig-cases/tests/api/idempotency_atomic_claim.rs::winner_runs_handler_exactly_once and Body-fingerprint conflict by same_key_different_body_returns_422. Path-C (ceiling 409) and Path-D (claim recovery) deferred to follow-up issue (logged in §Errata) — both require lower-level test infra (controllable handler latency / direct SQL manipulation) not currently in the harness.

Done (pre-ADR-030)

4

P0.2: Outbox FOR UPDATE SKIP LOCKED. Wrap SELECT+publish+UPDATE in single tx with row-level locks. Multi-worker test spawns 2× OutboxWorker against shared PgPool in single test process. Test isolates from the live devstack workers by creating a throwaway test database (craig_step4_test_<uuid>) inside the devstack postgres + a routing key (test.outbox-step4.#) no real subscriber binds to; staging 200 rows + asserting EventCollector observes each i exactly once.

Done (pre-ADR-030)

5

P0.3: Inbox processed-state retry semantics. Three-state machine (claimed-not-processed, succeeded, failed-at-cap). Migration adds failed_at/error_count/last_error/attempt_started_at. On handler failure: re-run with backoff up to 5 attempts; on cap: surface to DLX. Prerequisite #312 satisfied by MR !218; the §D3 ALTER applies cleanly across all 7 stateful services. Path A/B (winner+replay), Path C (under-cap retry), and Path D (at-cap DLX surface + replay-dedup) all covered by crates/craig-mq/tests/inbox_retry.rs. The §D3 spec called for Mock the publisher with EventCollector; craig-test-lib’s EventCollector is hardcoded to craig.events, so the test inlines a DlxCollector bound to craig.dlx instead. The handle_idempotently signature gains a &Publisher parameter (needed for the DLX surface path) and switches F: FnOnceF: Fn so the state machine can re-invoke the handler under the same claim — propagated to all 5 service call sites in the same MR.

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 object_status column distinguishes pending/present/failed; background AttachmentScanner reaps orphans. Scanner registered with cases (3 tables) and exchange (1 table) bootstraps. Per-table ReapPolicy distinguishes Delete (pure attachment tables) from ClearObjectKey (court_orders — row carries other metadata, only the document attachment is reverted). 4 new event types for compensating cleanup (*.upload_failed). The §D4.4 migration kept the DEFAULT 'present' on court_orders only — create_court_order doesn’t specify object_status, and 'no document yet' is the correct initial state for that table; the pure-attachment tables drop the DEFAULT to make missing object_status a runtime error.

Done (pre-ADR-030)

7

P1.2: Exchange send → outbox-driven worker pattern. New exchange_send_jobs table + ExchangeSendWorker (service-side at services/craig-exchange/src/send_worker.rs). Handler stages send-job in tx; worker drains, executes adapter, finalizes idempotently with correlation_id. send_exchange handler now returns 202 Accepted (was 200 OK with inline send) — existing tests + workflows.rs poll for terminal status. craig_correlation_id propagated into outbound payload as the partner-side dedup token. Bootstrap runs run_recovery_sweep once before the worker spawns to flip stuck in_flight rows from a peer that died mid-send. Backoff schedule: 5s/30s/120s/300s/900s/1800s with MAX_ATTEMPTS = 6. Worker semaphore caps concurrent in-flight sends to 8. Test deviation: §D5.7’s "concurrency cap respected" test deferred — exercising the cap requires a slow adapter under load + DB visibility into the in_flight row count, both larger than the inline tests this MR added; the cap constant is still pinned by defaults_match_plan unit test.

Done (pre-ADR-030)

8

P1.3: Placement state-read inside tx + foster-home FOR UPDATE. Add get_placement_for_update store fn; lock placement first then foster_home; transition guard runs against locked state. Two integration tests: concurrent_end_same_placement_only_one_wins (2 concurrent PUTs flipping the same placement to ended — exactly one returns 200, loser hits validate_placement_transition reject path with 400) and concurrent_end_different_placements_same_home_no_lost_update (2 placements pointing at same home — both decrements commit, final occupancy = initial − 2). Test design note: helper creates placements directly in active state because the create path’s increment_occupancy only fires when status='active' on insert; planned→active via update doesn’t bump occupancy (separate gap, orthogonal to §D6).

Done (pre-ADR-030)

9

P1.4: BFF silent-degradation cleanup. fetch_page split into two: the new fatal-on-error fetch_page returns Result<PageResponse<T>, BffApiError> for primary list views, and fetch_page_or_empty preserves the silent-fallback shape for sidebar/secondary widgets (each callsite must carry an explicit // silent fallback: <reason> comment going forward). New routes/error.rs defines the BffApiError enum (Upstream/UpstreamDeserialize/UpstreamTransport/SessionExpired) with HTTP status mapping (5xx → 502, 4xx pass-through, 401 → /login redirect) and an IntoResponse impl that renders a new templates/error/upstream_failure.html with a correlation_id for ops triage. 30 fetch_page callers across 18 route files converted to fatal-on-error ?-propagation: cases/list, intake/{worklist,referrals,reports}, exchange (4 list views), reporting (3 list views), placement/{placements×3, education, health, homes, matching×2}, security/{alerts, archive, audit, changes, nist, partners, reviews}, financial (4 list views including the joined adjustments sub-list which uses fetch_page_or_empty per §D7.5 partial-failure pattern), rules. The placement "new placement" form’s tokio::join’d dropdowns are both primary (form can’t render without either) so they ?-propagate; the financial payment-detail’s joined adjustments list uses fetch_page_or_empty (the payment row is primary, adjustments are decorative). Scope-cap: the broader ~74 unwrap_or_default sites that don’t go through fetch_page (sidebar widgets, lookup helpers, sub-list direct ApiClient calls) are out of scope — fetch_page is the load-bearing primary-list path.

Done (pre-ADR-030)

10

P2 cluster (#281): 4 sub-items, all shipped together. D8.1: validate_upload now takes the body slice and sniffs first 256 bytes via infer 0.16 against the known-signature table; declared MIME with mismatched signature → StoreError::SignatureMismatch. New content_disposition_attachment(filename) helper emits RFC 6266 §5 attachment; filename="<ascii>"; filename*=UTF-8''<percent-encoded> for non-ASCII filename round-trip. 7 attachment-download callsites updated. D8.2: New require_captcha: bool field on IntakeSettings; validate() bails when require_captcha=true && captcha_secret == "disabled". Default false (Cloudflare Turnstile keys are partner-config, not data-correctness). D8.3: DLQ threshold-exceeded alert in craig-security and rules cache-invalidate event in craig-rules both moved to outbox via stage_event; the DLQ tx now wraps record + threshold-count + advisory-event together. D8.4: IntakeSettings::validate() warns (not bails) when cors_origins == "*". upload_fixtures_smoke.rs::allowlisted_mime_with_mismatched_magic_bytes_is_rejected exercises the §D8.1 spoof corpus end-to-end (5 of 6 spoofs rejected — TruncatedPdfFirst8Bytes retains the PDF magic header so signature-sniff correctly classifies it; structural-validation gap noted as out of §D8.1 scope). 11 existing attachment-upload tests had placeholder bodies (b"test data") replaced with b"%PDF-1.4 …​" to satisfy the new magic-byte gate.

Done (pre-ADR-030)

11

273 — encryption mode (configuration-layer fail-closed). Option A picked: default required, devstack/CI/tests override to optional. Closes the bootstrap-time silent-plaintext failure mode (operator forgets to set CRAIG_FIELD_ENCRYPTION_KEY → service silently runs with encryptor=None → 5 callsites in services/craig-cases/src/api/persons.rs write plaintext SSN-last-four to the DB). New EncryptionMode { Required, Optional } enum on craig_common::ServiceSettings with [default] Required; bootstrap matches (mode, key_load) and bails on (Required, Err). Defense-in-depth: encrypt_field/decrypt_field/decrypt_person_pii/decrypt_persons_pii all take the mode; (None, Required) returns 500 with "startup invariant violated" for the case where a future refactor bypasses the boot guard. Mode threaded through axum Extension to the 4 persons handlers (search/create/get/update). Devstack docker-compose.yml and .env.example set CRAIG_CASES__ENCRYPTION_MODE=optional explicitly. 6 unit tests in services/craig-cases/src/api/encryption.rs::tests cover the cells of the (encryptor, mode, is_encrypted) matrix (passthrough/round-trip/tampered/disabled-but-encrypted/required-no-encryptor-encrypt/required-no-encryptor-decrypt).

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 deny.toml with cause + ownership notes — both are transitive-via-third-party with no direct CRAIG fix; xtask zen-engine bumped 0.35 → workspace 0.54); D10.3 HSTS header layer with max-age=31536000; includeSubDomains on every API response; D10.4 magic numbers → craig_common::constants (DEFAULT_PARTNER_RATE_LIMIT_RPM = 60, TOKEN_REFRESH_LEAD_TIME = 30s); D10.5 stale "Step 4 will swap to service-auth" comment in cases reports.rs replaced with permanent-posture comment about Keycloak service-account JWT trust; D10.6 Swagger-UI mount comment documenting public-by-design (Kerckhoffs’s principle); D10.7 string-typed enum boundaries (3 fields converted: CreateReportRequest.reporter_type String→ReporterType, .reporter_relation Option<String>→Option<RelationshipToChild>, ConvertReportRequest.priority String→Priority; wire shape unchanged via strum snake_case serde; craig-reference added as workspace dep on craig-cases-contracts; runtime VALID_PRIORITIES allowlist removed since DTO boundary now enforces). D10.1 large-file decomp deferred to follow-up issue #320 per §Errata. Successor plan application-authz-and-pii-hardening.adoc filed (Draft / Pre-shaping; epic &23) for application-layer authorization findings that surfaced during the two-week look-back review and are out of scope for platform-stab-2’s platform-correctness focus.

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 .claude/CLAUDE.md Phase Status; CHANGELOG wrap-up entry filed; epic &21 closed.

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_outbox and event_inbox tables exist

  • OutboxWorker is spawned in 7 stateful services

  • idempotency_responses is Postgres-backed (not in-memory DashMap)

  • DLX exchange (craig.dlx) is declared

  • /livez /readyz /healthz are split

  • xtask reconcile enumerates 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.

Related ADRs (existing): ADR-020 (closed the operation-layer fail-open this plan’s Step 11 closes the configuration layer of), ADR-021, ADR-022 (this plan’s Steps 3–5 implement its semantic concurrency requirements that the structural rollout deferred).

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.publish callsites 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_fire helper + 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::Value across 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

processing

claim INSERT succeeds; handler about to run

wait/poll

succeeded

handler returned a response that was persisted

replay

failed

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:

  1. POST arrives, body buffered, fingerprint computed, key derived

  2. try_claimClaimed

  3. Handler runs against the rebuilt request

  4. Response body buffered (1 MiB cap; body_too_large sentinel still applies)

  5. finalize_succeeded UPDATEs the row: status='succeeded', finished_at=now(), status_code, headers, body, body_too_large

  6. Live response returned to caller

Path B — loser, replays winner:

  1. POST arrives, body buffered, fingerprint computed, key derived

  2. try_claimExisting(row) with row.status = 'processing'

  3. Body fingerprint mismatch → 422 (RFC 9530 conflict; same as today)

  4. await_winner polls every 100ms (initial) backing off to 500ms; ceiling 5s

    1. Each poll re-reads (status, finished_at, status_code, headers, body, body_too_large, body_fingerprint)

    2. If status = 'succeeded' and fingerprints match → build replay Response (existing path) with x-idempotency-replay: true

    3. If status = 'failed' → caller may re-execute (return None; outer middleware re-runs try_claim once more, racing to set 'processing' again under the same key)

  5. Returns the replay Response

Path C — loser, ceiling exceeded:

  1. Same as Path B through step 4, but no flip in 5s

  2. await_winner returns Ok(None)

  3. Middleware emits 409 Conflict with Retry-After: 5 and a Problem Details body of type https://docs.craig/problems/idempotency-in-flight

  4. 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):

  1. Loser finds Existing(row) with status = 'processing' and now() > claim_expires_at

  2. Loser issues UPDATE … SET status='failed', finished_at=now() WHERE cache_key=$1 AND status='processing' AND claim_expires_at < now()

  3. 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)

  4. 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

try_claim SQL error

ApiError::internal("idempotency claim failed: …")

500

Loser body-fingerprint mismatch

(existing) 422 problem+json

422

await_winner ceiling timeout

(new) https://docs.craig/problems/idempotency-in-flight with Retry-After: 5

409

finalize_succeeded SQL error post-handler

log-and-return-live-response (matches existing fallback at line 363)

live status

finalize_failed SQL error post-handler-error

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

crates/craig-api/src/idempotency.rs

Replace check_cache + store_response with try_claim/await_winner/finalize_succeeded/finalize_failed; rewrite idempotency_middleware body to drive the state machine.

services/<svc>/migrations/<ts>_idempotency_atomic_claim.sql × 7

Additive ALTER per service (cases, exchange, financial, placement, reporting, rules, security).

services/<svc>/src/main.rs

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:

  1. 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

  2. Build N=5 identical POSTs with the same Idempotency-Key and same body

  3. concurrent_fire(5, builder) — fires all 5 in flight before any completes

  4. Assert: counter == 1 (handler ran exactly once)

  5. Assert: 5 responses all have status_code=201, identical body, exactly one with x-idempotency-replay=false and four with x-idempotency-replay=true

  6. Assert: idempotency_responses row has status='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

claimed_by UUID + claimed_at TIMESTAMPTZ + compare-and-swap UPDATE

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 published_at IS NULL; reclaimable by any worker

Worker process crashes mid-publish

Tx auto-aborts (Postgres releases locks)

Still published_at IS NULL; reclaimable

tx.commit() errors after successful publish

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 published_at IS NULL; duplicate publish on next tick

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 by 100 × 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_INTERVAL and retries

Migration

No schema change. The change is entirely in crates/craig-mq/src/outbox.rs::drain_once.

Test description (single test process — no multi-replica devstack needed)

A failing-test-first integration test in crates/craig-mq/tests/outbox_concurrent.rs:

  1. Embedded Postgres + the event_outbox schema

  2. Wrap Publisher with an EventCollector (existing utility) that records every published envelope

  3. Stage N=200 rows in event_outbox with published_at IS NULL

  4. Spawn 2× OutboxWorker instances against the same PgPool, sharing a single in-process EventCollector

  5. Drive drain_once concurrently (tokio::join!(w1.drain_once(), w2.drain_once())) repeated until both return 0

  6. Assert: EventCollector.count() == 200 (no duplicates, no drops)

  7. Assert: SELECT count(*) FROM event_outbox WHERE published_at IS NOT NULL returns 200

  8. Assert: every published_at is 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 ALTER TABLE event_inbox ADD COLUMN … migration in §D3.2 assumes event_inbox exists in every stateful service. craig-placement is the only service missing the base migration today (surfaced by test-framework-hardening Step 18 invariant sweeper as a real platform finding). #312 ships the missing <ts>_event_inbox.sql migration mirroring the other 6 services; once that lands, this Step 5 ALTER applies cleanly across all 7.

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

processed_at IS NULL, error_count < max_retries

re-run handler with backoff

Successfully processed

processed_at IS NOT NULL

dedup: log + return Ok

Permanently failed

processed_at IS NULL, error_count >= max_retries

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

max_retries

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 craig.dlx with original event_type as routing key

Reuses the existing DLX exchange; the audit consumer in craig-security already binds dlq.# and persists every dead-letter to dead_letter_audit (Step 4 of the prior plan).

Backoff implementation site

crates/craig-mq/src/inbox.rs (sleep before re-running handler)

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:

  1. Embedded Postgres with event_inbox schema (post-migration)

  2. Stand up a FaultyHandler (Step 2 helper) that errors on attempt 1 and succeeds on attempt 2

  3. Mock the publisher with EventCollector

  4. Build envelope e1

  5. Call handle_idempotently(db, &publisher, e1.clone(), &faulty_handler).await — expect Err(_); assert row state: error_count=1, processed_at IS NULL

  6. Call handle_idempotently(db, &publisher, e1.clone(), &faulty_handler).await — expect Ok(()); assert row state: processed_at IS NOT NULL, error_count=1

  7. Assert faulty_handler was 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?

services/craig-cases/src/api/contact_attachments.rs upload_attachment

121–124

128–142

no — direct pool insert

services/craig-cases/src/api/court_orders.rs upload_document

207–210

212–216

no — update_court_order_object_key against pool

services/craig-exchange/src/api/icpc.rs upload_attachment

485–488

491–500

no — direct pool insert

services/craig-cases/src/api/report_attachments.rs upload_attachment

110–113

116–140

partial — TX wraps create_attachment + publish (lines 116–140) but the blob put already happened at line 110, before tx.begin()

D4.2 Fix-shape decision: post-commit blob write + compensating cleanup

Chosen pattern (default proposal, applied to all 4 sites):

  1. Generate object_key deterministically up-front using craig_common::id::new_id() (UUID v7). The path remains <scope>/<parent>/<id>/<safe-name> exactly as today.

  2. BEGIN tx

  3. INSERT the metadata row with object_status = 'pending' (see migration in §D4.4).

  4. publish_* the outbox event in the same tx (only report_attachments.rs already does this; the other 3 sites adopt the §D3.1 outbox pattern at the same time).

  5. COMMIT tx. The DB now owns a pending row anchoring the blob’s lifecycle.

  6. Call object_store.put(&object_key, data) outside any tx.

  7. On put success: UPDATE attachments SET object_status = 'present' WHERE id = $1. On put failure: compensating-cleanup tx — DELETE FROM attachments WHERE id = $1 and stage a .upload_failed outbox event so consumers that already saw the .uploaded event can react. If cleanup also fails, the row sits at object_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 put and the defer-handler still orphans the blob

  • a process kill (OOM, SIGKILL on rolling deploy, SIGTERM on autoscaler scale-down) between put and the cleanup orphan — scopeguard can’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 put fails, compensating-cleanup tx succeeds

ApiError to caller, no blob, row deleted, *.upload_failed event published.

Phase 2 put fails, compensating-cleanup tx also fails

ApiError to caller, no blob, row sits at object_status = 'pending'. Background scanner reaps it.

Phase 2 put succeeds, status-promotion UPDATE fails

ApiError to caller, blob present, row at object_status = 'pending'. Scanner detects "blob exists for pending row", promotes to present. Idempotent.

Process killed between Phase 1 commit and Phase 2 put

Row at object_status = 'pending', no blob. Scanner deletes the row after a 1-hour grace window.

Process killed between Phase 2 put and status-promotion UPDATE

Blob present, row at object_status = 'pending'. Scanner promotes after object_store.head(&key) returns 200.

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).

D4.6 Test coverage (uses Step 2 fault-injector)

For each of the 4 sites: happy path, Phase-2 put failure, Phase-2 put failure + compensating-cleanup failure (scanner reaps), process-kill simulation (scanner reaps after grace), idempotency on partial Phase-2 success (scanner promotes).

D5. Exchange send: outbox-driven worker (P1 — Step 7)

Today services/craig-exchange/src/api/transactions.rs:56-144:

  1. Line 80–93: create_transaction writes a pending tx row to exchange_transactions directly against the pool — no surrounding tx.

  2. 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.

  3. Line 105–122 (success branch): begin tx → update_transaction_status('success', …​)publish_exchange_sent in tx → commit.

  4. Line 123–139 (failure branch): begin tx → update_transaction_status('failed', …​)publish_exchange_failed in 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

  1. 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.

  2. Worker loop (polls every 1s):

    1. 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 *;
    2. for each claimed job, spawn a task: invoke adapter.send(endpoint, &payload_with_correlation_id)

    3. on Ok(response): tx → UPDATE exchange_send_jobs SET status='sent', completed_at=now() + update_transaction_status('success') + publish_exchange_sent → commit

    4. on Err with attempts < MAX_ATTEMPTS: tx → UPDATE exchange_send_jobs SET status='pending', last_error=…​, next_attempt_at=now()+backoff(attempts) → commit

    5. on Err with attempts >= 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:

  1. Worker-side claim atomicity: FOR UPDATE SKIP LOCKED ensures only one worker (across instances) claims a given job. The attempts increment in the same UPDATE means a crash-during-send leaves the row in in_flight; on restart, a recovery pass reverts in_flight rows older than 2 × SEND_TIMEOUT back to pending.

  2. Adapter-side dedup: correlation_id is propagated into the payload as a craig_correlation_id field; adapters that map to a partner-side idempotency key emit it automatically.

  3. Status-update idempotency: the update_transaction_status finalize step is keyed by transaction_id and is harmless to apply twice.

D5.6 Recovery

On service restart, two sweeps run before normal draining begins:

  1. 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.

  2. Existing OutboxWorker drains any event_outbox rows that the original handler staged but never published.

D5.7 Test coverage (uses Step 2 fault-injector)

Happy path; transient-failure-then-success; process restart mid-send (recovery sweep); permanent partner failure (MAX_ATTEMPTS exhausted); idempotent-adapter.send (same correlation_id); concurrency cap respected.

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(&current.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:

  1. Stale state-machine check: get_placement reads via pool, not tx. Two concurrent requests can both validate active → ended as legal, both UPDATE. The second silently overwrites the first.

  2. Lost-update on occupancy: decrement_occupancy runs 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

  1. Move get_placement inside the tx, using a new locked variant get_placement_for_update that issues SELECT …​ FOR UPDATE.

  2. When the transition involves an occupancy change (today: status → 'ended' decrements), call get_foster_home_for_update inside the tx before the decrement.

  3. 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(&current.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

  1. fetch_page<T> returns Result<PageResponse<T>, ApiError> instead of swallowing.

  2. Introduce a route-level error template + flash-banner pattern at services/craig-web/src/routes/error.rs (new) for "main view failed to load".

  3. Audit each unwrap_or_default() callsite; classify as legit-fallback (decorative / optional) vs silent-degradation (primary data); fix the silent ones.

  4. 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

ApiError::Upstream { path, status }

5xx → 502; 4xx pass-through; 401 → /login redirect

Upstream returned an HTTP error.

ApiError::UpstreamDeserialize { path }

502

Upstream returned 200 but JSON didn’t match the BFF’s view struct.

ApiError::UpstreamTransport { path }

502

Connection refused, timeout, DNS fail.

ApiError::SessionExpired

302 → /login

Token expired or refresh failed.

ApiError::Forbidden

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:

  1. Primary resource (the entity the URL points at) is fatal-on-error: render error template.

  2. 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.

cases/list.rs, placement/placements.rs, intake/worklist.rs, financial.rs, reporting.rs, intake/reports.rs, placement/homes.rs

Switch to fetch_page (fatal); render upstream_failure.html on Err.

Primary detail view, joined sub-list

Sub-list rendered as table on detail page.

exchange.rs:718-734 agreements + transactions; cases/detail.rs joined contacts/persons; placement/placements.rs joined documents

Use fetch_page for primary; fetch_page_or_empty + banner for joined sub-lists.

Sidebar widget / count chip

Decorative count or short list shown alongside primary content.

Dashboard counts, nav badge counts, security audit "recent activity" widget

Keep fetch_page_or_empty; add // silent fallback: <reason> comment.

Lookup cache miss

Optional name resolution (UUID → display name).

routes/mod.rs:99-168 batch-lookup helpers

Keep current behavior; ensure tracing::warn! is present.

Form-submit redirect path

POST handler reads upstream after write to render redirect target’s success view.

report.rs:283-295, reporting.rs:322-330, all set_flash callsites with Err arm

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_KEYinfo!("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

services/craig-web/src/routes/reporting.rs

893

routes/reporting/{dashboard,issues,afcars,ncands,quality,mod}.rs. Aligns with existing route-prefix groupings.

services/craig-web/src/routes/exchange.rs

859

routes/exchange/{cases,placements,courier_jobs,mod}.rs.

services/craig-web/src/routes/financial.rs

709

routes/financial/{ive,tanf,medicaid,rate_cache,mod}.rs.

services/craig-cases/src/api/reports.rs

672

api/reports/{create,read,decision,follow_up,convert,mod}.rs. Request DTOs move into matching submodule.

services/craig-rules/src/api.rs

634

api/{rule_sets,evaluations,admin,mod}.rs. Lands AFTER D8.3 cache-invalidation refactor.

services/craig-cases/src/api/cases.rs

595

api/cases/{crud,assignment,plans,mod}.rs.

services/craig-web/src/routes/report.rs

541

routes/report/{intake,investigation,disposition,mod}.rs.

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

services/craig-cases/src/api/reports.rs:84 (reporter_type)

String

craig_reference::ReporterType (existing)

services/craig-cases/src/api/reports.rs:88 (reporter_relation)

Option<String>

Option<craig_reference::RelationshipToChild> (existing)

services/craig-cases/src/api/reports.rs:114 (action_kind)

String

craig_reference::DispositionFollowUpKind (verify-or-add)

services/craig-cases/src/api/reports.rs:106 (disposition_kind)

String

craig_reference::DispositionKind (verify-or-add)

services/craig-cases/src/api/reports.rs:121 (priority)

String

craig_reference::ReferralPriority

services/craig-web/src/routes/reporting.rs:59 (severity)

String

craig_reference::QualityIssueSeverity (verify-or-add)

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.adoc from 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:

  1. 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

  2. glab epic view 21 — confirms epic body lists this plan

  3. Antora docs render platform-stabilization-2.adoc without errors (verifiable via local Antora build or cargo xtask api-docs)

  4. nav.adoc Active section visibly shows platform-stabilization-2

  5. 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:

  • crates/craig-test-lib/src/concurrent.rs ships richer surfaces than concurrent_fire alone — concurrent_fire_collect, concurrent_fire_synchronized, concurrent_fire_until_first_success, concurrent_fire_first_n, ConcurrentRunReport, TaskOutcome, TaskResult. The deprecated concurrent_fire alias is retained for forward-compatibility.

  • crates/craig-test-lib/src/fault/ ships the FaultInjector trait + Attempt + ScenarioGuard plus 3 wrapper injectors (latency, flap, backpressure).

Step 2 therefore reduces to a gap-fill MR: enumerate each fault injector this plan’s Steps 3–9 actually call for (CipherErrorInjector, RabbitDownInjector, ObjectStoreErrorInjector, PublishInTxFailureInjector per the file list below), confirm none of them already exist in craig-test-lib::fault, and ship the missing ones inline. Each base injector requires a production-seam refactor in the same MR that introduces it — they pair with §D-section work, not with Step 2 alone.

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 injectors cipher, 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):

  1. CipherErrorInjector: wraps FieldEncryptor, exposes inject_error_on_next_n(n). Calls to encrypt_str / decrypt_str fail with a synthetic craig_crypto::Error for the next n invocations.

  2. DbErrorInjector: wraps PgPool / sqlx Executor. Has inject_error_after(n) — the next n queries succeed; query n+1 returns sqlx::Error::PoolTimedOut (or a configurable variant).

  3. RabbitDownInjector: wraps Publisher, exposes inject_unavailable_for(duration). Calls to publish / publish_in_tx / publish_dlx return lapin::Error::IOError(io::Error::new(io::ErrorKind::ConnectionRefused, …​)) for the duration.

  4. ObjectStoreErrorInjector: wraps Store, exposes inject_put_failure_for_keys(prefix). Calls to put whose key starts with prefix return StoreError::ObjectStore(…​). Optionally inject_head_failure_for_keys(prefix) for the scanner test.

  5. PublishInTxFailureInjector: wraps craig_mq::stage_event, exposes fail_for_event_type(event_type). Calls staging an envelope whose event_type matches return sqlx::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

  • Drop impl 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:

  1. cargo nextest run -p craig-test-lib --test concurrent — 1 smoke test where concurrent_fire(10, |i| async move { i }) returns vec![0..10] (order-independent set comparison)

  2. cargo nextest run -p craig-test-lib --test fault_injection — 1 smoke test per injector exercising the inject-then-call flow

  3. cargo clippy -p craig-test-lib --tests --locked — -D warnings — clean

  4. .claude/docs/testing.md shows 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.

  1. Generate matching migration files in all 7 stateful services using §D1’s ALTER TABLE block. Use sqlx migrate add to mint timestamp.

  2. In crates/craig-api/src/idempotency.rs:

    1. Extend CachedRow with status: String, started_at, claim_expires_at, finished_at; mark response columns Option<…>

    2. Add try_claim, await_winner, finalize_succeeded, finalize_failed

    3. Replace idempotency_middleware body to drive the §D1 state machine

    4. Add idempotency_in_flight_response() helper returning 409 + Problem Details + Retry-After: 5

  3. Map every new error path through ApiError per §D1 table.

  4. Test (tests/idempotency_atomic_claim.rs): three async tests using concurrent_fire from 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.

  1. Wrap drain_once body in let mut tx = self.pool.begin().await?;

  2. Append FOR UPDATE SKIP LOCKED to SELECT; switch all DB ops to &mut *tx

  3. Move both UPDATE branches inside the loop and inside the tx

  4. Add tx.commit().await?; at end; early-return Ok(0) when rows empty

  5. No migration. No callsite changes.

  6. Test (tests/outbox_concurrent.rs): embedded Postgres, 200 staged rows, two OutboxWorker instances against same PgPool, tokio::join! drain loop, assert EventCollector.count() == 200. Bonus stress variant with tokio::time::sleep(Duration::from_millis(20)) shim inside EventCollector::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.

  1. Generate matching migrations in 6 consumer services (placement is publisher-only).

  2. Add Publisher::publish_dlx per §D3.

  3. In crates/craig-mq/src/inbox.rs:

    1. Add INBOX_MAX_RETRIES = 5, INBOX_BACKOFF_BASE = 1s, INBOX_BACKOFF_CAP = 60s constants

    2. Change handler: F from FnOnce to Fn

    3. Add publisher: &Publisher and queue_name: &str arguments

    4. Implement four-path state machine per §D3

    5. fn backoff_for(error_count) → Duration with jitter; unit-test bounds + monotonicity

  4. Update every handle_idempotently call site to pass the new arguments. Grep for callsites in services/{cases,exchange,financial,reporting,rules,security}/src/subscribers/*.rs.

  5. Test (tests/inbox_retry.rs): under-cap retry (uses FaultyHandler from 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.

  1. Apply §D4.4 migrations (4 total). Verify backfill: existing rows go to present. Drop column DEFAULT after backfill.

  2. Add mark_object_present, delete_attachment_pending to each store module.

  3. For court_orders: set_object_pending, mark_object_present, clear_object_pending.

  4. Add publish_*_upload_failed event functions.

  5. Rewrite each handler to §D4.3 shape (4 sites).

  6. Implement AttachmentScanner per §D4.5. Register in each service’s main.rs.

  7. Tests per §D4.6 — 5 per site × 4 sites using Step 2 inject_blob_put_failure and inject_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.

  1. Apply §D5.2 migrations + correlation_id ALTER.

  2. Add store/send_jobs.rs: stage_send_job, claim_pending (FUSL SQL), finalize_sent, finalize_retry, finalize_failed, recover_in_flight. All idempotent on job_id.

  3. Implement ExchangeSendWorker per §D5.4. Loop: recover_in_flight once at spawn, then drain_once every POLL_INTERVAL. Failure-class predicate: 4xx non-retryable, 5xx + transport retryable.

  4. Refactor send_exchange handler per §D5.3.

  5. Spawn worker from main.rs after OutboxWorker.

  6. Update adapters/standard.rs::transform_outbound to insert craig_correlation_id from staged job.

  7. 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.

  1. Add get_placement_for_update to store/placements.rs per §D6.2 — adjacent to existing get_placement (line 73).

  2. Rewrite update_placement (api/placements.rs:260-329) to §D6.3 shape.

  3. Audit no other handler reads-then-writes placement outside tx: Grep "get_placement(app.db.inner()" on services/craig-placement/src/api/**.

  4. Tests per §D6.5 — 5 cases using Step 2 concurrent_fire and fail_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.

  1. Extend ApiError with §D7.3 variants. Add bff_status(), user_facing_message().

  2. Rewrite fetch_page per §D7.2. Add fetch_page_or_empty. Update all callers (type system forces this).

  3. Implement routes/error.rs per §D7.4.

  4. Walk §D7.6 audit table. Per category:

    1. Primary list view → ?-propagation; route-level mapper calls render_upstream_failure

    2. Joined sub-list → tokio::join! with fetch_page (primary) + fetch_page_or_empty (secondary); set partial-failure flag in template context

    3. Sidebar → keep fetch_page_or_empty; add // silent fallback: <reason> comment + ensure tracing::warn!

    4. Auth-failure → propagate ApiError::SessionExpired; route-level mapper redirects

  5. 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:

  1. D8.1 (validate_upload signature change is largest blast radius — land first)

  2. D8.4 (one-line warn in validate())

  3. D8.2 (require_captcha field + validate() check)

  4. D8.3 (DLQ alert + rules cache invalidation outbox migration; depends on craig_mq::stage_event already 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.

  1. Add EncryptionMode enum + field to ServiceSettings (option-agnostic shape).

  2. Rewrite services/craig-cases/src/main.rs:73-83 to four-arm match from §D9.3.

  3. Thread encryption_mode through AppState to handlers.

  4. Update encrypt_field / decrypt_field signatures + 5 callsites.

  5. 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.

  6. (Option A only) update docker-compose.yml + CI manifests + test harnesses.

  7. 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:

  1. D10.3 (HSTS — 4-line additive change)

  2. D10.6 (Swagger comment — one comment line)

  3. D10.5 (stale comment + 4 expects)

  4. D10.4 (magic numbers + new constants module + migration)

  5. D10.2 (dep dedupe; verify with cargo tree -d)

  6. D10.1 (large-file decomp; mechanical, large diff — land before D10.7)

  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).

  1. Spawn plan-completion-audit subagent per delivery-protocol.md

  2. Verify all 11 prior steps complete via MR list

  3. Flip Status table all-Complete

  4. Move plan from Active to archive

  5. Add archive.adoc row under Infrastructure & Reliability (or similar) with all step MR numbers

  6. Update CLAUDE.md Phase Status if relevant

  7. CHANGELOG wrap-up entry

  8. Close epic &21

Files Touched

File Change

docs/modules/ROOT/pages/plans/platform-stabilization-2.adoc

New plan file (this content)

docs/modules/ROOT/nav.adoc

Active section: add platform-stabilization-2 xref

CHANGELOG.adoc

Step 1 entry under Unreleased; subsequent entries per step

crates/craig-api/src/idempotency.rs

Step 3 — atomic-claim state machine

crates/craig-mq/src/outbox.rs

Step 4 — FOR UPDATE SKIP LOCKED

crates/craig-mq/src/inbox.rs, crates/craig-mq/src/publisher.rs

Step 5 — three-state retry + DLX surface

crates/craig-store/src/scanner.rs, crates/craig-store/src/validation.rs

Step 6 attachment scanner; Step 10 magic-byte validation

services/craig-cases/src/api/{contact_attachments,court_orders,report_attachments}.rs, services/craig-exchange/src/api/icpc.rs

Step 6 — atomic blob+DB+event upload

services/craig-exchange/src/{api/transactions,send_worker,store/send_jobs}.rs

Step 7 — exchange send outbox-driven worker

services/craig-placement/src/{api/placements,store/placements}.rs

Step 8 — placement state-read inside tx

services/craig-web/src/routes/{mod,error,error/*}.rs + ~26 audit-fix files

Step 9 — BFF silent-degradation cleanup

services/craig-{intake,security,rules}/src/…​

Step 10 — P2 cluster fixes

crates/craig-common/src/{settings,constants}.rs, services/craig-cases/src/{main,api/encryption,api/persons}.rs

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)

crates/craig-test-lib/src/{concurrent,fault_injection}.rs + tests

Step 2 — minimal test infra

Verification

  1. cargo nextest run --workspace --lib — all unit tests pass

  2. cargo xtask dev restart — devstack reloads after schema changes (Steps 3/5/6/7 add migrations)

  3. cargo nextest run --workspace --locked --profile integration — full integration battery passes including new concurrency / fault-injection / state-machine tests

  4. cargo xtask e2e — Playwright E2E suite passes

  5. cargo xtask validate --skip-docker — fmt + clippy + cargo-deny + JDM ruleset validation pass

  6. cargo xtask security — auth + injection + infrastructure phases pass; pentest CI catches no new high/critical alerts

  7. cargo xtask perf --profile load — k6 load profile passes SLO thresholds

  8. Manual: kill craig-cases between Phase 1 commit and Phase 2 put in Step 6; restart; assert scanner reaps orphan within 5 min

  9. Manual: kill craig-exchange mid-send in Step 7; restart; assert no stuck in_flight jobs after 60s

  10. Manual: stop craig-cases (docker compose stop craig-cases); GET BFF list pages; assert 502 with error template; restart; assert 200 with list

  11. cargo tree -d after Step 12 D10.2 — no zen-engine or jsonwebtoken duplicates

Documentation Updates

  • .claude/docs/services.md — outbox/inbox/idempotency semantic notes; new exchange_send_jobs table; new object_status columns

  • .claude/docs/security.md — encryption-mode posture (after Step 11 decision); CORS production-guard; magic-byte upload validation

  • .claude/docs/testing.mdconcurrent_fire helper + fault-injection harness usage docs (Step 2); Failure-path testing helpers section

  • 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

  1. #273 encryption-mode defaultrequired (fail-closed-by-default, breaking change for devstack/CI) vs optional (zero migration, ops must remember to flip in prod). Recommendation: required. Pending user decision.

  2. Idempotency loser-side semantics — when the loser observes the winner’s processing row, 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.

  3. Outbox locking styleFOR UPDATE SKIP LOCKED (simpler, Postgres-native) vs claimed_by/claimed_at columns (more portable, allows lease-expiration recovery). Default proposal: FOR UPDATE SKIP LOCKED with LIMIT N; revisit if multi-region / multi-DB topology emerges.

  4. Inbox max retries + backoff — what’s the cap before DLQ surface? Default proposal: 5 attempts, exponential backoff capped at 60s, then DLQ.

  5. ExchangeSendWorker location — service-side vs craig-mq shared. Default proposal: service-side at services/craig-exchange/src/send_worker.rs.

  6. State-machine matrix testing crate (deferred to epic &22 Phase A.4) — proptest vs 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 (deprecated concurrent_fire alias retained)

    crates/craig-test-lib/src/concurrent.rs

    FaultInjector trait + ScenarioGuard + Attempt

    Same shape, public API

    crates/craig-test-lib/src/fault/mod.rs

    Generic wrapper injectors (latency, flap, backpressure)

    All 3 shipped

    crates/craig-test-lib/src/fault/{latency,flap,backpressure}.rs

    CipherErrorInjector

    Deferred — 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

    RabbitDownInjector

    Deferred — pairs with §D2 (outbox FOR UPDATE SKIP LOCKED) and §D3 (inbox three-state)

    Steps 4 / 5

    ObjectStoreErrorInjector

    Deferred — pairs with §D4 (atomic blob+DB+event upload)

    Step 6

    PublishInTxFailureInjector

    Deferred — 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_fire alias for forward-compatibility (already done in Phase A.1). No new code in this MR; ships a crates/craig-test-lib/tests/platform_stab_2_step2_helpers_present.rs smoke 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 used status (SMALLINT). To make room for the new TEXT status carrying the state machine, the §D1 migration 20260505151229_idempotency_atomic_claim.sql first renames the legacy column statusstatus_code, then adds the state-machine columns. The Rust CachedRow struct in crates/craig-api/src/idempotency.rs is 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 via x-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_422

    n/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 processing with claim_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 the mod tests block (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" HashMap lookup, pub(crate) discipline, test silent-skip regression, strum enum-boundary continuation (D10.7 was partial), and cargo deny RUSTSEC 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.

Edit this page · latest