T2-7 — Reported-change → dry-run materiality → recert nudge + change-of-circumstance notices (#680)
On this page
Epic &56 / Track 2, T2-7 (#680). When a worker authors a fact change during an active SNAP certification,
canopy-persons emits a fact-change event; canopy-renewals (the cert owner) reacts by re-determining eligibility in a
non-persisting dry-run — holding policy frozen to the determination-of-record — and if the change is material,
raises a worker-actioned recert nudge + a ChangeInCircumstancesNotice. SNAP-only, 6 dependency-sliced MRs.
Realizes ADR-027 §6 and the dry-run-replay path
ADR-028 named.
Context
-
Requirement (ADR-027 §5/§6): a reported change during an active cert period runs a materiality check — a non-persisting "what-if" re-determination vs the frozen determination-of-record — and on a material change raises a worker-actioned recert nudge (never automatic) + a
ChangeInCircumstancesNotice. -
Trigger is the fact write, reacted to via the event bus (see Decisions A). The fact write lands in program-agnostic canopy-persons (§7), so the SNAP-specific reaction is decoupled: persons emits the existing
*.claimedevent (T1-5); canopy-renewals subscribes (the canopy-security wildcard-subscriber pattern). §5 (change-reporting, #868) and §6 (this) are independent consumers of the same event. -
None of this exists today:
-
canopy-rules cannot replay a historical ruleset version, and audits every evaluation (no ephemeral mode);
-
the orchestrator/snap have no non-persisting dry-run path;
-
canopy-renewals has no event subscriber and never calls canopy-eligibility;
-
the determination snapshot freezes only the 14 main thresholds — not pay-period factors or SE-deduction settings, which also drive the verdict;
-
ChangeInCircumstancesNoticeis an enum variant with no route and no template.
-
-
SNAP-only (per T2-1 Decision B: SNAP is the only non-FTI, orchestrator-live program; tanf/medicaid need the hearing-scoped path of T2-8/#681; caps/wic are not orchestrator-reachable).
Three user-confirmed design decisions (details in Decisions A–C):
-
True corpus replay — canopy-rules gains real ruleset-version history (not a refuse-on-drift shortcut).
-
Frozen policy, current facts — the dry-run scores current facts under the baseline’s complete frozen policy (corpus + the full policy bundle), so the diff is fact-driven, never a policy update.
-
Full ADR-007 parity — canopy-web worker surface + CLI verbs + Playwright all land inside T2-7.
Scope
In scope: ruleset-version persistence + corpus-pinned + ephemeral (?audit=false) /v1/evaluate; the
orchestrator + canopy-snap non-persisting dry-run path (corpus + full policy bundle pinned through all three rules
calls); the determination-snapshot policy-bundle enrichment; the renewals fact-change subscriber + the materiality
predicate + the [snap.materiality] threshold; the worker-actioned recert nudge (new table); the
ChangeInCircumstancesNotice route + template; canopy-web worker surface; CLI parity; Playwright E2E; ADR-027 §6 /
ADR-028 amendments.
Out of scope (each filed as a GitLab issue /relate #680 before MR6):
| Out of scope | Reason | Owner |
|---|---|---|
Reconciling the metadata change-report endpoint + its FPL heuristic with materiality |
T2-7’s trigger is the fact event, not the change-report; the change-report path is a separate metadata concern |
Follow-up |
§5 change-reporting (snap_change_reports + 10-day clock + adjustment redetermination) |
Independent consumer of the same fact event |
#868 |
Single-fact-overlay isolation |
"current facts" already include the authored change (Decision A), so no overlay is needed |
N/A |
Auto-create the recert application on a worker-accepted nudge |
The nudge is worker-actioned; app provisioning is separate |
Follow-up |
Coalescing rapid multi-edit nudges/notices into one per cert/window |
Correctness = one nudge per material change; coalescing is an enhancement |
Follow-up |
Dry-run materiality for tanf/medicaid/caps/wic |
FTI + non-orchestrator-reachable |
T2-8 (#681) |
Backfill pre-T2-7 snapshots lacking the full policy bundle |
Only post-MR3 determinations carry the full bundle; pre-1.0 reseeds |
On-demand only |
Status
| MR | Description | Status |
|---|---|---|
MR1 — plan + ADR + threshold param |
This plan |
Done (2026-06-26) — !690 |
MR2 — canopy-rules corpus replay + ephemeral eval |
|
Done (2026-06-26) — !691 |
MR3 — snap dry-run + snapshot policy-bundle |
Enrich the snapshot’s |
Done (2026-06-26) — !692 |
MR4 — eligibility dry-run orchestration |
|
Done (2026-06-26) — !693 |
MR5 — renewals subscriber + materiality + notice |
renewals fact-change event subscriber + |
Done (2026-06-26) — !694 |
MR6 (FINAL) — web + CLI + E2E + docs |
canopy-web nudge surface; CLI verbs (dry-run + nudge action); gated Playwright E2E; Antora api/data-models + ADR as-built + CHANGELOG; master-plan + epic status flip. |
Done (2026-06-26) — !695 |
-
Epic &56 — single issue #680, delivered as 6 dependency-sliced MRs (justified per gitlab-issue-mr-standards exactly as T2-6/#687 → 9 MRs: each slice independently reviewable + green; bundling would be one unreviewable cross-service diff).
Relates to #680on MR1–5;Closes #680on MR6 only (verify #680 stays OPEN after each non-final merge). -
Only MR6 updates the umbrella master-plan (the epic-&56 plan) T2-7 row + the epic &56 status; MR1–5 update only the service-scoped Antora docs they touch.
-
Branches
feat/fact-authoring-t2-7-{plan,rules-corpus-replay,snap-dryrun,eligibility-dryrun,renewals-materiality,web-cli-e2e}, each cut fresh from amainthat already has its deps; regular merge commits, never squash. -
Merge order (mandatory, linear): MR1 → MR2 → MR3 → MR4 → MR5 → MR6. MR2/MR3 are the long poles.
Decisions
Data model
New table — canopy-rules own DB (ADR-001):
CREATE TABLE ruleset_corpus_versions (
corpus_hash TEXT NOT NULL, -- the #682 SHA-256 corpus hash
ruleset_name TEXT NOT NULL, -- logical JDM `name`
content JSONB NOT NULL, -- winning ruleset raw JDM (STRUCTURAL-VALUE: JDM is opaque, ADR-003)
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (corpus_hash, ruleset_name)
); -- INSERT ... ON CONFLICT DO NOTHING at startup, before serving traffic.
New table — canopy-renewals own DB (MR5):
CREATE TABLE recert_nudges (
id UUID PRIMARY KEY, -- UUID v7
certification_id UUID NOT NULL REFERENCES snap_certifications(id),
household_id UUID NOT NULL,
baseline_determination_id UUID NOT NULL, -- the determination-of-record (cert.determination_id)
source_event_id UUID NOT NULL, -- the persons fact-change event id (idempotency)
source_person_id UUID NOT NULL, -- the person whose fact changed (notice payload)
triggering_fact_kind TEXT NOT NULL, -- 'income' | 'asset' | 'expense' | 'member'
baseline_status TEXT NOT NULL,
baseline_benefit_cents BIGINT, -- NULL when baseline denied
dry_run_status TEXT NOT NULL,
dry_run_benefit_cents BIGINT, -- NULL when dry-run denied
benefit_delta_cents BIGINT, -- NULL when either side denied
is_material BOOLEAN NOT NULL,
notice_id UUID, -- the ChangeInCircumstancesNotice, when material
action_taken TEXT, -- 'filed_recert' | 'dismissed' | NULL (pending)
action_by UUID,
action_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (certification_id, source_event_id) -- Decision G idempotency
);
Snapshot policy-bundle enrichment (MR3): extend the snapshot’s plaintext policy_params (today only the 14
build_snap_eligibility_thresholds keys) to also carry pay_periods. (the 5 frequency factors) and
self_employment_standard_deduction_pct + _enabled. This is the *complete verdict-affecting policy set (confirmed
by audit: thresholds + pay-period factors + SE settings; certification_months/renewal_months are excluded — they
set dates, not the verdict). Pre-T2-7 snapshots lack these keys → the dry-run treats a baseline whose bundle is
incomplete as NoBaselineSnapshot-class (manual review), never a wrong verdict.
New contract types (canopy-contracts-rules / canopy-contracts-eligibility):
-
CorpusHash(String)newtype (incanopy-contracts-rules, wherecorpus_hashalready lives). -
DryRunOutcome { status, benefit_amount: Option<Decimal>, benefit_unit: Option<String>, corpus_hash_used: CorpusHash }— snap → orchestrator. -
DryRunRequest { baseline_determination_id, household_id, as_of: NaiveDate }(Decision J) +DryRunResult { baseline: VerdictRef, dry_run: VerdictRef, corpus_hash: CorpusHash, as_of: NaiveDate },VerdictRef { status, benefit_amount }. -
Typed errors
CorpusUnavailable,NoBaselineSnapshot(thiserror).
Implementation
All seal/open + PII stay service-local; the dry-run response to renewals is {status, benefit_amount} only (no
PII/FTI crosses a boundary — ADR-004 clean). Each commit builds green; per commit the pre-commit token gate + 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 → report URL).
MR1 — Plan + ADR + threshold param
-
Port this plan to
.adoc+ nav entry (the(plan)commit). -
Amend ADR-027 with a T2-7 as-built amendment: the event-subscriber trigger (Decision A); corpus replay (B); the complete frozen-policy bundle ©; the unsigned + write-free dry-run (D); the dry-run
as_of(J); the pre-T2-7 backfill boundary. -
Amend ADR-028: the snapshot’s
corpus_hash+ the enrichedpolicy_paramsbundle are replay inputs (realizing the reproducibility the Decision already intended); canopy-rules gains corpus-version history. -
Add
[snap.materiality] benefit_delta_threshold_centstorulesets/georgia/jurisdiction.toml+ acitations.tomlentry. The materiality dollar amount is a Canopy operational/product decision, not a federal figure —authority = "operational", and flagged for Georgia SME confirmation (#921). Do not cite7 CFR 273.12(a)(5)(simplified-reporting), and do not conflate with the[snap.verification_thresholds]PAMMS-3035 $25 verification triggers. -
Files:
docs/…/adrs/adr-027-.adoc,adr-028-.adoc,rulesets/georgia/{jurisdiction,citations}.toml,nav.adoc, the plan.adoc.
MR2 — canopy-rules corpus replay + ephemeral eval
-
Deps:
cargo add lru parking_lot(neither is a workspace dep yet). -
Store:
ruleset_corpus_versionsmigration; extendNamedFilesystemLoader::scanto retain the winning name→raw map; persist the current corpus idempotently inRulesEngine::new/bootstrap before the service reports ready (closes the startup race). -
Replay loader + cache:
InMemoryLoader(implDecisionLoaderover pre-parsedArc<DecisionContent>);parking_lot::Mutex<lru::LruCache<CorpusHash, Arc<DecisionEngine>>>(cap ~8) — clone theArcout and drop the guard beforespawn_pinned(never hold aparking_lotguard across.await). Replay evals reuse the existingLocalPoolHandle. -
Pin + ephemeral params on
POST /v1/evaluate(EvaluateParamsalready carriestrace; addcorpus_hash+audit):-
?corpus_hash=X→ live or replay; the responsecorpus_hashreports the pinned hash (not alwaysengine.corpus_hash()); derivation folding uses the pinned version’s content (moot for the no-trace dry-run); unknown → 422CorpusUnavailable. -
?audit=false→ skip the auditrecord_evaluation_tx+ the outbox stage, so a dry-run evaluation writes nothing. -
Client (
canopy-rules-client):evaluate_with_corpus_hashgains optionalcorpus_hash+auditparams.
-
-
Load-bearing tests: replay-over-stored-bytes ≡ live output for the same input (proptest over a few corpora); a determination’s
corpus_hashis replayable immediately after the boot that produced it (race closed); pinned eval returns the pinned hash;?audit=falsewrites no audit/outbox row (assert counts); unknown corpus → 422; INSERT idempotent across restarts; cache eviction → rebuild identical. -
Files:
services/canopy-rules/src/{engine.rs,api/mod.rs,store.rs}, new migration,services/canopy-rules/Cargo.toml,crates/canopy-rules-client/src/lib.rs,crates/canopy-contracts-rules/src/rule_sets.rs, rootCargo.toml. (No new path → the== 4OpenAPI count is unchanged.)
MR3 — snap dry-run + snapshot policy-bundle
-
Snapshot enrichment: extend the snapshot capture so
policy_paramsincludes the full bundle (thresholds +pay_periods+ SE settings). All new determinations carry it. As-built:policy_paramsserializes a typedSnapPolicyBundle(the 15 thresholds stay flat + identically named so the derivation-graphParamresolution is unchanged);PayPeriodswas promoted intocanopy-contracts-snapso it can ride the bundle, re-exported from the service.SnapParameters::policy_bundle()/::from_policy_bundle()convert (the latter nullscertification/renewalmonths — a dry-run computes no dates). -
Extract
evaluate_verdictfromdetermine.rs(the verdict-computation region: SE pre-processing → assemblerules_input→ alien pre-check → main eval → parse). It begins aftercreate_snap_applicationand ends before the DEK-mint/sign/persist tail; keep app-create + antecedent validation indetermine()only. As-built:evaluate_verdictis a thin coordinator overbuild_rules_input+run_alien_precheck+run_eligibility_rulesets+parse_verdict(the decomposition keeps each fn under the B2 100-LOC budget; B2 stays 123, B3a 754 — the opaque-JDM-I/Oserde_json::Valuesites carry// STRUCTURAL-VALUE).-
As-built: parameterized by
&SnapParameters(live fordetermine, rebuilt from the frozenSnapPolicyBundlefor the dry-run — so policy is read ONLY from the passed table, never live globals) plus a singleVerdictMode { Live, DryRun { corpus_hash } }enum that encodes the correlated(corpus_pin, want_trace, audit)triple (trace ⟺ live ⟺ unpinned), keeping the call sites from constructing a nonsensical combination. -
Thread the corpus pin +
audit=falsethrough all three rules calls —se_deduction::compute,alien_eligibility::evaluate, and the main eval — viaVerdictMode, so every call replays the pinned corpus and writes no audit row. (se_deduction+alien_eligibilitygained themodeparam; the dry-run passes a syntheticSnapApplicationIdas the rules auditcontext_id, moot underaudit=false.)
-
-
Endpoint
POST /v1/determine/dry-run: body =DryRunDetermineRequest { context, policy_bundle, corpus_hash }; returnsDryRunOutcome. Mints no DEK, seals nothing, signs nothing, persists nothing, emits nothing (and viaaudit=false, no rules-audit rows). As-built: noas_ofon the snap-level request — the orchestrator (MR4, Decision J) reads facts as-of the change’s effective date and assembles theApplicationContext, so snap’s verdict pipeline needs no date.RulesClient::evaluate_pinnednow maps a pinned-corpus 422 to a typedUnprocessableEntityso the unknown-corpus case surfaces as 422 (CorpusUnavailable), not a 500 (the MR4 caller degrades to manual review). -
Load-bearing tests:
evaluate_verdict≡ the fulldetermine()(status, benefit_amount)for the same input (refactor guard, via the dry-run pinned to the determination’s own corpus + bundle); a dry-run leaves snap row counts unchanged (write-free; household-scoped, parallel-safe); the snapshot serializes the full bundle; unit round-trips forpolicy_bundle()/from_policy_bundle(). -
Files:
services/canopy-snap/src/{determine.rs,se_deduction.rs,alien_eligibility.rs,api/determine_handler.rs,api/mod.rs,params.rs},crates/canopy-contracts-snap/,crates/canopy-rules-client/src/lib.rs,crates/canopy-test-lib/src/clients/snap.rs. *OpenAPI count 22 → 23.
MR4 — eligibility dry-run orchestration
-
Endpoint
POST /v1/eligibility/determine/dry-run: factor the household/context-assembly out ofdetermine_inner; the dry-run path skips theeligibility_requestsslot, signature verification,program_determinations/combined_resultspersistence, and the outbox event. -
Body
{ baseline_determination_id, household_id, as_of }(Decision J). Fetch the baseline verdict (status +benefit_amount, plaintext onsnap_determinations) viaGET /v1/determinations/{id}(not/v1/snap/…—paths::GET_DETERMINATION); fetch the pinnedcorpus_hash+ the enrichedpolicy_paramsbundle viaGET /v1/determinations/{id}/snapshot. Verify the baseline determination belongs to the requested household (reject mismatch). Assemble the household context at the suppliedas_of, dispatch the snap dry-run pinned to corpus + bundle with?audit=false. ReturnDryRunResult. -
Completeness check at fetch: inspect the baseline snapshot’s
policy_paramsfor the full-bundle keys; if any are missing (a pre-T2-7 snapshot) or the snapshot is null, return typedNoBaselineSnapshot. Unknown corpus →CorpusUnavailable. renewals degrades both to "manual review", never a 500. SNAP-only; other programs → 422. -
Load-bearing tests: same facts+corpus →
baseline.verdict == dry_run.verdict; changed input → reflected; legacy/incomplete baseline → typed error, not a panic; cross-household baseline → rejected; non-SNAP → 422; noeligibility_requestsrow created. -
Files:
services/canopy-eligibility/src/{orchestrator.rs,api/handlers.rs,api/mod.rs}, contracts. OpenAPI count 7 → 8. -
As-built (#680): the context-assembly extraction is two pieces — a
HouseholdContextstruct (replacingfetch_household_context’s 6-tuple return) + a shared `assemble_application_context— used by bothdetermine_inneranddry_run.dry_run(+resolve_baseline_replay+ thefetch_baseline_read/fetch_baseline_snapshot/dispatch_snap_dry_runhelpers) lives inorchestrator.rs; the eligibilityApplicationContextis adapted to canopy-snap’sApplicationContextvia a serde round-trip (the same boundary the live HTTP dispatch crosses), so noas_ofis sent in the snap-level body (the facts are already read as-of). The "completeness check" is realized by deserializingpolicy_paramsinto the typedSnapPolicyBundle(a pre-T2-7 partial bundle fails to deserialize → 422). The typedNoBaselineSnapshot/CorpusUnavailableare realized asApiError::UnprocessableEntity(422) with distinct messages — the renewals caller degrades on the 422 status (the Rust enum does not cross the HTTP boundary), matching the established codebase pattern.DryRunRequestcarries noprogramfield, so SNAP-only is enforced by construction (the baseline is a canopy-snap determination); a non-SNAP / unknownbaseline_determination_idis simply unknown to canopy-snap → 404 (the "non-SNAP → 422" test is N/A without a program selector — documented here rather than forcing a selector the consumer (renewals, SNAP-only) never needs).canopy-eligibilitygained deps oncanopy-contracts-snap+canopy-contracts-rules.
MR5 — renewals subscriber + materiality + notice
-
Subscriber: wire a renewals event subscriber in
main.rs(the canopy-securitysubscribe_*pattern; durable queue e.g.canopy-renewals.materiality) onincome.claimed/asset.claimed/expense.claimed/household.member_claimed. UseEventEnvelope.id(UUID v7) as therecert_nudges.source_event_id. Handler: derive the household —household.member_claimedcarrieshousehold_iddirectly; income/asset/expense carry onlyperson_id, so resolve person→household via a lightweight personsGET /v1/persons/{id}. Read the change’svalid_fromfrom the event as the dry-runas_of(Decision J; skip retroactivevalid_from< baselineas_of→ manual review). Look up the active cert (get_active_certification); if none, early-return (no-op — covers the initial-application case, which fires events before any cert exists). Otherwise run the materiality flow. -
Outbound auth: capture
boot.service_token_source(renewals does not today) + configCANOPY_RENEWALSELIGIBILITY_URL+CANOPY_RENEWALSOIDC_SERVICE_CLIENT_ID/SECRET(note:OIDC_SERVICE_CLIENT_ID, notOIDC_CLIENT_ID). NewEligibilityClient(mirrorcanopy-applications/src/persons_client.rs); the dry-run call carries the service identity. -
Predicate + nudge: call the eligibility dry-run (baseline =
cert.determination_id), apply the Decision-F predicate (threshold from[snap.materiality]viaPolicyParams); insert arecert_nudgesrow (idempotent on(certification_id, source_event_id)). Onis_material, emitrenewal.material_changecarryinghousehold_id+person_id+ benefit/delta/change fields. -
Notice: new
entry inrulesets/georgia/notices/manifest.toml+ a new SNAP Typst template; add every template#inputs.<key>todefault_program_dataand to the entry’sprogram_data_keys. Confirm the 10-day advance-notice floor (ADR-010) treats an informational nudge notice correctly — exempt it from the floor if it would otherwise be rejected, documenting the exemption in the manifest entry. -
Load-bearing tests: material change → nudge row + event; immaterial → no event; verdict flip with sub-threshold delta → material (OR); denied baseline → delta skipped; redelivered event → one nudge (unique key); no active cert → no-op; retroactive
valid_from< baselineas_of→ no-op (manual review); theChangeInCircumstancesNoticeroutes as informational (is_adverse=false) and is not rejected by the 10-day floor; notice renders with real values (program_data present). -
Files:
services/canopy-renewals/src/{main.rs,subscriber.rs (new),eligibility_client.rs (new),api/mod.rs,params.rs,events.rs,store.rs}, new migration,rulesets/georgia/notices/manifest.toml, new.typtemplate,services/canopy-noticesroute-count test (+1). -
As-built (deviations from the sketch above):
-
Outbound auth is graceful-degrade, not hard-require. The plan implied capturing
boot.service_token_source; the as-built mirrors the established canopy pattern for an optional outbound-dependent subscriber (canopy-notices' recovery subscriber) — without OIDC creds the materiality subscriber is simply not registered (loudwarn!) and the rest of canopy-renewals still serves. Thecanopy-renewalsKeycloak client already existed in the realm; only the devstack compose env vars + config defaults were added. New files:subscriber.rs,eligibility_client.rs,persons_client.rs,materiality.rs(notapi/mod.rs— MR5 adds no HTTP endpoint; the worker-facing nudge surface is MR6). -
Retroactive guard uses
certification_start_dateas the baseline-as_ofproxy (the cert row doesn’t carry the determination’s evaluation date). A change effective before the cert start is treated as a retroactive correction → manual review (Decision J). -
recert_nudges.notice_idstaysNULLat insert. TheChangeInCircumstancesNoticeis generated asynchronously by canopy-notices after consumingrenewal.material_change, so there is no synchronous link-back in MR5; the column is reserved for a future notices→renewals link-back event (the worker surface in MR6 does not require it). -
Minimal renewals persons client (one
GET /v1/persons/{id}method) rather than a shared crate; extracting a shared persons-client crate (now a 3rd copy across applications / notices / renewals) is filed as a DRY follow-up (#924,/relate#680). -
Event payload carries presentation aliases (
change_reasons/previous_benefit_amount/new_benefit_amount) the notice template reads viaprogram_data, alongside the semanticchange_type+ raw*_cents(Decision H). The recert_nudges row stores the canonical cents written directly from the dry-run, not derived from the event. -
Test scope — MR5 unit/integration-tests the decomposable units (materiality predicate branches + proptests, the
recert_nudgesidempotency constraint, therenewal.material_change→change-in-circumstancesrouting, the Typst render). The end-to-end subscriber glue (the live dry-run + degrade routing over HTTP, person→household resolution, the active-cert / retroactive gates, the emit-only-if-material decision) crosses service boundaries and the repo has no mock-HTTP harness — so per the Verification section it is covered by the MR6 Playwright + integration ladder, with a lighter complementary mock-HTTP layer filed as #925 (/relate#680, #923).
-
MR6 (FINAL) — web + CLI + E2E + docs
-
Web: surface pending recert nudges (the
recert_nudgesrows) in the case-detail renewals tab with worker actions (file recert / dismiss) POSTing to renewals; reuse the existing banner affordance incanopy-web/src/api/actions.rs. -
CLI (ADR-007 parity — every new REST op gets a verb):
canopy eligibility determine dry-runandcanopy renewals nudge {list,action}. No ADR-007 amendment. -
E2E: gated Playwright — author a material income change → nudge appears → file recert →
ChangeInCircumstancesNoticeproduced; plus an immaterial-change negative path. -
Docs: Antora
api/canopy-{rules,eligibility,snap,renewals,notices}.adoc+data-models/(new tables/columns, endpoints, event, notice, the enriched snapshot bundle); ADR-027/028 as-built; CHANGELOG== Unreleased; umbrella master-plan T2-7 → Done + epic &56 status.Closes #680. -
Files:
services/canopy-web/src/api/{actions,renewals,case_detail}.rs,tools/canopy-cli/src/cmd/*, Playwright spec, Antora pages, CHANGELOG, plans.
As-built (MR6, 2026-06-26):
-
The worker-facing nudge endpoints are net-new in MR6. MR5 wrote the
recert_nudgestable + the subscriber; it added no HTTP surface. MR6 adds the two read/action endpoints the web + CLI consume —GET /v1/renewals/snap/nudges?household_id=&pending_only=(list) andPOST /v1/renewals/snap/nudges/{id}/action(record afiled_recert/dismisseddecision). The action is guardedWHERE is_material AND action_taken IS NULL, so a double-submit / redelivered BFF retry is a no-op (404) — the first decision stands. The worker subject is theaction_byactor, forwarded by the BFF in the request body (the endpoint is service-caller-gated; the actor-in-body pattern matchesDetermineRequest.requested_by). Renewals OpenAPI path count 11 → 13. -
CLI:
canopy eligibility dry-run, a sibling ofdetermine(notdetermine dry-run). ADR-007 documentseligibility determineas a leaf verb, and clap cannot make one verb both a leaf and a subcommand group — so nestingdry-rununderdeterminewould restructure the existingeligibility determinesurface and require an ADR-007 amendment. The sibling verb keeps the ADR-007 surface byte-stable with no amendment (the plan’sdetermine dry-runspelling was pre-implementation).renewals nudge {list,action}lands as planned. -
Web: a dedicated
case-detail-renewalscomposition section (ADR-021), not a banner. The nudge surface is a new case-detail section (programs=["snap"], row 19 span 12, empty-state when no pending nudges) with inline per-row file-recert / dismiss action forms POSTing to two new BFF action handlers (/actions/renewals/{file-recert,dismiss-nudge}) that forward to the nudge action endpoint with the worker asaction_by. "Reuse the banner affordance" is realized as the post-action?focus_section=renewalsredirect (the established worker-action redirect pattern); the forms carry the standard_csrfhidden field + program-scope gate.data-recert-nudge/data-nudge-actionhooks back the E2E. -
E2E oracle is relational, negative leg is a bounded smoke. The material signal is a verdict-flip (a construction-extreme added wage), so the journey holds under any jurisdiction’s limits; oracles are the nudge surfacing → dropping off pending after the file (UI presence/absence) + a notice-count increase (the notice type is not UI-differentiable). The immaterial negative leg ($1 change) is a bounded-wait smoke check — the exhaustive immaterial coverage is the
materiality.rsunit + proptest layer and the renewals pending-guard integration test (recert_nudge_list_and_action_enforce_pending_material_guard). Proven green live (journey-snap-income-materiality, both tests, on the demo+full stack). -
Seed-reset prerequisite (#926, folded). The gated journey could not run: the demo-profile seed reset (
xtask seed --reset) aborted atcanopy_snapbecause itsTRUNCATE … CASCADEcascades into the append-onlydetermination_snapshots(the ADR-028 #678 guard), blocking all demo-profile E2E project-wide. Fixed at the architecturally-correct root —reset_tablesnow wraps each per-DB truncate in the guard’s documented maintenance window (BEGIN; SET LOCAL canopy.snapshot_maintenance = 'on'; TRUNCATE …; COMMIT;), generically for every DB (a harmless placeholder where no such guard exists, so it stays correct as the demo + random seeders converge; no table-exclusion or guard-disable). Filed as #926,/relate#680, fixed here as afix(seed):commit since it is a hard prerequisite for this MR’s E2E deliverable.
Verification
Per MR (each must pass before push):
-
cargo build -p <touched>+cargo clippy -p <…> --all-targets — -D warnings; -
focused 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(any newserde_json::Valueis the JDM/policy STRUCTURAL-VALUE carve-out; OFFSET, never raise); -
cargo xtask check-docs+docs plan-lint;cargo xtask policy(citation coverage, MR1); -
the full pre-push battery on every push.
End-to-end (MR6 Playwright + the integration ladder): seed a SNAP household with an approved certification → a
worker authors a material income change (persons fact write → income.claimed) → renewals' subscriber runs the
dry-run → assert the dry-run wrote nothing (snap + rules row counts unchanged) → a recert_nudges row with
is_material=true → a ChangeInCircumstancesNotice PDF with appeal rights → the worker files a recert. Plus an
immaterial-change negative path, an idempotency check (redelivered event → one nudge), and the MR2 corpus-replay
determinism + ephemeral-no-write checks.
Risks / sharp edges
Critical (system correctness):
-
Incomplete policy freeze — corpus alone doesn’t freeze pay-period factors or SE-deduction settings; the snapshot must capture the full bundle and the dry-run must read policy only from it, not live
params(Decision C). -
Corpus pin must thread all three rules calls —
se_deduction,alien_eligibility, and the main eval; pinning only the main call leaves SE/alien on the live corpus (Decision C). -
Corpus-version startup race — persist the corpus row before the router serves traffic (Decision B).
-
parking_lotguard across.await— clone theArc<DecisionEngine>out and drop the guard beforespawn_pinned(MR2). -
Truly write-free dry-run — the rules calls must use
?audit=false, else each dry-run writes audit + outbox rows (Decision D); the snap path mints no DEK/snapshot/determination.
Behavioral (logic / user-facing):
-
Denial-NULL benefit — verdict-change first; benefit-delta only when both
approved(Decision F). -
Notice payload completeness —
renewal.material_changemust carryhousehold_idandperson_idor the notices subscriber drops it; new template keys must be indefault_program_data+program_data_keys(Decision H). -
Notice advance-notice floor — exempt the informational nudge notice from the 10-day adverse-action floor (MR5).
-
Nudge/notice noise on rapid edits — one nudge+notice per material fact-change event; coalescing is a filed follow-up.
Legacy / config:
-
Pre-T2-7 / incomplete baselines — typed
NoBaselineSnapshot/CorpusUnavailable→ renewals "manual review", never a 500 (MR4). -
Renewals outbound auth —
OIDC_SERVICE_CLIENT_ID/SECRET(notOIDC_CLIENT_ID) + captureboot.service_token_source(MR5). -
OpenAPI path-count tests — bump snap 22→23 (MR3) and eligibility 7→8 (MR4); rules
== 4unchanged (query params only). -
Threshold citation — the materiality amount is operational, not
7 CFR 273.12(a)(5);authority = "operational"+ SME confirmation (#921, MR1). -
Event→household asymmetry — income/asset/expense
.claimedcarry onlyperson_id; the subscriber resolves person→household via persons (member events carryhousehold_iddirectly) (MR5). -
Derivation folding under a pin — for a traced pinned eval, folding must use the cached replay engine’s own loader, not the live
self.loader; moot for the no-trace dry-run, but get it right in MR2.
Follow-ups
Filed as GitLab issues /relate #680 before MR6:
-
Reconcile the metadata change-report endpoint + its FPL heuristic with materiality (T2-7’s trigger is the fact event; the change-report path is untouched).
-
Coordinate #868 (§5 change-reporting) as the second independent consumer of the persons fact event.
-
Coalesce rapid multi-edit nudges/notices into one per cert/window.
-
Auto-create the recert application on a worker-accepted nudge.
-
Backfill pre-T2-7 snapshots lacking the full policy bundle (only if a non-reseed environment needs it).
-
Georgia SME confirmation of the
benefit_delta_threshold_centsamount + its citation (#921).