canopy-enrollment API Reference

On this page

Overview

Cross-link: canopy-enrollment Data Model (#419)

Manages the post-determination enrollment lifecycle: creating enrollment records, issuing benefits to EBT, tracking issuance history, and handling expungement (12 months unused per 7 USC §2016(h)(9)).

Base URL

http://localhost:8006/v1

Authentication

Bearer token (Keycloak RS256 JWT)

Minimum role

eligibility_specialist

Swagger UI

http://localhost:8006/swagger-ui

Database

canopy_enrollment

Receiver contract (OIDC S-enrollment, #1435 / ADR-043 §C)

canopy-enrollment is the eleventh service on the ADR-043 receiver contract (canopy_auth::ReceiverContract) — see the tanf API page for the bearer-shape and guard-family description — a TERMINAL exchange target with ZERO user-only routes. The enrollment specifics:

  • The #408 household gate runs on EffectiveUser: its worker-actor arms were unreachable pre-slice (no actor verifier — every issuance/annual-summary read passed). An exchanged worker now hits the live assignment check (deny audited; supervisor/admin bypass by design); service/system traffic (the portal BFF) keeps the pass-through arm.

  • require_service_or_exchanged on the #408-gated issuances read and the two worker-actioned adverse-action writes (schedule, cancel); the annual summary adds the #1441 portal arm on top (require_service_or_exchanged_or_portal on portal:enrollment:read — the portal’s ONE enrollment surface; issuances is portal-killed). The widening is what makes the gate reachable for worker bearers — the BFF sends the worker’s exchanged bearer; the cancel attribution string now carries the worker’s own sub. Stay/reopen (appeals nested hops, FU-B), enact-sweep, the batchGet aggregate, and the enrollments CRUD stay service-only (ADR-023 D4).

  • Azp allowlist: canopy-web-exchanger only.

Endpoints

POST /v1/enrollments

Create an enrollment record (typically auto-created after approval).

Request: CreateEnrollmentRequest

{
  "household_id": "uuid",
  "determination_id": "uuid",
  "application_id": "uuid",
  "certification_start_date": "2026-04-01",
  "certification_end_date": "2026-09-30",
  "monthly_issuance_amount": "535.00",
  "expedited": false,
  "application_date": "2026-03-15"
}

Response (201): SnapEnrollment object.

Errors: 409 Conflict when the household already has a live enrollment (pending_issuance/active/suspended) — the snap_enrollments_one_live_per_household partial unique (#1130). One live enrollment per household is structural; re-determination adjustment semantics are #1133.

GET /v1/enrollments

List enrollments. Filter by household_id.

GET /v1/enrollments/{id}

Get a single enrollment.

POST /v1/enrollments/{id}/issue

Issue benefits for a month (EBT provisioning).

Request: IssueBenefitsRequest

{
  "benefit_month": "2026-04-01"
}

Response (200): SnapBenefitIssuance with proration details if mid-month.

409 when the enrollment’s status is not pending_issuance/active (#1092): terminated/suspended/expired rows keep active = true (the soft-delete flag), so the lifecycle status is the issuance guard — a closed case can no longer draw allotments. pending_issuance stays allowed for the expedited first-issuance flow (the first issuance is what activates the enrollment).

Pending-issuance settlement (background, #1138). The auto-enroll subscriber creates the first issuance pending inside the inbox transaction (no adapter call there — a network round-trip inside the inbox tx is the #1091 anti-pattern); an in-service advisory-locked settlement pass (5 s tick) drives it: EBT adapter outside any transaction, then the lifecycle_revision-fenced mark + first-issuance activation in one short tx — the manual path’s exact sequence. The same pass is the missed-signal reconciliation sweep: a pending issuance on a terminated/suspended enrollment is deliberately NEVER auto-settled (the money may already have moved — #1095’s reconciliation surface) and is surfaced by the hourly aged-pending operator alert instead.

GET /v1/enrollments/{id}/issuances

List all benefit issuances for an enrollment.

GET /v1/households/{household_id}/issuances

List all benefit issuances for a household across all of its enrollments. Optional query parameters from=YYYY-MM and to=YYYY-MM (both required if either is set) restrict the window; include_all=true adds pending / failed / reversed issuances (default omits them so overpayment math sees only money the household actually received).

RBAC (#408 Pub 1075 AC-6): service bearer or exchanged worker bearer. When the request resolves a HUMAN (EffectiveUser, an exchanged worker — #1443 retired the actor header), they must either carry supervisor/admin role OR have an active household_assignments row in canopy-applications for this household. Bare service = system traffic, allowed unconditionally. Allow and deny paths both emit audit events (enrollment.household_issuance.read / .access_denied) which canopy-security persists via its wildcard subscriber.

POST /v1/households/issuances:batchGet

Get the issued-benefit aggregate for a set of households for ONE benefit month in one round-trip (#1203, D5 row 3) — one household_id = ANY($1) set query replacing the federal extracts' 2-hop list-enrollments-then-list-issuances walk per universe row. Capped at 500 IDs per request (422 on overflow); duplicates collapse to one entry; the response follows first-occurrence request order. Service callers only (§B4 bulk-read posture) — 403 for worker JWTs.

GET-OR-ZERO, exact-set: every requested UNIQUE id gets exactly one entry — a household with no issued allotments on file for the month (unknown ids included) ships issued_total: "0" / issuance_count: 0, an honest zero, never a dropped entry, so consumers assert exact id-set equality. benefit_month normalizes to its month start server-side (any day selects that whole month — the QC snapshot date is mid-month), applied as a half-open [month, month+1) range.

Deliberate semantics ruling ("issued is issued", #1203): there is NO enrollment-status predicate — an issued allotment under a now-suspended/ terminated/expired enrollment IS counted. Today’s FNS-388 sums only issuances reached through active/pending_issuance enrollments, a 2-hop walk artifact that understates issued benefits (QC’s walk never filtered); the cutover is named again in the MR5 CHANGELOG entry.

RBAC / audit posture: unlike the two #408-gated interactive reads above, this is a service-tier reporting surface with no worker-actor path — it emits zero enrollment.household_issuance.read/.access_denied events (the same unaudited posture as the service-tier enrollment list reads). The #408-gated endpoints and their gate are untouched.

Request: BatchHouseholdIssuancesRequest

{ "household_ids": ["uuid", "uuid"], "benefit_month": "2026-03-15" }

Response (200): Vec<HouseholdIssuedSummary>[{ household_id, issued_total (string Decimal), issuance_count }].

GET /v1/households/{household_id}/annual-summary

Home "Your year" recap (#719). Returns the total + per-month SNAP benefits issued to a household in a calendar year (?year=YYYY, defaults to the current year). Sums allotment_amount for the household’s issuance_status = 'issued' rows grouped by benefit_month within [year-01-01, (year+1)-01-01). Expunged issuances are included — the money was issued (matches the /issuances "issued" listing, which also doesn’t subtract expungements). Shares the {household_id} path with the issuance list (GET annual-summary vs GET issuances are distinct operations under sibling paths). The applicant-portal /home/state proxy calls this with the household derived from the session.

Response (200): AnnualBenefitSummary{ household_id, year, total_issued (string Decimal), months: [{ benefit_month, amount }] }. An unknown / no-issuance household is a 200 with total_issued: "0" and an empty months (never 404).

RBAC: as the issuance list PLUS the #1441 portal arm — require_service_or_exchanged_or_portal on portal:enrollment:read (the portal arrives citizen-class here; on issuances it is 403 portal_on_non_portal_route) — and since #1442 the portal arm requires the signed ownership claim binding this household; the same #408 least-privilege gate + read-audit applies (a resolved worker needs supervisor/admin or an assignment; a bare service or portal read is allowed and unaudited).

POST /v1/enrollments/{id}/terminate

410 Gone (#1095, epic &72): direct termination was removed. A termination is an ADVERSE ACTION — it requires a first-class action row (enrollment_pending_terminations), a dispatched adequate/advance notice, and a stay-free enactment window (7 CFR 273.13; PAMMS 3705); the bare status flip skipped every one of those legal protections. The route stays registered so callers get this explanation instead of a mute 404. Scheduling arrives with the MR 1.2 action API; guarded enactment with the MR 3.2 enact primitive. PAMMS 2415 partial-retention semantics move with it (the cutoff math stays unit-pinned in closure.rs).

Adverse Actions (#1096, epic &72)

The action surface canopy-appeals validates filings against (Phase 2) and canopy-web schedules through. All service-caller. Since #1102 (epic &72 MR 3.2) the schedule command STAGES enrollment.adverse_action_scheduled in its own transaction (producer ACTIVE — the notices consumer deployed at MR 3.1), and every legal DATE on the action spine derives in [jurisdiction].timezone, never UTC.

POST /v1/adverse-actions

Schedule an adverse action. The policy terms (advance-notice days from [notices], CB availability per P4, the jurisdiction@version policy string) are SNAPSHOTTED onto the row — later policy edits never govern an existing action. Default effective date = today (gated decision clock) + advance days; a caller-supplied date may only push LATER for non-exempt actions (earlier → 400). One OPEN action per enrollment (P8): a second schedule 409s. idempotency_key is the worker-source generation key — a retried schedule with the same key returns the SAME action (200; 201 on first creation). 422 when the enrollment has no head_of_household_person_id (pre-#1096 row awaiting the MR 1.3 backfill); 409 against non-terminable enrollments.

System-source provenance (#1107): the optional source claim (ActionSourceClaim { created_source, source_reference, source_generation }) creates the action under the claimant’s own idempotent triple instead of the worker/idempotency-key triple. periodic_report is the only vocabulary value: its source_reference must be the certification UUID and the handler refuses (409) when the (certification, generation) completion tombstone exists — the completion-before-trigger no-op, serialized against the tombstone consumer on a per-source advisory lock. periodic_report-sourced actions snapshot cb_available = false with a PAMMS 3730:37 cb_rule regardless of shape (P4: periodic-report failure is never CB-continuable).

GET /v1/adverse-actions and GET /v1/adverse-actions/{id}

Cursor-paginated list (after_id = last id of the previous page; UUID v7 time order; household_id scope; open=true for scheduled-only) and the single-action lookup a filed appeal must validate against. Both carry active_stays (links currently stayed) and the provenance triple.

POST /v1/adverse-actions/{id}/cancel

Cancels a scheduled action and moots its links ATOMICALLY — every stayed link releases in the same transaction, so no appeal is left staying a dead action. Idempotent (an already-cancelled action returns 200); enacted/vetoed actions 409.

PUT /v1/adverse-actions/{id}/stays/{appeal_id}

The synchronous fenced stay surface: {"command": "stay" | "restay" | "release" | "veto", "actor": …} → a StayReceipt (link_status, active_stays, action_status). built for canopy-appeals to call AT FILING TIME (Phase 2, MR 2.1): the filing flow persists the receipt BEFORE its continued-benefits grant commits, which is what closes the grant-vs-sweep race once that caller lands — the fence and receipt are established here. A NEW stay is refused (409) against a non-scheduled action, so cancel’s links-released invariant holds over time. Idempotent: replaying a terminal link status returns the stored state. A veto also moots a still-scheduled action (action_status: vetoed) — durable and order-independent (it lands even after a release). restay (#1132) is the EXPLICIT judicial re-stay — the one legal released → stayed transition (timely final appeal, Chart B3), idempotent on a stayed link, refused (409) on a vetoed/absent link or a non-scheduled action; the plain stay command keeps its monotonic refusal, so released → stayed happens only through this distinct, audited command (any service caller may send it — per-service command authz is #1008 territory).

GET /v1/adverse-actions/{id}/stays/{appeal_id}

Read one appeal’s link state, receipt-shaped (#1100, epic &72 MR 2.3): the per-appeal ground truth canopy-appeals' reconciliation scanner compares its persisted stay receipts against. Service-caller only; read-only (no signal appended). 404 when no link exists between the pair.

POST /v1/adverse-actions/{id}/reopen

The NARROW periodic-report reopen (#1108, epic &72 MR 5.3 — PAMMS 3730 Chart 3730.1 rows 4/5): un-terminate the enrollment an ENACTED periodic_report-sourced action closed, because the late form/verification arrived. canopy-renewals validates the 30-day window (it owns the 3730 calendar) and calls here; this endpoint enforces the enrollment side’s invariants — source scope (anything else is #1113 restoration territory → 422), action enacted (409), enrollment terminated by THIS action (enactment is the only path to terminated; the terminated_reason == reason_code belt turns any violation into a 409), no live successor enrollment (the household re-applied → 409; a successor committing CONCURRENTLY with the reopen trips the snap_enrollments_one_live_per_household partial unique instead and 409s as the typed raced refusal, #1130), receipt not in the future (400). One transaction under the global lock order + the per-(certification, generation) advisory lock: status back to active (or suspended when a live suspension survived), terminated_* and the PAMMS 2415 retention pair cleared, lifecycle_revision BUMPED (stale pre-reopen fenced EBT writes die), the append-only enrollment_reopens receipt inserted (UNIQUE per action — replay returns it, 200), a reopened signal appended. The action row stays enacted — history is truth. received_date is the PRORATION ANCHOR: the issuance path prorates the receipt month’s allotment from it; months missed between termination and receipt are NOT restored (#1113). No event rides the reopen (the caller holds the synchronous receipt; no consumer exists — topology-first). Service-caller only.

Request: { "received_date": "2026-07-15", "actor": "worker:jdoe" }201 AdverseActionReopenView (200 on replay).

POST /v1/adverse-actions/enact-sweep

Run one guarded enact-sweep pass NOW (#1102). Every claimed due action goes through THE enact gate: zero active stays from any appeal (veto refuses), dispatch evidence for the CURRENT notice version (dispatched_date ≤ noticed_effective_date − required_advance_days; exempt actions need only a dispatched adequate notice), and the enact window (past the noticed month = no enact-late). Pass ⇒ terminated with terminated_date = the NOTICED legal date, PAMMS 2415 partial retention, and the lifecycle_revision fence bump; failure ⇒ a successor legal window (re-anchored enact_not_before, notice_repair signal, re-published scheduled event) + an operator alert. Always 200 + the typed EnactSweepReport bucketed by outcome: since #1220 (scale audit H14, ADR-001 A1 §B6-ii) the sweep claims due actions in bounded batches via FOR UPDATE SKIP LOCKED leases, so concurrent passes — replicas, the hourly loop, this trigger — share the due cohort instead of electing an advisory-lock leader (the pre-#1220 202 skipped arm is gone); rows a concurrent pass claimed are simply absent from this report — they are handled by whichever pass claimed them (an API-triggered pass returns its report; the background loop’s report surfaces only in its log line). Double-enact stays impossible at the gate (the lease is a work-sharing hint, not the correctness authority). Service-caller only.

Error Codes

Code Meaning

400

Invalid request body, or invalid YYYY-MM / half-open window on the household-issuances endpoint

401

Missing or invalid JWT

403

Requires eligibility_specialist role or above; or actor not authorized for the household (#408)

404

Enrollment not found

400

benefit_month not the first of the month (#1095 — a benefit month is a MONTH; proration anchors on the enrollment’s persisted application_date and applies only to the application month per 7 CFR 273.10(a)(1)(ii))

409

Duplicate issuance for benefit month; enrollment not issuable (#1092); the lifecycle_revision fence tripped mid-issuance (#1095 — the EBT transfer stays pending for reconciliation); or the INITIAL month prorates under the $10 floor and is not issued (7 CFR 273.10(a)(1)(iii), #1129 — issue the next full month instead; reopen months are never suppressed)

410

Direct terminate (removed by #1095 — see above)

422

Unknown field in TerminateEnrollmentRequest (deny_unknown_fields)

Benefit Proration

If the effective date is mid-month, benefits are prorated: (monthly_amount / days_in_month) * remaining_days, rounded DOWN to the nearest lower whole dollar (7 CFR 273.10(a)(1)(ii); the mode is the cited snap.issuance.proration_rounding jurisdiction key, #1129). Two anchors share the math (issuance::prorate_month_from): the persisted application_date for the first issuance in the application month, and — since #1108 — a periodic-report reopen’s received_date for the receipt month (Chart 3730.1: "prorate benefits from the date … received"; the issuance path reads the enrollment_reopens anchor whose receipt month equals the requested benefit month). An INITIAL month prorating under the snap.issuance.initial_proration_minimum_cents floor ($10) is refused with a 409 per 7 CFR 273.10(a)(1)(iii) — the reopen anchor is deliberately NOT suppressed (Chart 3730.1 sits outside the initial-month text; suppressing would take money from the household without federal basis).

Events Published

  • enrollment.adverse_action_scheduled (#1102 — the pinned AdverseActionScheduledV1: action id, provenance triple incl. created_source/source_reference, recipient, verbatim legal effective_date, pre-formatted benefit amount, cb_available; published at schedule time and for every successor legal window minted by evidence repair or post-decision reschedule)

  • enrollment.adverse_action_terminated (#1107 — pinned AdverseActionTerminatedV1: action/enrollment/household ids, reason code, the NOTICED terminated_date, and the full provenance triple; staged in the SAME transaction as the guarded enact’s termination writes, so the event exists iff the termination does. Consumed by canopy-renewals' periodic-report terminal consumer.)

  • enrollment.adverse_action_vetoed (#1107 — pinned AdverseActionVetoedV1 with veto_source = stay_command | appeal_reversal + the provenance triple; staged with the veto write, only when the action actually mooted scheduled → vetoed. Routes a periodic_report-sourced action’s cycle to renewals' re-determination worker queue.)

  • enrollment.adverse_action_cancelled (#1107 — pinned AdverseActionCancelledV1 with the cancel reason + actor + the provenance triple; staged with the cancel write on newly-cancelled only. reason = periodic_report_completed marks the tombstone consumer’s benign supersession — consumers must not route it to a worker.)

  • enrollment.created, enrollment.benefits_issued, enrollment.expungement_pending, enrollment.household_issuance.read / .access_denied

  • benefit.issued, benefit.expunged

Events Consumed

  • renewal.snap_periodic_report_processed (#1107, queue canopy-enrollment.events) — records the per-(certification, generation) completion tombstone in periodic_report_completions AND cancels a still-scheduled matching periodic_report-sourced action in the same inbox transaction (reason = periodic_report_completed, cancelled event staged with it). An already-ENACTED match with a reopen receipt on record is the expected #1108 epilogue (reopen first, then the worker completes — debug-quiet); an enacted match with NO reopen receipt is the un-reopened late completion → operator alert naming the reopen endpoint.

  • determination.completed.snap — auto-enrollment (#1014 typed consumption).

  • notice.generated / notice.dispatched — order-independent notice-evidence stamps onto adverse_action_notices (#1101; legacy notices without an adverse_action_id skip, present-but-garbage nacks).

  • appeal.decision_recorded / appeal.withdrawal_finalized — Phase-2 appeal-resolution convergence (#1102), inside the inbox transaction so the enact gate reads its own link writes: reversal = durable veto (tombstoning termination_appeal_links so a late stay grant hits the monotonic refusal; a reversal after enactment appends the reversal_after_enactment restoration signal + operator alert), agency-favorable with a recorded CB cessation reschedules the action to the next issuance cycle under post-decision ADEQUATE notice (Chart B1 — the successor letter never re-offers CB), withdrawal finalization releases the stay and runs the gate (a lapsed window repairs with the resumption re-notice).

Edit this page · default