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

http://localhost:8016/v1

Authentication

Bearer token (Keycloak RS256 JWT)

Minimum role

Varies per endpoint

Swagger UI

http://localhost:8016/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_exchanged on POST /v1/determine (the orchestrator’s service token or its re-exchanged hop-2 bearer; devstack EXCHANGE_TARGETS includes 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; EffectiveUser on 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:

  1. Evaluates income against the SMI threshold via the rules engine (household-level, once)

  2. Confirms the activity-hour minimum (combined across all categories, household-level)

  3. Applies the age gate per child (denies kids who age out)

  4. Computes copayment tier from jurisdiction.toml [caps.copayment_tiers]

  5. Selects approved-provider rate (12-month authorization period)

  6. 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 the snapshot_hash signed into the determination; the snapshot is stored immutably in determination_snapshots in the same transaction. CAPS is non-FTI, so (unlike tanf/medicaid) the snapshot does not join the ADR-014 chain.

  7. Signs the determination with ECDSA P-256, person_id set before signing (ADR-002 / ADR-035 MR1) — the signature now covers snapshot_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.

NOTE
until the worker-fact corpus (epic &56 / #858), the orchestrator cannot source CAPS’s worker-facts (parent activity, per-child special-needs), so 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).

GET /v1/providers/{id}

Fetch a provider by id. Returns 404 if unknown.

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

DELETE /v1/providers/{id}

Soft-delete — flips status to inactive. The row remains queryable via GET /v1/providers/{id} and GET /v1/providers?status=all; it disappears from the default active listing. Existing authorizations keep their FK.

GET /v1/providers

List providers. ?status=active (default) returns only active rows; ?status=all returns active + inactive. Other values return 422.

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 status= value not recognised)

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 actor sub + reason, no plaintext

Edit this page · default