ADR-052: Subsidy-Agreement Temporal Ledger

On this page

Status

Accepted (2026-07-20). Lands with #1067 (schema + store, epic &70); consumed by the monthly payment generator (#1068 — its as-built payment semantics are ADR-053), the ERR/SG creation flows (#1069/#1070), the closed-cohort import (#1071), the review workflow (#1081), and the read API (#1083). Program plan: Subsidy-Agreement Temporal Foundation.

Partially superseded by ADR-054 (2026-07-21, #1081 U1) — named statements only: reviews are no longer "performed-work records" but a three-state chain (completed / open slot / superseded) that OWNS the schedule; the terms renewal_review_due/paper_review_due columns are bootstrap anchors, not the schedule; the two dues-after-start CHECKs and the reviews (agreement_id, review_type, due_on) unique are dropped; the #1067 migration comments carrying those claims are superseded in place. The ledger mechanics in this ADR (close-only windows, single-writer store, coverage predicate, projection) stand.

Context

GA policy 22.8/22.9 anchors every kinship subsidy (ERR/SG/NRSG + the 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 education extension. CRAIG had no enrollment entity; the ACF-199 export (#161) is blocked on it.

Two independent reviews (2026-07-20) rejected a mutable-current-state design: a single row whose amount/caregiver/dates are overwritten cannot support historical payment generation ("which amount applied to March?"), cannot survive audit, and lets post-approval edits rewrite entitlements. They also rejected exposing payable active agreements before any eligibility workflow exists to validate the evidence.

Decision

D1 — Four-part separation, close-only mutation

subsidy_agreements (immutable identity: child, program, signed-artifact metadata, counties, evidence linkage, import provenance, predecessor lineage) · subsidy_agreement_parties (signatories) · subsidy_agreement_terms (effective-dated immutable revisions: amount + basis, review schedule, education extension, approval binding) · subsidy_agreement_status_intervals (business-effective status ledger with reason codes + approvals) · subsidy_agreement_reviews (22.9 performed-work records; populated by #1081). Rows are never edited in place: a term revision or status interval is CLOSED (its effective_until set) by its successor — nothing else about it ever changes.

Scope honesty: this is current-known business history with close-only mutation, not a bitemporal store. created_at/created_by record transaction time, but the model cannot reconstruct "what the system believed on date X" after a correction, and a correction/amendment protocol is deliberately deferred until a real consumer needs it (tracked at that consumer, not pre-built).

D2 — Half-open windows; terminal tails excluded from coverage

Windows are half-open [effective_from, effective_until); the as-of predicate is from ⇐ d AND (until IS NULL OR d < until). Terminal statuses (terminated/withdrawn/declined) hold an open-ended head interval so status_as_of stays truthful after closure — but terminal intervals are EXCLUDED from the coverage predicate: an agreement’s coverage is the union of its non-terminal intervals ([first_from, terminal_head.from) once closed). The overlap guard and the blocking invariant share this predicate, so a successor agreement after closure is legal while same-program double-coverage is impossible.

D3 — Single-writer store; no public mutation surface in the foundation

craig_financial::subsidy::store is the only legal writer. Compound primitives take &mut PgConnection (a caller-held transaction — a pool executor would autocommit between the close-head/insert/projection steps) and serialize on the parent row (FOR UPDATE) plus a per-(child, program) advisory lock (two fresh creates have no parent row to lock). Transitions CAS on the open interval’s row id — status-based expectations have an ABA hole (active → suspended → active). Business dates are explicit parameters, strictly after the open head’s start and never in the future (the current_status projection must never report a future state; scheduled transitions are a non-goal here). Term revisions are server-numbered (head.revision + 1).

#1067 ships NO mutation API: creation lands with the eligibility-bearing flows (#1069 ERR, #1070 SG, #1071 import), reviews and suspend/reinstate/terminate with #1081 — nothing payable can exist unvalidated. The module compiles into the lib target (deliberate deviation from the binary-private store convention) so the temporal tests can drive the primitives directly.

D4 — Projection + blocking invariants

subsidy_agreements.current_status is a projection of the interval head, updated in the same transaction as every interval write. Eight blocking invariants (subsidy_* in crates/craig-test-lib/sql/invariants/craig-financial/, registered in BLOCKING_INVARIANTS) verify projection==head, exactly-one-head (LEFT JOIN cardinality), window/revision contiguity, party rules, and cross-agreement coverage non-overlap — they hold by construction, so any violation means a raw write bypassed the store.

D5 — Lineage semantics

In-place reinstatement (22.8) is a new active interval on the SAME identity (terminated → active in the machine; cause-specific approval enforced by #1081). A post-closure re-application is a NEW identity row with predecessor_agreement_id set — predecessor must be terminal at link time, self-links are CHECK-rejected, and UNIQUE(predecessor_agreement_id) forbids branching (cycles are impossible given terminal-at-link + unique successor).

D6 — Import path (#1071)

import_agreement_history inserts a complete historical chain as recorded — no fabricated pending head, terminal tails never touch the open-agreement index. Provenance (source_system/external_reference/import_batch_id/imported_at) is required by construction with an all-or-none CHECK and a UNIQUE(source_system, external_reference) replay key (idempotent re-import → 409).

Consequences

  • #1068 can reconstruct any historical month via terms_as_of/status_as_of/ active_agreements_as_of — no event-log archaeology.

  • Approval bindings live on the rows they approved; audit needs no reconstruction.

  • Writers must hold a transaction and pass explicit business dates — slightly heavier call sites, deliberately.

  • Reason codes are service-validated vocabularies (craig-reference consts), not DB CHECKs — #1081 can extend a set additively with a policy citation, no migration.

  • Cross-program coexistence and subsidy/per-diem/TANF same-month exclusivity are policy checks owned by #1068/#1069 — this ledger only forbids same-program double-coverage.

Amendment (#1070, 2026-07-24) — the activation seam is used; the generic create is gone

The reserved pending → active seam is now exercised by exactly ONE path: the witnessed guardianship activation (activate_guardianship_agreement, ADR-056) — the public transition writer refuses native sg/nrsg activation outright (the F4 belt), and imported pending rows have no activation path (#1116 — decided by #1071/ADR-057: open-pending imports are unrepresentable at the store; the refusal is belt-only). The stamp-based generic create_agreement this record described was DELETED (#1070 F3): native creation is exactly two proof-typed store fns (ERR one-shot, guardianship two-step); only import_agreement_history remains ApprovalStamp-based. D5’s re-application lineage is guardianship-family-scoped on the new path — the ERR→SG handoff deliberately carries NO predecessor link. One further exception to "terms rows never change": the activation’s one-time anchor rewrite (renewal/paper dues re-derived from the activation approval act, 22.9) — see ADR-056 §Approval-anchored review dues for its guard and audit trail.

Amendment (#1071, 2026-07-25) — the D6 import contract is completed

ADR-057 completes the D6 sketch as-built: import_agreement_history is now PROOF-TYPED (ApprovalAction::ImportHistory at StateOffice, covers() re-checked in-transaction — no longer stamp-forgeable by any store caller; the historical ApprovalStamp`s remain on the interval/terms rows only, hygiene-checked), refuses an open-`pending head outright (D6’s "no fabricated pending head" is now the stronger "pending heads are UNREPRESENTABLE" — the typed ImportPendingHead; #1116 closed), and materializes the cycle-1 review slots for active AND suspended heads in the same call. Each imported identity is born with payment_cutover_month written by the identity insert (present-iff-imported by CHECK — provenance all-or-none now extends to the money boundary), and family exclusivity widened from same-program to the FAMILY taxonomy (the guardianship one-open index spans sg/nrsg/ersg/enrsg; a new relative-care index spans rcs/ercs). The UNIQUE(source_system, external_reference) replay key this record described remains, but replay identity for the conversion surface lives on the STAGING record’s canonical hash (ADR-057 D4) — the live-row key is the last-resort backstop, not the replay contract.

Edit this page · latest