canopy-eligibility API Reference
On this page
Overview
Orchestrator service that dispatches eligibility requests to program services (canopy-snap, canopy-tanf, etc.) in parallel, verifies JWS signatures on returned determinations (ADR-002), and assembles combined results.
- Base URL
- Authentication
-
Bearer token (Keycloak RS256 JWT)
- Minimum role
-
eligibility_specialist
- Swagger UI
- Database
-
canopy_eligibility - Tables
-
eligibility_requests(one row per determination request),program_determinations(per-program signed-determination rows),combined_results(assembled cross-program result per application). Orchestrator bookkeeping only — no restricted data; details in the data-models page.
Receiver contract (OIDC S-eligibility, #1430 / ADR-043 §C)
canopy-eligibility is the sixth service on the ADR-043 receiver contract
(canopy_auth::ReceiverContract) — see the
tanf API page for the full bearer-shape and guard-family description —
and the fleet’s ONE hop-2 receiver. The eligibility specifics:
-
Hop-2 pair audience: the user-context arm accepts exact
aud=canopy-eligibilityOR exactly the{canopy-eligibility, canopy-eligibility-exchanger}pair — the delegable hop-1 shape canopy-web mints for the two determine senders (approve / run-determination). The orchestrator re-exchanges that bearer per flipped fan-out target (EXCHANGE_TARGETS; devstack: canopy-tanf + canopy-medicaid), so the worker’s identity rides the whole chain. Any OTHER multi-audience shape stays 403aud_not_exact, and a pair token replayed at a single-exact service is 403 there. -
require_user_onlyon the six bulk-run mutations: create/enact/ cancel/retry-failures (admin) and pause/resume (supervisor/admin). Service bearers are always 403 (service_class_on_user_only); underCANOPY_ELIGIBILITY__ENFORCE_USER_ONLY_ROUTES=true(devstack: on) only an exchanged per-target token carrying the role passes — operators mint through exchange (the #1501 runbook pattern). -
Every other route is dual and unchanged behind the
exchanged_gate(an exchanged worker passes the existing role bars; the #596 cross-program-alerts gates now bind the caseworker path to a VERIFIED exchangedsub, with the canopy-web service arms retained for SSR reads until C1). -
Attribution via
EffectiveUseron the four bulk-run ledger sites (created_by / enacted_by / canceled_by / the H22 action actor).DetermineRequest.requested_bystays caller-supplied (#985-class follow-on). -
Azp allowlist:
canopy-web-exchangerONLY (least privilege — the eligibility exchanger mints for the fan-out targets, never for eligibility itself).
Endpoints
POST /v1/eligibility/determine
Trigger a multi-program eligibility determination.
Since #1471 (ADR-002 Amendment 1 D10): the combined result, the request’s
completed flip, and the determination.completed outbox event commit in
ONE transaction — no consumer can observe a completion without a persisted
combined result. Since #1511 the order ledger’s failed_terminal flip
commits atomically with an origin-echoing determination.order_failed
event (ids + machine failure_code only — no PII, no upstream prose) on
all three terminal paths: the direct terminal settle, the retryable
attempts-cap conversion, and the sweep’s stale-claim cap conversion — so
an order requester (medicaid CMD) is never left waiting on a clock nobody
answers. A synthesized (non-definitive) ProgramResult additionally
carries a typed failure classification (unconfigured | breaker_open |
transient_upstream | upstream_conflict | terminal_rejection |
contract_violation | signature_quarantined | persist_failed, plus the
upstream status and a bounded excerpt) so callers branch on the class
instead of parsing basis prose; raw upstream error bodies no longer cross
the boundary.
Request: (DetermineRequest)
{
"application_id": "uuid",
"household_id": "uuid",
"programs": ["snap"],
"requested_by": "worker-or-service-id"
}
Response (200): (DetermineResponse)
{
"request_id": "uuid",
"application_id": "uuid",
"programs_approved": [
{ "program": "snap", "status": "approved", "benefit_amount": "535.00", "basis": null,
"determination_id": "uuid" }
],
"programs_denied": [],
"programs_pending": [],
"total_monthly_benefit": "535.00",
"assembled_at": "2026-05-28T12:00:00Z"
}
Each entry in programs_approved / programs_denied / programs_pending is a ProgramResult (program, status, optional benefit_amount, optional basis, optional determination_id, optional denial_reason_codes). determination_id is the persisted determination id for a verified outcome — present on approved / denied (and any program-returned pending_verification) results, and absent on synthesised-pending results (unconfigured program, open circuit breaker, signature quarantine, dispatch failure); the canopy-web worker BFF records terminal outcomes back to canopy-applications keyed by it (Plan 4 MR4 / G5). denial_reason_codes is carried for denials (canopy-eligibility does not persist them on the determination row). Determinations with signature_verified: false are quarantined and excluded from the combined result.
POST /v1/eligibility/determine/dry-run
Non-persisting dry-run (T2-7, #680; ADR-027 §6). Without target_policy, re-scores a household’s current facts against the frozen policy + pinned corpus of a baseline determination-of-record — the materiality diff the renewals fact-change subscriber consumes. With target_policy (#1472), the same current facts score against the named policy instead — the COLA "preview under October policy" arm the #1213 bulk admin surface composes with. SNAP-only; not worker-facing.
Minimum role: service caller or caseworker-or-above (same gate as POST /v1/eligibility/determine); the downstream canopy-snap calls present canopy-eligibility’s own service token (ADR-019).
Request: (DryRunRequest)
{
"baseline_determination_id": "uuid",
"household_id": "uuid",
"as_of": "2026-06-26",
"target_policy": { "corpus_hash": "<hex64>", "params_digest": "<hex64>" } // optional, #1472
}
as_of is the evaluation date — for materiality, the triggering change’s effective date (valid_from, Decision J); for a COLA preview, the target cutover date (e.g. 2026-10-01). Facts are read as-of that date (not "today", not the baseline’s as_of). The orchestration: fetch the baseline determination from canopy-snap (verdict + household ownership check); without a target, read its frozen policy_params bundle + corpus_hash from the snapshot; re-fetch the household’s current facts as-of as_of; dispatch the pinned write-free snap dry-run with exactly one policy source. A corpus-less / pre-T2-7 baseline (whose 422s are bundle-extraction failures) still previews under a target — it just can’t replay; a baseline with NO input snapshot at all 422s in either mode (the snapshot supplies the context-faithful application id — structurally dead in real data since #911, kept defensive). Write-free end to end — no eligibility_requests slot, no signature verification, no program_determinations / combined_results persistence, no outbox event.
Response (200): (DryRunResult)
{
"baseline": { "status": "approved", "benefit_amount": "535.00" },
"dry_run": { "status": "approved", "benefit_amount": "489.00" },
"corpus_hash": "<the replayed (or target) ruleset corpus version>",
"as_of": "2026-06-26",
"target_policy": { "corpus_hash": "<hex64>", "params_digest": "<hex64>",
"effective_period": { "start": "2026-10-01", "end_exclusive": "2027-10-01" } } // target mode only
}
baseline / dry_run are VerdictRef { status, benefit_amount } (unsigned — a dry-run is not a determination of record). In target mode, dry_run is the verdict under the named target and target_policy echoes snap’s full resolution — the requested pins plus the matched parameter set’s intrinsic validity window, i.e. exactly the value a subsequent bulk dispatch passes as expected_policy_target (#1467 pre-write pin). Absent on baseline replay, keeping that wire byte-identical. Errors degrade to manual review, never a 500: a caller without the service/caseworker-or-above role → 403; a cross-household or snapshot-less baseline (any mode), or (baseline replay only) a corpus-less / pre-T2-7 incomplete frozen policy bundle → 422; an unknown pinned corpus, or a target params_digest matching no loaded snap parameter set → 422; an unknown baseline determination → 404; a household with no members → 400.
GET /v1/eligibility/requests/{id}
Get an eligibility request by ID.
Response (200): EligibilityRequest. Returns 404 when the request ID is unknown.
GET /v1/eligibility/requests/{id}/determinations
Get all program determinations for an eligibility request.
Response (200): array of ProgramDetermination. Since #1479 each row
also exposes the #1467 policy provenance: policy_target (corpus hash
params digest + effective period — bulk rows stamped per #1213, interactive
rows from the signed envelope as it arrived) and evaluated_as_of (the
envelope’s evaluation date). null when no attestation arrived (pre-#1467
rows, emitters with attestation off; evaluated_as_of is also null on
bulk-adopted rows, which the read view cannot attest).
GET /v1/eligibility/results/{application_id}
Get the combined result for an application.
Response (200): CombinedResult. Returns 404 when no combined result has been assembled for the application.
GET /v1/eligibility/workers/{worker_id}/cross-program-alerts
The worker-SCOPED alerts feed (#596, PUB-1075 AC-6 least privilege).
Rows derive from program_determinations (statuses: denied,
sanctioned, time_limit_exceeded, disqualified, terminated,
abawd_exceeded; provenance-quarantined rows — signature_verified =
false — never surface) and are filtered to households the effective
worker holds an ACTIVE household_assignments row for, looked up live
from canopy-applications per request. Scope-THEN-limit: the household
predicate applies before the top-N, so a small caseload is never starved
by jurisdiction noise. Accepts ?limit={n} (clamped [1, 50], default 10
— a bounded triage feed, deliberately not a keyset page) and (#1518)
optional repeated programs= keys (snake_case Program slugs; empty =
unscoped, unknown slugs → 422) — the BFF passes the worker’s program
scope, an axis ORTHOGONAL to the assignment filter.
Authorization (the effective worker is the path param):
| Caller | Behavior |
|---|---|
caseworker / eligibility_specialist / quality_control |
The path MUST name the caller’s own |
supervisor / admin |
Any worker — the triage-narrowing affordance (self included). |
service |
Allowlisted to |
Fail-closed: canopy-applications unreachable (or the lookup blowing its
3s absolute deadline, or an assignment set past the 5,000 cap) is a
coded 502 (applications_unreachable / assignment_set_too_large) —
NEVER the unscoped list, with no deployment override (per the ratified
\#596 spec). A consecutive-failure breaker (5 → open 30s) keeps BFF
retries from amplifying an applications outage. A worker with zero
assignments receives []. Each read publishes the aggregate
eligibility.cross_program_alerts.accessed audit event (ids only).
Response (200): array of CrossProgramAlert.
GET /v1/eligibility/cross-program-alerts/all
The UNSCOPED jurisdiction-wide alerts feed (#596): supervisor/admin
triage tooling, plus the allowlisted canopy-web service caller
rendering supervisor dashboards. Never consults assignments — supervisor
triage survives a canopy-applications outage. Accepts ?limit={n}
(clamped [1, 50], default 10) and (#1518) optional repeated programs=
keys (same contract as the scoped feed) — the supervisor’s dashboard
panel passes their claim’s programs, so "unscoped" here means
assignment-unscoped, not program-unscoped.
The pre-#596 GET /v1/eligibility/cross-program-alerts path is RETIRED
(404, no alias): old replicas 404 the new paths and new replicas 404 the
old one, so mixed deployments fail closed in both directions.
Response (200): array of CrossProgramAlert. 403 when the caller is
neither supervisor-or-above nor the allowlisted service.
GET /v1/eligibility/case-status
Latest determination for a single household, used by the case-search
status badge. Accepts ?household_id={hid}. Returns 404 when the
household has no determination yet (the badge falls back to "Pending").
Response (200): CaseStatus. Returns 404 when the household has no
determination yet.
GET /v1/eligibility/determinations
Minimum role: dual-or-portal (#1441: service, caseworker-or-above worker, or the scoped portal credential on portal:determinations:read; since #1442 the portal arm also requires the signed ownership claim binding the queried household — the portal home page’s coupling, see the authorization inventory).
Every program determination for a household, newest first. Powers the
canopy-web case-detail identity hero + Determination tab so a single
round-trip lights up active benefits across all programs without
fanning out across per-program services. Accepts ?household_id={hid}.
Response (200): array of ProgramDetermination.
Error Codes
| Code | Meaning |
|---|---|
400 |
Missing application_id or household_id, empty programs list |
401 |
Missing or invalid JWT |
403 |
Caseworker-tier caller naming a foreign worker on the scoped alerts path; a non-allowlisted service on either alerts feed ( |
404 |
Eligibility request, combined result, or household case-status not found |
502 |
Program service unreachable (circuit breaker open); or the #596 assignment lookup failed closed ( |
SSA Pre-Dispatch (Medicaid only, #384)
When a determination request includes medicaid, the orchestrator queries canopy-verification’s POST /internal/v1/ssa/solq surface pre-dispatch for each household member who matches the SOLQ gate:
-
age >= 65(ABD FBR threshold), OR -
disability_statusisdisabledordisabled_veteran.
Successfully-returned SolqRecord values are keyed by person_id and forwarded to canopy-medicaid in the dispatch payload as ApplicationContext.ssa_solq: Option<HashMap<Uuid, SolqRecord>>. The orchestrator never persists or re-emits these records — per ADR-004, raw SOLQ responses live only in canopy-medicaid’s database (Computer Matching Agreement scope).
Configuration:
-
CANOPY_ELIGIBILITY__VERIFICATION_URL— base URL for canopy-verification (optional). When unset, pre-dispatch SOLQ is skipped and canopy-medicaid’s ABD SSA-linked COA gates fall back to their pre-#384Option::unwrap_or(false)defaults (Pickle / DAC / DW / Widow 60-64 / Former SSI Disabled Child become non-evaluable). -
CANOPY_INTERNAL_API_KEY—x-service-api-keyvalue forwarded to canopy-verification on the internal call.
SOLQ fetch failures (timeout, non-2xx, parse error) degrade gracefully to None for the affected member; Medicaid dispatch continues without blocking. Step 4(b) of the medicaid-ssa-orchestrator-wiring plan (replacing the NoopSolqAdapter with a real SSA transport) stays Blocked on CMA execution.
TANF Deprivation Inference (Plan 4 G9)
canopy-tanf needs a deprivation basis (deprivation_type + deprivation_verified) and a dependent_children count to determine eligibility, but the orchestrator-side ApplicationContext does not otherwise carry them (nothing captures deprivation at intake yet). The orchestrator forwards them in the dispatch payload as ApplicationContext.dependent_children / deprivation_type / deprivation_verified (+ the head-of-household applicant_person_id), inferred from household composition (infer_tanf_deprivation):
-
dependent_children= members with age< 18(falling back to the relationship label when date-of-birth is unknown); -
a single adult caretaker with at least one minor child →
deprivation_type = "CSO"(continued absence),deprivation_verified = true; -
any other shape (two-plus caretakers, or no minor child) → no inferred basis (TANF then denies for an explicit basis).
deny_unknown_fields).
Signature Verification
The orchestrator independently verifies each program determination’s JWS signature:
-
Receives
SnapDeterminationwithsignaturefield from canopy-snap -
Clears the
signaturefield and re-serializes the determination payload -
Calls
VerifyingKeyRegistry::verify(program, payload, signature) -
If verification fails: stores with
signature_verified = false, status becomessignature_quarantined -
Only verified determinations are included in the combined benefit total
See ADR-002 for design rationale.
Bulk Cohort Runs (#1213, ADR-002 A1/A3)
The October-COLA mass re-determination surface. Full request/response schemas: the served OpenAPI
(docs/modules/ROOT/openapi/eligibility.json); machine codes ride RFC 9457 ProblemDetails.code.
| Endpoint | Auth | Behavior |
|---|---|---|
|
admin |
Create a cohort run (v1: |
|
supervisor+ or service |
Keyset pages / full status (counts, canary, breaker window) / seq-keyset failures (with
|
|
admin |
The fail-closed gate ladder: enabled → downstream sign-off → previewed state → audited clean-preview
override → full-target preflight (corpus + provenance exactly equal the pin) → |
|
supervisor+ / admin |
Incident controls — live even when the core is disabled (H18). Cancel drains (B5): claims settle before the run terminalizes. |
|
supervisor+ / admin |
Write-arm gated. Resume restores |
Every bulk POST refuses an Idempotency-Key header (400 idempotency_key_not_allowed, H19) and
requires a reason (1..=500 chars) recorded on the action ledger. The determination.requested
consumer and the enact self-call (DetermineRequest.bulk + x-canopy-bulk-generation) are internal
service surfaces — exact canopy-eligibility identity only.
The single-case order substrate (#1504, ADR-002 Amendment 4)
determination.requested is a two-arm payload: Cohort (the #1213 bulk shape) and Order —
the Amendment 1 D1 field list verbatim (a RequestOrigin {source, ref_id} requester identity,
subject, programs, pinned as_of, signed D9 trigger, requested_by). Program services publish
Orders (canopy-medicaid’s CMD subsystem first, origin source medicaid-cmd); eligibility’s
consumers — attached on EVERY boot since #1504, no longer gated on bulk_runs_enabled
(CANOPY_MQ_PREFETCH_COUNT=1 is therefore a boot requirement fleet-wide) — execute each order
exactly once on the determination_orders ledger: idempotent materialization on
UNIQUE (origin_source, origin_ref), claim-token-fenced settles, backoff-scheduled retry via the
always-on 1-minute sweep (attempt cap 12), the KAT-pinned deterministic dispatch key, and
definitive-result adoption on crash recovery. DeterminationCompletedV1 echoes the order’s
origin so the requester settles its own row; the order self-call (DetermineRequest.order) is
exact-self-identity only and pins the orchestrator’s as_of to the order’s date. Operations:
the CMD change-report rollout runbook.