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 ( |
|
Unchanged |
(b) Determination-error overpayment |
A worker’s retroactive fact correction |
Replay the frozen snapshot with corrected facts → per-month delta |
|
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 typedNoBaselineSnapshot/CorpusUnavailabledegradation 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 withis_provisionalonDerivedFactNode(canopy-contracts-eligibility/src/snapshot.rs:554); the determination row’sexpiration_date. -
The #382 store:
create_claim(executor, &CreateClaimRequest)already in canopy-snap (canopy-snap/src/store/overpayments.rs), andClaimBasis{AgencyError, InadvertentHouseholdError, Ipv}(canopy-overpayments/src/lib.rs). canopy-snap already subscribes toappeal.overpayment_assessed(main.rs:260). -
NoticeType::OverpaymentNoticealready exists (canopy-reference/src/enums.rs:342); wiring = the T2-7ChangeInCircumstancesNoticerecipe + the existinghearing-rightsTypst component. -
Facts: canopy-persons
GET /households/{id}/full?as_of=(prior art = the eligibility orchestrator’s fetch +ApplicationContextassembly,orchestrator.rs:229); issuances: canopy-enrollmentlist_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 #681on MR1–5;Closes #681on 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_assemblyhave 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 aredead_codein the non-test build and the pre-pushclippy --all-targets — -D warningsgate rejects them. A capability ships with its sole consumer (the same pattern as MR3, whereSnapHearingClientshipped withget_appeal_hearing_view). The rejected alternatives — a#[allow(dead_code)], or adding a[lib]target solely to makepubitems "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.
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 |
The recompute must reproduce the determination’s own evaluation, with only the fact corrected — so it reads the household as-of |
C |
Hearing-scoped FTI-safe read |
A non-restricted projection ( |
D |
Per-month sizing + the claim window |
Window: |
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 |
E |
Provisional-derived exclusion (conservative) |
If the snapshot’s derivation graph holds any |
F |
|
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 |
G |
Worker-actioned, never automatic |
Trigger = an explicit worker action supplying |
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) → |
J |
Idempotency — checked first, before the overlap guard |
Unique |
K |
Overlapping-claim guard (within schema limits) |
The shared #382 |
L |
Claim classification — a snap-local enum, mapped at the store boundary |
|
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 + |
N |
Recipient |
|
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_centstorulesets/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.
-
inrulesets/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"]. Noeffective_date(Decision F). The event payload carries these exact keys (incl. the pre-formattedoverpayment_amountstring —extract_program_datacopies 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 + thehearing-rightscomponent called withcontinued-benefits-available: falseand noeffective_dateblock (debt-establishment, Decision F). -
Add the template’s
#inputs.<key>keys todefault_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_datealreadyNone).
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-viewon canopy-snap (matches the existing/v1/determinations/{id}+/snapshotconvention,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; gaterequire_service_caller(). Newpaths::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 (nosnap_urlfield today,canopy-appeals/src/config.rs) + captureboot.service_token_source(the T2-7-renewals pattern) + a minimalSnapHearingClient; 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), notcanopy-applications/src/persons_client.rs(which is write/finalize).GET /v1/households/{id}/full?as_of=returns the typedcanopy_contracts_persons::batch::HouseholdFull { household, members: Vec<MemberFull> }(the orchestrator parses it asserde_json::Value; we deserialize the typed DTO — no B3a debt). Mirrors MR3’sSnapHearingClient(embedsServiceTokenSource,with_service_identityper call). Defaulthttp://canopy-persons:8002. -
enrollment_client.rs—GET /v1/households/{id}/issuances?from=&to=&include_all=(canopy-enrollment/src/api/mod.rs:497,require_service_caller()-gated) → typedVec<canopy_contracts_enrollment::models::SnapBenefitIssuance>(carriesbenefit_month,allotment_amount: Decimal,retained,prorated, proration days — all the sizing inputs). Service-token variant (not the worker-bearer-forwardingEnrollmentClientin appeals, since this is an in-boundary service call). Defaulthttp://canopy-enrollment:8006. -
context_assembly.rs— pure assembly of the correctedApplicationContext(the contracts type lives incanopy-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/valueisOption<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) fromsnapshot.facts.household, and the frozen derived policy inputs (utility_tier,categorical_eligibility_type,alien_eligibility_inputs) the handler recovers by unsealingprogram_inputin-boundary (snap owns the DEK;PgRedactionKeyStore::openunder subject("determination_snapshot", id)). Head-of-householdperson_id(recipient) is read from the frozenMemberLeaf`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_urltoSnapConfig+config/canopy-snap/default.yaml;boot.service_token_sourceis already captured (main.rs:146, fail-closed) and OIDC creds already required.canopy-contracts-enrollmentadded toCargo.toml. -
Tests (in-module
#[cfg(test)]): a purecontext_assemblyunit test (redaction filtered, head-of-household, member→record mapping); a client round-trip viacanopy_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 cannedHouseholdFullparses + 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):
-
Idempotency first (Decision J): look up
overpayment_recomputesby(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. -
Take a per-household lock (Decision K):
pg_advisory_xact_lock(hash(household_id))to serialize guard-then-insert per household. -
Resolve baseline — a new private snap helper
resolve_baseline_snapshotreading the determination’s own snapshot (as_of, frozen bundle,corpus_hash) + theSnapDeterminationRead(effective_date,expiration_date, supersession). Degrade →NoBaselineSnapshot/CorpusUnavailable(Decision I: HTTP 200, no audit row written). Rejectcorrection_as_of > snapshot.as_of(Decision B). -
Provisional guard (E):
snapshot.derivation_graph.map_or(false, |g| g.nodes.iter().any(|n| n.is_provisional))→ProvisionalExcluded. -
Overlap guard (K): a non-closed (
open|in_repayment)overpayment_claimsrow ondetermination_id = baseline, OR a prioroverpayment_recomputeswindow intersection →OverlappingClaim. -
Derive recipient (N):
person_id= the snapshot’s head-of-household (SnapshotFacts.household); absent → manual review. -
Replay as-of
snapshot.as_of(B): assemble the correctedApplicationContextvia the persons client +context_assemblyat?as_of=snapshot.as_of(the corrected fact already applies, sincevalid_from <= as_of) →dry_run_determine(rules, &frozen_bundle, ctx, corpus_hash, bearer)→correctverdict (benefit_amount: Option<Decimal>), write-free. -
Size (D), in
Decimalthen cents: compute the window[start, end](Decision D, capped at supersession/expiration/today); fetch issuances via the enrollment client; group bybenefit_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 -> BelowThresholdThe
prorate/partial-month logic + the retained-exclusion overlapcontinued_benefits.rs:compute_overpayment— extract a sharedoverpayment-windowhelper (DRY); the exact partial-month semantics are SME-confirmable (Risk #15). -
Persist (one tx): map
RecomputeClaimBasis→canopy-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_typeis a separate free-text field — neverclaim_basis.into()); insert theoverpayment_recomputesaudit row (person_id,notice_id = NULL; the unique constraint is the race backstop, Decision J); stage theOverpaymentClaimedEvent(with the formattedoverpayment_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 + typedoutcome. -
OpenAPI snap 24→25. Files:
canopy-snap/src/{determine.rs, api/{determine_handler,mod}.rs, store/{overpayments,recomputes}.rs}, a sharedoverpayment-windowhelper, 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, notcorrection_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 differentcorrection_as_of) under the advisory lock → no double-claim; non-SNAP → 422; the replay writes norule_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 againstcanopy-contracts-snap, B3a offset). Redirect?focus_section=. -
CLI:
canopy snap overpayment recompute+canopy snap determination hearing-view(siblings undersnap, T2-7 clap precedent — no ADR-007 amendment).requested_byis 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 onlycorrection_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+OverpaymentNoticePDF 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):
-
Policy from the frozen bundle only —
dry_run_determinenever reads liveparams; a since-changed threshold must not move a historical overpayment. -
Corpus pin threads all three rules calls — reuse
dry_run_determine/VerdictMode::DryRun; no live-corpus call. -
Write-free replay — only the audit row + #382 claim + outbox event are written, in one tx; no determination/snapshot/DEK minted.
-
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:
-
Provisional exclusion is a hard, fail-safe gate — any provisional node → manual review; can only over-exclude.
-
Overlap guard is schema-bounded — same-
determination_id+ prior-recompute window; the cross-determination path-(a) case is a documented manual-review limitation. -
Denial-NULL + underpayment —
Decimal::ZEROfor a denied side; now-denied vs approved-paid = full clawback; a raised benefit =UnderpaymentFound/NoOverpayment(no claim). -
Threshold is scoped — agency-error/non-participating only; amount + "participating" def + adverse-vs-debt + IPV are SME-flagged (MR1 issue).
-
Legacy/incomplete/unknown-corpus — typed degradation, HTTP 200 +
outcome_message, never 500.
Config / contract:
-
OpenAPI counts — snap 23→24 (MR3), 24→25 (MR4–5); update
api/mod.rs:201each time. -
Two paths must not be conflated —
appeal.overpayment_assessedstays claim-creation-only;snap.overpayment_claimedis the only one routed to the notice; the overlap guard (K) prevents double-claims. -
Appeals service identity — needs a service-token to call the gated hearing-view (capture
boot.service_token_source). -
canopy-snap’s new clients (M) — must read the household as-of
snapshot.as_of(the evaluation date), where the retroactive correction already applies — nevercorrection_as_ofor "today". -
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_ofoverlapping windows can’t both insert.
Correctness defects fixed pre-implementation (from external review — re-verify in code):
-
Replay date =
snapshot.as_of, notcorrection_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-ofsnapshot.as_ofbecausevalid_from <= as_of.correction_as_ofbounds the window only. -
Sizing math is intricate + must reuse, not reinvent — per-month sum of
Decimal allotment_amountacross enrollments, excluderetained, prorate first/last month, cap the window at supersession/expiration/today. Overlapscontinued_benefits.rs:compute_overpayment— extract a shared helper; SME-confirm partial-month semantics. -
Result model represents degraded outcomes —
RecomputeOutcomecarriesNoBaselineSnapshot/CorpusUnavailable;OverpaymentRecomputeResult.{baseline,correct,corpus_hash}areOption; no audit row for a degraded outcome (socorpus_hash_used NOT NULLholds). -
Layering + types —
canopy-contracts-snapmust NOT depend oncanopy-overpayments; use a snap-localRecomputeClaimBasismapped at the store boundary.error_typeis a separate free-text field, neverclaim_basis.into()(ClaimBasisonly hasas_str()). -
Recipient identity —
person_id(required byCreateClaimRequest+ the notices subscriber) is derived from the snapshot head-of-household, threaded into the claim/audit/event. -
Notice wiring is three points — the
entry and a[templates.snap.overpayment]manifest entry and the.typ; the event must carry the exactoverpayment_amountkey (extract_program_datacopies keys verbatim). -
Participating = enrollment, not determinations — Decision D′'s threshold scope is checked via the enrollment client.
-
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_overpaymentsums whole-month allotments (excludingretained), matchingcontinued_benefits.rs::compute_overpaymentexactly; it does not prorate a partial first/last month. Proration is unimplemented codebase-wide + SME-pending (Risk #15) — tracked in #935. -
No shared
overpayment-windowhelper extracted. The plan suggested extracting a DRY helper shared withcontinued_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. Onlyfirst_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::BelowThresholdis a forward-ready contract variant; wiring the floor (loadminimum_claim_centsintoSnapParameterTable) + the non-participating enrollment gate is SME-pending (#927) → tracked in #936. -
Frozen derived inputs are recovered, not re-derived.
context_assemblyrecoversutility_tier/categorical_eligibility_type/alien_eligibility_inputs+ the household composition frozen from the snapshot (the derived fields by unsealingprogram_inputin-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_centsamount/scope + the "participating" definition; the adverse-vs-debt notice semantics; whether a determination-error overpayment is ever IPV; the month-window edge semantics.
References
-
ADR-028: Determination Input Snapshot §70 (the in-boundary consumer work this realizes) + Amendment 4 (the as-built record).
-
ADR-027: Worker Fact Authoring and Provenance (the T2-7 dry-run/replay this builds on).
-
ADR-004: Legally-Scoped Data Tenancy (the FTI boundary).
-
T2-7 plan — the dry-run/replay machinery (~90% reuse).