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 *.claimed event (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;

    • ChangeInCircumstancesNotice is 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):

  1. True corpus replay — canopy-rules gains real ruleset-version history (not a refuse-on-drift shortcut).

  2. 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.

  3. 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 .adoc + nav; ADR-027 / ADR-028 amendments; [snap.materiality] benefit_delta_threshold_cents in jurisdiction.toml + citations.toml (SME-flagged, #921). No code.

Done (2026-06-26) — !690

MR2 — canopy-rules corpus replay + ephemeral eval

ruleset_corpus_versions store + InMemoryLoader + bounded engine cache + ?corpus_hash= pin (returns the pinned hash) + ?audit=false ephemeral mode on /v1/evaluate; CorpusHash + EvalMode types; client pin/ephemeral params.

Done (2026-06-26) — !691

MR3 — snap dry-run + snapshot policy-bundle

Enrich the snapshot’s policy_params to the full SNAP policy bundle; extract evaluate_verdict (corpus pin + frozen bundle through all three rules calls); POST /v1/determine/dry-run (write-free, ?audit=false) → unsigned DryRunOutcome.

Done (2026-06-26) — !692

MR4 — eligibility dry-run orchestration

POST /v1/eligibility/determine/dry-run; factor context-assembly out of determine_inner; fetch baseline via /v1/determinations/{id} (+/snapshot), verify household ownership; pin corpus+bundle; non-persisting; SNAP-only.

Done (2026-06-26) — !693

MR5 — renewals subscriber + materiality + notice

renewals fact-change event subscriber + EligibilityClient (service-token); materiality predicate + threshold; recert_nudges table (idempotent on event id); emit renewal.material_change (carries household_id+person_id); ChangeInCircumstancesNotice route + Typst template + default_program_data.

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. Closes #680.

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 #680 on MR1–5; Closes #680 on 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 a main that 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

# Decision Resolution

A

Trigger = fact-change event subscriber

The fact write is the authoritative trigger, and it lands in program-agnostic canopy-persons (§7) — so the SNAP-specific reaction is decoupled via the event bus (ADR-004). canopy-renewals subscribes to persons income/asset/expense/member.claimed events (T1-5, already emitted; canopy-security’s wildcard subscriber is the precedent). On a fact change for a household with an active cert, it runs the dry-run against current facts (which already include the just-authored change — no overlay). §5 (#868) and §6 (this) are independent consumers of the same event; neither depends on the other. Rejected: a synchronous BFF action (domain orchestration in the presentation layer + fires only on a dedicated action, missing §6’s "any authorized mechanism"); depending on #868 (over-couples two independently-shippable units).

B

True corpus replay

Storage: new ruleset_corpus_versions table (canopy-rules' own DB, ADR-001), corpus_hash → each winning ruleset’s name + raw JSON; populated idempotently at startup before the router serves traffic. Lookup: ?corpus_hash= on /v1/evaluate — omitted (or equal to the live corpus hash) uses the live engine; a stored non-live hash uses a cached replay engine and returns the pinned hash (not the live one); an unknown hash → typed 422 CorpusUnavailable. (As-built MR2: the pin + ephemeral flag travel as a small shared EvalMode { corpus_hash, audit }; there is no magic current literal — omit the param for the live corpus.)

C

Frozen policy = corpus + the COMPLETE policy bundle, current facts

Corpus alone is insufficient: pay-period factors and SE-deduction settings (pct + enabled) also drive the verdict and are injected before the main ruleset. So the snapshot is enriched to freeze the full policy bundle (14 thresholds + pay-period factors + SE-deduction settings), and the dry-run re-injects the whole bundle — evaluate_verdict reads policy from the passed bundle, never live params. The corpus pin threads through all three rules calls (se_deduction::compute, alien_eligibility::evaluate, main eval), not just the main one. (This completes the "Resolved policy parameters" reproducibility ADR-028 already intended.)

D

Dedicated, truly write-free dry-run

Extract a pure evaluate_verdict and expose dedicated dry-run endpoints (not a dry_run flag through the persist path, which mints a DEK + seals + signs). The dry-run’s rules calls use ?audit=false (MR2 ephemeral mode) so no rule_evaluations/outbox rows are written either — write-free end to end. Output is an unsigned DryRunOutcome (not a determination of record; ADR-002 signing attaches to persisted determinations only).

E

Ownership split

eligibility/snap stay pure determiners: the dry-run returns raw {status, benefit_amount}, no threshold knowledge. renewals owns the subscriber, the materiality predicate, the threshold, the nudge, and the notice trigger.

F

Materiality predicate

material = verdict_changed OR (both_approved AND delta >= threshold), delta = abs(baseline.benefit − dry_run.benefit). Verdict-change is checked first; the delta branch is reached only when both verdicts are approved (a denied baseline has benefit_amount = NULL). Threshold = [snap.materiality] benefit_delta_threshold_cents (i64 cents).

G

Idempotency via the event id

The recert nudge is keyed by a unique (certification_id, source_event_id) and the subscriber’s inbox dedups redelivery by event id — so at-least-once delivery and worker retries produce exactly one nudge + one notice per material fact-change event (a clean natural key, unlike a per-request change_report_id).

H

ChangeInCircumstancesNotice via event routing

renewals emits renewal.material_change carrying both household_id and person_id (the notices subscriber drops events missing either) + baseline_benefit/dry_run_benefit/delta/change_type. A new entry routes it to a new SNAP Typst template; every #inputs.<key> the template reads is added to default_program_data (missing keys render "—", not a hard-fail) and listed in program_data_keys.

I

SNAP-only

The eligibility dry-run rejects non-SNAP programs (422). Consistent with T2-1 Decision B.

J

Dry-run as_of = the change’s effective date

The dry-run evaluates the household as-of the triggering change’s effective date (valid_from of the authored version), not "today" (which would miss a forward-effective change) nor the baseline’s as_of (which would exclude the change — it is effective later). The subscriber reads valid_from from the fact event and threads it through DryRunRequest.as_of → eligibility → persons via the existing ?as_of= plumbing (T1-4 Slice 3). So a future-effective material change fires the nudge at authoring time. Retroactive corrections (valid_from < baseline as_of) are out of scope here (the deferred correction/overlay follow-up) — the subscriber treats them as manual-review.

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 (in canopy-contracts-rules, where corpus_hash already 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 enriched policy_params bundle are replay inputs (realizing the reproducibility the Decision already intended); canopy-rules gains corpus-version history.

  • Add [snap.materiality] benefit_delta_threshold_cents to rulesets/georgia/jurisdiction.toml + a citations.toml entry. The materiality dollar amount is a Canopy operational/product decision, not a federal figureauthority = "operational", and flagged for Georgia SME confirmation (#921). Do not cite 7 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_versions migration; extend NamedFilesystemLoader::scan to retain the winning name→raw map; persist the current corpus idempotently in RulesEngine::new/bootstrap before the service reports ready (closes the startup race).

  • Replay loader + cache: InMemoryLoader (impl DecisionLoader over pre-parsed Arc<DecisionContent>); parking_lot::Mutex<lru::LruCache<CorpusHash, Arc<DecisionEngine>>> (cap ~8) — clone the Arc out and drop the guard before spawn_pinned (never hold a parking_lot guard across .await). Replay evals reuse the existing LocalPoolHandle.

  • Pin + ephemeral params on POST /v1/evaluate (EvaluateParams already carries trace; add corpus_hash + audit):

    • ?corpus_hash=X → live or replay; the response corpus_hash reports the pinned hash (not always engine.corpus_hash()); derivation folding uses the pinned version’s content (moot for the no-trace dry-run); unknown → 422 CorpusUnavailable.

    • ?audit=false → skip the audit record_evaluation_tx + the outbox stage, so a dry-run evaluation writes nothing.

    • Client (canopy-rules-client): evaluate_with_corpus_hash gains optional corpus_hash + audit params.

  • Load-bearing tests: replay-over-stored-bytes ≡ live output for the same input (proptest over a few corpora); a determination’s corpus_hash is replayable immediately after the boot that produced it (race closed); pinned eval returns the pinned hash; ?audit=false writes 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, root Cargo.toml. (No new path → the == 4 OpenAPI count is unchanged.)

MR3 — snap dry-run + snapshot policy-bundle

  • Snapshot enrichment: extend the snapshot capture so policy_params includes the full bundle (thresholds + pay_periods + SE settings). All new determinations carry it. As-built: policy_params serializes a typed SnapPolicyBundle (the 15 thresholds stay flat + identically named so the derivation-graph Param resolution is unchanged); PayPeriods was promoted into canopy-contracts-snap so it can ride the bundle, re-exported from the service. SnapParameters::policy_bundle() / ::from_policy_bundle() convert (the latter nulls certification/renewal months — a dry-run computes no dates).

  • Extract evaluate_verdict from determine.rs (the verdict-computation region: SE pre-processing → assemble rules_input → alien pre-check → main eval → parse). It begins after create_snap_application and ends before the DEK-mint/sign/persist tail; keep app-create + antecedent validation in determine() only. As-built: evaluate_verdict is a thin coordinator over build_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/O serde_json::Value sites carry // STRUCTURAL-VALUE).

    • As-built: parameterized by &SnapParameters (live for determine, rebuilt from the frozen SnapPolicyBundle for the dry-run — so policy is read ONLY from the passed table, never live globals) plus a single VerdictMode { 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=false through all three rules callsse_deduction::compute, alien_eligibility::evaluate, and the main eval — via VerdictMode, so every call replays the pinned corpus and writes no audit row. (se_deduction + alien_eligibility gained the mode param; the dry-run passes a synthetic SnapApplicationId as the rules audit context_id, moot under audit=false.)

  • Endpoint POST /v1/determine/dry-run: body = DryRunDetermineRequest { context, policy_bundle, corpus_hash }; returns DryRunOutcome. Mints no DEK, seals nothing, signs nothing, persists nothing, emits nothing (and via audit=false, no rules-audit rows). As-built: no as_of on the snap-level request — the orchestrator (MR4, Decision J) reads facts as-of the change’s effective date and assembles the ApplicationContext, so snap’s verdict pipeline needs no date. RulesClient::evaluate_pinned now maps a pinned-corpus 422 to a typed UnprocessableEntity so the unknown-corpus case surfaces as 422 (CorpusUnavailable), not a 500 (the MR4 caller degrades to manual review).

  • Load-bearing tests: evaluate_verdict ≡ the full determine() (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 for policy_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 of determine_inner; the dry-run path skips the eligibility_requests slot, signature verification, program_determinations/combined_results persistence, and the outbox event.

  • Body { baseline_determination_id, household_id, as_of } (Decision J). Fetch the baseline verdict (status + benefit_amount, plaintext on snap_determinations) via GET /v1/determinations/{id} (not /v1/snap/…​paths::GET_DETERMINATION); fetch the pinned corpus_hash + the enriched policy_params bundle via GET /v1/determinations/{id}/snapshot. Verify the baseline determination belongs to the requested household (reject mismatch). Assemble the household context at the supplied as_of, dispatch the snap dry-run pinned to corpus + bundle with ?audit=false. Return DryRunResult.

  • Completeness check at fetch: inspect the baseline snapshot’s policy_params for the full-bundle keys; if any are missing (a pre-T2-7 snapshot) or the snapshot is null, return typed NoBaselineSnapshot. 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; no eligibility_requests row 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 HouseholdContext struct (replacing fetch_household_context’s 6-tuple return) + a shared `assemble_application_context — used by both determine_inner and dry_run. dry_run (+ resolve_baseline_replay + the fetch_baseline_read / fetch_baseline_snapshot / dispatch_snap_dry_run helpers) lives in orchestrator.rs; the eligibility ApplicationContext is adapted to canopy-snap’s ApplicationContext via a serde round-trip (the same boundary the live HTTP dispatch crosses), so no as_of is sent in the snap-level body (the facts are already read as-of). The "completeness check" is realized by deserializing policy_params into the typed SnapPolicyBundle (a pre-T2-7 partial bundle fails to deserialize → 422). The typed NoBaselineSnapshot / CorpusUnavailable are realized as ApiError::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. DryRunRequest carries no program field, so SNAP-only is enforced by construction (the baseline is a canopy-snap determination); a non-SNAP / unknown baseline_determination_id is 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-eligibility gained deps on canopy-contracts-snap + canopy-contracts-rules.

MR5 — renewals subscriber + materiality + notice

  • Subscriber: wire a renewals event subscriber in main.rs (the canopy-security subscribe_* pattern; durable queue e.g. canopy-renewals.materiality) on income.claimed/asset.claimed/expense.claimed/household.member_claimed. Use EventEnvelope.id (UUID v7) as the recert_nudges.source_event_id. Handler: derive the household — household.member_claimed carries household_id directly; income/asset/expense carry only person_id, so resolve person→household via a lightweight persons GET /v1/persons/{id}. Read the change’s valid_from from the event as the dry-run as_of (Decision J; skip retroactive valid_from < baseline as_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) + config CANOPY_RENEWALSELIGIBILITY_URL + CANOPY_RENEWALSOIDC_SERVICE_CLIENT_ID/SECRET (note: OIDC_SERVICE_CLIENT_ID, not OIDC_CLIENT_ID). New EligibilityClient (mirror canopy-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] via PolicyParams); insert a recert_nudges row (idempotent on (certification_id, source_event_id)). On is_material, emit renewal.material_change carrying household_id + person_id + benefit/delta/change fields.

  • Notice: new entry in rulesets/georgia/notices/manifest.toml + a new SNAP Typst template; add every template #inputs.<key> to default_program_data and to the entry’s program_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 < baseline as_of → no-op (manual review); the ChangeInCircumstancesNotice routes 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 .typ template, services/canopy-notices route-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 (loud warn!) and the rest of canopy-renewals still serves. The canopy-renewals Keycloak 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 (not api/mod.rs — MR5 adds no HTTP endpoint; the worker-facing nudge surface is MR6).

    • Retroactive guard uses certification_start_date as the baseline-as_of proxy (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_id stays NULL at insert. The ChangeInCircumstancesNotice is generated asynchronously by canopy-notices after consuming renewal.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 via program_data, alongside the semantic change_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_nudges idempotency constraint, the renewal.material_changechange-in-circumstances routing, 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_nudges rows) in the case-detail renewals tab with worker actions (file recert / dismiss) POSTing to renewals; reuse the existing banner affordance in canopy-web/src/api/actions.rs.

  • CLI (ADR-007 parity — every new REST op gets a verb): canopy eligibility determine dry-run and canopy renewals nudge {list,action}. No ADR-007 amendment.

  • E2E: gated Playwright — author a material income change → nudge appears → file recert → ChangeInCircumstancesNotice produced; 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_nudges table + 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) and POST /v1/renewals/snap/nudges/{id}/action (record a filed_recert/dismissed decision). The action is guarded WHERE 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 the action_by actor, forwarded by the BFF in the request body (the endpoint is service-caller-gated; the actor-in-body pattern matches DetermineRequest.requested_by). Renewals OpenAPI path count 11 → 13.

  • CLI: canopy eligibility dry-run, a sibling of determine (not determine dry-run). ADR-007 documents eligibility determine as a leaf verb, and clap cannot make one verb both a leaf and a subcommand group — so nesting dry-run under determine would restructure the existing eligibility determine surface and require an ADR-007 amendment. The sibling verb keeps the ADR-007 surface byte-stable with no amendment (the plan’s determine dry-run spelling was pre-implementation). renewals nudge {list,action} lands as planned.

  • Web: a dedicated case-detail-renewals composition 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 as action_by. "Reuse the banner affordance" is realized as the post-action ?focus_section=renewals redirect (the established worker-action redirect pattern); the forms carry the standard _csrf hidden field + program-scope gate. data-recert-nudge / data-nudge-action hooks 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.rs unit + 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 at canopy_snap because its TRUNCATE … CASCADE cascades into the append-only determination_snapshots (the ADR-028 #678 guard), blocking all demo-profile E2E project-wide. Fixed at the architecturally-correct root — reset_tables now 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 a fix(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 new serde_json::Value is 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):

  1. 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).

  2. Corpus pin must thread all three rules callsse_deduction, alien_eligibility, and the main eval; pinning only the main call leaves SE/alien on the live corpus (Decision C).

  3. Corpus-version startup race — persist the corpus row before the router serves traffic (Decision B).

  4. parking_lot guard across .await — clone the Arc<DecisionEngine> out and drop the guard before spawn_pinned (MR2).

  5. 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):

  1. Denial-NULL benefit — verdict-change first; benefit-delta only when both approved (Decision F).

  2. Notice payload completenessrenewal.material_change must carry household_id and person_id or the notices subscriber drops it; new template keys must be in default_program_data + program_data_keys (Decision H).

  3. Notice advance-notice floor — exempt the informational nudge notice from the 10-day adverse-action floor (MR5).

  4. Nudge/notice noise on rapid edits — one nudge+notice per material fact-change event; coalescing is a filed follow-up.

Legacy / config:

  1. Pre-T2-7 / incomplete baselines — typed NoBaselineSnapshot/CorpusUnavailable → renewals "manual review", never a 500 (MR4).

  2. Renewals outbound authOIDC_SERVICE_CLIENT_ID/SECRET (not OIDC_CLIENT_ID) + capture boot.service_token_source (MR5).

  3. OpenAPI path-count tests — bump snap 22→23 (MR3) and eligibility 7→8 (MR4); rules == 4 unchanged (query params only).

  4. Threshold citation — the materiality amount is operational, not 7 CFR 273.12(a)(5); authority = "operational" + SME confirmation (#921, MR1).

  5. Event→household asymmetry — income/asset/expense .claimed carry only person_id; the subscriber resolves person→household via persons (member events carry household_id directly) (MR5).

  6. 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_cents amount + its citation (#921).

Edit this page · default