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: craig_test_lib::concurrent::wait_for_blocked_behind (bounded Result, pg_blocking_pids-based) + repoint both occupancy_recompute_race.rs wait sites off the db-wide poll

Done (2026-07-30) — MR !1110 merged (a7b566e2)

U2 (MR-B)

The four handlers (staleness gate / tx+lock), store widening + get_kinship_option_for_update, kinship live-row predicates, the 7-test race matrix with observed-RED protocol, CHANGELOG/security/developer-guide riders

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

update_placement (placements.rs:502)

assigned_worker_sub LIVE; attrs status/placement_type (no rule reads attrs)

A

case.assignment_changed inbox

FIX: staleness gate under the existing lock

update_kinship_option (kinship.rs:255)

assigned_worker_sub LIVE; attrs approved (no rule reads attrs)

C

same inbox handler (kinship_options leg)

FIX: tx + FOR NO KEY UPDATE + staleness gate + live-row predicates

delete_kinship_option (kinship.rs:306)

assigned_worker_sub LIVE; attrs approved (Action::Update — no delete action in the ruleset, ADR-060 D6)

C

same

FIX: as above; soft delete keeps its active = true guard

update_foster_home (foster_homes.rs:431)

None + attrs license_status/admin_unit (no rule reads attrs; texas supervisor_sub inert — builder passes None)

C

own endpoint (license/admin_unit); trainings/docs write siblings, not this row

FIX: tx + FOR NO KEY UPDATE + locked-row transition validation; NO staleness gate (no policy-consumed mutable input). Missing utoipa 400 added

create_placement (placements.rs:206)

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

create_kinship_option (kinship.rs:106)

nil-id ref, caller’s own sub

D

Same — #1213

create_foster_home (foster_homes.rs:360)

None, role-only

D

Exempt

record_training (foster_homes.rs:507)

parent home ref: None, role-only; parent attrs mutable but policy-unconsumed

D

update_foster_home (parent fields)

Exempt today; becomes in-scope only via the ADR-060 D5 evolution boundary

update_training (foster_homes.rs:639)

as above

D

as above

Exempt (same boundary note)

delete_training (foster_homes.rs:700)

as above

D

as above

Exempt (same boundary note)

upload_document (home_documents.rs:127)

parent home ref: None, role-only; parent license_status mutable but policy-unconsumed

D

update_foster_home

Exempt (same boundary note)

delete_document (home_documents.rs:303)

as above

D

as above

Exempt (same boundary note)

create_education (education.rs:136) / update_education (:321) / delete_education (:392)

None, empty/static attrs

D

Exempt

create_health (health.rs:55) / update_health (:252) / delete_health (:327)

None, empty attrs; no health_record MUTATION rule tests i_assigned in either bundle (Georgia: none at all; texas has one read-only i_assigned rule — inert, the builder passes None, and a live example of the ADR-060 D5 builder-side trigger)

D

Exempt — the AC-3 named exemption, recorded as a code comment on health_resource_ref

Design (summary — normative text in ADR-060)

  • Conjunctive two-snapshot policy: decisive pre-tx authz.check (ADR-050 position, fail-fast, no locks) + in-tx FOR NO KEY UPDATE re-read + in-memory compare of assigned_worker_sub → 409 CONCURRENT_MODIFICATION on 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_update switch to FOR NO KEY UPDATE (callers audited: create_placement + guard_home_for_transition capacity guards stay correct — self-conflicting lock, trigger takes its own FOR UPDATE); update_kinship_option/delete_kinship_option/update_foster_home widen &PgPool → executor-generic; kinship UPDATE gains AND 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_shape stays 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

reassignment_in_window_denies_stale_caseworker (AC-4)

409 CONCURRENT_MODIFICATION + row unmodified; retry → 403 (pre-read gate)

RED pre-fix: 200 + applied

2

reassignment_in_window_409s_role_authorized_supervisor

409 + retry → 200 (the declared conservative arm)

RED pre-fix: 200 first attempt

3

unassigned_worker_denied_at_preread

403 (conjunctive policy’s first arm; mid-flight assignment TO the caller also denied here, retry succeeds)

green today — reason recorded

4

kinship_reassignment_in_window_denies_stale_caseworker

409 + row unchanged

RED pre-fix

5

kinship_delete_vs_reassignment_denied

409 + row still live (active = true AND deleted_at IS NULL)

RED pre-fix: 204 + soft-deleted

6

kinship_update_vs_soft_delete_404s

404 + row stays inactive (no resurrection)

RED pre-fix: 200 (updater ignores active)

7

foster_home_license_race_revalidates_on_locked_row (AC-2)

400 INVALID_STATE_TRANSITION + row keeps revoked (pending→active valid; window flips to revoked; revoked→active invalid)

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.

Edit this page · latest