ADR-053: Subsidy Payment Generation
On this page
Status
Accepted (2026-07-20). Lands with #1068 (epic &70) as the ADR-052 ledger’s first payment consumer. Plan: Monthly Subsidy Payment Generator.
Context
The ADR-052 ledger records WHO is enrolled at WHAT monthly terms over WHAT windows, but nothing turned active coverage into money. The only payment writer was `placement.activated’s prorated per-diem period. GA 22.8 subsidies pay a full calendar-month award per enrolled child; they are TANF-funded (542 TANF-MOE; 552/553 TANF) and never Title IV-E claimable; and a child must never be paid BOTH a foster per-diem and a kinship subsidy for the same period. An external review (2026-07-20, ~30 findings) shaped the final design — the raw check-then-insert draft was concurrency-unsafe and left corrections undefined (the deferral ADR-052 explicitly handed to this consumer).
Decision
One payments table, nullable origins (user-decided fork)
Subsidy rows live in the EXISTING payments table with nullable origin columns, not
a parallel table: the lifecycle machine (pending→approved→issued→cleared|voided),
adjustments, claiming aggregation, list surface, and authz apply unchanged.
agreement_id + term_id mark the subsidy origin (the composite FK onto
subsidy_agreement_terms (agreement_id, id) makes citing another agreement’s
revision impossible — term_id is the PRICING PROVENANCE the correction protocol
diffs against); the placement trio is NULL on subsidy rows
(payments_origin_exclusive admits exactly the two shapes; case_id is optional
only on the subsidy side — post-case children).
Unit-month accounting (user-decided fork)
A subsidy row is a UNIT-MONTH row: exact calendar month, day_count = 1,
daily_rate = gross_amount = net_amount = the term’s monthly award — enforced by
payments_subsidy_calendar_month + payments_subsidy_unit_month. The per-diem
day×rate reading never applies; presentation surfaces label the award "Monthly
Amount" keyed on agreement_id.
Structural never-IV-E
payments_subsidy_never_ive (NOT ive_eligible AND ffp_rate = 0 on every subsidy
row) makes a federal claim from a TANF-funded subsidy row impossible at the schema —
the eligibility handler’s agreement_id IS NULL filter merely fronts it (and without
that filter one subsidy row would fail the whole set-based eligibility UPDATE).
Enhanced-cohort funding confirmation is on #1073; until then every subsidy row is
non-IV-E.
Child/month exclusivity locking
Every payment writer takes pg_advisory_xact_lock(hashtext('pay-excl:' || child_id))
inside its write transaction BEFORE its existence checks: the generator’s per-child
pass and placement.activated’s `persist_placement_payment. The subsidy/per-diem
exclusivity decision therefore always evaluates committed state — constraints alone
cannot close the two-writer TOCTOU window. Per-diem WINS an overlapping month (a
foster family is never starved; the 22.8 handoff flow is #1069’s scope): the
generator skips (skipped_exclusivity), the placement writer WARNs, and the
report-only subsidy_perdiem_same_month_overlap invariant is the operator queue.
Lock ordering is acyclic: pay-excl before any agreement-row FOR UPDATE; the #1067
history lock keys a disjoint (child || program) space.
Correction protocol: reconcile-then-generate, disbursed rows freeze
Each run reconciles BEFORE it generates, per child under the lock:
-
An UNDISBURSED (pending/approved) row whose re-derivation no longer matches — backdated status transition (agreement no longer active on the month) or backdated term revision (different
term_id/amount) — is VOIDED (void_subsidy_payments_for_agreement_month; the placement-scoped void can never match subsidy rows) and, if coverage still holds, regenerated in the same pass: the idempotency key (nowpayments_one_subsidy_per_agreement_month_v2on the #1069 M1 month identity; the original was dropped by the #1106 contract step) EXCLUDES voided rows. -
A DISBURSED (issued/cleared) row is NEVER auto-corrected: money already moved is frozen; mismatch is counted (
derivation_mismatch) and surfaces via the report-onlysubsidy_disbursed_derivation_mismatchinvariant for operator remediation (payment_adjustments / #1028). BOTH mismatch arms are covered — stale pricing (term/amount drift) and stale COVERAGE (a backdated transition removed the month’s active interval): the reconcile pass counts + WARNs the uncovered-disbursed case and the invariant’s coverage arm keeps it queued. -
The insert is conflict-free (
ON CONFLICT … DO NOTHINGagainst the partial unique key); a key loss reloads the survivor and compares derivations — matches countalready_existed, mismatches countderivation_mismatch, never blind. -
financial.payments_voidedis reshaped withcause(placement_ended|subsidy_reconciliation) + present-only scope keys.
Cross-program fail-closed + jurisdiction gate
A child with more than one ACTIVE program for a month generates NOTHING
(skipped_multi_program) pending #1073’s coexistence confirmation; the report-only
multiple_subsidy_payments_same_child_month invariant watches the data. Subsidy
payment policy is currently GEORGIA-DEFINED ONLY: the scheduler spawns only when
jurisdiction == "georgia" and the endpoint returns 400 UNSUPPORTED_JURISDICTION
elsewhere; TX generation is future policy work.
Run lease, batching, scheduler, trigger
One run at a time deployment-wide: pg_try_advisory_lock(hashtext('subsidy-gen'))
as a SESSION lock on a DETACHED (non-pooled) connection the run holds end-to-end —
its drop closes the socket, so error, panic, and future-cancellation paths (a
client disconnect killing the endpoint’s handler future) all free the lease;
disconnect release is the crash-safety property. Batching is keyset over
candidate CHILDREN (active-agreement ∪ live-row union) so the cross-program guard
always sees a whole child; each child is one transaction and counters merge only on
commit. The scheduler knob subsidy_generator_interval_seconds defaults to 0 =
disabled (enabling automated money generation is explicit deployment consent;
devstack/CI set 3600), runs its FIRST tick immediately (lease + idempotency key make
restarts free), and anchors its month on the JURISDICTION business date
(craig_common::BusinessClock, #1092 — the v1 UTC approximation is retired; see
the amendment below).
POST /v1/financial/subsidy-payments/generate (month bounded to
[current − 12, current]; 409 GENERATION_ALREADY_RUNNING under a held lease)
gates on SubsidyAgreement / Approve with an unscoped reference — the
collection-appropriate capability: GA admin+supervisor, TX admin-only, service
callers and TX regional supervisors DENIED by the #1083 leading rows. Manual runs
stamp the CALLER as created_by (Initiator::Manual), never "system".
Read surface
Generated rows are FINANCIAL artifacts under the Payment policy (identical exposure
to per-diem rows); the agreement LEDGER (terms history, parties, provenance) stays
behind the SubsidyAgreement policy. The TX-regional/service breadth of the Payment
policy is the pre-existing documented hazard (#1086’s scope). payment_type on
subsidy rows is subsidy_<program> (e.g. subsidy_sg) — self-describing in lists,
claiming, and audit.
Consequences
-
Money generation is idempotent, correction-safe, and auditable: every row cites the term revision that priced it; every void names its cause; every run emits ONE
financial.subsidy_generation_completedevent (run id + counters, no PII). -
Backdated ledger edits self-heal for undisbursed months on the next tick; disbursed months become operator work items — never silent rewrites.
-
The
paymentswire shape is nullable-origin (pre-1.0 breaking change, CHANGELOG’d): consumers must treatcase_id/placement_id/foster_home_idas optional. -
Multi-program children are unpaid until #1073 answers coexistence — fail-closed is deliberate; the invariant queue keeps it visible.
-
The devstack seeder anchors a 3-month unit-month history on the CURRENT month, so the immediate first tick is an
already_existedno-op and count-asserting suites stay clean.
Amendment (#1092, 2026-07-22) — the jurisdiction business clock
The v1 "UTC month boundaries" approximation is retired. #1081 U4’s review
enforcement made the skew a correctness issue (a UTC-early window would act on
agreements hours before the Georgia business day turns), so ALL business-date
derivations in financial now resolve through craig_common::BusinessClock —
jurisdiction → business calendar date; georgia → America/New_York via
chrono-tz (embedded tzdb, musl-safe); unmapped jurisdictions stay on UTC (the
documented pre-#1092 posture, fail-safe because every GA-policy write entry
point is jurisdiction-gated before any date bound is evaluated).
Design decisions carried by this amendment:
-
The mapping is canonical data in craig-common, keyed on
ServiceSettings::jurisdiction— deliberately NOT on the state-bundle activation axis (CRAIG__ACTIVE_STATE_BUNDLES), a decision that STANDS even after #1072 made financial a bundle consumer for its subsidy reference data: bundle data is fail-closed (absence refuses writes) while business dates must resolve for ANY configured jurisdiction and so carry a total fallback, and keying the clock on the bundle would give financial a SECOND jurisdiction knob that could disagree with the one its policy gates key on (#1072’s consumer treats that mismatch as its own fail-closed refusal). A timezone is a fact about the jurisdiction, not an operator knob or a policy choice. -
todayis request context, threaded explicitly: handlers and the scheduler resolve the business date ONCE per unit of work and pass aNaiveDateinto the store writers (transition_status,append_term_revision,complete_review— their future-guards and reconcile-queue windows take the parameter). Domain logic stays clock-free and deterministic under test; one request never straddles a midnight internally. -
Behavior changes at the swap (all in the 19:00/20:00–24:00 Eastern window — EST/EDT, and at the month edge until 04:00/05:00Z on the 1st): the scheduler and the omitted-month endpoint default stay on the OLD month until Eastern midnight turns;
business_date/completed_on"no future dates" guards now reject UTC-tomorrow (they accepted it before); the natural scheduling window[today, natural + 30d]opens/closes on Eastern midnight. The placement-EVENT instant projection (maintenance proration) was the one remaining UTC date derivation — scoped out as #1099, closed by § Amendment #1103 below.
Amendment (#1069 M3, 2026-07-24) — the truth-dated handoff exception
Two named changes, carried by ADR-055:
-
Enforcement dates: this ADR’s "enforcement business dates are ALWAYS the business today" rule now has exactly ONE exception — the sweep’s leg-3 per-diem handoff termination is dated
max(perdiem_started_on, head start. It is an exception because the per diem’s start is a recorded FACT, not a policy choice: dating the termination anywhere else makes the status and money ledgers disagree. Legs 1/2 are unchanged (a missed review date is policy, never backdated).
1 day) -
Whole-month per-diem exclusivity is STANDING policy (#1069 F5, landed with M1’s
expected_month_row): a live per-diem month voids the stale undisbursed subsidy row (causeperdiem_exclusivity; disbursed rows freeze withderivation_mismatch) regardless of any sweep knob — it completes this ADR’s committed per-diem-wins rule and is on the ⁂ #1073 list.
Amendment (#1070, 2026-07-24) — the payments-begin boundary rule
expected_month_row gains one rule between per-diem exclusivity and the active-as-of rule: a
month before a guardianship agreement’s stored payments_begin_month (the 1st of the month
after the transfer, shifted later by TANF termination — ADR-056 F2) skips with the named
BeforePaymentsBegin reason, counts into skipped_before_payments_begin (report + completion
event), and voids stray undisbursed rows (subsidy_reconciliation). The rule keys on the
flow-written FACT: legacy/imported rows carry a NULL boundary and keep the pre-#1070 judgment.
Consequence (⁂ #1073 item 5): a 1st-of-month transfer leaves that month unpaid by both the
truth-dated predecessor ERR and the boundary-gated SG.
Amendment (#1179, 2026-07-28) — per-diem replay idempotency (the lifetime key)
The placement.activated per-diem writer — this ADR’s "per-diem wins" counterpart —
previously inserted with no idempotency key, so any at-least-once re-execution (inbox
crash-before-stamp, concurrent redelivery, >30-day retention bypass, admin replay —
the #1178 class) silently minted a second live payment AND staged a second
financial.payment_created. As-built fix:
-
Lifetime uniqueness:
payments_one_perdiem_per_placement_periodon(placement_id, period_start) WHERE agreement_id IS NULL— deliberately no status filter, the inversion of this ADR’s voided-exclusive subsidy keys. The subsidy correction protocol NEEDS voided-frees-key (void a stale month, regenerate); the per-diem path must NOT have it —placement.endedvoids the row, an ended placement can never reactivate, so a freed key would let a stale replay create a payment nothing ever voids again. -
Arbiter-first consumer: the insert runs
ON CONFLICT … DO NOTHINGFIRST inside the pay-excl child lock; the reciprocal subsidy-overlap flag and thepayment_createdstage run ONLY on a real insert (staged-iff-inserted). An early probe on immutable payload data absorbs replays BEFORE the cases/rates lookups (an outage replay must not error into the retry ladder). -
Derivation compare on absorb (this ADR’s
persist_expected_rowreload-and-compare precedent, applied to the per-diem side): a matching incumbent is a benign replay (structured info); a mismatch (backdated rate correction, identity drift) is a detection-only structured error — never a second row, never DLQ noise. -
One-time fail-closed dedupe (
20260728100000migration): auto-repairs ONLY identical/untouched/pending duplicate groups into the durablepayments_dedupe_repairsledger (deleting their unpublishedpayment_createdoutbox rows); every other shape refuses the migration for manual reconciliation. -
Blocking invariant
perdiem_placement_duplicate(any-status GROUP BY overagreement_id IS NULL) — holds by construction under the index.
The ended-BEFORE-activated ordering hole (a single wrong payment for an ended placement, not a duplicate) is out of scope here — #1193 tracks the termination tombstone.
Amendment (#1103/#1099, 2026-08-04) — the per-diem start joins the business calendar
handle_placement_activated previously projected started_at.date_naive() (the UTC
date) into the first per-diem period; between 00:00Z and Eastern midnight the UTC date
is TOMORROW’s Eastern date, shifting the billable start (and the prorated day count)
forward by one day — divergent from the #1092 business clock every other financial
date boundary uses. The start is now BusinessClock::for_jurisdiction(..).date_of(started_at)
(the same projection the #1069 M2 placement-side eligibility read established).
Deterministic per payload, so the #1179 lifetime key (placement_id, period_start)
stays replay-stable: a re-emitted activation derives the same Eastern date. Upgrade
boundary (pre-1.0, accepted): rows created PRE-fix for boundary-window placements sit
at the UTC-dated key, so re-emitting THOSE events post-upgrade computes the Eastern
key, misses the #1179 probe, and would mint a second row — devstack reseeds; a
production deployment crossing this fix must not admin-replay pre-fix
placement.activated events for placements started between 00:00Z and Eastern
midnight (the CHANGELOG entry carries the same note).