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

http://localhost:8004/v1

Authentication

Bearer token (Keycloak RS256 JWT)

Minimum role

eligibility_specialist

Swagger UI

http://localhost:8004/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-eligibility OR 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 403 aud_not_exact, and a pair token replayed at a single-exact service is 403 there.

  • require_user_only on 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); under CANOPY_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 exchanged sub, with the canopy-web service arms retained for SSR reads until C1).

  • Attribution via EffectiveUser on the four bulk-run ledger sites (created_by / enacted_by / canceled_by / the H22 action actor). DetermineRequest.requested_by stays caller-supplied (#985-class follow-on).

  • Azp allowlist: canopy-web-exchanger ONLY (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 sub; a foreign worker or a non-UUID subject is 403. (Deployments must run a single UUID-sub issuer for workers — the household_assignments substrate keys on UUID-projected subjects; the canonical issuer+subject redesign is #1008.)

supervisor / admin

Any worker — the triage-narrowing affordance (self included).

service

Allowlisted to canopy-web only (the BFF’s on-behalf pattern); every other service bearer is 403 service_not_allowlisted. Interim ADR-019 posture until #1430 carries verified human context.

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 (service_not_allowlisted); or insufficient role

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 (applications_unreachable / assignment_set_too_large)

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_status is disabled or disabled_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-#384 Option::unwrap_or(false) defaults (Pickle / DAC / DW / Widow 60-64 / Former SSI Disabled Child become non-evaluable).

  • CANOPY_INTERNAL_API_KEYx-service-api-key value 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).

WARNING
This is a provisional, demo-grade simplification to unblock live TANF determinations for the SNAP-UAT demo. Real TANF deprivation (45 CFR Part 261 / PAMMS 1510-1515) is multi-factor and worker-verified and is not derivable from household size alone; the inference must be replaced by explicit intake capture before production — tracked in #669. Program services that don’t model deprivation ignore these fields (none use deny_unknown_fields).

Signature Verification

The orchestrator independently verifies each program determination’s JWS signature:

  1. Receives SnapDetermination with signature field from canopy-snap

  2. Clears the signature field and re-serializes the determination payload

  3. Calls VerifyingKeyRegistry::verify(program, payload, signature)

  4. If verification fails: stores with signature_verified = false, status becomes signature_quarantined

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

POST /v1/eligibility/bulk-runs

admin

Create a cohort run (v1: trigger=cola, programs=["snap"]). Resolves the live corpus + snap parameter provenance into the frozen expected_policy_target; enforces as_of = window start (B14), ONE active run, same-target rerun dedup, the cohort ceiling and a non-empty floor. 202 + Location.

GET /v1/eligibility/bulk-runs / /{id} / /{id}/failures / /{id}/actions

supervisor+ or service

Keyset pages / full status (counts, canary, breaker window) / seq-keyset failures (with successor_determination_id) / the append-only H22 action ledger. Live even when the core is disabled.

POST /v1/eligibility/bulk-runs/{id}/enact

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) → attestation_enabled → snap-legal-today ≥ as_of → the H15 re-arm (fresh epoch/generation/canary/watermark/deadline).

POST …/pause, …/cancel

supervisor+ / admin

Incident controls — live even when the core is disabled (H18). Cancel drains (B5): claims settle before the run terminalizes.

POST …/resume, …/retry-failures

supervisor+ / admin

Write-arm gated. Resume restores paused_from (breaker watermark resets; canary re-arms unless the pause was operator-initiated). Retry re-arms terminal failures under a fresh epoch + deadline; from completed_with_failures it re-runs the full enact ladder and re-acquires the single-active slot atomically (H14 → typed 409).

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.

Edit this page · default