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 (now payments_one_subsidy_per_agreement_month_v2 on 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-only subsidy_disbursed_derivation_mismatch invariant 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 NOTHING against the partial unique key); a key loss reloads the survivor and compares derivations — matches count already_existed, mismatches count derivation_mismatch, never blind.

  • financial.payments_voided is reshaped with cause (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_completed event (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 payments wire shape is nullable-origin (pre-1.0 breaking change, CHANGELOG’d): consumers must treat case_id/placement_id/foster_home_id as 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_existed no-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; georgiaAmerica/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.

  • today is request context, threaded explicitly: handlers and the scheduler resolve the business date ONCE per unit of work and pass a NaiveDate into 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
    1 day)
    . 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).

  • 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 (cause perdiem_exclusivity; disbursed rows freeze with derivation_mismatch) regardless of any sweep knob — it completes this ADR’s committed per-diem-wins rule and is on the ⁂ #1073 list.

References

  • ADR-052 — the ledger this consumes.

  • Plan · issues #1068 (+ #1088/#1089/ #1090 enablers; #1072/#1073 DFCS confirmations; #1091/#1092 follow-ups).

  • GA DFCS 22.8 (kinship subsidies; TANF funding), 22.9 (reviews).

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_period on (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.ended voids 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 NOTHING FIRST inside the pay-excl child lock; the reciprocal subsidy-overlap flag and the payment_created stage 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_row reload-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 (20260728100000 migration): auto-repairs ONLY identical/untouched/pending duplicate groups into the durable payments_dedupe_repairs ledger (deleting their unpublished payment_created outbox rows); every other shape refuses the migration for manual reconciliation.

  • Blocking invariant perdiem_placement_duplicate (any-status GROUP BY over agreement_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).

Edit this page · latest