Authz Staleness Recheck (#1187, epic &74)
On this page
Status
| Unit | Description | Status |
|---|---|---|
U0 |
ADR-060 (Proposed→Accepted at merge) + this plan page + nav — the decision record and the 18-handler audit table |
Done (2026-07-30) — MR !1109 merged (6f95e5a3) |
U1 (MR-A) |
Pid-scoped deterministic-wait primitive: |
Done (2026-07-30) — MR !1110 merged (a7b566e2) |
U2 (MR-B) |
The four handlers (staleness gate / tx+lock), store widening + |
Done (2026-07-30) — MR !1111 merged (cb239630); RED observed 6/7 pre-fix with the predicted statuses, GREEN 7/7 post-rebuild; #1187 closed |
Issue: #1187 (epic &74, weight 5, Plan::AUTHZ-RECHECK)
ADR: ADR-060
Follow-ups routed at preflight: #1212 (post-state/destination authorization semantics),
#1213 (create handlers mint assignment authority from the caller), #1214 (assignment
projection lacks causal ordering; also related to #1178), #1215 (kinship GET returns
soft-deleted rows)
Review state: v1 (engine re-run under the lock) REJECTED by external review — evaluator
serialization, untimed miss paths, and the ADR-050 audit sink’s second same-pool transaction
make a lock-held engine call unbounded; v2 (this design) passed a second independent
contextless round with no blocking findings (2026-07-30).
Context
update_placement authorizes on assigned_worker_sub from an unlocked pre-read, then takes
a row lock that re-validates only the state transition — never the authz field. The
concurrent writer is the case.assignment_changed inbox handler
(src/inbox/case_assignment.rs:39-63), which rewrites assigned_worker_sub by case_id.
Georgia’s caseworker-update rule keys on exactly that field. update_foster_home has no
tx/lock at all — even its license-transition check races. Kinship update/delete share
placement’s hole with no lock either.
The design rationale, rejected alternatives, honest guarantee ("serialized against the locked local projection"), evolution boundary, and lock-strength decision live in ADR-060 — not duplicated here.
The audit (AC-3): all 18 mutating handlers
Shape legend — A: pre-read authz + row lock, but no authz-relevant recheck under the lock; C: no tx/lock at all; D: authorization consumes no concurrently-mutable resource input (creates on nil-id refs, role-only rules, parent-scoped attrs no rule reads). (B — read+authz+write all under one lock — is unused: no current handler has that shape; not to be confused with the quality-budget IDs like B2 cited below.)
| Handler (file:line) | Authz inputs (builder) | Shape | Concurrent writer | Disposition |
|---|---|---|---|---|
|
|
A |
|
FIX: staleness gate under the existing lock |
|
|
C |
same inbox handler (kinship_options leg) |
FIX: tx + |
|
|
C |
same |
FIX: as above; soft delete keeps its |
|
|
C |
own endpoint (license/admin_unit); trainings/docs write siblings, not this row |
FIX: tx + |
|
nil-id ref, caller’s own sub |
D |
— |
No TOCTOU fix. #1213: mints assignment authority from the caller (Create rules don’t test assignment) — pre-existing, distinct gap |
|
nil-id ref, caller’s own sub |
D |
— |
Same — #1213 |
|
|
D |
— |
Exempt |
|
parent home ref: |
D |
|
Exempt today; becomes in-scope only via the ADR-060 D5 evolution boundary |
|
as above |
D |
as above |
Exempt (same boundary note) |
|
as above |
D |
as above |
Exempt (same boundary note) |
|
parent home ref: |
D |
|
Exempt (same boundary note) |
|
as above |
D |
as above |
Exempt (same boundary note) |
|
|
D |
— |
Exempt |
|
|
D |
— |
Exempt — the AC-3 named exemption, recorded as a code comment on |
Design (summary — normative text in ADR-060)
-
Conjunctive two-snapshot policy: decisive pre-tx
authz.check(ADR-050 position, fail-fast, no locks) + in-txFOR NO KEY UPDATEre-read + in-memory compare ofassigned_worker_sub→ 409CONCURRENT_MODIFICATIONon drift. No engine call, no audit tx, no HTTP under the lock. -
Stores: new
get_kinship_option_for_update(WHERE id = $1 AND active = true FOR NO KEY UPDATE);get_placement_for_update/get_foster_home_for_updateswitch toFOR NO KEY UPDATE(callers audited:create_placement+guard_home_for_transitioncapacity guards stay correct — self-conflicting lock, trigger takes its ownFOR UPDATE);update_kinship_option/delete_kinship_option/update_foster_homewiden&PgPool→ executor-generic; kinship UPDATE gainsAND active = true. -
B2 pre-empt: if the placements staleness gate tips the 92-line handler over the 100-line budget, extract the locked-phase validation into a helper (never the pre-tx terminal-fact check —
validate_update_shapestays fail-fast).
Tests (U2 — tests/api/authz_recheck_race.rs)
Interleave shape: tx A (raw pool) locks the seeded row FOR NO KEY UPDATE + captures
pg_backend_pid() → spawn the HTTP mutation (pre-read + fail-fast authz pass on MVCC; parks
at the handler’s locked read) → wait_for_blocked_behind(pid, 5s) → tx A rewrites the field
+ COMMIT → handler unblocks into the gate. Hygiene: tokens minted before tx A; SHOW
transaction_isolation asserted read committed; reassignment target = ADMIN_SUB_UUID
(jane.doe also holds caseworker — reassign-to-jane would make the supervisor case vacuous);
tx A always resolved before assertions; the spawned request is awaited, never detached;
mutation body uses reasonable_efforts (non-event field — never status:"ended", which
publishes downstream).
| # | Test | Assert | RED evidence |
|---|---|---|---|
1 |
|
409 |
RED pre-fix: 200 + applied |
2 |
|
409 + retry → 200 (the declared conservative arm) |
RED pre-fix: 200 first attempt |
3 |
|
403 (conjunctive policy’s first arm; mid-flight assignment TO the caller also denied here, retry succeeds) |
green today — reason recorded |
4 |
|
409 + row unchanged |
RED pre-fix |
5 |
|
409 + row still live ( |
RED pre-fix: 204 + soft-deleted |
6 |
|
404 + row stays inactive (no resurrection) |
RED pre-fix: 200 (updater ignores |
7 |
|
400 |
RED pre-fix: 200, stale-validated |
RED protocol: confirm the running devstack predates the fix → focused run
(-E 'test(authz_recheck_race)') → record per-test failures → cargo xtask dev start →
focused all-green → broad binary(api) + binary(constraints) passes.
Verification
Per-MR: scoped clippy (-p craig-placement -p craig-test-lib), craig-test-lib --lib
--run-ignored=all (the helper timeout test is #[ignore = "requires devstack"] — F-024
demands the visible skip, not a devstack_available() guard), placement --lib, the integration binaries above, scoped
cargo llvm-cov nextest -p craig-placement -p craig-test-lib (#1208 blocks the full
runner), quality-budgets --fail-on-regression, axis-coverage, plan-lint, check-docs
(exit 3 = standing overrides), local Antora render for the doc pages, cargo xtask api-docs
(the utoipa 400 addition). Full battery via the pre-push hook on every push.