Per-Diem Payment Idempotency (placement.activated double-pay)
On this page
Status
| Unit | Description | Status |
|---|---|---|
U0 |
Choreography: issue amendment, follow-ups #1192/#1193, this plan |
Done (2026-07-28) — commit 9738efec |
U1 |
Fail-closed dedupe migration + migration test + fixture reworks + constraint tests |
Done (2026-07-28) — commit df55af0f; J-review PASS (gate sound, no money-corruption holes); 3 reviewer notes adopted (created_by in the identity tuple; stamp-only/adjusted/divergent-identity refusal pins; staged-set discipline); 9/9 migration tests green on clones; live probes deterministically red pre-migration |
U2 |
Arbiter-first handler (early probe, derivation compare) + replay/conc tests |
Done (2026-07-28) — crash-window replay test verified deterministically RED against the pre-fix binary (second payment found); J-reviewed |
U3 |
Blocking invariant + classification test + docs (data-model, ADR-053, ADR-022, testing-reference, CHANGELOG) |
Done (2026-07-28) — invariant red/green harness on throwaway DB; blocking-classification xtask test; stale data-model index prose fixed |
Ship |
Battery (post devstack wipe+reseed), MR, merge, close-out |
Done (2026-07-28) — MR !1095 merged (fb339152); full battery green on attempt 2 (attempt 1 fast-failed at the axis cheap gate — 3 tags relocated beside the test attribute, 1794f7f8); #1179 closed with AC walk; epic &74 ticked |
Epic: &74
Issue: #1179 (Plan::PERDIEM-IDEM)
Follow-ups filed: #1192 (fixture residue cleanup), #1193 (ended-before-activated ordering hole — NOT solved here)
Review state: v1/v2 internal (scout workflow wf_8c5bca48-944); v3 folded the 2026-07-28 external review (~25 findings: lifetime key, fail-closed migration, arbiter-first effects, early probe, derivation compare, migration test, selector fixes). Approved 2026-07-28.
Context
-
Path:
handle_placement_activated(services/craig-financial/src/main.rs:595-672) →persist_placement_payment(:705-751) →create_payment(store/payments.rs:54-85): plain INSERT — noON CONFLICT, no existence check;record_subsidy_overlapand thepayment_createdoutbox stage surround it in one tx. A re-execution mints a second live payment AND stages a second event, silently. -
Inbox dedup is best-effort at-least-once (documented by #1180; redesign is #1178): crash-before-stamp re-runs a succeeded handler; concurrent Path A/C; >30-day retention bypass; admin replay.
-
Zero DB uniqueness and zero invariant coverage on
agreement_id IS NULLrows (both subsidy partial uniques and both duplicate-shaped invariants filter them out — verified exhaustively). -
Lifecycle trap:
placement.endedvoids pending/approved (store/payments.rs:197area) and an ended placement can never reactivate (services/craig-placement/src/transitions.rs:18-26) — a voided-exclusive key would let a stale replay create a fresh payment AFTER the ending that nothing ever voids again. The key must outlive row status.
Design
| # | Decision | Substance |
|---|---|---|
D1 |
Lifetime key: |
One per-diem row per (placement, period_start) EVER. The producer emits |
D2 |
Fail-closed dedupe migration, one tx: preflight → narrow auto-repair → ledger → outbox cleanup → zero-collision assert → index |
|
D3 |
Arbiter-first handler with early probe + derivation compare |
(a) Early probe before the DOB/rate lookups using immutable payload data only: existing row for |
D4 |
Blocking invariant over ALL per-diem rows |
|
D5 |
Fixture reworks (collide under the lifetime key) |
|
Units
U0 — issue amendment + label; follow-ups #1192/#1193 filed + related; this plan + nav entry; cargo xtask plan-lint.
U1 — migration per D2 (header carries the deploy runbook); scratch-DB migration test (the standing_alert_dedupe_backfill.rs pattern) asserting: survivor determinism incl. (created_at, id) ties, ledger rows exact, unpublished outbox rows removed, and ERROR raised for mixed-status / divergent-money / adjustment-dependent / handoff-referenced / voided-member groups; fixture reworks per D5 (same commit); constraint tests tests/constraints/perdiem_placement_unique.rs (// @axis: evil): dup live → 23505 naming the index; voided does NOT free the key; distinct period_start coexists; subsidy rows unaffected.
U2 — D3 code; // @axis: replay scenarios in placement_activated_billing.rs: (1) crash-window (clear processed_at, assert rows_affected == 1, republish same envelope → 1 payment, error_count = 0, exactly one payment_created whose payment_id = the sole row — scoped by run-unique child, the payload has no placement_id); (2) ended-then-stale-replay → NO new payment; (3) outage replay (delete rate bands first) → early probe absorbs before the rate lookup; (4) derivation mismatch (SQL-bump incumbent rate) → detection-only, no new row, Ok. Plus // @axis: conc store-level two-tx race: one Some, one None, one row (event discipline pinned by scenario 1; the staged-iff-Some branch lives in the private binary fn — recorded layering rationale).
U3 — invariant + BLOCKING_INVARIANTS; tests/invariants.rs root + tests/invariants/perdiem_placement_duplicate.rs (// @axis: fault): red-proof on a THROWAWAY DB (live-DB in-tx DROP INDEX would hold ACCESS EXCLUSIVE mid-battery), teardown-before-assert, loud setup failures; live-DB green fn. Blocking-classification xtask test: every BLOCKING_INVARIANTS name resolves to exactly one catalog entry. Docs: data-model-financial.adoc (fix stale index prose + add the lifetime index with contrasting semantics), ADR-053 amendment (per-diem idempotency + derivation compare), ADR-022 amendment (first arbiter-backed idempotent consumer), testing-reference selector fix (--run-ignored=all), CHANGELOG with destructive-migration disclosure.
Deploy runbook
Stop ALL craig-financial replicas → verify no sessions on craig_financial → recoverable backup → start ONE new-image instance (boot migration; fail-closed on hostile data) → validate payments_dedupe_repairs + counts + invariant sweep → scale out. Rollback to the old binary is unsafe (old writer lacks ON CONFLICT → replay = 23505 → handler error → DLQ). Dev volumes: expect one-time fail-closed on test residue → wipe + reseed.
Verification
-
Constraint probes:
cargo nextest run -p craig-financial --test constraints --run-ignored=all(red pre-migration, green post). -
Replay/conc/migration/invariant targets: same pattern with
--test <target>. MR CI runs--lib --binsonly — integration assurance is the local pre-push battery. -
cargo xtask invariantssweep green post-battery; full battery after a devstack wipe+reseed (existing volumes carry residue the fail-closed migration refuses).