canopy-caps API Reference
On this page
Overview
Cross-link: canopy-caps Data Model (#419)
CAPS / CCDF program service (45 CFR Part 98). Computes childcare eligibility: income test against State Median Income (initial 50% SMI / continued 85% SMI), activity requirement (24 hours/week minimum across employment / education / training), age gate (under 13, or under 19 for children with special needs), sliding-scale copayment, and provider authorization with rate and approval period.
All eligibility logic runs through the rules engine (ADR-003) — caps-eligibility.json ruleset evaluates income thresholds via smi-2026.json + jurisdiction.toml.
- Base URL
- Authentication
-
Bearer token (Keycloak RS256 JWT)
- Minimum role
-
Varies per endpoint
- Swagger UI
- Database
-
canopy_caps(isolated per ADR-001; no FTI scope — state-administered)
Receiver contract (OIDC S-caps, #1432 / ADR-043 §C)
canopy-caps is the eighth 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 a TERMINAL exchange target (single-exact aud=canopy-caps). The
caps specifics:
-
require_service_or_exchangedonPOST /v1/determine(the orchestrator’s service token or its re-exchanged hop-2 bearer; devstackEXCHANGE_TARGETSincludes canopy-caps) AND on the two worker-actioned authorization writes (PUT /v1/authorizations/{id},PUT /v1/authorizations/{id}/provider) — the BFF sends the worker’s own exchanged bearer (#1564 also corrected both senders' paths, broken since #448). Direct worker bearers stay 403. -
require_user_only(["data_steward"])on the determination redact;EffectiveUseron the redaction event actor. Service bearers are always 403; devstack enforces the broad-audience kill. -
Azp allowlist:
canopy-web-exchanger,canopy-eligibility-exchanger. Providers registry + the active:batchGet stay service-only.
Determination
POST /v1/determine
Run a CAPS eligibility determination. Called by canopy-eligibility orchestrator.
Minimum role: service-class token (orchestrator).
Request: CapsApplicationContext — a household context (income + parent activity records: hours/type/verification) carrying a per-child children[] list (each child’s person_id, age, special-needs flag, provider). CAPS is multi-subject (ADR-035): each child is individually authorized.
The handler, per child:
-
Evaluates income against the SMI threshold via the rules engine (household-level, once)
-
Confirms the activity-hour minimum (combined across all categories, household-level)
-
Applies the age gate per child (denies kids who age out)
-
Computes copayment tier from
jurisdiction.toml [caps.copayment_tiers] -
Selects approved-provider rate (12-month authorization period)
-
Assembles + persists the determination input snapshot (ADR-028 / T2-4): the household income
rules_input+ its ruleset output, the per-child gate results, the resolved policy params, and the ruleset corpus-hash (one household evaluation, so the corpus-hash is shared across the children). CAPS carries no itemized fact arrays, so the snapshot’s fact record is the household composition. Its SHA-256 (RFC 8785 canonical) becomes thesnapshot_hashsigned into the determination; the snapshot is stored immutably indetermination_snapshotsin the same transaction. CAPS is non-FTI, so (unlike tanf/medicaid) the snapshot does not join the ADR-014 chain. -
Signs the determination with ECDSA P-256,
person_idset before signing (ADR-002 / ADR-035 MR1) — the signature now coverssnapshot_hash
All N per-child determinations + snapshots + authorizations + outbox events persist in one transaction (all-or-nothing). An empty children[] list returns 422.
Response (200): DeterminationList — one signed SignableDetermination envelope per child (ADR-035), each carrying its person_id and the snapshot_hash binding the input snapshot (ADR-028); per ADR-002 (status, benefit amount, effective/expiration/renewal dates, basis, JWS signature, CAPS-specific data under program_extension). The orchestrator receives only outcome + hash, never the snapshot cleartext.
map_caps_context returns a structured input_unsatisfiable result naming them rather than dispatching. Direct service-token callers exercise the per-child path today.
GET /v1/determinations
List determinations for a household.
Minimum role: caseworker.
Query parameters: household_id (required).
Response (200): array of CapsDeterminationRead (each the CapsDetermination fields flattened, including the required snapshot_hash — #911 retired the snapshot_status marker with the ADR-028 §58 legacy backstop).
GET /v1/determinations/{id}
Fetch a determination by ID.
Minimum role: caseworker.
Response (200): CapsDeterminationRead — the CapsDetermination fields flattened, including the required snapshot_hash (#911: legacy pre-snapshot rows were deleted and the ADR-028 §58 snapshot_status marker retired). Returns 404 if no determination exists for the supplied ID.
GET /v1/determinations/{id}/authorizations
List provider authorizations attached to a determination.
Minimum role: caseworker.
POST /v1/determinations/{id}/redact
Crypto-shred a determination’s frozen input snapshot (T2-6 #687, ADR-036). The per-determination DEK in redaction_keys is tombstoned (its wrapped_dek overwritten with a zero sentinel + shredded_at stamped), so every sealed leaf becomes permanently unrecoverable, while the snapshot ciphertext and the signed snapshot_hash are left untouched — the snapshot still re-hashes to the signed value and the determination’s JWS stays verifiable (hash-over-ciphertext, ADR-036 Decision B). Only the plaintext PII is destroyed.
Minimum role: data_steward only — a dedicated, privileged, irreversible role for redaction/expungement. Admins do NOT auto-hold it (separation of duties, mirroring fti_auditor).
Request:
{
"reason": "..."
}
reason is mandatory; a blank reason is rejected with HTTP 400.
The shred and a plaintext-free determination.redacted audit event (carrying the steward’s sub + the reason) commit in one transaction (ADR-018); canopy-security audits it via the existing wildcard subscriber.
Response (200): { "determination_id": "…", "redacted_at": "…" }. Returns 400 on a blank reason, 403 if the caller lacks the data_steward role, and 404 for an unknown determination. Idempotent: re-redacting an already-shredded determination tombstones 0 rows and still returns 200.
The path uses the sub-resource form …/{id}/redact (mirroring snap’s reference impl), not the AIP-136 custom-method …/{id}:redact — axum/matchit 0.8 allows only one parameter per path segment.
Provider Authorization
POST /v1/authorizations/active:batchGet
Get the active-childcare flag for a set of households for ONE report month
in one round-trip (#1203, D5 row 6) — one household_id = ANY($1)
determinations⋈authorizations set query replacing the ACF-199 extract’s
per-AU 2-hop walk (determinations-by-household →
authorizations-per-determination; caps exposes no by-household
authorization endpoint, so the walk was the only route). 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, even though the
interactive determination/authorization reads stay caseworker-reachable.
GET-OR-FALSE, exact-set: every requested UNIQUE id gets exactly one
entry — has_active_authorization: false = honestly no qualifying
authorization (no determinations at all, determinations without
authorizations, or none active and window-overlapping the month),
present so consumers can assert exact id-set equality. The response is
THE BOOL the reporting walk computes today — never the determination or
authorization row vectors — so a 500-household response is bounded by
construction.
Predicate parity (frozen at plan review): the SQL window predicate is
the reporting client walk
(canopy-reporting/src/clients/mod.rs::household_has_active_childcare)
term for term: authorization_status = 'active', effective_date <
month + 1 month, end_date IS NULL OR end_date >= month — with NO
determination-status filter (the walk iterates every determination the
list endpoint returns, denied ones included). month normalizes to its
month start server-side (any day selects that whole month — the walk’s
defensive with_day(1)), giving the half-open [month, month+1) window.
Parity is pinned by a test that recomputes the walk through the existing
per-household endpoints and compares.
Request: BatchActiveAuthorizationsRequest
{ "household_ids": ["uuid", "uuid"], "month": "2026-03-15" }
Response (200): Vec<HouseholdChildcareEntry> —
[{ household_id, has_active_authorization }].
GET /v1/authorizations/{id}
Fetch a provider authorization — provider identity, child, approved rate, copayment, effective + expiration dates.
Minimum role: caseworker.
PUT /v1/authorizations/{id} (#448)
Partial update on an existing authorization. COALESCE-pattern: only the fields supplied are changed. Backs the worker-portal #392 BFF action handler actions_caps::update_authorization_caps.
Minimum role: service-class caller.
Request:
{
"authorized_weekly_hours": 30,
"copayment_tier": "0",
"status": "active",
"notes": "Worker reduced after parent moved to part-time",
"updated_by": "jane.doe"
}
notes + updated_by are accepted from the BFF for audit-payload schema stability but are not persisted (canopy-web’s tracing log is the audit source of truth).
Response (200): the updated CapsAuthorization row. Returns 404 if the id is unknown.
PUT /v1/authorizations/{id}/provider (#448, FK-validated per #396)
Switch the provider on an active authorization. Single-column UPDATE on provider_id. Post #396, new_provider_id is a Uuid and the column FKs caps_providers(id); switching to an unknown UUID surfaces as HTTP 422 (unprocessable entity: foreign key violation). Switching to an inactive provider is allowed at the DB layer — callers that want to forbid that should check provider status via GET /v1/providers/{id} before invoking.
Minimum role: service-class caller.
Request:
{
"new_provider_id": "0196eb5b-1f60-7e3f-9000-9a0bf1cfc9e7",
"effective_date": "2026-06-01",
"notes": "Family relocated, switching to neighborhood center",
"switched_by": "jane.doe"
}
Response (200): the updated CapsAuthorization row. Returns 404 if the authorization id is unknown, 422 if the provider id is unknown.
Provider Registry (#396)
CAPS providers are first-class rows in caps_providers; caps_applications.provider_id and caps_authorizations.provider_id FK here. Soft-delete only (status flips to inactive); historical authorizations preserve their FK after a provider is retired.
POST /v1/providers
Create a provider.
Minimum role: service-class caller.
Request:
{
"provider_code": "PRV-042",
"legal_name": "Sunshine Childcare LLC",
"doing_business_as": "Sunshine Daycare",
"ein": "12-3456789",
"license_number": "GA-CC-0042",
"license_type": "center",
"license_expires": "2027-12-31",
"contact_email": "admin@sunshine.example",
"contact_phone": "+14045551234",
"address_line1": "123 Sunny Street",
"city": "Atlanta",
"state": "GA",
"postal_code": "30303"
}
provider_code is unique; conflicts return HTTP 422.
Response (200): the new CapsProvider row (id assigned server-side).
PUT /v1/providers/{id}
Partial update — only fields present in the body are updated; omitted fields preserve their current value via COALESCE. provider_code and status are not editable here (rotate code via DELETE + POST; status via DELETE for soft-delete).
Error Codes
| Code | Meaning |
|---|---|
400 |
Invalid input (missing required field, negative income, etc.) |
401 |
Missing or invalid JWT |
403 |
Insufficient role (e.g. redact requires data_steward) |
404 |
Determination, authorization, or provider not found |
422 |
Unique-key violation (duplicate provider_code) or FK violation (unknown provider id on authorization or filter |
Events Published
-
caps.determined— determination completed (IDs and status only per ADR-004) -
caps.authorization_created— provider authorization created (IDs only) -
determination.redacted(T2-6 #687) — a data steward crypto-shredded a determination’s snapshot; carries the actorsub+ reason, no plaintext