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

http://localhost:8007/v1

Authentication

Bearer token (Keycloak RS256 JWT)

Minimum role

caseworker

Swagger UI

http://localhost:8007/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-exchanger only.

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/certifications/{id}

Get a certification by ID.

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_date falls within [as_of, as_of + days], ordered soonest-expiring first (certification_end_date ASC, id ASC).

  • Federal-universe scope (active_on set): certifications in force on that date (certification_start_date ⇐ active_on ⇐ certification_end_date) — the SNAP QC / FNS-388 caseload universe. When active_on is present, days is ignored.

Query parameters:

  • limit — rows per page; clamped server-side to 1..=200 (default 50).

  • after_end_date + after_id — the keyset cursor (pass the previous page’s next_cursor fields 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 to 1..=366. Ignored when active_on is 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 over days.

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): UniverseSnapshotRowsPageitems + 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"
}

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

GET /v1/renewals/snap/periodic-reports/{id}

Fetch one cycle. 404 if unknown.

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 }

reasonreported_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_kindaction_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 informational pr-due letter) and moves the cycle scheduled → notice_sent in 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-scheduled cycle 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 the periodic_report provenance triple (idempotent — a crash between the schedule call and the cycle stamp converges next tick), exempt-shaped (adequate notice; cb_available=false per 3730:37), effective date = last workday at/before month-end per the [jurisdiction.holidays] workday calendar (#1158 — observed holidays inside coverage_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 and combined_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_closures schedules the exempt termination — the GENERIC failure_to_provide_verification reason under the SAME periodic_report provenance 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 by reason_code: the new pr-vcl-termination adequate 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 stays vcl_pending so a late verification still cures through verified → processed, whose completion tombstone cancels the in-flight action. Telemetry: pr_vcl_terminations on SchedulerCheckView.

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 snap path (use the SNAP routes instead)

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 GET /v1/renewals/{program}/due

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; carries household_id + person_id (the notices recipient gate) + change_type / change_reasons / previous_benefit_amount / new_benefit_amount (presentation, forwarded to the ChangeInCircumstancesNotice template) + the raw baseline_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 (typed RenewalPeriodicReportDueV1: cycle/certification/household/person ids, generation, due month, the 5th-of-month file_by_date, the month-end closure_date). Deliberately NOT action-bound. Routed by the notices manifest to the pr-due letter.

  • renewal.snap_periodic_report_processed (#1106) — staged in the periodic-report completion transaction (typed RenewalPeriodicReportProcessedV1: 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 in xtask/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:

  • terminatedsnap_certifications.status = terminated + terminated_at (through the SINGLE status setter, executor-generic since #1107) and the cycle → terminated with the NOTICED legal date. A cycle already processed/cancelled is left alone (WARN — never an overwrite).

  • vetoed, and cancelled with any reason other than periodic_report_completed → the cycle → cancelled (*_pending_redetermination) + one idempotent pr_redeterminations entry.

  • cancelled with reason = periodic_report_completed → ack no-op (the tombstone consumer superseded the action because the household completed; the cycle is already processed).

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 only person_id, so the subscriber resolves person → household via GET /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_from before 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 via POST /v1/renewals/snap/nudges/{id}/action (the case-detail buttons + canopy renewals nudge action).

Edit this page · default