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
|
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.
Related
-
ADR-022 — outbox patterns the writer flows will use
-
Foundation plan — full design
review provenance -
State machines — the agreement status machine
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.