Plan: generation-published report runs + bulk extract contracts (#1202 + #1203, epic &73)

On this page
NOTE

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 households:batchGet (+ as_of on the existing persons batch handler) + applications applications:batchGet + the persons batch.rs roundtrip-proptest gap.

Done (2026-08-05) — !1074 (impl e8102b30, merge 75d885d3)

2

MR2 enrollment households/issuances:batchGet + snap abawd/tracking:batchGet.

Done (2026-08-05) — !1075 (impl 4c108092, merge c5d7f049)

3

MR3 tanf work-requirements:batchGet + time-limits:batchGet (read-only)
caps authorizations/active:batchGet.

Done (2026-08-05) — !1076 (impl a81a0c90, merge 8d21710c)

4

MR4 reporting substrate: report_generations + report_runs
report_run_universe migrations; typed client errors + post_idempotent
full-call deadline; UniversePager + xtask policy update; supervised worker (zero kinds wired); knobs + lazy pool; contracts runs.rs; GET /runs endpoints; ops metrics + runbook.

Done (2026-08-06) — !1078 (impl c5c91173, merge 959de5c1)

5

MR5 SNAP wave: FNS-388 + QC on the pipeline; generation-filtered readers
provenance; POSTs → 202; abawd NULL migration; the "issued is issued"
FNS-orphan-exclusion CHANGELOG rulings.

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_status exists 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=false on outage. CMS-416 silently drops up to 500 children per failed chunk. ACF-196/CMS-64 return fabricated inserted counts. QC’s served total_in_scope counts 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 CompletenessRead drain) 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 legacy published 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-MM for ACF-199/T-MSIS, year=YYYY for 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 + prior published→superseded, then report_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 is submitted/accepted (or a generation is flagged locked by ops) — shipped federal filings are never silently replaced.

  • Cleanup: a janitor deletes superseded/abandoned generations' OUTPUT ROWS (+ their report_run_universe rows) after a retention window (run_generation_row_retention_days, default 30, domain 7..=365); report_generations rows are permanent. Retention clocks (impl, MR4 — the schema carries no superseded_at): superseded is measured from the SUCCESSOR’s published_at (exact by construction — promotion supersedes and publishes in one tx), abandoned from created_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; later total_in_scope disagreement is irrelevant — the snapshot is the work definition). Processing iterates the MATERIALIZED list by seq keyset: 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 in input_generation_ids and the universe query filters generation_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:batchGet and persons:batchGet take as_of: NaiveDate (the store already supports it — batch.rs:28; the batch handler’s today-hardcoding is the gap being closed); the run pins as_of = period end for 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’s extracted_at records 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_unavailable when the last abandon_reason is transient-class, else crashed (fence loss / worker death / no recorded reason).

  • Attempts semantics (exact): report_run_claim refuses rows with attempts >= 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>; zero serde_json::Value in 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_seq makes a duplicate/replayed checkpoint a detectable no-op (idempotent — an ambiguous commit is safe to re-issue); universe_total set-once at Draining completion; degraded_count ⇐ processed_count table CHECK. MR5 (R2): a checkpoint_in(tx) variant was added (D4’s chunk tx requires the checkpoint inside the same transaction; MR4 shipped only the pool variant); a DuplicateNoOp observed 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 carry expedited_unknown so 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_counters from 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_secs AND claim_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’s retry_request wraps only send()).

  • 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 knob run_upstream_concurrency (default 16, domain 1..=64) bounds any residual per-row fan-out AND the buffer_unordered sub-batching when >500 adults arise in one ACF-199 chunk. No cross-chunk pipelining (out-of-order completion breaks the monotone cursor).

  • runs_enabled default 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 LOCAL statement/lock timeouts. Knobs CANOPY_REPORTING__RUN_* (from_config check()-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 exceed pagination::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 /readyz worker check — canopy_api::BackgroundWorkerHealth atomics (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 POST /v1/households:batchGet

BatchGetHouseholdsRequest{household_ids, as_of}Vec<HouseholdMembershipSlim{household_id, members: Vec<MemberRef{person_id, relationship}>}>

ABSENT = missing/inactive as-of ⇒ consumer maps to per-universe-row skipped_orphan (#315). Valid-time corpus via get_with_members (store/households.rs:42-66); compact projection (NOT HouseholdWithMembers — bounded size); no person core / SSN path / Pub-1075 events.

2

applications POST /v1/applications:batchGet

{application_ids}Vec<ApplicationCore{id, household_id, status, expedited_eligible: Option<bool>, received_at}>

ABSENT ⇒ the #1155 cert_type_unknown bucket; expedited_eligible: None ⇒ counted expedited_unknown detail class.

3

enrollment POST /v1/households/issuances:batchGet

{household_ids, benefit_month} (normalized to month start server-side; half-open month range in SQL) → Vec<HouseholdIssuedSummary{household_id, issued_total, issuance_count}>

GET-OR-ZERO, exact-set. Join THROUGH enrollments (snap_enrollments e JOIN snap_benefit_issuances i ON i.enrollment_id = e.id WHERE e.household_id = ANY($1) AND i.benefit_month >= $2 AND i.benefit_month < $2 + 1 month AND i.issuance_status = 'issued' GROUP BY e.household_id) — rides existing indexes, NO new index. Deliberate semantics ruling — "issued is issued": no enrollment-status predicate (today’s FNS-388 sums only under active/pending_issuance enrollments — a 2-hop-walk artifact that understates issued benefits; QC never filtered). Named in MR5’s CHANGELOG. Separate service-caller surface; the #408-gated portal reads untouched.

4

tanf POST /v1/work-requirements:batchGet

{person_ids}Vec<WorkRequirementStatusEntry{person_id, on_file, required, exempt, status, sanction_level}> (real columns, migration 20260325000000:78-104)

READ-ONLY get-or-default, no row created — closes the read-that-writes hazard. Tables have NO unique(person_id): selection = latest (created_at, id) per person, deterministic tie-break, documented (matching get_or_create’s newest-row read); >1 row is expected history, not corruption. The fold honors `on_file (ACF-199 maps on_file:false per the current no-row semantics — an explicit mapping table in the MR). The single get-or-create GETs stay for determine.rs (conversion = filed follow-up after a caller audit).

5

tanf POST /v1/time-limits:batchGet

{person_ids}Vec<TimeLimitStatusEntry{person_id, on_file, months_used}>

Same posture; months_used ONLY (all the extract reads, per TanfTimeLimitSummary; avoids the TanfParameterTable dependency for synthesized entries).

6

caps POST /v1/authorizations/active:batchGet

{household_ids, month}Vec<HouseholdChildcareEntry{household_id, has_active_authorization}>

Exact-set; one determinations⋈authorizations query; window predicate identical to today’s client walk (reporting clients/mod.rs:528-533).

7

snap POST /v1/abawd/tracking:batchGet

{household_ids, as_of}Vec<HouseholdAbawdEntry{household_id, is_abawd_household: bool}>

Exact-set; returns THE BOOL the consumer computes today (bounded — never the record vector); false = honestly no qualifying tracking.

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

filtered_not_in_scope

universe row the kind’s fold EXCLUDES by rule (non-approved determination in ACF-199/T-MSIS — today’s silent continue)

detail counter (counts as processed)

no output row, BY RULE

continues

orphan_404

household absent from batch (#315)

skipped_orphan_count (outside processed)

excluded

continues; reconciles

cert_type_unknown

application absent

detail counter (#1155)

ships unknown

continues

expedited_unknown

application present, expedited_eligible: None

detail counter

ships unknown

continues

citizenship_degraded

person absent OR present-with-null citizenship (T-MSIS)

degraded_count + detail

ships "unknown"

continues

fpl_not_computable

no usable income / orphan household

detail counter (NULL is honest, ADR-036 — now ALSO counted)

ships NULL

continues

cms416_no_band

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

error/universe_drift

contract violation

inexact batch id-set; undecodable page/progress; missing total; promotion reconciliation failure

error/contract_violation

stale pins

build/params hash mismatch at reclaim

error/stale_pins

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 ReportRunAccepted{run_id, generation_id, poll_url} + Location header / 409 with the in-flight handle / 503 at cap or runs_enabled=false. The 201-rule deviation is pre-sanctioned (parent plan scale-audit-adr001-bulk-read.adoc "5→202 / 3→201"; shipped #1205 202).

run status

NEW GET /v1/reporting/runs/{id}ReportRunStatus{run_id, generation_id, report_kind, period_date, state, attempts, universe_total, processed_count, skipped_orphan_count, degraded_count, detail_counters: Vec<NamedCount> (from the generation row, never from progress), error_code, abandon_reason: Option<String>, requested_at, finished_at, result_url: Option<String>} + Retry-After while running. NEW GET /v1/reporting/runs?kind=&period=&limit= — ordered desc(requested_at), clamped, 404/422 behavior specified. RBAC: supervisor-or-above OR service caller (explicit OR path + role tests). Org-visible (deliberate deviation from #1205 requester-scoping: a hidden colleague run makes 409 handles un-pollable; chain jobs scope because they are security-sensitive).

report reads

Every list/get/CSV endpoint filters by the PUBLISHED generation and carries RunProvenance{generation_id, run_id, state, universe_total, processed_count, skipped_orphan_count, degraded_count, extracted_at, as_of} (typed, from report_generations).

deleted DTOs

QcSnapshotGenerated (MR5); Acf199Generated, TmsisGenerated, Cms416Generated (MR6). Acf196Generated/Cms64Generated SURVIVE (stay synchronous 201 + honest counts + published-input guard). FNS-388’s POST loses its SnapMonthlyReport response; the type stays as the GET DTO (+provenance).

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 run_one_pass directly; devstack uses a short tick.

D8. abawd + honest-count fixes

  • abawd_household migration: nullable + historical values NULLed with a migration comment (outage-fabricated false is indistinguishable from sourced false — they cannot be trusted as verified; NULL = "unverified legacy"). Ripple: domain Option<bool>, contracts, OpenAPI, CSV renders empty (#1155 precedent), data-model doc. The new pipeline always writes Some(_).

  • 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-MSIS eligibility_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 households:batchGet (+ as_of on the existing persons batch handler) + applications applications:batchGet + the persons batch.rs roundtrip gap. First commit: this plan committed + nav-linked, follow-ups filed, AC narrowed on both issues.

Relates to #1203

2

enrollment issuances:batchGet + snap abawd:batchGet

Relates to #1203

3

tanf work-requirements/time-limits batchGets + caps authorizations/active:batchGet

Relates to #1203

4

reporting substrate: three migrations (generations, runs, run-universe
backfill + generation_id columns); typed client errors + post_idempotent
full-call deadline; UniversePager + xtask policy update; supervised worker (zero kinds); knobs + 2-conn lazy pool; contracts runs.rs; GET /runs; ops metrics + runbook

Relates to #1202

5

SNAP wave: FNS-388 + QC on the pipeline; generation-filtered readers
provenance; POSTs → 202; abawd NULL migration; semantics-ruling CHANGELOG bullets

Relates to #1202 + #1203

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_of honored (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_of support)
    src/store/households.rs:42-66 (get_with_members).

  • canopy-eligibility src/orchestrator.rs:1242 — buffer_unordered idiom; canopy-security src/main.rs:197-202 — lazy 2-conn pool; src/config.rs:389-639 — the from_config validation shape.

Edit this page · default