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 |
Done (2026-07-20) — MR0 !1012 merged e49f7193; issues filed as specified |
1 (MR1) |
#1082 — readonly principal: |
Done (2026-07-20) — this MR: model + 4 backend configs + hash re-bless + four-role harness matrix + 9 doc surfaces |
2 (MR2) |
#1067 — enums (+ |
Done (2026-07-20) — this MR. Design deltas (living spec): |
3 (MR3) |
#1083 — |
Done (2026-07-20) — this MR. Design deltas (living spec): the coverage pin also asserts the |
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: usernamecarol.reader, display "Carol Reader", emailcarol.reader@craig.example.com, passwordpassword, roles[readonly], sub00000000-0000-0000-0000-000000000004. Update the model doc-comment, the "render the 3 devstack test users" literals, and the keycloakdevstack_default_includes_three_dev_userstest. -
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_UUIDconsts + re-exports, token acquisition, and extendharness_clients!to emitreadonly_*_clientshims (macro hardcodes three roles,harness.rs:514-531; the #848 structural-completeness pin + doc comment → four). -
Docs:
.claude/CLAUDE.mdpinned-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;Ersgper 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_codevocabularies as craig-reference consts (service-validated). -
First: fix
prop_no_panic!(parse_never_panics.rs:30bindscatch_unwindto_— 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_agreementis the internal 23505 constraint-name classifier feedingDuplicateOpenAgreementvsDuplicateImport. -
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 ≥1caregiverparty; everyparty.person_id != child_id— synchronous)
initialpendinginterval + 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 viatransitions.rsfree-fn pair; business date strictly after the head’seffective_fromAND 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-derivedrevision = head.revision + 1; closes head at the neweffective_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_oftruthful) 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.adocmermaid 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 exactREFERENCES.len() == 7test 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 replayDuplicateImport· 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-libsubsidymodule — 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.adoccount/list. -
Rulesets GA+TX
\*-authz-subsidy_agreement.json: leadingi_service == trueDENY row (hitPolicy first — the payment template’s human rows wildcardis_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 (scopeassigned_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::Readviasubsidy_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=0clamp to 1 (documented — the shared helper permits 0); deterministic ordering (sort allowlist + id tie-breaker). -
Envelope: add
Deserializetocraig_common::PageResponse(additive; currently Serialize-only,pagination.rs:45). TypedFinancialSubsidyAgreementsClient(TypedClient impl +harness_clients!row + exports; noApiResponse<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.rsUPDATE to agreements (+ handler doc/counter text which names only payments+adjustments) + live replay test. -
cargo xtask api-docs --service craig-financialwith 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)
-
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). -
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). -
cargo xtask validate-migration-constraints --bail. -
Full invariant sweep with the 8 new subsidy invariants asserted green (pre-existing report-only violations triaged; gate mode remains the merge gate).
-
Scoped reconciliation run asserting the 4 new references scanned with zero SKIP lines.
-
Full pre-push battery → J1–J8 subagent → MR → merge per standing procedure → close-out.
Follow-on issues (filed at Step 0)
-
#1084 —
case.assignment_changedcarries no supervisor + no creation-time coverage — supervisor_sub enrichment path (relates cases + this plan’s MR2). -
#1085 — financial
Json<Value>list envelopes → typedPageResponsemigration (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::Denied403-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).