T2-8 — Overpayment recompute-from-snapshot + in-boundary hearing-view + OverpaymentNotice (#681)

On this page

Epic &56 / Track 2, T2-8 (#681) — the last open child of &56. When a worker authors a retroactive fact correction on a past SNAP determination, T2-8 sizes the resulting overpayment by replaying that determination’s frozen snapshot (corpus + full policy bundle) against the corrected facts — isolating the fact error from any since-changed policy — then files the #382 claim and emits an OverpaymentNotice. The recompute runs inside the owning program service (canopy-snap self-assembles its context via new persons/enrollment clients), so FTI never crosses to canopy-appeals/canopy-reporting (ADR-004) and canopy-tanf/canopy-medicaid adopt the same pattern later. SNAP-only, 6 dependency-sliced MRs. Issue #681 (weight 8; type::feature + priority::medium). Realizes the ADR-028 §70 consumer work.

Context

Problem. There is no mechanism for a worker to size + notice an overpayment when a retroactive fact correction (valid_from <= the determination’s as_of) reveals that a past determination was wrong. T2-7 Decision J explicitly deferred this case to "manual review". Separately, ADR-028 §70 (Accepted) requires that appeals/overpayment recovery consume a frozen snapshot inside the owning program service for FTI programs — never pulling restricted data into canopy-appeals/canopy-reporting, which have no Pub 1075 controls (ADR-004) — and nothing realizes that consumer work yet.

Two overpayment paths — kept strictly separate, overlap-guarded (Decision K):

Path Trigger How sized Event T2-8

(a) Continued-benefits recoupment

A hearing upholds the agency

Sum issuances paid during the continued-benefits window (canopy-appeals/src/continued_benefits.rs:47)

appeal.overpayment_assessed

Unchanged

(b) Determination-error overpayment

A worker’s retroactive fact correction

Replay the frozen snapshot with corrected facts → per-month delta

snap.overpayment_claimed

This plan

Why replay, not a fresh redetermination? A fresh redetermination scores corrected facts against current policy
corpus. An overpayment must size what should have been paid under the policy in effect then, with only the fact corrected — so a since-changed threshold never leaks a policy change into a historical claim. Replaying the frozen snapshot isolates the fact error (the ADR-028/T2-7 "what-if" principle). The trigger is deliberately worker-actioned, never automatic (Decision G).

Why in canopy-snap (not the orchestrator)? dry_run_determine needs an ApplicationContext (household facts). In T2-7 the orchestrator assembled that from canopy-persons. For an FTI program the orchestrator must never see FTI facts (ADR-004), so the in-boundary recompute requires the owning program service to self-assemble. To be genuinely FTI-pattern-ready (the confirmed scope), SNAP realizes the pattern now: canopy-snap gains a persons + enrollment client and self-orchestrates — it does not call back through canopy-eligibility (Decision M; the rejected fig leaf).

The gap T2-8 fills: (1) a hearing-scoped FTI-safe read on canopy-snap; (2) the overpayment recompute-from-snapshot (worker-actioned); (3) OverpaymentNotice wired; (4) provisional-derived exclusion from automated recovery; (5) an overlapping-claim guard.

Reuse (~90% prior art; verified against source). This plan adds little net-new machinery:

  • dry_run_determine(rules, bundle: &SnapPolicyBundle, ctx: ApplicationContext, corpus_hash: CorpusHash, bearer: Option<&str>) → Result<DryRunOutcome, ApiError> — the public, write-free replay primitive (canopy-snap/src/determine.rs:970, T2-7 MR3). The recompute’s core.

  • The eligibility orchestrator’s resolve_baseline_replay (orchestrator.rs:838) — the typed NoBaselineSnapshot/CorpusUnavailable degradation pattern MR5 re-homes as a new snap-local helper (not called cross-service).

  • The frozen snapshot + read endpoint GET /determinations/{id}/snapshot (canopy-snap/src/api/determine_handler.rs:318, service/admin-gated); the derivation graph with is_provisional on DerivedFactNode (canopy-contracts-eligibility/src/snapshot.rs:554); the determination row’s expiration_date.

  • The #382 store: create_claim(executor, &CreateClaimRequest) already in canopy-snap (canopy-snap/src/store/overpayments.rs), and ClaimBasis{AgencyError, InadvertentHouseholdError, Ipv} (canopy-overpayments/src/lib.rs). canopy-snap already subscribes to appeal.overpayment_assessed (main.rs:260).

  • NoticeType::OverpaymentNotice already exists (canopy-reference/src/enums.rs:342); wiring = the T2-7 ChangeInCircumstancesNotice recipe + the existing hearing-rights Typst component.

  • Facts: canopy-persons GET /households/{id}/full?as_of= (prior art = the eligibility orchestrator’s fetch + ApplicationContext assembly, orchestrator.rs:229); issuances: canopy-enrollment list_issuances_for_household (api/mod.rs:487).

SNAP-only — the only non-FTI, replay-capable program. tanf/medicaid replay paths (each ~T2-7-sized) are a tracked follow-on.

Scope

In scope: the hearing-scoped FTI-safe determination view + appeals consuming it; the SNAP overpayment recompute-from-snapshot (worker-actioned, in canopy-snap); per-month sizing + the overlapping-claim guard + claim creation via #382 create_claim; the provisional-derived exclusion; the OverpaymentNotice route + Typst template; the [snap.overpayment] minimum_claim_cents threshold (scoped + SME-flagged); canopy-web worker surface; CLI parity; gated Playwright E2E; the ADR-028 as-built recording.

Out of scope (each filed /relate #681 before MR6): tanf/medicaid (FTI) recompute + their replay paths; continued-benefits recoupment changes (path a); appeals-as-trigger (post-hearing, service-token); recoupment scheduling / repayment-plan UX / the 273.13 advance floor (#382); underpayment correction (recorded as UnderpaymentFound, no claim); automated (non-worker) establishment; reconstruction-from-audit for pre-snapshot determinations; FTI snapshot ADR-014 chain; provisional post-verification override; coalescing multiple corrections per window.

Status

  • Relates to #681 on MR1–5; Closes #681 on MR6 only (autoclose-keyword rule — verify #681 stays OPEN after each non-final merge).

  • MR6 closes epic &56 (verify the auto-flip; close explicitly if not) and is the only MR to update the umbrella master-plan (the epic-&56 plan) + epic status; MR1–5 update only the service-scoped docs they touch.

  • Regular merge commits, never squash. Branches feat/fact-authoring-t2-8-{plan,overpayment-notice,hearing-view,recompute-clients,overpayment-recompute,web-cli-e2e}.

  • Merge order (mandatory, linear): MR1 → MR2 → MR3 → MR4–5 → MR6. MR4–5 (the recompute) is the long pole. MR2 before MR4–5 (notice route live when the recompute emits); MR3 independent, ordered before MR4–5 for review locality.

  • Why MR4 folds into MR4–5 (deviation from the original 6-MR slice, recorded 2026-06-27): the persons/enrollment clients + context_assembly have exactly one consumer — the recompute. canopy-snap is a bin-only crate (no [lib]; its integration tests are black-box HTTP against the live service, not white-box), so client methods with no in-crate consumer are dead_code in the non-test build and the pre-push clippy --all-targets — -D warnings gate rejects them. A capability ships with its sole consumer (the same pattern as MR3, where SnapHearingClient shipped with get_appeal_hearing_view). The rejected alternatives — a #[allow(dead_code)], or adding a [lib] target solely to make pub items "API surface" so the lint goes quiet — both silence a correct lint with a fiction; neither is architecture. The clients stay in separate modules (persons_client.rs, enrollment_client.rs, context_assembly.rs) for review locality.

MR Description Status

MR1 — plan + ADR as-built + threshold

This plan .adoc + nav; ADR-028 as-built recording of the §70 SNAP consumer work; [snap.overpayment] minimum_claim_cents in jurisdiction.toml+citations.toml (7 CFR 273.18(e), scoped, SME-flagged); file SME + follow-up issues. No code.

Done (2026-06-27) — plan + nav; ADR-028 Amendment 4 as-built; [snap.overpayment] minimum_claim_cents + citation; SME + follow-up issues filed.

MR2 — OverpaymentNotice

snap.overpayment_claimed → overpayment_notice + [templates.snap.overpayment] manifest entry + SNAP Typst template w/ hearing-rights (claim-appeal, effective_date=None, Decision F) + default_program_data keys; route-count test +1, render test. No subscriber change.

Done (2026-06-27) — snap.overpayment_claimed → overpayment_notice routing + Typst manifest + overpayment.typ (hearing-rights, no effective_date) + render test.

MR3 — hearing-scoped FTI-safe read

GET /v1/determinations/{id}/hearing-viewHearingDeterminationView (fields in Data model), require_service_caller()-gated; appeals captures a service-token + SnapHearingClient, fetches it live. OpenAPI snap 23→24.

Done (2026-06-27) — GET /v1/determinations/{id}/hearing-viewHearingDeterminationView, service-caller-gated; appeals SnapHearingClient. OpenAPI snap 23→24.

MR4–5 — self-assembly clients + overpayment recompute (core)

canopy-snap gains a persons_client.rs (as-of full-read; prior art = the eligibility orchestrator, not canopy-applications) + enrollment_client.rs + context_assembly.rs (HouseholdFullApplicationContext), and the recompute that consumes them — POST /v1/determinations/{id}/overpayment-recompute: idempotency-first → per-household lock → resolve snapshot → provisional/overlap guards → derive recipient → replay as-of snapshot.as_of → size (per-month Decimal) → create_claim + audit row + event, one tx. Typed outcomes (incl. degraded), 200 not 500. OpenAPI snap 24→25. (Originally sliced as a setup-only MR4 + core MR5; merged because a capability ships with its sole consumer — see the merge-order note.)

Done (2026-06-27) — MR !699 (4dc7ee81); clients + context_assembly + the recompute + overpayment_recomputes table + event. Handler split into recompute_{handler,sizing,persist} to offset B1/B2/B3a with zero lock raises. OpenAPI snap 24→25.

MR6 (FINAL) — web + CLI + E2E + docs

canopy-web hearing-view + recompute action (typed, B3a offset); CLI canopy snap {overpayment recompute, determination hearing-view}; gated Playwright E2E proven green; Antora + ADR as-built + CHANGELOG; master-plan T2-8 → Done; close epic &56. Closes #681.

Done (2026-06-27) — worker-portal recompute action + hearing-view display in the determination tab; CLI snap {overpayment recompute, determination hearing-view}; gated journey-snap-overpayment-recompute E2E; Antora api/data-model + notices/appeals notes + CHANGELOG; master-plan T2-8 → Done; epic &56 closed.

Decisions

# Decision Resolution

A

In-boundary recompute in the owning program service

Runs in canopy-snap (owns snapshot + DEK); FTI never crosses to appeals/reporting; contracts program-generic so FTI programs adopt unchanged. Rejected: recompute in canopy-appeals (no Pub 1075 controls).

B

Replay reads facts as-of the baseline snapshot’s as_of (the evaluation date), corrected

The recompute must reproduce the determination’s own evaluation, with only the fact corrected — so it reads the household as-of snapshot.as_of (snapshot.rs:84, "the single evaluation date the verdict scored against"), not correction_as_of. Because the retroactive correction’s valid_from <= snapshot.as_of, the current-store read as-of snapshot.as_of already returns the corrected value (the new version supersedes the old as-of that date). Reading as-of correction_as_of would pull a different (later) household composition/expenses and mis-size the claim. dry_run_determine reads policy only from the frozen bundle, corpus pinned through all three rules calls. correction_as_of is used only to bound the claim window (Decision D), never as the fact-read date. Eligibility precondition correction_as_of <= snapshot.as_of (a forward-effective change is a T2-7 nudge, not an overpayment — reject otherwise).

C

Hearing-scoped FTI-safe read

A non-restricted projection (HearingDeterminationView; no sealed ciphertext/restricted leaves; unseal stays service-local), require_service_caller()-gated (admin/QC keep /snapshot). FTI-safety via the projection, not a per-appeal ownership callback (rejected as over-coupling).

D

Per-month sizing + the claim window

Window: start = max(first_of_month(correction_as_of), first_of_month(determination.effective_date)); end = first_of_month(min(expiration_date-or-∞, supersession_date-or-∞, today)) — capped at the determination’s supersession date (SnapDeterminationRead.superseded_by’s effective/as_of) so months a later determination covers are never clawed back, and at `today when expiration_date is None (open-ended). Per-month: paid_m = Σ of SnapBenefitIssuance.allotment_amount (a Decimal) over the issuances whose benefit_month = m, excluding retained = true (legitimately kept by policy, exactly as path-(a) continued_benefits.rs does, #447). correct_m = the replayed correct allotment for m (the single replay amount; for a prorated first/last month it is prorated the same way the original issuance was — prorated/proration_days_* on the issuance). overpayment_cents = Σ_m to_cents(max(0, paid_m − correct_m)). correct >= paid every month → NoOverpayment; a corrected verdict that raises the benefit → UnderpaymentFound. The proration/partial-month math is intricate and overlaps continued_benefits.rs:compute_overpaymentextract a shared overpayment-window helper (DRY) and SME-confirm the partial-month semantics (Risk #15).

D′

Minimum-claim threshold is scoped, not blanket

7 CFR 273.18(e) allows declining a claim only for agency-error, non-participating households below a State minimum. So minimum_claim_cents applies only when claim_basis = AgencyError AND the household is not currently participating; otherwise bypassed (any positive overpayment → claim). Participating is checked against enrollment, not determinations: active participation lives in canopy-enrollment (enrollment status/active/termination), so the recompute queries the enrollment client for an active SNAP enrollment as-of today — not a snap determination row. Defaults so MR2/MR5 ship un-blocked (T2-7 #921 precedent): minimum_claim_cents = 0; "participating" = an active (non-terminated) SNAP enrollment as-of today; notice = debt-establishment (no 273.13 floor); never IPV. authority = "operational", SME-flagged.

E

Provisional-derived exclusion (conservative)

If the snapshot’s derivation graph holds any is_provisional DerivedFactNodeProvisionalExcluded (no claim, manual review). The flat graph has no verdict node, so verdict-ancestry isn’t computable; "any provisional node" is the fail-safe gate — can only over-exclude (to manual review), never under-exclude (auto-claim off an inferred input). Refinement = follow-up.

F

OverpaymentNotice = debt-establishment notice with claim-appeal rights, not a 273.13 advance-benefit notice

273.13’s 10-day advance floor governs adverse benefit actions (future-allotment reduction); T2-8 only establishes the claim (recoupment is #382, out of scope). The notice informs of the claim + the right to a hearing on the claim (273.15/273.18), renders hearing-rights (appeal deadline = notice_date + appeal_deadline_days), and carries effective_date = None — so the floor never fires and the subscriber needs no change (it already sets None, canopy-notices/src/main.rs:216). Emits typed OverpaymentClaimedEvent (IDs + amount + basis + a pre-formatted overpayment_amount display string + person_id; no FTI/facts). Notice plumbing: extract_program_data copies exact keys from the event payload (event_routing.rs:138), so the payload must contain the literal overpayment_amount key the template reads (the typed overpayment_cents is the audit number; the event carries both). Wiring also requires a [templates.snap.overpayment] manifest entry (canopy-typst manifest), not just the .typ + the routing entry. The subscriber drops events without person_id (canopy-notices/src/main.rs:181) — so the event carries the derived recipient person_id (Decision N).

G

Worker-actioned, never automatic

Trigger = an explicit worker action supplying correction_as_of + claim_basis. Not an event subscriber; not appeals (appeals stores no corrected-facts date — appeals-as-trigger is a follow-on). T2-7’s forward subscriber is untouched.

H

SNAP-only; FTI-pattern-ready

Recompute + hearing-view reject non-SNAP (422). Contracts program-generic; tanf/medicaid is a follow-on gated on their replay paths.

I

Legacy / incomplete / unknown-corpus baselines — the result model must represent them

Pre-T1-10 (no snapshot) or pre-T2-7 (incomplete bundle) → NoBaselineSnapshot; unknown corpus → CorpusUnavailable. These are RecomputeOutcome variants (not just typed errors), returned as HTTP 200 + outcome_message. So: RecomputeOutcome includes NoBaselineSnapshot + CorpusUnavailable; OverpaymentRecomputeResult.{baseline, correct, corpus_hash} are Option (a degraded outcome has no verdicts); and no overpayment_recomputes audit row is written for a degraded outcome (the replay never ran) — so corpus_hash_used stays NOT NULL only because rows are inserted only on a real attempt that resolved the corpus. Manual review, never 500, never a wrong claim.

J

Idempotency — checked first, before the overlap guard

Unique (baseline_determination_id, correction_as_of). Step 0 of the flow looks up an existing overpayment_recomputes row by that key and, if present, returns it (+ its claim) as HTTP 200before the overlap guard runs, so a retry never trips OverlappingClaim on the claim it itself created. The DB unique constraint is the race backstop (catch the violation → return the existing row). The key is intentionally coarse: a second, genuinely-new correction sharing the same correction_as_of after a claim exists is routed to manual review by the overlap guard, not silently returned stale (Risk #17).

K

Overlapping-claim guard (within schema limits)

The shared #382 overpayment_claims stores no covered-months range, so general month-intersection isn’t queryable without an ADR-001 cross-service schema change (out of scope). After the idempotency check, the guard flags: (i) any non-closed overpayment_claims row (status ∈ {open, in_repayment} — not just open) on determination_id = baseline, or (ii) any prior overpayment_recomputes row for the household whose [covered_period_start, covered_period_end] (new local columns) intersects this window → OverlappingClaim (manual review). Race-safety: two different correction_as_of with overlapping windows can both pass an app-level check, so the recompute takes a per-household pg_advisory_xact_lock(hash(household_id)) at the start of the tx, serializing guard-check-then-insert per household. Documented limitation: cross-determination overlap vs a path-(a) claim on a different determination can’t be auto-detected → manual review.

L

Claim classification — a snap-local enum, mapped at the store boundary

claim_basis ∈ {AgencyError, InadvertentHouseholdError}. canopy-contracts-snap must NOT depend on canopy-overpayments (that crate carries SQL/domain-storage concerns — a layering violation). So define a small RecomputeClaimBasis enum in canopy-contracts-snap and map it to canopy-overpayments::ClaimBasis inside canopy-snap (which already depends on both) when building CreateClaimRequest. Reject Ipv at the boundary (IPV flows through appeals, 7 CFR 273.16). The #382 error_type field is a separate caseworker free-text classification (ClaimBasis only exposes as_str(), there is no .into()); set it to a fixed "determination_error_recompute", never claim_basis.into(). SME-confirm whether a determination-error overpayment is ever IPV.

M

The program service self-assembles the corrected context

The whole recompute (fetch own snapshot → assemble context from canopy-persons → replay → size → claim → notice) runs in canopy-snap, which gains a persons + enrollment client + config + boot.service_token_source. Rejected: orchestrate SNAP via canopy-eligibility (harmless for non-FTI SNAP but doesn’t establish the FTI-needed pattern — a fig leaf vs the confirmed scope).

N

Recipient person_id derivation

CreateClaimRequest requires person_id, and the notices subscriber drops events lacking it. The recompute derives the recipient person_id from the snapshot’s head-of-household (the SNAP filing unit’s head, in SnapshotFacts.household); it is not on the request. It flows into the CreateClaimRequest, the overpayment_recomputes row, and the OverpaymentClaimedEvent. If absent for a legacy snapshot, degrade to manual review.

Data model

New table — canopy-snap own DB (MR5). The recompute audit record; the #382 overpayment_claims row stays the claim of record.

-- SPDX-License-Identifier: AGPL-3.0-or-later
CREATE TABLE overpayment_recomputes (
    id                        UUID PRIMARY KEY,            -- UUID v7
    baseline_determination_id UUID NOT NULL,
    household_id              UUID NOT NULL,
    person_id                 UUID NOT NULL,               -- recipient = snapshot head-of-household (Decision N)
    correction_as_of          DATE NOT NULL,               -- correction's effective date; bounds the window only (B/D/G/J)
    baseline_benefit_cents    BIGINT,                       -- paid monthly allotment (NULL if baseline denied)
    correct_benefit_cents     BIGINT,                       -- replayed corrected allotment (NULL if now denied)
    overpayment_cents         BIGINT NOT NULL,              -- sum per-month max(0, paid - correct); >= 0
    affected_months           INT    NOT NULL,
    covered_period_start      DATE   NOT NULL,              -- first_of_month(correction_as_of); overlap guard (K)
    covered_period_end        DATE   NOT NULL,              -- first_of_month(determination window end)
    outcome                   TEXT   NOT NULL,              -- claim_created | below_threshold | provisional_excluded | overlapping_claim | no_overpayment | underpayment_found
    outcome_message           TEXT,                         -- manual-review detail (Decision I)
    claim_basis               TEXT,                         -- agency_error | inadvertent_household_error (Decision L)
    claim_id                  UUID,                         -- #382 overpayment_claims row, when claim_created
    notice_id                 UUID,                         -- NULL (notice generated async by canopy-notices)
    corpus_hash_used          TEXT   NOT NULL,
    requested_by              UUID   NOT NULL,              -- the worker (actor-in-body)
    created_at                TIMESTAMPTZ NOT NULL DEFAULT now(),
    UNIQUE (baseline_determination_id, correction_as_of)   -- Decision J idempotency
);
CREATE INDEX idx_overpayment_recomputes_household_period
    ON overpayment_recomputes (household_id, covered_period_start, covered_period_end);

New contracts (canopy-contracts-snap; SPDX header, newtypes, thiserror per coding-conventions):

// MR3 — non-restricted projection; ONLY fields the snapshot actually exposes:
//   status/benefit (on the determination row), as_of, corpus_hash, policy_params (a hash, not a
//   "bundle version"); members carry fact_id, not version_id.
HearingDeterminationView {            // no sealed ciphertext, no FTI (Decision C)
    determination_id: Uuid, household_id: Uuid,
    status: String, benefit_amount: Option<Decimal>, benefit_unit: Option<String>,
    effective_date: Option<NaiveDate>, expiration_date: Option<NaiveDate>, as_of: NaiveDate,
    corpus_hash: CorpusHash, policy_params_digest: String,   // a digest of the frozen policy_params
    facts_summary: Vec<HearingFactRef>,                       // identities/labels the snapshot HAS, no values
}
HearingFactRef { kind: String, person_id: Option<Uuid>, fact_id: Option<Uuid>, label: String }

// MR5 request/result — claim_basis is a snap-LOCAL enum (Decision L); contracts-snap must not depend on canopy-overpayments.
enum RecomputeClaimBasis { AgencyError, InadvertentHouseholdError }   // mapped to canopy-overpayments::ClaimBasis inside canopy-snap
OverpaymentRecomputeRequest { correction_as_of: NaiveDate, claim_basis: RecomputeClaimBasis }
    // requested_by is the AUTHENTICATED caller (BFF/CLI injects it from the token), not a body field
OverpaymentRecomputeResult {
    outcome: RecomputeOutcome, outcome_message: Option<String>,
    baseline: Option<VerdictRef>, correct: Option<VerdictRef>,   // None for a degraded outcome
    overpayment_cents: i64, affected_months: u32, claim_id: Option<Uuid>, corpus_hash: Option<CorpusHash>,
}
enum RecomputeOutcome {     // #[serde(rename_all = "snake_case")]
    ClaimCreated, BelowThreshold, ProvisionalExcluded, OverlappingClaim, NoOverpayment, UnderpaymentFound,
    NoBaselineSnapshot, CorpusUnavailable,                       // degraded -> HTTP 200, manual review
}
OverpaymentClaimedEvent {   // the snap.overpayment_claimed payload; no FTI/facts (Decision F/N)
    household_id: Uuid, person_id: Uuid, claim_id: Uuid, program: String,
    overpayment_cents: i64, overpayment_amount: String,         // formatted display string for the notice (exact-key copy)
    claim_basis: String,                                        // the lowercase tag (ClaimBasis::as_str())
}
// thiserror ProvisionalExcluded stays internal; NoBaselineSnapshot/CorpusUnavailable surface as RecomputeOutcome, not errors.

Reused (no schema change): #382 overpayment_claims (written via create_claim); determination_snapshots (read-only); the determination expiration_date.

Implementation

Per commit: build green, the pre-commit token gate, and a fresh Explore J1–J8 subagent over the staged diff (reported as text). Every MR ends with the delivery checklist (branch → docs-on-branch → full battery → commit → push → MR → URL).

MR1 — Plan + ADR as-built + threshold

  • Port this plan to .adoc + nav. Record the as-built realization in ADR-028 (in-document amendment): the SNAP slice of §70’s consumer work (Decisions C/B/E/K + the FTI-never-crosses payloads A/F).

  • Add [snap.overpayment] minimum_claim_cents to rulesets/georgia/{jurisdiction,citations}.toml: cite 7 CFR 273.18(e), authority = "operational", document the scope (agency-error/non-participating, Decision D′), flag for Georgia SME. File the SME issue (amount + "participating" def + adverse-vs-debt notice + IPV-classification) and the out-of-scope follow-ups.

  • Files: docs/…​/adrs/adr-028-*.adoc, rulesets/georgia/{jurisdiction,citations}.toml, nav.adoc, the plan .adoc.

MR2 — OverpaymentNotice

Three wiring points, not two: the entry, the .typ template, and a Typst manifest [templates.snap.overpayment] entry — plus the default_program_data keys.

  • in rulesets/georgia/notices/manifest.toml: event_type = "snap.overpayment_claimed", notice_type = "overpayment_notice", program = "snap", template_key = "overpayment", regulatory_basis = "7 CFR 273.18", program_data_keys = ["claim_id", "overpayment_amount", "claim_basis"]. No effective_date (Decision F). The event payload carries these exact keys (incl. the pre-formatted overpayment_amount string — extract_program_data copies keys verbatim, event_routing.rs:138).

  • [templates.snap.overpayment] manifest entry (version, file = "snap/overpayment.typ", form_number) — required by the Typst manifest (canopy-typst/src/manifest.rs), else the template won’t resolve.

  • New rulesets/georgia/notices/snap/overpayment.typ — claim amount + basis + the hearing-rights component called with continued-benefits-available: false and no effective_date block (debt-establishment, Decision F).

  • Add the template’s #inputs.<key> keys to default_program_data (canopy-notices/src/generator.rs:384). Route-count test +1; a render test (renders with real values + hearing-rights). No subscriber change (effective_date already None).

MR3 — Hearing-scoped FTI-safe read

  • Why not reuse /snapshot? It returns the sealed blob, service/admin-gated — unusable by appeals. The hearing-view is a distinct unsealed, non-restricted projection (Data model) + establishes the FTI-redaction pattern. Not gold-plating: it is the issue’s literal "appeals reads the frozen snapshot in-boundary" deliverable.

  • GET /v1/determinations/{id}/hearing-view on canopy-snap (matches the existing /v1/determinations/{id} + /snapshot convention, canopy-contracts-snap/src/paths.rs) → HearingDeterminationView (Data model — only fields the snapshot exposes). Handler reads the frozen snapshot in-service, unseals service-locally, projects to the non-restricted DTO; 404 legacy/null-snapshot, 422 incomplete; gate require_service_caller(). New paths::GET_DETERMINATION_HEARING_VIEW; OpenAPI snap 23→24 (api/mod.rs:201).

  • canopy-appeals: add CANOPY_APPEALS__SNAP_URL + OIDC service-client creds to its config (no snap_url field today, canopy-appeals/src/config.rs) + capture boot.service_token_source (the T2-7-renewals pattern) + a minimal SnapHearingClient; fetch the view live on the appeal read path. Tests: non-restricted projection, no sealed ciphertext, legacy/incomplete → typed, non-service caller → 403.

MR4–5 — Self-assembly clients + overpayment recompute-from-snapshot (the core)

Self-assembly clients (Decision M; formerly the standalone MR4). canopy-snap gains, in separate modules for review locality:

  • persons_client.rs — the as-of full-household read. Prior art is the eligibility orchestrator’s household-full fetch + context assembly (canopy-eligibility/src/orchestrator.rs:206 + assemble_application_context:373), not canopy-applications/src/persons_client.rs (which is write/finalize). GET /v1/households/{id}/full?as_of= returns the typed canopy_contracts_persons::batch::HouseholdFull { household, members: Vec<MemberFull> } (the orchestrator parses it as serde_json::Value; we deserialize the typed DTO — no B3a debt). Mirrors MR3’s SnapHearingClient (embeds ServiceTokenSource, with_service_identity per call). Default http://canopy-persons:8002.

  • enrollment_client.rsGET /v1/households/{id}/issuances?from=&to=&include_all= (canopy-enrollment/src/api/mod.rs:497, require_service_caller()-gated) → typed Vec<canopy_contracts_enrollment::models::SnapBenefitIssuance> (carries benefit_month, allotment_amount: Decimal, retained, prorated, proration days — all the sizing inputs). Service-token variant (not the worker-bearer-forwarding EnrollmentClient in appeals, since this is an in-boundary service call). Default http://canopy-enrollment:8006.

  • context_assembly.rs — pure assembly of the corrected ApplicationContext (the contracts type lives in canopy-contracts-snap/src/determine.rs) from two sources, so the replay differs from the original by exactly the corrected leaves and nothing is re-derived (zero drift): (a) the corrected income/asset/expense leaves from the persons full-read, skipping ADR-036-redacted leaves (amount/value is Option<Decimal>, None⟺redacted), passed raw (snap’s pipeline re-normalizes against the frozen bundle — normalizing here would be wrong); (b) the frozen household composition (members, household_size, has_elderly_disabled_member) from snapshot.facts.household, and the frozen derived policy inputs (utility_tier, categorical_eligibility_type, alien_eligibility_inputs) the handler recovers by unsealing program_input in-boundary (snap owns the DEK; PgRedactionKeyStore::open under subject ("determination_snapshot", id)). Head-of-household person_id (recipient) is read from the frozen MemberLeaf`s (relationship `self/head/head_of_household, else first). Why recover frozen rather than re-derive: utility_tier/categorical were set by the orchestrator at determination time; re-deriving them in snap could diverge and mis-size an income-only correction — the snapshot already froze them, so we reuse them (the baseline is the determination of record, the ADR-028 reproducibility invariant).

  • Config adds persons_url + enrollment_url to SnapConfig + config/canopy-snap/default.yaml; boot.service_token_source is already captured (main.rs:146, fail-closed) and OIDC creds already required. canopy-contracts-enrollment added to Cargo.toml.

  • Tests (in-module #[cfg(test)]): a pure context_assembly unit test (redaction filtered, head-of-household, member→record mapping); a client round-trip via canopy_test_lib::mock::spawn_router (canopy-snap is bin-only with no httpmock dep — the repo uses in-process axum mocks) + ServiceTokenSource::new_for_tests, asserting a canned HouseholdFull parses + assembles, and an issuances window parses.

The recompute (the core). POST /v1/determinations/{id}/overpayment-recompute (matches the existing /v1/determinations/{id} convention, not /v1/snap/…​). Body = OverpaymentRecomputeRequest{correction_as_of, claim_basis}; require_service_caller(); requested_by = the authenticated caller the BFF/CLI injects, not a body field. Flow (order matters):

  1. Idempotency first (Decision J): look up overpayment_recomputes by (baseline_determination_id, correction_as_of); if present, return it + its claim (HTTP 200) — before any guard, so a retry never trips its own claim.

  2. Take a per-household lock (Decision K): pg_advisory_xact_lock(hash(household_id)) to serialize guard-then-insert per household.

  3. Resolve baseline — a new private snap helper resolve_baseline_snapshot reading the determination’s own snapshot (as_of, frozen bundle, corpus_hash) + the SnapDeterminationRead (effective_date, expiration_date, supersession). Degrade → NoBaselineSnapshot/CorpusUnavailable (Decision I: HTTP 200, no audit row written). Reject correction_as_of > snapshot.as_of (Decision B).

  4. Provisional guard (E): snapshot.derivation_graph.map_or(false, |g| g.nodes.iter().any(|n| n.is_provisional))ProvisionalExcluded.

  5. Overlap guard (K): a non-closed (open|in_repayment) overpayment_claims row on determination_id = baseline, OR a prior overpayment_recomputes window intersection → OverlappingClaim.

  6. Derive recipient (N): person_id = the snapshot’s head-of-household (SnapshotFacts.household); absent → manual review.

  7. Replay as-of snapshot.as_of (B): assemble the corrected ApplicationContext via the persons client + context_assembly at ?as_of=snapshot.as_of (the corrected fact already applies, since valid_from <= as_of) → dry_run_determine(rules, &frozen_bundle, ctx, corpus_hash, bearer)correct verdict (benefit_amount: Option<Decimal>), write-free.

  8. Size (D), in Decimal then cents: compute the window [start, end] (Decision D, capped at supersession/expiration/today); fetch issuances via the enrollment client; group by benefit_month:

    let correct = correct.benefit_amount.unwrap_or(Decimal::ZERO);     // Decimal; denied -> 0
    let mut overpayment = Decimal::ZERO;
    for m in months(start..=end) {                                     // calendar months
        let paid_m: Decimal = issuances.iter()
            .filter(|i| i.benefit_month == m && !i.retained)           // exclude retained (#447)
            .map(|i| i.allotment_amount).sum();                        // sum across enrollments
        let correct_m = prorate(correct, m, &issuances);               // prorated first/last month
        overpayment += (paid_m - correct_m).max(Decimal::ZERO);
    }
    let overpayment_cents = to_cents(overpayment);                     // *100, checked, i64
    // every correct_m >= paid_m -> NoOverpayment; correct raised benefit -> UnderpaymentFound;
    // claim_basis=AgencyError AND not participating (enrollment, D') AND < minimum_claim_cents -> BelowThreshold

    The prorate/partial-month logic + the retained-exclusion overlap continued_benefits.rs:compute_overpaymentextract a shared overpayment-window helper (DRY); the exact partial-month semantics are SME-confirmable (Risk #15).

  9. Persist (one tx): map RecomputeClaimBasiscanopy-overpayments::ClaimBasis; store::overpayments::create_claim(&mut tx, &CreateClaimRequest{ person_id, household_id, determination_id: Some(baseline), claim_amount_cents: overpayment_cents, claim_basis, error_type: "determination_error_recompute".into(), discovered_at: today, discovered_by: Some(requested_by) }) (error_type is a separate free-text field — never claim_basis.into()); insert the overpayment_recomputes audit row (person_id, notice_id = NULL; the unique constraint is the race backstop, Decision J); stage the OverpaymentClaimedEvent (with the formatted overpayment_amount + person_id) → OverpaymentNotice.

    • Write discipline: the replay writes nothing (dry_run_determine, ?audit=false); the only writes are the audit row + the #382 claim + the outbox event, in one tx. No determination/snapshot/DEK minted. All outcomes are HTTP 200 + typed outcome.

    • OpenAPI snap 24→25. Files: canopy-snap/src/{determine.rs, api/{determine_handler,mod}.rs, store/{overpayments,recomputes}.rs}, a shared overpayment-window helper, new migration, contracts.

    • Load-bearing tests: retroactive correction → correct<paid → claim sized right (per-month, prorated, retained excluded) + event; replay reads as-of snapshot.as_of, not correction_as_of (a later composition change must NOT move the verdict); forward-effective change (correction_as_of > as_of) → rejected; provisional-fed → ProvisionalExcluded; non-closed (open OR in_repayment) path-(a) claim on the determination → OverlappingClaim; superseded baseline → window capped (no later-determination months); sub-threshold AgencyError + non-participating → BelowThreshold; correct>paid → UnderpaymentFound; denied-recompute vs approved-paid → full clawback; legacy/incomplete → NoBaselineSnapshot/CorpusUnavailable (200, no audit row); resubmit same key → one claim; two concurrent submissions (same and different correction_as_of) under the advisory lock → no double-claim; non-SNAP → 422; the replay writes no rule_evaluations/snapshot/determination rows.

MR6 (FINAL) — web + CLI + E2E + docs

  • Web: a case-detail/appeals surface to display the hearing-view + a worker recompute action (claim_basis + correction_as_of) POSTing via the BFF (typed against canopy-contracts-snap, B3a offset). Redirect ?focus_section=.

  • CLI: canopy snap overpayment recompute + canopy snap determination hearing-view (siblings under snap, T2-7 clap precedent — no ADR-007 amendment). requested_by is not a flag — it is the authenticated caller’s identity from the CLI’s token/session (the BFF injects the worker the same way), so the body carries only correction_as_of + claim_basis.

  • E2E (gated Playwright, proven green live): seed SNAP household + approved determination → retroactive income correction (valid_from <= as_of) → worker recompute → OverpaymentClaim + OverpaymentNotice PDF w/ claim-appeal rights; + provisional / sub-threshold / overlapping negative paths.

  • Docs: Antora api/canopy-{snap,appeals,notices}.adoc + data-models/; ADR-028 as-built; CHANGELOG == Unreleased; master-plan T2-8 → Done; move this plan → plans/archive/; close epic &56. Closes #681.

Verification

Per MR: cargo build / clippy --all-targets — -D warnings; focused integration tests on the service’s dedicated postgres (set -a; source .ports.env; set +a; cargo nextest run -p <svc> --profile integration); cargo xtask quality-budgets (B3a offset, never raise — type the web calls); check-docs + docs plan-lint; cargo xtask policy (citations, MR1); the full pre-push battery on every push.

End-to-end (MR6): seed a SNAP household + approved determination → author a retroactive income correction → recompute → assert the replay wrote nothing (snap + rules row counts unchanged) → an overpayment_recomputes row + a #382 overpayment_claims row sized per Decision D → an OverpaymentNotice PDF with claim-appeal rights → the hearing-view returns the non-restricted projection (no sealed ciphertext). Plus provisional / sub-threshold / overlapping / idempotency negatives.

Risks / sharp edges

Critical (system correctness):

  1. Policy from the frozen bundle onlydry_run_determine never reads live params; a since-changed threshold must not move a historical overpayment.

  2. Corpus pin threads all three rules calls — reuse dry_run_determine/VerdictMode::DryRun; no live-corpus call.

  3. Write-free replay — only the audit row + #382 claim + outbox event are written, in one tx; no determination/snapshot/DEK minted.

  4. FTI never crosses — the hearing-view DTO + the event carry IDs/amount/non-FTI summary only; unseal is service-local. MR3 test asserts no sealed ciphertext in the DTO (safe-by-construction for the FTI adopter).

Behavioral:

  1. Provisional exclusion is a hard, fail-safe gate — any provisional node → manual review; can only over-exclude.

  2. Overlap guard is schema-bounded — same-determination_id + prior-recompute window; the cross-determination path-(a) case is a documented manual-review limitation.

  3. Denial-NULL + underpaymentDecimal::ZERO for a denied side; now-denied vs approved-paid = full clawback; a raised benefit = UnderpaymentFound/NoOverpayment (no claim).

  4. Threshold is scoped — agency-error/non-participating only; amount + "participating" def + adverse-vs-debt + IPV are SME-flagged (MR1 issue).

  5. Legacy/incomplete/unknown-corpus — typed degradation, HTTP 200 + outcome_message, never 500.

Config / contract:

  1. OpenAPI counts — snap 23→24 (MR3), 24→25 (MR4–5); update api/mod.rs:201 each time.

  2. Two paths must not be conflatedappeal.overpayment_assessed stays claim-creation-only; snap.overpayment_claimed is the only one routed to the notice; the overlap guard (K) prevents double-claims.

  3. Appeals service identity — needs a service-token to call the gated hearing-view (capture boot.service_token_source).

  4. canopy-snap’s new clients (M) — must read the household as-of snapshot.as_of (the evaluation date), where the retroactive correction already applies — never correction_as_of or "today".

  5. Concurrency — the per-household pg_advisory_xact_lock + the unique constraint serialize guard-then-insert; identical submissions return the existing row (200), different-correction_as_of overlapping windows can’t both insert.

Correctness defects fixed pre-implementation (from external review — re-verify in code):

  1. Replay date = snapshot.as_of, not correction_as_of — reading as-of the correction date pulls a later household composition/expenses and mis-sizes the claim. The corrected fact already applies as-of snapshot.as_of because valid_from <= as_of. correction_as_of bounds the window only.

  2. Sizing math is intricate + must reuse, not reinvent — per-month sum of Decimal allotment_amount across enrollments, exclude retained, prorate first/last month, cap the window at supersession/expiration/today. Overlaps continued_benefits.rs:compute_overpayment — extract a shared helper; SME-confirm partial-month semantics.

  3. Result model represents degraded outcomesRecomputeOutcome carries NoBaselineSnapshot/CorpusUnavailable; OverpaymentRecomputeResult.{baseline,correct,corpus_hash} are Option; no audit row for a degraded outcome (so corpus_hash_used NOT NULL holds).

  4. Layering + typescanopy-contracts-snap must NOT depend on canopy-overpayments; use a snap-local RecomputeClaimBasis mapped at the store boundary. error_type is a separate free-text field, never claim_basis.into() (ClaimBasis only has as_str()).

  5. Recipient identityperson_id (required by CreateClaimRequest + the notices subscriber) is derived from the snapshot head-of-household, threaded into the claim/audit/event.

  6. Notice wiring is three points — the entry and a [templates.snap.overpayment] manifest entry and the .typ; the event must carry the exact overpayment_amount key (extract_program_data copies keys verbatim).

  7. Participating = enrollment, not determinations — Decision D′'s threshold scope is checked via the enrollment client.

  8. Idempotency key is intentionally coarse — one recompute per (determination, correction_as_of); a genuinely-new correction at the same date after a claim exists routes to manual review via the overlap guard (not silently stale).

As-built notes (MR4–5)

Where the implementation refined the plan (living-spec; the deviations are tracked, not buried):

  • Sizing is whole-month, proration deferred (#935). size_overpayment sums whole-month allotments (excluding retained), matching continued_benefits.rs::compute_overpayment exactly; it does not prorate a partial first/last month. Proration is unimplemented codebase-wide + SME-pending (Risk #15) — tracked in #935.

  • No shared overpayment-window helper extracted. The plan suggested extracting a DRY helper shared with continued_benefits.rs, but the two computations genuinely differ — continued-benefits sums the full paid amount (no correct baseline to subtract), whereas the recompute subtracts a recomputed correct allotment per month. Only first_of_month + the retained/issued filter overlap, which is not worth a cross-crate extraction. Snap-local sizing it is.

  • Threshold floor is dormant by default (#936). Georgia’s minimum_claim_cents = 0, so every positive overpayment establishes a claim — the plan’s documented default. RecomputeOutcome::BelowThreshold is a forward-ready contract variant; wiring the floor (load minimum_claim_cents into SnapParameterTable) + the non-participating enrollment gate is SME-pending (#927) → tracked in #936.

  • Frozen derived inputs are recovered, not re-derived. context_assembly recovers utility_tier/categorical_eligibility_type/alien_eligibility_inputs + the household composition frozen from the snapshot (the derived fields by unsealing program_input in-boundary); only the income/asset/expense leaves are re-fetched corrected from persons. This is stronger than the plan’s "rebuild from persons" — zero re-derivation drift on the verdict-affecting derived fields.

Follow-ups

File as GitLab issues /relate #681 before MR6:

  • Filed (MR5): proration of partial benefit months (#935); the minimum-claim floor + non-participating gate (#936). Both /relate #681 + #927.

  • tanf/medicaid (FTI) overpayment recompute + their snapshot-replay paths (each ~T2-7-sized; the in-boundary pattern is ready).

  • Appeals-as-trigger (post-hearing, service-token) once a corrected-facts date is recorded on the appeal.

  • Recoupment scheduling / repayment-plan UX + the 273.13 advance-notice question when recovery-by-reduction is scheduled (#382).

  • FTI snapshot ADR-014 hash-chain entry (ADR-028 §54); provisional post-verification override; underpayment-correction workflow; coalescing multiple corrections per window.

  • Georgia SME confirmation: minimum_claim_cents amount/scope + the "participating" definition; the adverse-vs-debt notice semantics; whether a determination-error overpayment is ever IPV; the month-window edge semantics.

References

Edit this page · default