Plan: Subsidy-Agreement Temporal Foundation (#1067 + #1082/#1083)

On this page

Status

Step Description Status

0

Plan committed (this file, nav → Active) via docs-only MR0 to main BEFORE MR1; GitLab: #1082 (MR1) / #1083 (MR3) filed under epic &70, #1067 AC replaced, follow-ups #1084 (assignment-event supervisor gap), #1085 (financial Json<Value>PageResponse), #1086 (TX regional admin-unit path), #1087 (ListScope::Denied divergence)

Done (2026-07-20) — MR0 !1012 merged e49f7193; issues filed as specified

1 (MR1)

#1082 — readonly principal: DEV_USERS user 4 (carol.reader, sub …0004), four IdP configs, harness Role::Readonly + readonly_*_client shims, docs

Done (2026-07-20) — this MR: model + 4 backend configs + hash re-bless + four-role harness matrix + 9 doc surfaces

2 (MR2)

#1067 — enums (+ prop_no_panic! fix), five-table migration, subsidy lib module (typed errors, compound primitives over &mut PgConnection, import primitive, coverage-window overlap guard), blocking invariants, reconciliation, ADR-052, state machine

Done (2026-07-20) — this MR. Design deltas (living spec): SubsidyStoreError gained InvalidReasonCode/ClosedProgram/InvalidTermsForProgram and dropped MissingApproval (structurally unreachable — ApprovalStamp is required by construction; 13 variants total, store-level chokepoints strengthening the no-unvalidated-state doctrine); the terminated → active reinstatement edge re-runs the coverage-overlap guard under the history advisory lock (J-review R1 — a CLOSED successor leaves no partial-unique backstop); the bin now consumes the LIB’s transitions (single copy — a bin-side duplicate would dead-code-warn on the lib-only agreement fns); migration-validator "passes --bail" reads as "contributes zero gaps" (the tool has no baseline; 46 pre-existing grandfathered gaps make a repo-wide --bail structurally red — battery runs it advisory)

3 (MR3)

#1083 — ResourceType::SubsidyAgreement, GA/TX rulesets (leading service deny; TX regional deny; TX read/list row split), get/list handlers, summary/detail DTOs, PageResponse + Deserialize, typed client, API tests, assignment-inbox extension

Done (2026-07-20) — this MR. Design deltas (living spec): the coverage pin also asserts the subsidy_agreement snake_case rendering (ruleset filenames depend on it); the list has no client sort parameter — the store’s fixed created_at DESC, id DESC order satisfies the determinism requirement without an allowlist surface; rulesets/README.adoc’s stale hardcoded count now defers to the exact `== 51 test pin

4

Close-out: epic &70 checkboxes, plan → Archive, deferred-issue reconciliation

Done (2026-07-20) — #1082/#1067/#1083 all merged + closed with epic ticks; plan archived; follow-ups #1084–#1087 filed at Step 0 stand as the deferred register

Issues: #1067 (MR2 anchor) + #1082 (MR1) + #1083 (MR3), children of epic &70. Consumers: #1068 (generator), #1069/#1070 (creation flows), #1071 (import), #1081 (reviews + mutations).
Branches: MR0 docs/subsidy-foundation-plan; MR1 feature/1082-readonly-principal; MR2 feature/1067-subsidy-foundation; MR3 feature/1083-subsidy-read-api — each from up-to-date main.
Provenance: two user review verdicts (2026-07-20) drove this shape — R1 rejected mutable-current-state (→ foundation-first split: no public mutations here); R2’s ~30 findings drove the DDL rigor, locking signatures, typed errors, import/overlap semantics, authz scoping, and the three-issue split. Internal contextless rounds R1(0P1/2P2/6P3), R2(0P1/2P2/1P3), R3(1P1/1P2/5P3 — terminal-tail coverage predicate, MR0 sequencing, harness macro, DuplicateImport, email domain, composite-FK ON DELETE, invariant phrasing) all fixed in place before approval.

Context

GA policy 22.8/22.9 (verified 2026-07-19; epic &70 body) anchors every kinship subsidy (ERR/SG/NRSG + closed ERSG/ENRSG/RCS/ERCS cohorts) on a signed program-specific Application & Agreement with 12-month renewal reviews (+6-month paper reviews for the SG/NRSG/RCS families), suspension distinct from termination, and an age-18→19 school/GED extension. CRAIG has no enrollment entity; the ACF-199 export (#161, epic &68) is blocked on it. Historical payment generation (#1068) and audit require a ledger, not mutable current state: agreement identity (immutable), effective-dated term revisions, business-effective status intervals, and review records are separated; the only projections are current_status and the assignment denorm, maintained single-writer. Nothing payable can exist unvalidated because nothing here can mutate: creation arrives with the eligibility-bearing flows (#1069/#1070/#1071), reviews and status mutations with #1081.

MR1 — #1082: readonly principal (w2)

  • Source of truth DEV_USERS (xtask/src/cmd/identity/model.rs:75-103) gains user 4: username carol.reader, display "Carol Reader", email carol.reader@craig.example.com, password password, roles [readonly], sub 00000000-0000-0000-0000-000000000004. Update the model doc-comment, the "render the 3 devstack test users" literals, and the keycloak devstack_default_includes_three_dev_users test.

  • Backends: regenerate keycloak via cargo xtask identity render; authentik blueprint / zitadel craig.tf / kanidm bootstrap.sh mirrored per their render/hand-kept status. Honest framing: the pinned sub is authoritative in Keycloak (the harness backend); Kanidm/ZITADEL generate provider ids — no cross-IdP subject normalization is claimed. Re-bless the byte-exact render hash tests.

  • Harness: Role::Readonly, READONLY_SUB + READONLY_SUB_UUID consts + re-exports, token acquisition, and extend harness_clients! to emit readonly_*_client shims (macro hardcodes three roles, harness.rs:514-531; the #848 structural-completeness pin + doc comment → four).

  • Docs: .claude/CLAUDE.md pinned-sub table row; devstack.adoc §Test Users; idp-integration .adoc; user-testing-guide.adoc; developer-guide.adoc; testing-reference.adoc; devstack/zitadel/README.md; shared-crates.adoc (four-role harness API). CHANGELOG entry.

  • Verify: identity render + hash tests; readonly token mints against devstack keycloak; battery.

MR2 — #1067: schema + store (w5)

D1 — Enums (craig-reference, Financial section, PaymentStatus derive block)

  • SubsidyProgram: Err, Sg, Nrsg, Ersg, Enrsg, Rcs, Ercs (snake_case tokens; Ersg per 22.8). Doc-comments: per-variant 22.8 semantics, closed-cohort dates (ERSG/ENRSG 2014-08-01, RCS/ERCS 2014-01-01), Trace: cite. pub const fn open_for_enrollment(self) → bool (Err|Sg|Nrsg) with an EnumIter-exhaustive pinning test. COSTAR/UAS mapping stays #1072.

  • SubsidyAgreementStatus: Pending, Active, Suspended, Terminated, Withdrawn, Declined.

  • Per-status reason_code vocabularies as craig-reference consts (service-validated).

  • First: fix prop_no_panic! (parse_never_panics.rs:30 binds catch_unwind to _ — a panicking parse passes; assert the result), fix any newly exposed panics, then add the two enum lines. Round-trip token-pinning tests.

D2 — DDL (one 14-digit migration; every table id UUID PRIMARY KEY DEFAULT uuidv7()

client-side new_id(); explicit FKs with intentional ON DELETE; parent→child creation order; passes validate-migration-constraints --bail)

subsidy_agreements: child_id NOT NULL · case_id NULL · program NOT NULL CHECK(7 tokens) · current_status NOT NULL CHECK(6 tokens) — projection, see D3 · form_number/form_version NOT NULL · signed_at DATE NOT NULL · agreement_document_key NULL · approving_county NOT NULL · legal_county NULL · placement_id NULL · legal_reference NULL · import provenance source_system/external_reference/import_batch_id/imported_at with all-or-none CHECK + UNIQUE(source_system, external_reference) replay key · predecessor_agreement_id NULL FK→self ON DELETE RESTRICT + CHECK (predecessor_agreement_id <> id) + UNIQUE(predecessor_agreement_id) (no branching; store requires the predecessor terminal at link time — cycles impossible). Semantics pinned: in-place reinstatement = same identity, new interval (#1081); new identity + predecessor link = re-application after closure (#1069/#1070); ADR-052 defines both · assigned_worker_sub NULL + supervisor_sub NULL (both partially indexed) · created_by/created_by_name/created_at NOT NULL. Named partial unique subsidy_agreements_one_open_per_child_program (child_id, program) WHERE current_status NOT IN ('terminated','withdrawn','declined').

subsidy_agreement_parties: own UUID PK (reconciliation walks it) · agreement_id NOT NULL FK CASCADE · person_id NOT NULL · role NOT NULL CHECK(caregiver|co_caregiver) · UNIQUE(agreement_id, person_id).

subsidy_agreement_terms: agreement_id NOT NULL FK CASCADE · revision INT NOT NULL CHECK(>=1) + UNIQUE(agreement_id, revision) + UNIQUE(agreement_id, id) (composite-FK target) · effective_from NOT NULL / effective_until NULL + CHECK(effective_until IS NULL OR effective_until > effective_from)half-open [from, until); as-of predicate from ⇐ d AND (until IS NULL OR d < until) (never the inclusive rate-table shape) · partial unique (agreement_id) WHERE effective_until IS NULL · monthly_amount NOT NULL CHECK(>=0) · amount_basis NOT NULL · education_extension NOT NULL DEFAULT false + education_basis/ education_verified_by/education_expires_on NULL with all-or-none-when-extension CHECK
expiry-ordering CHECK · renewal_review_due NOT NULL CHECK(> effective_from) · paper_review_due NULL CHECK(IS NULL OR > effective_from) (ERR rows NULL — store-enforced per program, probed) · approved_by/approved_at NOT NULL + approval_level NOT NULL CHECK(county_director|regional_director|state_office) · created_by/created_at NOT NULL.

subsidy_agreement_status_intervals: agreement_id NOT NULL FK CASCADE · status NOT NULL CHECK(6) · effective_from NOT NULL / effective_until NULL + ordering CHECK + half-open · partial unique open-per-agreement · reason_code NULL + CHECK(required + nonblank for suspended/terminated/withdrawn/declined) — controlled vocabulary (D1), note ≤ REASON_MAX · approval trio as terms · created_by/created_at NOT NULL (transaction time — business time is the effective_* pair).

subsidy_agreement_reviews: agreement_id NOT NULL FK CASCADE · review_type NOT NULL CHECK(renewal_12mo|paper_6mo) · due_on NOT NULL · UNIQUE(agreement_id, review_type, due_on) · completed_on/outcome/verified_by/verified_at all-or-none CHECK; outcome CHECK(vocab) · supervisor_approved_by/_at pair CHECK · evidence_document_key NULL · resulting_term_id NULL via table-level FOREIGN KEY (agreement_id, resulting_term_id) REFERENCES subsidy_agreement_terms(agreement_id, id) ON DELETE NO ACTION (composite — cross- agreement impossible; NO ACTION so the agreement-delete cascade checks at end-of-statement)
CHECK(resulting_term_id IS NULL OR completed_on IS NOT NULL). One direction only — terms carry no review pointer. Scheduling single-source (ADR-052): terms' due-date columns are the SCHEDULE; review rows are performed-work records — #1081 owns derivation + backfill of pre-existing open agreements (stated in #1081 AC). Partial index (due_on) WHERE completed_on IS NULL.

Other indexes: child_id, case_id, current_status, terms/intervals (agreement_id, effective_from).

D3 — Store (src/subsidy/ module tree — store.rs, models.rs, error.rs — compiled into the

LIB target as pub; the binary consumes craig_financial::subsidy::…, replacing the use craig_financial as _; marker (main.rs:69). Deliberate deviation from the binary-private store, doc-commented; nothing else from the private store is exposed.)

  • Typed errors: SubsidyStoreError { NotFound, InvalidTransition{from,to}, StaleHead{expected,actual}, InvalidBusinessDate(reason), MissingApproval, MissingReason, DuplicateOpenAgreement, DuplicateImport, NoCaregiverParty, ChildAsParty, Db(sqlx::Error) } — documented future HTTP mappings 404/400/409/409/400/400/409/409/400/400/500. is_duplicate_open_agreement is the internal 23505 constraint-name classifier feeding DuplicateOpenAgreement vs DuplicateImport.

  • Compound primitives take &mut PgConnection (caller-held tx — a pool executor autocommits and drops FOR UPDATE between statements); only single-statement reads are PgExecutor-generic. Sub-fn decomposition (insert_identity/parties/interval/terms); no #[allow(too_many_lines)].

  • create_agreement(&mut conn, CreateAgreementParams) — identity + parties (Vec; native creates require ≥1 caregiver party; every party.person_id != child_id — synchronous)
    initial pending interval + revision-1 terms, one tx. Explicit business dates (initial_status_effective_from, terms_effective_from) — never defaulted from transaction time. Assignment initialized from params.

  • import_agreement_history(&mut conn, ImportAgreementParams) — #1071’s primitive: identity with REQUIRED provenance (replay-key 23505 → DuplicateImport) + full historical interval
    term lists, validated (contiguous half-open, machine-legal statuses, terminal tail allowed), inserted directly — no fabricated pending head; terminal histories never touch the open-agreement index.

  • transition_status(&mut conn, agreement_id, expected_head_interval_id, new_status, TransitionStamp) — FOR UPDATE parent; CAS on the open interval’s row id (status-based expected has the Active→Suspended→Active ABA); machine-validated via transitions.rs free-fn pair; business date strictly after the head’s effective_from AND not in the future (future-effective transitions forbidden — the projection must never report a future state; scheduled transitions are an ADR-052 non-goal); closes head [from, d), inserts the new open interval, updates the projection.

  • append_term_revision(&mut conn, agreement_id, expected_head_term_id, params) — same CAS shape; server-derived revision = head.revision + 1; closes head at the new effective_from.

  • Cross-agreement overlap guard: create/import take pg_advisory_xact_lock(hashtext(child_id::text || program)) and reject overlap with any sibling agreement’s coverage window for (child, program). Coverage predicate (pins terminal-tail semantics): terminal statuses DO hold an open-ended head interval [d, NULL) (keeps projection + status_as_of truthful) but terminal intervals are EXCLUDED from coverage — coverage = union of non-terminal intervals: [first_from, terminal_head.effective_from) once closed, [first_from, ∞) while open. Guard and invariant share the predicate, so successor-after-closure is legal (positive test required, with and without predecessor link). Cross-program coexistence + subsidy/per-diem/TANF same-month exclusivity are #1068/#1069 policy checks — out of scope here, referenced in ADR-052.

  • Reads: get_agreement (identity + parties + terms + intervals, ledgers ordered (effective_from, id)) · list_paged/count (filters child/case/program/current_status
    assigned_worker_sub + supervisor_sub — ListScope consumes them; sort allowlist + id tie-breaker) · as-of seams for #1068: terms_as_of, status_as_of, active_agreements_as_of — half-open predicates, boundary-day tested.

  • transitions.rs: machine pending→active|withdrawn|declined; active→suspended|terminated; suspended→active|terminated; terminated→active; withdrawn/declined terminal — matrix test 36/8/28 + state-machines.adoc mermaid section.

  • ADR-052 scope honesty: current-known business history with close-only mutation — not bitemporal; corrections model deferred with rationale; two projections (current_status, assignment) maintained single-writer.

D4 — Invariants, reconciliation, tests, docs (MR2)

  • Blocking invariant SQLs (names in BLOCKING_INVARIANTS, full metadata headers): projection == head-interval status · zero-heads for non-terminal projections (LEFT JOIN cardinality) · interval overlap/gap per agreement · term overlap · revision contiguity · party ≠ child · ≥1 caregiver party per non-imported agreement · cross-agreement (child, program) coverage overlap.

  • reconcile.rs: 4 REFERENCES (agreement→cases.cases · agreement.child→cases.persons · parties.person→cases.persons · agreement.placement→placement.placements); bump the exact REFERENCES.len() == 7 test to 11; docs table rows. Semantic consistency (placement belongs to child/case) is validated by the creation flows (#1069/#1070) — stated, not silent.

  • Store temporal tests (tests/store.rs + tests/store/ modules, postgres_test_db_url ("craig_financial"), tx-rollback or explicit cleanup): CAS winner/loser (interval + term, concurrent, @axis: conc) · rollback-mid-create leaves nothing · future/equal/backdated dates · boundary days · ABA replay rejected by head-id CAS · import replay DuplicateImport · reinstatement collision · successor-after-closure positive · zero-head impossible via primitives. Constraint probes: every CHECK/unique incl. party role, approval level, review dedup/all-or-none, provenance all-or-none, both window orderings, NULL-bypass attempts.

  • Docs: ADR-052 (+nav) · data-model-financial.adoc (5 tables + ER + indexes) · state-machines.adoc · cross-service-reconciliation.adoc · shared-crates.adoc (enums; financial-lib subsidy module — crate docs currently claim a pure-function lib, correct them) · CHANGELOG · this plan’s Status flips per step.

MR3 — #1083: read API + authz (w3)

  • ResourceType::SubsidyAgreement; coverage test → exact == 51; rulesets/README.adoc count/list.

  • Rulesets GA+TX \*-authz-subsidy_agreement.json: leading i_service == true DENY row (hitPolicy first — the payment template’s human rows wildcard is_service). GA: admin/supervisor all · readonly read+list · caseworker assigned read+list · default deny. TX: admin · supervisor split into a READ row (i_supervises) and a LIST row (scope assigned_supervisor) — list evaluation carries no resource (engine.rs:838) · caseworker assigned read+list · readonly assigned read+list · regional_supervisor explicit DENY row
    rationale
    (policy requires admin-unit scoping; no admin-unit attribute exists on claims or this resource; the payment template’s scope-"all" regional row is the documented hazard; follow-up issue tracks the real path) · default deny. L3 pins: service deny, service+human-roles probe, TX read-vs-list rows. Devstack reseed.

  • Handlers (api/subsidy_agreements.rs, read-only): list — auto_scope_list; Denied → 403 explicitly (this module’s contract; existing financial handlers diverge — follow-up issue); AssignedWorker/AssignedSupervisor → store filter params; Custom → 500 typed. Get — Action::Read via subsidy_agreement_resource_ref (attrs status+program STRUCTURAL-VALUE; assigned_worker_sub; supervisor_sub from the column).

  • DTOs (craig-reporting… no — craig-financial-contracts, subsidy_agreements.rs; garde(skip) responses; module docs corrected — they currently claim responses stay service-local):

    • SubsidyAgreementSummary (list rows): id, child_id, case_id, program, current_status, signed_at, assigned_worker_sub, monthly_amount (current term), terms_effective_from (current term), renewal_review_due (current term), created_at.

    • SubsidyAgreementDetail (get): summary fields + form_number, form_version, agreement_document_key, approving_county, legal_county, placement_id, legal_reference, source_system, external_reference, import_batch_id, imported_at, predecessor_agreement_id, supervisor_sub, created_by, created_by_name + parties[] {id, person_id, role}
      terms[] {id, revision, effective_from, effective_until, monthly_amount, amount_basis, education_extension, education_basis, education_verified_by, education_expires_on, renewal_review_due, paper_review_due, approved_by, approved_at, approval_level, created_by, created_at} + status_intervals[] {id, status, effective_from, effective_until, reason_code, note, approved_by, approved_at, approval_level, created_by, created_at} — ledgers ordered (effective_from, id). Reviews deliberately absent (surface with #1081).

  • Query DTO: enum filters via FromStr → 400 INVALID_ENUM_VALUE; garde 422 declared in OpenAPI responses + tested; page=0/per_page=0 clamp to 1 (documented — the shared helper permits 0); deterministic ordering (sort allowlist + id tie-breaker).

  • Envelope: add Deserialize to craig_common::PageResponse (additive; currently Serialize-only, pagination.rs:45). Typed FinancialSubsidyAgreementsClient (TypedClient impl + harness_clients! row + exports; no ApiResponse<Value>).

  • API tests: full-detail happy (typed body) · every filter/sort + count/list predicate parity · role scoping incl. readonly live + service 403 (@axis: evil) · unauth 401 · invalid enum 400 · ordering determinism · pagination clamps · GET-surface evil cases (query strings, path UUIDs, unicode, sort, page — the corpus gate scans only mutating verbs; these tests exist regardless).

  • Assignment inbox: extend case_assignment.rs UPDATE to agreements (+ handler doc/counter text which names only payments+adjustments) + live replay test.

  • cargo xtask api-docs --service craig-financial with an assertion both new routes appear in the regenerated page (the generator exits 0 on skips, api_docs.rs:92).

Verification (per MR; false-green paths closed)

  1. cargo nextest run -p craig-reference -p craig-financial-contracts -p craig-authz -p craig-financial -p craig-common -p craig-test-lib -p xtask (units incl. matrix, render hashes, REFERENCES count).

  2. cargo xtask dev reseed (rulesets/realm flips) → devstack suites --run-ignored=all (three tiers: pure unit · direct-DB #[ignore] store/constraint tests · TestHarness API tests).

  3. cargo xtask validate-migration-constraints --bail.

  4. Full invariant sweep with the 8 new subsidy invariants asserted green (pre-existing report-only violations triaged; gate mode remains the merge gate).

  5. Scoped reconciliation run asserting the 4 new references scanned with zero SKIP lines.

  6. Full pre-push battery → J1–J8 subagent → MR → merge per standing procedure → close-out.

Follow-on issues (filed at Step 0)

  • #1084 — case.assignment_changed carries no supervisor + no creation-time coverage — supervisor_sub enrichment path (relates cases + this plan’s MR2).

  • #1085 — financial Json<Value> list envelopes → typed PageResponse migration (payments/claims/ adjustments).

  • #1086 — TX regional-supervisor admin-unit scoping path (claims field + resource attribute
    Custom-scope handling) — unblocks replacing the MR3 deny row.

  • #1087 — ListScope::Denied 403-vs-empty-page divergence across existing financial handlers.

Reference anchors

services/craig-financial/src/store/payments.rs:131-164 (CAS stamp shape) · services/craig-financial/src/main.rs:69 (lib-marker wiring) · crates/craig-authz/src/ engine.rs:838 (list scope input has no resource) · xtask/src/cmd/identity/model.rs:75-103 (DEV_USERS) · xtask/src/cmd/reconcile.rs:45-139 (+ exact-count test :389) · xtask/src/cmd/invariants.rs:47-78 (BLOCKING_INVARIANTS + sweep modes) · crates/craig-test-lib/src/harness.rs:514-531 (harness_clients! three-role macro) · crates/craig-reference/tests/properties/parse_never_panics.rs:30 (vacuous catch_unwind) · crates/craig-common/src/pagination.rs:45 (PageResponse derives).

Edit this page · latest