Plan: generation-published report runs + bulk extract contracts (#1202 + #1203, epic &73)
On this page
- Status
- Context
- Design
- D1. Generations + atomic promotion (the publication model)
- D2. Stable inputs
- D3. Run substrate —
report_runs(lift #1205; phase protocol; durable failure) - D4. Worker — supervision, heartbeat, failure ladder
- D5. Bulk surfaces — SEVEN new endpoints + two adoptions
- D6. Skip taxonomy (CLOSED — covering non-emitting universe rows)
- D7. Wire changes (pre-1.0: CHANGELOG per MR, zero shims)
- D8. abawd + honest-count fixes
- Delivery
- Test plan
- Verification
- Conventions checklist
- Critical reuse points
Rev 2. Rev 1 was rejected at external review for relocating the partial-report hazard (chunks committed into live canonical tables that existing readers serve unfiltered). Rev 2 is rebuilt around the review’s four structural requirements — run-scoped output generations with atomic promotion; stable/versioned inputs; an explicit phase/EOF checkpoint protocol; a durable typed failure/lease model — plus every itemized finding. Scope is narrowed on-issue (#1202/#1203); follow-ups are filed at plan commit.
Status
| Step | Description | Status |
|---|---|---|
0 |
Pre-implementation: follow-ups #1328–#1337 filed + related; #1202/#1203 AC narrowed on-issue; this plan committed + nav-linked. |
Done (2026-08-05) — this MR |
1 |
MR1 persons |
Done (2026-08-05) — !1074 (impl e8102b30, merge 75d885d3) |
2 |
MR2 enrollment |
Done (2026-08-05) — !1075 (impl 4c108092, merge c5d7f049) |
3 |
MR3 tanf |
Done (2026-08-05) — !1076 (impl a81a0c90, merge 8d21710c) |
4 |
MR4 reporting substrate: |
Done (2026-08-06) — !1078 (impl c5c91173, merge 959de5c1) |
5 |
MR5 SNAP wave: FNS-388 + QC on the pipeline; generation-filtered readers |
Done (2026-08-06) — !1079 (impl f5b60873 + 87d9607b, merge a5db0393) |
6 |
MR6 TANF+Medicaid wave: ACF-199, T-MSIS, CMS-416 (+ input-generation pinning, CMS-416 index + keyset); readers + POSTs; ACF-196/CMS-64 guards + honest counts; api docs; plan → Done/Archive; closes #1202 + #1203. |
Done (2026-08-06) — MR6 (final; merge SHA recorded in the closing comments on #1202/#1203) |
Status: Done (2026-08-06) — all six MRs merged; closing comments on #1202/#1203
carry the SHA trail
Epic: &73
Issues: #1202 (critical), #1203 (critical)
Branches: feature/1202-1203-bulk-batch-mr1 → … → mr6
Context
Scale-audit finding C2: the five federal extracts (FNS-388, FNS-7176 QC, ACF-199, T-MSIS, CMS-416) cannot be produced correctly at GA scale, and fail dishonestly.
-
#1202 (execution model): all five run inline in POST handlers — hyper cancels the future on any client/LB disconnect (60–300s ingress idle vs multi-hour serial runtimes ⇒ P(success) ≈ 0); no run state, no resume; FNS-388/CMS-416 aggregate in memory (total loss on cancel); cancelled runs leave partial upserts served as complete.
snap_monthly_reports.submission_statusexists but nothing advances it. -
#1203 (serial N+1 + silent corruption): 3–5 sequential upstream calls per case (GA ≈ 3M+ round-trips ≈ 8–42h; fail-closed paths P(complete) ≈ e^-48 at a 1e-5 blip rate). ACF-199’s per-adult work-req/time-limit GETs are get-or-CREATE — a federal read extract mutates canopy-tanf state (racy SELECT-then-INSERT). QC warn-skips whole rows (201 returns the count of what LANDED), no-log-skips benefits, and fabricates
abawd_household=falseon outage. CMS-416 silently drops up to 500 children per failed chunk. ACF-196/CMS-64 return fabricatedinsertedcounts. QC’s servedtotal_in_scopecounts landed rows — skips are unrecoverable from served data. -
Reruns are broken today regardless: partial-column upserts leave stale fields, shrunken universes leave stale rows, FNS-388 rerun violates a unique index.
-
Sibling #1204 (keyset universes + the fail-closed
CompletenessReaddrain) shipped and is load-bearing precedent. -
ACF-196, CMS-64, WPR stay synchronous (local-DB aggregations, sub-second) but get the fabricated-count fix and the published-input guard.
Design
D1. Generations + atomic promotion (the publication model)
New durable table report_generations (NOT reaped — it IS the provenance):
CREATE TABLE report_generations (
id UUID PRIMARY KEY CHECK (uuid_extract_version(id) = 7),
report_kind TEXT NOT NULL CHECK (report_kind IN
('fns_388','qc_7176','acf_199','tmsis','cms_416')),
period_date DATE NOT NULL,
-- kind-specific period canonicalization ENFORCED, not documented:
CHECK (report_kind NOT IN ('fns_388','acf_199','tmsis')
OR period_date = date_trunc('month', period_date)::date),
CHECK (report_kind <> 'cms_416'
OR (EXTRACT(MONTH FROM period_date) = 1 AND EXTRACT(DAY FROM period_date) = 1)),
state TEXT NOT NULL DEFAULT 'staged'
CHECK (state IN ('staged','published','superseded','abandoned')),
-- run summary, copied at terminal transition (survives job reaping):
universe_total BIGINT,
processed_count BIGINT NOT NULL DEFAULT 0,
skipped_orphan_count BIGINT NOT NULL DEFAULT 0,
degraded_count BIGINT NOT NULL DEFAULT 0,
detail_counters JSONB, -- named counts, persisted INDEPENDENTLY of progress
-- stable-input pins (set at creation; reclaim requires equality):
build_version TEXT NOT NULL,
params_hash TEXT NOT NULL, -- ReportingParameterTable content hash
as_of DATE NOT NULL, -- persons valid-time pin
extracted_at TIMESTAMPTZ NOT NULL DEFAULT now(), -- honesty stamp, non-valid-time sources
input_generation_ids JSONB, -- downstream pinning (cms_416 -> tmsis gens)
run_id UUID, -- producing run (survives run reap; NULL = legacy backfill)
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
published_at TIMESTAMPTZ,
-- implication, not biconditional: superseded generations RETAIN their
-- historical published_at (impl-discovered correction, 2026-08-05)
CHECK (state <> 'published' OR published_at IS NOT NULL)
);
CREATE UNIQUE INDEX report_generations_published_uq
ON report_generations (report_kind, period_date) WHERE state = 'published';
-
Output tables gain
generation_id UUID NOT NULL REFERENCES report_generations(same-DB FK — one service):snap_qc_universe,tanf_acf199_snapshots,medicaid_tmsis_eligibility_extracts,medicaid_cms416_reports,snap_monthly_reports. Uniqueness moves to(generation_id, <natural key>). Writes are plain INSERTs into a fresh generation — the partial-column-upsert and stale-row rerun defects vanish by construction (no cross-generation upserts exist at all). -
The migration backfills one
legacypublished generation per existing (kind, period) and stamps existing rows — readers never need a NULL-generation fallback path. -
Readers (QC pages + CSV, ACF/T-MSIS/CMS lists + CSVs, FNS get/list) resolve the published generation for (kind, period) and filter by it. Non-published generations are invisible. Each read response carries the generation’s typed provenance (from
report_generations— no progress decoding on the read path). MR6 (R2): the TANF/Medicaid reader shape — the QC idiom translated to URLs without a period segment: an OPTIONAL period query param (month=YYYY-MMfor ACF-199/T-MSIS,year=YYYYfor CMS-416) with latest-published fallback, a typed{items, provenance}envelope per kind, and 404 when no published generation exists (the pre-existing 100-row list bound applies within the resolved generation; keyset list envelopes stay the filed follow-up). -
Promotion happens inside the finalize tx: verify output reconciliation (staged row count == the kind’s expected emission count derived from processed/detail counters; FNS: the monthly row exists; CMS-416: band rows consistent with the map), then
staged→published+ priorpublished→superseded, thenreport_run_finalize_in(tx, done). One commit = the visibility swap. -
Rerun semantics (explicit): a new enqueue for a (kind, period) with a published generation creates a NEW generation; promotion supersedes the old one. Immutability guard: promotion REFUSES (
error/immutable_target) when the target period’s FNS-388 report row issubmitted/accepted(or a generation is flaggedlockedby ops) — shipped federal filings are never silently replaced. -
Cleanup: a janitor deletes
superseded/abandonedgenerations' OUTPUT ROWS (+ theirreport_run_universerows) after a retention window (run_generation_row_retention_days, default 30, domain 7..=365);report_generationsrows are permanent. Retention clocks (impl, MR4 — the schema carries nosuperseded_at): superseded is measured from the SUCCESSOR’spublished_at(exact by construction — promotion supersedes and publishes in one tx), abandoned fromcreated_at(conservative; never visible). Batch-bounded (50 generations/pass) with a dirty-candidate filter so the daily cadence always makes progress.report_runs(the job table) keeps its 7-day reap (run_reap_days, domain 7..=90) — provenance lives on the generation, so reaping breaks nothing (the #1205 durable-record split, applied).
D2. Stable inputs
-
Universe materialization: phase 1 of every run drains the universe (keyset pages) into
report_run_universe (generation_id, seq, item_id UUID, aux JSONB NULL)— checkpointed like any other phase.universe_total= the materialized count (exact; latertotal_in_scopedisagreement is irrelevant — the snapshot is the work definition). Processing iterates the MATERIALIZED list byseqkeyset: upstream insert/delete churn cannot skip or duplicate work. Rows reaped with the generation. -
CMS-416’s local universe: materialized the same way from
SELECT DISTINCT person_id … WHERE report_month >= $jan1 AND report_month < $jan1 + interval '1 year' AND chip_indicator = false(half-open dates, no EXTRACT) — plus a supporting index migration(report_month, chip_indicator, person_id)and an EXPLAIN pin. Downstream pinning: CMS-416 refuses to enqueue unless every month of the target year has a PUBLISHED tmsis generation; the ids land ininput_generation_idsand the universe query filtersgeneration_id = ANY(pinned). CMS-64 and WPR (staying synchronous) get the same guard: refuse unless the input generations for the period are published. -
as_of: persons
households:batchGetandpersons:batchGettakeas_of: NaiveDate(the store already supports it — batch.rs:28; the batch handler’s today-hardcoding is the gap being closed); the run pinsas_of = period endfor monthly kinds / snapshot date for QC. Sources WITHOUT a valid-time corpus (applications, enrollment, tanf, caps, snap-abawd) are read as-of-extraction; the generation’sextracted_atrecords it, the api doc states it, and the plan’s claim is narrowed accordingly: universes are reproducible; enrichments are as-of-extraction snapshots. Valid-time for those sources = filed follow-up. -
Pins:
build_version(CARGO_PKG_VERSION) +params_hash(canonical hash of the loaded ReportingParameterTable) captured at generation creation; claim/reclaim compares — mismatch ⇒finalize(error, stale_pins); the operator re-enqueues (fresh generation, fresh pins). A resumed run can never mix two FPL tables or fold algorithms.
D3. Run substrate — report_runs (lift #1205; phase protocol; durable failure)
report_runs lifts the chain_verify_jobs skeleton (canopy-security migration
20261015000000_chain_verification_projections.sql:47-102 table + state matrix,
:195-506 guarded fns; Rust wrappers chain_verify/jobs.rs): states
queued/running/done/error; DB-minted uuidv7 claim_token; FOR UPDATE SKIP
LOCKED claim with an expired-reclaim arm that PRESERVES progress; token-fenced
checkpoint doubling as heartbeat; finalize_in(tx); reap ≥7-day floor; partial
queued/reclaim/reap indexes; one-active-run-per-(report_kind, period_date)
unique index. Plain plpgsql (one DB role — no SECURITY DEFINER/grant matrix; the
claim token still never crosses the status endpoint). Deltas from review:
-
generation_id UUID NOT NULL(created with the run at enqueue). -
abandon_reason TEXT+abandoned_at— token-fenced best-effort write when the worker abandons; cleared on successful reclaim. Terminal attribution is durable: attempts-cap ⇒upstream_unavailablewhen the last abandon_reason is transient-class, elsecrashed(fence loss / worker death / no recorded reason). -
Attempts semantics (exact):
report_run_claimrefuses rows withattempts >= max_attempts— it terminalizes them (error, code per abandon_reason) in the same statement. Claim increments attempts; a run does work at attempts 1..=max and is terminalized at the (max+1)th claim attempt, never worked. Pinned by test. -
Progress = a TAGGED enum, phase-explicit (
sqlx::types::Json<RunProgress>; zeroserde_json::Valuein src):RunProgress::V1 { kind_tag, phase }, phase ∈Draining { source_cursor: Option<KindCursor> }|Processing { after_seq: i64, aggregates: KindAggregates }|Drained { aggregates }. Kind↔tag mismatch on decode ⇒contract_violation. Empty universe: Draining completes with total=0 ⇒ phase goes straight to Drained ⇒ finalize (0+0=0 reconciles). -
Checkpoint integrity:
report_run_checkpoint(id, token, expected_seq, new_progress, Δs…)— deltas CHECKed non-negative in-fn; cursor/seq strictly monotonic vs stored progress (regression RAISE);expected_seqmakes a duplicate/replayed checkpoint a detectable no-op (idempotent — an ambiguous commit is safe to re-issue);universe_totalset-once at Draining completion;degraded_count ⇐ processed_counttable CHECK. MR5 (R2): acheckpoint_in(tx)variant was added (D4’s chunk tx requires the checkpoint inside the same transaction; MR4 shipped only the pool variant); aDuplicateNoOpobserved mid-pass HALTS the pass conservatively — the run resumes cleanly on the next claim, and the safe-re-issue semantics hold at the SQL layer; the Fns388 aggregates carryexpedited_unknownso the D6 detail class survives resume. -
Malformed-progress handling: the claim wrapper fetches id/token/state RAW first, decodes progress SEPARATELY; decode failure ⇒ the worker holds a valid token and
finalize(error, contract_violation)`s — never abandonment-by-panic. The status endpoint reads counters from COLUMNS + `detail_countersfrom the generation row — it never decodes progress. -
The done CHECK (
state <> 'done' OR (universe_total IS NOT NULL AND processed_count + skipped_orphan_count = universe_total)) proves counter arithmetic; OUTPUT completeness is proven by the promotion-time reconciliation (D1). Together they are the correctness gate — the claim is stated exactly that way, no stronger.
D4. Worker — supervision, heartbeat, failure ladder
main.rs: worker = ReportWorker::spawn(bg_pool, cfg, scoped_clients, params_table)
// clients ALREADY scoped_source(svc_token); ReportingParameterTable passed in;
// JoinHandle retained; health gauge feeds /readyz; graceful shutdown on ctrl-c
loop: Idle -> sleep(tick_ms); Serviced -> continue; Err -> capped backoff 250ms->5s
reap_if_due() // time-based, runs on EVERY iteration incl. the Serviced arm
run_one_pass:
raw = report_run_claim(worker, claim_secs) else Idle // terminalizes over-cap rows
pins_check(raw.generation)? else finalize(error, stale_pins)
progress = decode(raw.progress) else finalize(error, contract_violation) // token in hand
pulse = spawn heartbeat task (every heartbeat_secs, token-fenced;
fence-lost signal => cancel work, zero further writes) // supervised pulse
phase Draining: page source -> INSERT universe rows + checkpoint (per page)
phase Processing:
loop: items = next seq-keyset slice from report_run_universe
enrich = try_join!(batch legs) // bounded retry; full-call deadline
transient-exhausted => record abandon_reason; stop pulse; Abandoned
(rows, deltas, aggs) = fold_chunk(...) // pure per-kind fold
tx { INSERT output rows (generation_id); checkpoint(expected_seq, ...) }
last slice empty => checkpoint phase=Drained (atomic with the final slice's tx)
phase Drained:
tx { write final rows (fns_388 monthly / cms_416 bands);
copy counters -> generation; RECONCILE staged counts;
promote (staged->published, prior->superseded, immutability guard);
report_run_finalize_in(done) }
-
Heartbeat is a supervised pulse task through fetch/enrich/fold/DB work — a 90s upstream call under a 300s lease can’t be reclaimed mid-flight; config relationship rules require
claim_secs >= 3×heartbeat_secsANDclaim_secs > overall_call_deadline + heartbeat_secs(validated at boot, never clamped). -
Full-call deadline: the client funnels wrap bearer acquisition + send + bounded body read + decode under ONE
overall_timeout(~90s) — a stalled body cannot escape the budget (today’sretry_requestwraps onlysend()). -
Typed client errors: the reporting funnels return
ReportingClientError { kind: TransientExhausted{class} | OverallTimeout | Auth | NotFound | Conflict | Unprocessable | Decode | Contract }instead of anyhow strings — the ladder dispatches on it. Idempotency keys are minted ONCE per logical call outside the retry closure. -
Error dispatch (exhaustive): transient-exhausted / overall-timeout ⇒ abandon (durable reason); auth ⇒ abandon (
auth); decode/contract/short-batch/drift/ pins ⇒ finalize(error, contract-class); local DB statement-timeout or deadlock ⇒ abandon (retryable); pool outage ⇒ engine error (backoff loop); promotion reconciliation failure or unique violation ⇒ finalize(error, contract_violation); final CHECK refusal ⇒ ditto (a bug, loud). -
Concurrency: in-chunk legs
try_join!; the one knobrun_upstream_concurrency(default 16, domain 1..=64) bounds any residual per-row fan-out AND thebuffer_unorderedsub-batching when >500 adults arise in one ACF-199 chunk. No cross-chunk pipelining (out-of-order completion breaks the monotone cursor). -
runs_enableddefault TRUE; when false the worker parks AND enqueue answers 503 (a 202 for work that will never run is a lie) — the per-control operator override, accountable and visible. -
Enqueue lock ordering: under the advisory lock, re-check active-target FIRST (⇒ 409 with handle) then capacity (⇒ 503). Queue cap bounds queued rows; concurrent RUNNING is bounded by worker replicas (documented; per-kind global bounds = filed follow-up).
-
Chunk txs use
SET LOCALstatement/lock timeouts. KnobsCANOPY_REPORTING__RUN_*(from_configcheck()-validated): tick_ms 5000 [500..=60000] · first_tick_delay_secs 60 · claim_secs 300 [60..=600] · heartbeat_secs 60 · max_attempts 5 · max_queued 10 · chunk_size 200 [50..=200, must not exceedpagination::MAX_LIMIT] · upstream_concurrency 16 [1..=64] · runs_enabled true · reap_days 7 [7..=90] · generation_row_retention_days 30 [7..=365]. -
Worker health (impl, MR4): a reusable non-gating
/readyzworkercheck —canopy_api::BackgroundWorkerHealthatomics (last pass/beat, last success, error streak, serviced count) stamped by the loop + the pulse; degraded NEVER 503s readiness (the outbox-check posture — pulling a replica cannot revive its in-process worker). OTel: claims/serviced/abandons/finalizes counters, queue depth + oldest-queued-age gauges (sampled in reap_if_due), and a last-success-age observable.
D5. Bulk surfaces — SEVEN new endpoints + two adoptions
House batch shape (#626/#1252): AIP-231 :batchGet POST, 500-id cap (422 over,
const-assert ≥ MAX_LIMIT), require_service_caller, duplicates collapsed,
first-occurrence order, one ANY($1) set query, EXPLAIN-pinned, named request AND
response DTOs in the owning contract crate (+ path consts + OpenAPI + roundtrip
proptests). Responses are compact reporting projections, bounded under the 2MiB
idempotency-replay cache (size analysis per endpoint in each MR description).
Reconciliation is exact id-set equality (response ids == requested unique ids,
no dupes/extras, first-occurrence order); effects map back per UNIVERSE row (two
determinations sharing one absent household = two skipped rows).
| # | endpoint (owner) | request → response | semantics |
|---|---|---|---|
1 |
persons |
|
ABSENT = missing/inactive as-of ⇒ consumer maps to per-universe-row
|
2 |
applications |
|
ABSENT ⇒ the #1155 |
3 |
enrollment |
|
GET-OR-ZERO, exact-set. Join THROUGH enrollments ( |
4 |
tanf |
|
READ-ONLY get-or-default, no row created — closes the read-that-writes
hazard. Tables have NO unique(person_id): selection = latest |
5 |
tanf |
|
Same posture; months_used ONLY (all the extract reads, per
|
6 |
caps |
|
Exact-set; one determinations⋈authorizations query; window predicate identical to today’s client walk (reporting clients/mod.rs:528-533). |
7 |
snap |
|
Exact-set; returns THE BOOL the consumer computes today (bounded — never the
record vector); |
Adopted unchanged in shape: persons persons:batchGet (+ as_of param added to
the HANDLER — the store already supports it) with projection: [] (CMS-416) /
[income] (T-MSIS); tanf summary:batchGet (#1252 — its internal per-person
CAPS-context query is a known residual N+1, filed); the universe drains (feeding
the Draining phase).
Pager abstraction (replaces "adopt CompletenessRead unchanged"): the worker
cannot drain-to-Vec (that is the hazard) and xtask’s completeness-reads policy
(policy.rs:1819) forbids raw cursors in federal modules. New UniversePager<T, C>
in clients/completeness.rs: the same fail-closed guarantees (first page must carry
total_in_scope; exhaustion must reconcile pulled == total) exposed
page-at-a-time for the Draining phase; CompletenessRead stays for the
synchronous overpayments surface. The xtask policy + its doc update in the same MR
(MR4) to bless exactly the two types. MR5 (R2): the pager gained
resume(cursor, total) + next_cursor() — a start-only pager cannot honor the
per-page-checkpoint contract across a reclaim; the pulled==total tripwire is
re-armed on resume from the page-one total persisted in the kind cursor
(KindCursor::SnapCert.total_in_scope).
D6. Skip taxonomy (CLOSED — covering non-emitting universe rows)
| class | trigger | counter | row outcome | run outcome |
|---|---|---|---|---|
|
universe row the kind’s fold EXCLUDES by rule (non-approved determination in
ACF-199/T-MSIS — today’s silent |
detail counter (counts as processed) |
no output row, BY RULE |
continues |
|
household absent from batch (#315) |
|
excluded |
continues; reconciles |
|
application absent |
detail counter (#1155) |
ships unknown |
continues |
|
application present, |
detail counter |
ships unknown |
continues |
|
person absent OR present-with-null citizenship (T-MSIS) |
|
ships "unknown" |
continues |
|
no usable income / orphan household |
detail counter (NULL is honest, ADR-036 — now ALSO counted) |
ships NULL |
continues |
|
person absent / DOB missing / over-age / no configured band |
detail counters per sub-class |
excluded from bands |
continues; reconciles |
transient-exhausted |
bounded retries exhausted, any leg |
never a skip |
none (tx never opens) |
abandon (durable reason) ⇒ reclaim |
universe drift |
materialized-universe reconciliation failure |
— |
— |
|
contract violation |
inexact batch id-set; undecodable page/progress; missing total; promotion reconciliation failure |
— |
— |
|
stale pins |
build/params hash mismatch at reclaim |
— |
— |
|
Counter semantics: every universe row lands in exactly ONE of {processed (incl.
filtered/degraded/unknown overlays), skipped_orphan}; degraded ⇐ processed
(CHECK); promotion reconciles emitted-row counts per kind against processed −
non-emitting classes. FNS-388 orphan ruling (explicit): an orphan household is
EXCLUDED from total_households (today it counts the household but loses
members — inconsistent); named in MR5’s CHANGELOG.
D7. Wire changes (pre-1.0: CHANGELOG per MR, zero shims)
| surface | change |
|---|---|
5 generate POSTs |
Same URLs/bodies; responses → 202 |
run status |
NEW |
report reads |
Every list/get/CSV endpoint filters by the PUBLISHED generation and carries
|
deleted DTOs |
|
flip-riders per MR |
canopy-test-lib reporting client (5 of the 8 generate wrappers flip: 2 in MR5, 3 in MR6; a status-aware enqueue outcome type replaces the typed 2xx-only decoder — the 409 handle must be returnable); contracts-reporting roundtrips; reporting_test.rs; api docs. Verified: no other generate-POST callers exist. |
escape hatch |
NONE — a sync flavor recreates the ingress-idle hazard. Tests drive
|
D8. abawd + honest-count fixes
-
abawd_householdmigration: nullable + historical values NULLed with a migration comment (outage-fabricatedfalseis indistinguishable from sourcedfalse— they cannot be trusted as verified; NULL = "unverified legacy"). Ripple: domainOption<bool>, contracts, OpenAPI, CSV renders empty (#1155 precedent), data-model doc. The new pipeline always writesSome(_). -
ACF-196/CMS-64:
rows_affected()-honest counts + the refuse-unpublished-inputs guard (D2). Their synchronous 201 shape is unchanged. -
Overclaim narrowing (docs + this plan’s claims): CMS-416
member_months = enrolled,eligible = enrolled, T-MSISeligibility_start ≈ report_month+ hardcoded managed-care fields, ACF unavailable-state defaults are PRE-EXISTING approximations — documented in api/canopy-reporting.adoc as known limitations, filed as a follow-up, excluded from this plan’s correctness claims.
Delivery
Six MRs. Every MR: own CHANGELOG bullet, own api-doc updates (owning service),
own OpenAPI path-count pins, own contract roundtrips. "Dormant" = no caller yet,
but each endpoint is a LIVE documented service API from its own MR onward.
Rollout/mixed-version: migrations forward-only, additive-first (generation_id
nullable-until-backfilled within the migration tx); old-binary/new-schema safe
per MR; RunProgress enum-versioned — an unknown version is refused loudly.
| MR | contents | issue |
|---|---|---|
1 |
persons |
|
2 |
enrollment |
|
3 |
tanf |
|
4 |
reporting substrate: three migrations (generations, runs, run-universe |
|
5 |
SNAP wave: FNS-388 + QC on the pipeline; generation-filtered readers |
|
6 |
TANF+Medicaid wave: ACF-199, T-MSIS, CMS-416 (+ pinning, index, keyset); readers + POSTs; ACF-196/CMS-64 guards + honest counts; api docs (undocumented endpoints + 502-vs-500 drift + limitations); plan → Done/Archive; closing comments |
closes #1202 + #1203 |
Follow-ups (filed at plan commit): run-status web UI · operator cancel · tanf
single-GET conversion (caller audit) · valid-time corpora for
enrollment/tanf/caps/snap sources · per-kind global concurrency across replicas ·
pre-existing field approximations (CMS-416 member_months etc.) · list-endpoint
keyset envelopes · submission_status lifecycle · auto re-enqueue for crashed ·
tanf summary:batchGet internal N+1.
Test plan
-
Publication: output INVISIBLE while running/abandoned/errored; prior published generation served until promotion; promotion atomicity (reader mid-swap sees old XOR new); smaller rerun leaves zero stale rows visible; submitted-FNS immutability refusal; reconciliation failure ⇒ error + nothing promoted; provenance survives run reaping.
-
Phase/checkpoint: crash after the final chunk tx but BEFORE finalize ⇒ resume sees Drained, finalizes without re-processing (aggregates identical — the no-double-count invariant); duplicate/ambiguous checkpoint re-issue is a no-op; backward cursor/negative delta refused; empty universe ⇒ done; source insert/delete/update + equal-count replacement DURING a run ⇒ materialized universe unaffected.
-
Stable inputs: historical
as_ofhonored (valid-time fixture straddling the period); pins mismatch at reclaim ⇒ stale_pins; CMS-416/CMS-64/WPR refuse unpublished input generations. -
Failure model: heartbeat pulse survives an upstream call longer than the heartbeat interval; fence-lost ⇒ zero further writes; abandon_reason durable ⇒ exact attempts-cap attribution; attempts boundary exact (max works, max+1 terminalizes without work); worker panic ⇒ supervised restart; graceful shutdown; runs_enabled=false ⇒ enqueue 503 + worker parked; two replicas claim distinct runs.
-
Batches: exact-set reconciliation (dupes/extras/omissions each refused); shared-absent-household maps to N universe-row skips; >500-adult ACF chunk sub-batches; response sizes bounded per fixture; tanf batches leave row counts unchanged; tanf latest-row selection deterministic; QC mid-month snapshot date hits the month’s issuances; non-approved determinations counted; every CMS-416 no-band sub-class.
-
Run API: 202+Location / 409-with-handle (typed client outcome) / 503 (cap AND disabled); status never 500s on malformed progress; list ordering/limits; 404s; RBAC OR-path matrix; POST returns promptly and the run survives client disconnect.
-
Proptests: fold chunk-partition/permutation invariance; universe-row conservation (every row → exactly one taxonomy class; counters reconcile) under arbitrary skip patterns; RunProgress roundtrip per kind; cursor monotonicity; pager fail-closed invariants.
-
Integration battery: enqueue→poll→published over a seeded mini-universe; provenance on reads; OpenAPI pins per service.
Verification
Per MR: cargo fmt --check --all · workspace clippy -D warnings ·
RUSTDOCFLAGS="-D rustdoc::broken_intra_doc_links" cargo doc --workspace
--no-deps · cargo xtask quality-budgets (full table) · cargo xtask plan-lint
· cargo xtask check-docs · full pre-push battery · api-docs bless (all six MRs
touch wire).
Conventions checklist
SPDX on new .rs · typed errors (the client error enum included) · no
unwrap/expect in prod · proptests on folds/invariants/serde · B3a:
sqlx::types::Json<RunProgress> + typed detail counters, zero new
serde_json::Value in src · pagination clamps + const-asserts · keyless-POST
rule honored via post_idempotent (key minted once per logical call; the plain
post funnel DELETED if caller-less) · fail-closed + per-control overrides
(runs_enabled false ⇒ 503, never a dead-letter 202) · plan committed
nav-linked before implementation; plan-lint vocabulary · Relates to/Closes
per the MR table; closing comments at MR6 · CHANGELOG + api-docs + OpenAPI pins
per wire-touching MR · no pre-1.0 shims (4 DTOs deleted; readers cut over
per-kind with their wave; the legacy generation backfill is data, not code).
Critical reuse points
-
canopy-security migration
20261015000000_chain_verification_projections.sql(:47-102, :195-506) — the lifted job skeleton;src/chain_verify/jobs.rs— wrapper shapes;src/api/mod.rs:790-905— the 202/409/503 mapping. -
canopy-reporting
src/api/mod.rs:228/359/516/673/791— the five handlers;src/clients/mod.rs+src/clients/completeness.rs— funnels/cursors/pager. -
canopy-tanf
src/api/work_requirement_handlers.rs:395-477— the #1252 batch pattern;:44-55/:896-908— the hazard bypassed. -
canopy-api
src/retry.rs(:68/:82/:197) — budgets
post_with_idempotency_key;src/idempotency.rs:231— the 2MiB replay bound the projections must respect. -
canopy-persons
src/store/batch.rs:28(as_ofsupport)
src/store/households.rs:42-66(get_with_members). -
canopy-eligibility
src/orchestrator.rs:1242— buffer_unordered idiom; canopy-securitysrc/main.rs:197-202— lazy 2-conn pool;src/config.rs:389-639— the from_config validation shape.