canopy-renewals API Reference
On this page
Overview
Cross-link: canopy-renewals Data Model (#419)
Manages SNAP certification periods, interim contacts at certification midpoints, change reports during certification, and the background renewal scheduler that publishes due-date events.
- Base URL
- Authentication
-
Bearer token (Keycloak RS256 JWT)
- Minimum role
-
caseworker
- Swagger UI
- Database
-
canopy_renewals
Receiver contract (OIDC S-renewals, #1436 / ADR-043 §C)
canopy-renewals is the twelfth service on the ADR-043 receiver
contract (canopy_auth::ReceiverContract) — a TERMINAL exchange
target with ZERO user-only routes (the enforce flag is inert, set for
fleet consistency). The renewals specifics:
-
require_service_or_exchanged(CASEWORKER_OR_ABOVE_ROLES)on the six web-driven worker writes (certification create, snap + program interim-contact and change-report, nudge action) — the BFF sends the worker’s exchanged bearer (fail-on-denied, #1560 dispatch); a direct worker bearer stays 403. -
Everything else stays service-only (FU-B / ADR-023 D4): the machine surfaces (universe snapshots, scheduler run, rollup refresh, the periodic-report pipeline commands, redetermination action) and every SSR read.
-
Body-field attribution (
action_by,actor) is unchanged this slice — the survey flags stand; the exchanged bearer now carries the verified worker identity in-token on the widened routes for a follow-on to consume. -
Azp allowlist:
canopy-web-exchangeronly.
Endpoints
POST /v1/renewals/snap/certifications
Create a certification period.
Request:
{
"household_id": "uuid",
"application_id": "uuid",
"determination_id": "uuid",
"certification_type": "standard",
"certification_start_date": "2026-04-01",
"certification_end_date": "2026-09-30"
}
certification_type is the caller-DECLARED MT-87 category (#956 — the
server persists it verbatim and never infers it from the period length):
standard (6 months), elderly_disabled (12 months, no earned income),
senior (36 months), abawd (4 months) per PAMMS 3105 Chart 3105.1. An
out-of-vocabulary value is refused at deserialization (422). The
interim-contact due date is server-derived (declared standard type
only); it is not a request field.
Response (201): SnapCertification object.
GET /v1/renewals/snap/certifications
Get the active certification for a household.
Query parameters: household_id (required)
GET /v1/renewals/snap/due
List active certifications, keyset-paginated (#1204). Two scopes share this endpoint:
-
Due-window scope (default, interactive): certifications whose
certification_end_datefalls within[as_of, as_of + days], ordered soonest-expiring first (certification_end_date ASC, id ASC). -
Federal-universe scope (
active_onset): certifications in force on that date (certification_start_date ⇐ active_on ⇐ certification_end_date) — the SNAP QC / FNS-388 caseload universe. Whenactive_onis present,daysis ignored.
Query parameters:
-
limit— rows per page; clamped server-side to1..=200(default 50). -
after_end_date+after_id— the keyset cursor (pass the previous page’snext_cursorfields together; omit both for the first page). A lone one is ignored and the first page is served. -
days(default:shared.timing.renewals_api_default_lookahead_days, currently 90) — due-window width; clamped server-side to1..=366. Ignored whenactive_onis set. -
as_of(optional, ADR-033 tier-2 override) — anchor date for the due window, defaulting to the gated clock’s today when omitted. -
active_on(optional) — federal-universe scope anchor; takes precedence overdays.
Response (200): a SnapCertificationPage envelope — items (this page’s SnapCertification rows), next_cursor ({after_end_date, after_id}, present only when the page was exactly limit rows), and total_in_scope (the authoritative COUNT(*) of the requested scope, populated only on the first page). There is no unbounded single-array mode: at GA caseload scale the old ?days=1464 full-caseload dump (~450–500MB) could not survive the reporting client’s 30s whole-body timeout (#1204, scale audit C2). canopy-reporting page-loops the active_on scope to exhaustion and asserts extracted == total_in_scope (fail-closed completeness tripwire, #1042).
POST /v1/renewals/snap/universe-snapshots
Freeze an immutable federal-universe snapshot generation (#1470; ADR-002
Amendment 1 D5 — the #1213 bulk-cohort source). Materializes, server-side in
ONE transaction (one MVCC snapshot), every certification in force on
active_on (same scope predicate as GET /snap/due?active_on=), then
commits the authoritative row_count with the rows. Unlike a paged scan of
the live table, the frozen generation is immune to churn: paging its rows to
exhaustion returns exactly row_count members, always.
Minimum role: service-caller
Request: { "active_on": "2026-10-01" }
Response (201): UniverseSnapshot — { id, active_on, row_count, created_at }.
GET /v1/renewals/snap/universe-snapshots/{id}/rows
Keyset page over a frozen generation (#1470). Rows carry
{ seq, certification_period_id, household_id, application_id, determination_id }
— determination_id is the certification’s establishing determination, the
supersession baseline the bulk core stamps per case. seq is dense
1..row_count, so ?after=<seq> paging can neither skip nor duplicate a
member. 404 for an unknown generation (never an empty page). Generations are
reaped after CANOPY_RENEWALS__UNIVERSE_SNAPSHOT_RETENTION_DAYS (default 30).
Minimum role: service-caller
Response (200): UniverseSnapshotRowsPage — items + next_cursor (absent on the final page).
GET /v1/renewals/snap/interim-contacts/due
List certifications with overdue interim contacts.
Query parameters: as_of (optional, ADR-033 tier-2 override) — anchor date for the scan, defaulting to the gated clock’s today when omitted.
GET /v1/renewals/overdue
Cross-program "Overdue cases" panel feed (#520). Phase 1 returns SNAP rows whose certification has expired but is still flagged active — the actionable cases for a worker right now. TANF / Medicaid / CAPS / WIC need per-program due-date endpoints before they can join the aggregator (Phase 2 follow-up); the panel wire shape (program field) survives unchanged.
Query parameters: as_of (optional, ADR-033 tier-2 override) — anchor date for the overdue scan (and the days_overdue arithmetic), defaulting to the gated clock’s today when omitted.
Response (200): array of OverdueCase objects. Each row carries household_id, program, action_type (distinguishes "renewal certification expired" from "interim contact overdue"), due_date, and days_overdue — render-ready for the worker portal panel.
GET /v1/renewals/caseload-trend
Supervisor/analyst dashboard caseload-depth trend (#702). Returns the count of distinct households whose SNAP certification was in force at the close of each day/week bucket over the look-back window, zero-filled so the series is continuous within materialized coverage (a bucket with no active certs reads count: 0, never omitted). Service-caller only (ADR-019).
Since #1218 (scale audit H11) the series is served from the snap_caseload_daily rollup — render cost is O(buckets), independent of caseload size (the pre-#1218 per-render 12-bucket × whole-caseload aggregate exceeded the panel’s 5s budget at GA scale, and each Retry stacked another). The semantics are unchanged: the rollup is the SAME lossless interval reconstruction (a cert counts at bucket-end W iff certification_start_date ⇐ W ⇐ certification_end_date and terminated_at is null or after W; COUNT(DISTINCT household_id) dedups recertification overlap), materialized daily over the full serving window ([anchor−735d, anchor+8d]) by the canopy-renewals.caseload-rollup window-fenced job — so retroactive mutations (a Chart 3730.1 reopen clearing terminated_at, a backdated certification insert) self-heal within the freshness contract. One clock governs everything: the handler’s single gated-clock reading bounds the window, ends the series spine, and anchors the freshness check.
Honest 503 contract: absent coverage (first boot before the immediate probe materializes; coverage-exceeding eval dates after a long scheduler outage) or a generation older than 48 whole hours returns 503 Service Unavailable + Retry-After: 60 — never a fabricated zero-series ("caseload collapsed") and never a beyond-contract stale 200. The tail bucket is at most ~24h stale in normal operation; POST /v1/renewals/caseload-rollup/refresh (below) refreshes on demand.
Query params: window (<n>d / <n>w, default 12w, clamped ≤ 366 days / ≤ 104 weeks), bucket (day | week, default week; any other value → 422), program (snap only in this slice; any other value → 422).
Response (200): CaseloadTrend — { buckets: [{ bucket_start, count }], bucket }, oldest bucket first. (503): RFC 9457 problem body + Retry-After.
Honest scope: standard SNAP certification depth only. It excludes Transitional SNAP (snap_tsnap_certifications, owned by canopy-snap) and all non-SNAP programs; the cross-program roll-up is the deferred canopy-reporting HTTP-aggregation (#728, ADR-001). It is not application inflow (that is canopy-applications' distinct GET /v1/applications/caseload-trend, #718).
POST /v1/renewals/caseload-rollup/refresh
Recompute the snap_caseload_daily serving window NOW (#1218 R4) — the manual twin of the daily fenced job, for operators (e.g. after a mass reinstate), journeys, and tests (devstack seeds land after boot). Service-caller only; deliberately NO caller-supplied date (a trigger mutates; scan dates are never a caller knob). Runs under the rollup job’s advisory lock and, on success, consumes today’s fence window so the background probe doesn’t repeat the sweep. ADR-007 CLI parity: canopy renewal caseload-rollup-refresh.
Response (200): { skipped: false, rows_refreshed } — the refresh ran (rows = the full coverage width). (202): { skipped: true, rows_refreshed: null } — another caller held the advisory lock; a refresh is running elsewhere.
POST /v1/renewals/snap/certifications/{id}/interim-contact
Record an interim contact.
Request:
{
"contact_method": "phone",
"notes": "Confirmed household composition unchanged."
}
Contact methods: phone, mail, in_person.
Regulatory basis: 7 CFR 273.12(a)(1)(ii) — interim contact at certification midpoint.
POST /v1/renewals/snap/certifications/{id}/change-report
Record a change in circumstances during certification.
Request:
{
"change_type": "income_change",
"report_method": "phone",
"description": "New employment started",
"reported_monthly_income": 2800.00,
"household_size": 3
}
Change types: income_change, household_change. If reported income exceeds the 130% FPL gross income limit (loaded from rules engine), the case is flagged for redetermination.
GET /v1/renewals/snap/nudges (T2-7 #680)
List the recert nudges the materiality subscriber recorded for a household, newest first. Backs the case-detail renewals section (canopy-web) and canopy renewals nudge list.
Query params: household_id (required, UUID); pending_only (optional, default false) — when true, returns only the material, not-yet-actioned nudges (the actionable worker queue).
Returns 200 with an array of RecertNudge (id, certification_id, household_id, source_person_id, triggering_fact_kind, baseline_status/baseline_benefit_cents, dry_run_status/dry_run_benefit_cents, benefit_delta_cents, is_material, action_taken/action_by/action_at, created_at).
POST /v1/renewals/snap/nudges/{id}/action (T2-7 #680)
Record a worker’s file-recert / dismiss decision on a pending material nudge. Backs the case-detail file-recert / dismiss buttons (via the canopy-web /actions/renewals/* handlers) and canopy renewals nudge action.
Request:
{
"action": "filed_recert",
"action_by": "9b1f…-worker-uuid"
}
action ∈ filed_recert | dismissed; action_by is the acting worker’s subject UUID (forwarded by the BFF — the endpoint is service-caller-gated). The update is guarded WHERE is_material AND action_taken IS NULL, so an unknown id, an immaterial nudge, or an already-actioned one all return 404 (a re-action is a no-op — the first decision stands). Returns 200 with the updated RecertNudge. Filing records the worker’s intent only; provisioning the recert application is a tracked follow-up.
Periodic-report Cycles (#1106, epic &72 MR 5.1)
The PAMMS 3730 periodic-report state machine (MT-87, eff. June 2026; 7 CFR 273.12(a)(5)(iii)). Cycles are minted by the daily scheduler (no creation API — the calendar is system-driven): the P11 cohort scan materializes one scheduled generation-1 cycle per legacy extended certification, computing the 3730 calendar (15th-of-prior-month initial notice, 5th-of-due-month combined notice, month-end closure). Notice dispatch and closure enactment ride the 5.2 adverse-action pipeline; these endpoints record the worker-side workflow (3730 Steps 2-5). All are service-caller-gated; the transition STAMPS (form received, VCL sent, verified, processed) are server-set from the gated clock — never accepted from the wire — while the one caller-suppliable date, the VCL response deadline, is floor-validated to ≥10 calendar days (3730:218) and CHECK-backstopped. Out-of-order commands return 409 naming the cycle’s current status.
GET /v1/renewals/snap/periodic-reports
List a household’s cycles, oldest due month first.
Query parameters: household_id (required, UUID).
Response (200): array of SnapPeriodicReport.
POST /v1/renewals/snap/periodic-reports/{id}/form
Record form receipt (3730 Step 2). complete: false is the worker’s "No" on the Periodic Report Details page — a form not completed in its entirety is NOT filed (3730:35), the cycle moves to form_incomplete, and it stays eligible for the 5th-of-month combined notice. complete: true moves it to form_complete; a complete form can never be un-filed (later complete: false → 409).
Request:
{ "form_kind": "528", "complete": true }
form_kind must be in the jurisdiction’s [snap.periodic_reporting] accepted_form_kinds vocabulary (["528", "297", "508"] for Georgia — 3730:110-117; anything else → 422 naming the configured list; #1165). Legal from scheduled / notice_sent / form_incomplete.
POST /v1/renewals/snap/periodic-reports/{id}/vcl
Record a verification checklist sent (3730 Step 4, driven by reported changes per 3730:43-52 or discrepancies per 3730:56). The sent date is server-stamped; due_date defaults to sent + vcl_response_days and anything allowing fewer than 10 calendar days is 422 (3730:218; a DB CHECK backstops it). Legal only from form_complete — verification is decided after a complete form is examined (3730:41).
Request:
{ "reason": "reported_change", "detail": "wage change on the 528", "due_date": null }
reason ∈ reported_change | discrepancy.
POST /v1/renewals/snap/periodic-reports/{id}/verified
Record that all required verification arrived and discrepancies are resolved (3730 Step 4 → 5). Legal only from vcl_pending. No body.
POST /v1/renewals/snap/periodic-reports/{id}/complete
Process the cycle (3730 Step 5) in ONE locked transaction: the certification row lock (serializes against recertification), the processed stamp, one change-report row per reported change (the 130%-FPL redetermination flag decided in the INSERT), and the renewal.snap_periodic_report_processed outbox event commit or roll back together. Legal from form_complete (no verification required, 3730:41) or verified. Changes recorded here are effective the first month of the new reporting period (3730:62); completion does NOT assign a new certification period (3730:161, :69-70). An empty changes list is the no-changes path.
Request:
{ "changes": [ { "change_type": "income_change", "report_method": "mail", "description": "new wages", "reported_monthly_income": 2800.00, "household_size": 3 } ] }
Response (200): the processed cycle. 409 when the cycle is not completable or its certification is no longer active (a superseding recertification cancels open cycles in its own transaction — the late completion loses cleanly).
POST /v1/renewals/snap/periodic-reports/{id}/reopen
Reopen a TERMINATED cycle when the missing form/verification arrives within 30 days after the due month (#1108, epic &72 MR 5.3 — Chart 3730.1 rows 4/5). Cross-service in converge-on-retry order: validate → canopy-enrollment’s idempotent per-action reopen (POST /v1/adverse-actions/{id}/reopen — un-terminates the enrollment, bumps lifecycle_revision, records the receipt) → one local tx under the certification lock (cycle rejoins the state machine, certification reinstates terminated → active with terminated_at cleared). A crash between the enrollment call and the local tx converges when the SAME request is retried (the cycle is still terminated, the enrollment replay no-ops); a retry with a DIFFERENT received_date is refused 409 naming the recorded date — the enrollment’s stored receipt is the proration anchor and a divergent local stamp would silently split them.
The ARM is derived from the row, never claimed: a cycle that reached the VCL (vcl_sent_date set) died on verification — the receipt is the VERIFICATION (row 4; cycle → verified; SOP = receipt + reopen_verification_sop_workdays workdays; form_kind must be omitted, 422 otherwise); any other terminated cycle is a nonfiler — the receipt IS the form (row 5; cycle → form_complete; SOP = receipt + reopen_late_filing_sop_days calendar days, expedited N/A; form_kind required, from the same accepted_form_kinds vocabulary, and complete per 3730:35). reopened_date/sop_due_date stamp the row; terminated_date survives as history. Benefits prorate from the receipt date on the enrollment side (the receipt-month issuance uses the anchor; missed intervening months are NOT restored — #1113); processing then flows through the NORMAL VCL/verify/complete machinery, whose completion event finds the enacted action already reopened and leaves it untouched.
Request:
{ "received_date": "2026-07-15", "form_kind": "528", "actor": "worker:jdoe" }
Response (200): the reopened cycle. 400 — receipt in the future or before the due month. 409 — cycle not terminated, the window elapsed (receipt > due-month-end + reopen_window_days → re-application), canopy-enrollment refused (not enacted / successor enrollment / wrong source), or the household re-applied (the one-active-cert partial unique refuses the reinstate). 422 — arm-inconsistent form_kind.
Re-determination Worker Queue (#1107, epic &72 MR 5.2)
When a periodic-report termination’s adverse action dies on the household’s rights (appeal veto) or a human choice (worker cancel), the cycle’s eligibility question routes HERE for a worker decision — never an automatic re-trigger. Entries are minted by the adverse-action terminal consumer (idempotent on the dead action id); the cycle is simultaneously stamped cancelled with a *_pending_redetermination reason. Cancels carrying reason = periodic_report_completed (the tombstone consumer’s benign supersession) never reach this queue.
GET /v1/renewals/snap/redeterminations
Query parameters: household_id (required), pending_only (default false — true narrows to the actionable queue).
Response (200): array of PrRedetermination (certification_id, periodic_report_id, generation, adverse_action_id, trigger_kind ∈ action_vetoed/action_cancelled, action_taken/action_by/action_at).
POST /v1/renewals/snap/redeterminations/{id}/action
Request: { "action": "redetermined" | "dismissed", "action_by": "<worker uuid>" } — guarded WHERE action_taken IS NULL; a double-submit, unknown id, or already-actioned entry collapses to 404 (the recert-nudge shape). Recording redetermined captures intent only, like the nudge filed_recert.
Scheduler Trigger (#1109, epic &72 MR 6.1)
POST /v1/renewals/scheduler/run
Run one daily-check pass NOW — the renewals face of enrollment’s enact-sweep trigger (#1102). One pass = cycle materialization (#1106) + the two 3730 drains below + the due-list gauges, every stage drained to exhaustion in keyset pages within the CANOPY_RENEWALS__PR_TICK_BUDGET_SECS time budget (#1210 — the pre-#1210 pass capped each stage at one 500-row page), under the scheduler’s advisory lock (the background loop — hourly probes under the #1211 once-per-UTC-day window fence — and this trigger serialize; 202 + skipped: true when another replica holds it, 200 + the SchedulerCheckView counters when this call ran the pass). Gauges are true COUNT(*) magnitudes, and the materializer’s tombstones discriminate calendar_elapsed_at_cutover (due month already gone when the pipeline first ran) from materializer_behind (pr_cycles_missed_behind — the pipeline’s own lateness, ERROR-logged). Runs on the service’s gated clock — deliberately NO caller-supplied date on the wire: a trigger MUTATES, so scan dates are never a caller knob (ADR-033 tier-2 as_of is a read-path affordance). Journeys pair this with the /test/clock fleet advance (interval loops do not re-fire on a logical advance). Service-caller only.
The Two-Notice Calendar Drains (#1107)
The daily scheduler drains the two 3730 notice triggers (keyset pages until empty within the #1210 tick budget — a WARN-skipped cycle is cursor-passed within the tick and retried next tick, so a skip-buried page head can never starve the tail; both require the ADR-019 service identity — without it the drains skip and the due-list gauges keep the backlog visible):
-
15th-of-prior-month (3730:83): stages
renewal.snap_periodic_report_due(routed by canopy-notices to the informationalpr-dueletter) and moves the cyclescheduled → notice_sentin ONE tx — exactly-once by construction. The recipient is the live enrollment’s head of household, resolved over HTTP outside the tx. -
5th-of-due-month (3730:98-108): only for cycles whose 15th notice was actually sent (
notice_sent/form_incomplete; #1210 — the combined notice IS the termination notice per 3730:100, and it must never be a household’s first-ever notice; a still-scheduledcycle past the 5th stays in the initial-notice list, gets its 15th letter late, and qualifies here afterward): creates the termination adverse action in canopy-enrollment under theperiodic_reportprovenance triple (idempotent — a crash between the schedule call and the cycle stamp converges next tick), exempt-shaped (adequate notice;cb_available=falseper 3730:37), effective date = last workday at/before month-end per the[jurisdiction.holidays]workday calendar (#1158 — observed holidays insidecoverage_years, weekend-only floor beyond; a late drain recovers with today). The routed COMBINED notice becomes the action’s dispatched evidence, and enrollment’s hourly enact sweep is the month-end closure executor — closure needs no renewals-side code. The cycle records the action id andcombined_notice_sent_date(which removes it from the due list); its STATUS is untouched so a late form still walks the state machine (Chart 3730.1 rows 3-4). -
Lapsed VCL (#1128; Chart 3730.1 row 2): for a cycle whose complete form was filed but whose verification checklist lapsed unanswered,
drain_vcl_closuresschedules the exempt termination — the GENERICfailure_to_provide_verificationreason under the SAMEperiodic_reportprovenance triple (the reason is generic; the PR context is provenance; enrollment’s P4 source override keeps CB off per 3730:37’s "…and provide any required verification"), effective = last workday at/before month-end. canopy-notices fans the letter WITHIN the provenance byreason_code: the newpr-vcl-terminationadequate termination notice ("thank you for filing; the verification was not provided"), never the nonfiler combined letter. When the 5th already minted a still-live nonfiler action for the cycle, the triple replay ADOPTS it — one termination, same month-end date; the combined letter’s continuation condition already noticed the verification ground ("provide any verification the agency requests before the closure date"), and 3730:108 bars a further termination notice after the combined letter, so the adopted action keeps its nonfiler reason and letter as the notice of record (documented residual — re-noticing would violate the no-further-notice rule). The stamp (vcl_termination_triggered_date+ action id) removes the cycle from the closure due set; STATUS staysvcl_pendingso a late verification still cures throughverified → processed, whose completion tombstone cancels the in-flight action. Telemetry:pr_vcl_terminationsonSchedulerCheckView.
Program-parameterized Endpoints (#448)
Multi-program counterparts to the SNAP routes above. Backs the worker-portal #392 BFF action handlers record_interim_contact_{tanf,medicaid,caps,wic} and submit_change_report_{tanf,medicaid,caps,wic}. Non-SNAP programs own their certification lifecycle in their own service per ADR-001; canopy-renewals just records the change-report event with a household_id reference for cross-service auditing.
The path-param {program} accepts one of: tanf / medicaid / caps / wic. Passing snap returns 400 — SNAP traffic must use the literal-path routes above so the existing cert-row update side-effect still fires.
POST /v1/renewals/{program}/certifications/{id}/interim-contact
Minimum role: service-class caller.
Request:
{
"household_id": "uuid",
"contact_method": "phone",
"notes": "Confirmed continued enrollment"
}
Response (201): the inserted SnapChangeReport row (table reused; program column discriminates).
POST /v1/renewals/{program}/certifications/{id}/change-report
Minimum role: service-class caller.
Request:
{
"household_id": "uuid",
"change_type": "household_change",
"report_method": "phone",
"description": "Added 1 child"
}
Response (201): the inserted SnapChangeReport row.
GET /v1/renewals/{program}/due
Worker-portal MyQueue per-program due feed (plan: worker-intake-program-independence MR3). Accepts any canopy_reference::Program slug. Phase 1 returns the SNAP page when program=snap (same data + keyset envelope as /v1/renewals/snap/due) and an empty, complete page (items: [], next_cursor: null, total_in_scope: 0) for every other program — those services don’t yet expose per-program due-date stores. Returns 422 for unknown program slugs so a typo surfaces rather than silently empty-resulting.
Query parameters: same keyset + scope params as GET /v1/renewals/snap/due (limit, after_end_date, after_id, days, as_of, active_on).
Response (200): a SnapCertificationPage envelope (see GET /v1/renewals/snap/due).
Minimum role: caseworker.
Response (200): array of SnapCertification objects.
Response (422): unknown program slug.
Error Codes
| Code | Meaning |
|---|---|
400 |
Invalid certification type, missing household_id; on program-parameterized routes: unknown program slug or literal |
401 |
Missing or invalid JWT |
403 |
Insufficient role |
404 |
Certification not found / no active certification |
409 |
Duplicate active certification for household |
422 |
Unknown program slug on |
Events Published
-
certification.created,certification.renewal_due,certification.expired -
interim_contact.due,interim_contact.recorded -
change_report.created,change_report.fpl_exceeded -
renewal.material_change(T2-7 #680) — emitted when a mid-cert reported change is material; carrieshousehold_id+person_id(the notices recipient gate) +change_type/change_reasons/previous_benefit_amount/new_benefit_amount(presentation, forwarded to theChangeInCircumstancesNoticetemplate) + the rawbaseline_benefit_cents/dry_run_benefit_cents/benefit_delta_cents. No PII (ADR-004). -
renewal.snap_periodic_report_due(#1107) — the 15th-of-prior-month notice trigger (typedRenewalPeriodicReportDueV1: cycle/certification/household/person ids, generation, due month, the 5th-of-monthfile_by_date, the month-endclosure_date). Deliberately NOT action-bound. Routed by the notices manifest to thepr-dueletter. -
renewal.snap_periodic_report_processed(#1106) — staged in the periodic-report completion transaction (typedRenewalPeriodicReportProcessedV1:schema_version,periodic_report_id,certification_id,household_id,generation,due_month,processed_date; IDs/dates only per ADR-004). Consumed from plan MR 5.2 by canopy-enrollment’s per-(certification, generation)completion tombstone (the completion-before-trigger no-op); until then the key is registered inxtask/mq-topology-allow.toml.
Adverse-action terminal consumer (#1107, epic &72 MR 5.2)
Queue canopy-renewals.adverse-actions binds enrollment.adverse_action_{terminated,vetoed,cancelled}. Non-periodic_report sources are acked untouched. On the inbox transaction:
-
terminated→snap_certifications.status = terminated+terminated_at(through the SINGLE status setter, executor-generic since #1107) and the cycle →terminatedwith the NOTICED legal date. A cycle alreadyprocessed/cancelledis left alone (WARN — never an overwrite). -
vetoed, andcancelledwith any reason other thanperiodic_report_completed→ the cycle →cancelled(*_pending_redetermination) + one idempotentpr_redeterminationsentry. -
cancelledwithreason = periodic_report_completed→ ack no-op (the tombstone consumer superseded the action because the household completed; the cycle is alreadyprocessed).
Registered unconditionally — deliberately NOT behind the OIDC gate the materiality subscriber sits behind (it needs no HTTP clients).
Materiality subscriber (T2-7 #680, ADR-027 §6)
canopy-renewals owns the SNAP certification, so it reacts to the program-agnostic
canopy-persons fact-change events (income.claimed, asset.claimed,
expense.claimed, household.member_claimed) on the durable
canopy-renewals.materiality queue. For each change to a household with an
active certification, it runs a non-persisting materiality dry-run
(POST /v1/eligibility/determine/dry-run) against the determination-of-record’s
frozen policy + pinned corpus, records the verdict diff in recert_nudges
(idempotent on the fact-change event id, Decision G), and — when the change is
material (Decision F: a verdict flip, or both-approved with a benefit delta
>= the [snap.materiality] benefit_delta_threshold_cents threshold) — emits
renewal.material_change for the ChangeInCircumstancesNotice (Decision H).
Decisions / degradation:
-
Household resolution — membership events carry
household_id; income/asset/expense events carry onlyperson_id, so the subscriber resolves person → household viaGET /v1/persons/{id}as-of the change date. -
as_of= the change’s effective date (valid_from, Decision J), so a forward-effective change still fires at authoring time. A retroactive correction (valid_frombefore the cert start) is treated as manual review (no nudge). -
No active certification → no-op (covers the initial-application case, which fires fact events before any cert exists).
-
Degrade, never a 500 — a dry-run that returns 4xx (cross-household / legacy / incomplete baseline / unknown corpus or determination) is logged as manual review and produces no nudge; a transient failure (eligibility/persons unreachable) is retried via the inbox.
-
Outbound auth (ADR-019) — the dry-run carries canopy-renewals' service identity (
CANOPY_RENEWALSOIDC_SERVICE_CLIENT_ID/SECRET, base URLs…ELIGIBILITY_URL/…__PERSONS_URL). Without OIDC creds the subscriber is not registered (graceful degradation) and the rest of canopy-renewals still serves. -
Worker surface (MR6) — the nudge is never automatic. Pending material nudges surface to the worker via
GET /v1/renewals/snap/nudges?pending_only=true(the case-detail renewals section +canopy renewals nudge list), and the worker files or dismisses one viaPOST /v1/renewals/snap/nudges/{id}/action(the case-detail buttons +canopy renewals nudge action).