canopy-tanf API Reference
On this page
Overview
Cross-link: canopy-tanf Data Model (#419)
TANF program service. Computes eligibility determinations (income/asset tests, AU composition, deemed income), tracks federal 60-month time limits and 45 CFR Part 261 work requirements, manages PAMMS 1345-1370 personal responsibility agreements, GRG (grandparent-as-caregiver) payments per PAMMS 1210, and an IRC §6103(l)(7)-scoped FTI audit log (ADR-004 + ADR-014). Cross-program overpayment recovery (PAMMS 9000 / 42 USC 609(a)(1); 45 CFR 263.11) shares the surface with canopy-snap and canopy-medicaid.
All eligibility logic runs through the rules engine (ADR-003) — federal regulation values live in rulesets/federal/, jurisdiction values in rulesets/{jurisdiction}/jurisdiction.toml traced via citations.toml.
- Base URL
- Authentication
-
Bearer token (Keycloak RS256 JWT)
- Minimum role
-
Varies per endpoint
- Swagger UI
- Database
-
canopy_tanf(isolated per ADR-001; FTI scope under IRC §6103(l)(7), SSA SOLQ/BINDEX under TANF CMA per ADR-004)
Receiver contract (OIDC S-tanf, #1425 / ADR-043 §C)
canopy-tanf is the first service on the ADR-043 receiver contract
(canopy_auth::ReceiverContract). Every bearer is classified by shape —
Service (carries a service:* role), LegacyWorker (non-service with a
broad canopy/canopy-internal-service audience), or ExchangedUser
(non-service with a narrow audience, i.e. an RFC 8693 per-target token) — and
three guard families replace the transitional ADR-019 gates on the routes
below:
-
require_service_or_exchanged— service callers pass as before; an exchanged user-context token passes when itsaudis exactlycanopy-tanf, itsazpis on theauthorized_exchanger_azpsallowlist, and it carries a caseworker-or-above role. Used byPOST /v1/determineand the discrepancy resolve. -
require_user_only— a human-role route (fti_auditor/data_steward): service-class bearers are always 403 (service_class_on_user_only); withenforce_user_only_routes=truea legacy broad-audience worker bearer is also 403 (aud_not_exact) — only an exchanged per-target token with the named role passes. Used by the FTI audit log and redaction routes. -
exchanged_gate(middleware, post-auth) — on every other route in the API router an exchanged-shaped bearer must still pass the exact-audience
azp-allowlist checks; dual routes therefore accept exchanged tokens uniformly with no handler changes. (The merged shared admin route sits outside the gate; itsrequire_service_callerrejects exchanged bearers anyway.)
Attribution on the service-or-exchanged writes resolves through
EffectiveUser: an exchanged bearer attributes its own sub; a bare
service bearer attributes the service itself (#1443 retired the
verified-actor shape — the middleware 401s ANY request carrying
X-Canopy-Actor). Receiver knobs
(CANOPY_TANFACCEPT_OWN_AUDIENCE / AUTHORIZED_EXCHANGER_AZPS /
__ENFORCE_USER_ONLY_ROUTES) are documented in
the configuration reference; conformance
coverage is the F4 matrix (canopy_test_lib::conformance, activated for
canopy-tanf).
Determination
POST /v1/determine
Run a TANF eligibility determination. Called by canopy-eligibility orchestrator.
Minimum role: service-or-exchanged (#1425): the orchestrator’s service token, or an exchanged user-context token (aud=canopy-tanf exactly, allowlisted azp, caseworker-or-above role). FTI access attribution (accessed_by) resolves via EffectiveUser — the exchanged bearer’s own sub, or the calling service itself (#1443 retired the verified-actor shape).
Request: ApplicationContext — application context, household composition (AU), per-member income/assets/expenses, SSA data when in scope.
The handler:
-
Builds the assistance unit per PAMMS 1501 (composition rules)
-
Evaluates GRG path (PAMMS 1210) when grandparent caregivers are present
-
Runs work-requirement screening (45 CFR Part 261)
-
Loads active sanction tier + lifecycle (
tanf_work_requirements.sanction_level/sanction_expires_at; PAMMS 1351) andnon_compliantpersonal-responsibility rows (PAMMS 1345-1370). These flow into thetanf-eligibilityJDM ruleset asinput.active_sanction_level/input.sanction_expired/input.personal_responsibility_failures/input.personal_responsibility_pending(#416). -
Runs gross + net income tests via canopy-rules (PAMMS 1615 thresholds)
-
Checks federal 60-month time limit
-
Assembles + persists the determination input snapshot (ADR-028 / T2-4): the eligibility
rules_input(incl. the time-limit/sanction/PR inputs) + its output, the benefit calc, the income/asset/expense facts + household composition, the resolved policy params, and the ruleset corpus-hash. Its SHA-256 (RFC 8785 canonical) becomes thesnapshot_hashsigned into the determination; the snapshot is stored immutably indetermination_snapshots. TANF is FTI-bearing (IRC §6103(l)(7)), so the snapshot’s creation additionally joins the ADR-014fti_audit_loghash chain (resource_type='determination_snapshot') — the FTI-derived artifact at rest inherits the Pub 1075 §4 tamper-evidence + §9 breach pathway. -
Signs the determination with ECDSA P-256 (ADR-002) — the signature now covers
snapshot_hash. The determination, its snapshot, and the FTI chain entry commit in one transaction.
Response (200): SignableDetermination — the universal signed-determination envelope (ADR-002) with status, benefit amount, basis, sanction state, time-limit posture, JWS signature, and the snapshot_hash binding the input snapshot (ADR-028). The orchestrator receives only outcome + hash, never the snapshot cleartext (which stays inside canopy-tanf’s Pub 1075 boundary).
Status values emitted: approved, denied, sanctioned (#416 — emitted when the applicant has an active non-expired PAMMS 1351 sanction). denial_reason_code extends with sanction and personal_responsibility per PAMMS 1351 + 1345-1370 respectively.
Response (503, dormant until #1279): when the chain-v2 FTI append arm is enabled (CANOPY_TANF__CHAIN_V2_APPEND_ENABLED, #1207 / ADR-014 Amendment 7) and the append hits an Environment-class refusal — topology missing, epoch not active/current, routing-version skew, or a malformed source registry — the determination aborts fail-closed (a Pub 1075 determination cannot proceed without its chain row) with the fixed detail audit chain unavailable; the environment specifics go to logs only. With the flag off (the default), this arm is unreachable.
GET /v1/determinations
List determinations newest-first, keyset-paginated (#1195).
Minimum role: caseworker.
Query parameters: limit (default 50, max 200), after_determined_at + after_id (keyset cursor — pass the previous page’s next_cursor fields together; omit both for the first page), month (optional, any day in the month).
The list is ordered (determined_at DESC, id DESC) — newest first, with the UUID-v7 id as a stable tiebreak — and keyset-paginated over that compound cursor (there is no offset; the old hard LIMIT 200 cap that silently truncated the ACF-199 federal universe is gone). The default page rides idx_tanf_determinations_determined_at_id, an index scan with no top-N sort. month scopes the universe to determinations whose coverage window [effective_date, expiration_date] overlaps that calendar month (the ACF-199 monthly-caseload universe); when set, the first page carries total_in_scope (the authoritative scoped COUNT(*)) so a page-looping extractor can assert completeness. The month scope is service-caller-only (#1249, ADR-001 Amendment 1 least privilege): the completeness universe belongs to the reporting extractor — an interactive caller gets 403 and uses the unscoped list.
Response (200): TanfDeterminationPage — items (array of TanfDeterminationRead, each the TanfDetermination fields flattened including the required snapshot_hash) + next_cursor ({after_determined_at, after_id} while a full page may have more; null at the end) + total_in_scope (first page of a month-scoped query only; null otherwise).
GET /v1/determinations/{id}
Fetch a determination by ID.
Minimum role: caseworker.
Response (200): TanfDeterminationRead — the TanfDetermination 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}/explanation
Human-readable explanation of the determination — rule trace, threshold values referenced, and basis. Useful for caseworker review and hearing documentation.
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. TANF is FTI-bearing, so the determination.redacted event is plaintext-free (IDs + actor + reason only, ADR-004).
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). A require_user_only route (#1425): service-class bearers are always 403; under enforce_user_only_routes the bearer must be an exchanged per-target token carrying the role.
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.
Work Requirements (45 CFR Part 261)
POST /v1/work-requirements/evaluate
Re-evaluate work-requirement status for an AU member.
Minimum role: caseworker.
Request:
{
"person_id": "uuid",
"as_of_date": "2026-05-01"
}
GET /v1/work-requirements/{person_id}
Return current work-requirement assignment + status (engaged / partially engaged / unengaged / exempt) and hours required. Get-or-create: a person with no row on file gets a fresh defaults row INSERTed and returned — the interactive/determine surface’s contract, kept deliberately (see the batch sibling below for the read-only alternative).
Minimum role: caseworker.
POST /v1/work-requirements:batchGet
Get the work-requirement status projection for a set of persons in one
round-trip (#1203, D5 row 4) — one person_id = ANY($1) set query
replacing the ACF-199 extract’s per-adult GET. 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 per-person GET above stays caseworker-reachable.
READ-ONLY — the hazard this endpoint ends: the single GET above is
get-or-create, so the federal ACF-199 read extract was INSERTing
tanf_work_requirements rows (a racy SELECT-then-INSERT per adult). The
batch NEVER creates a row — pinned by row-count-unchanged tests — while
the single GET keeps its get-or-create contract for determine.rs
(conversion is a filed follow-up behind a caller audit).
GET-OR-DEFAULT, exact-set: every requested UNIQUE id gets exactly one
entry. on_file: false means no row exists; the entry then carries
synthesized column defaults (required: true, exempt: false,
status: "pending") — except sanction_level, which ships null rather
than the column’s DEFAULT 0, because a no-row entry must not fabricate a
sanction datum. on_file is the discriminator the extract maps on.
Multi-row history: tanf_work_requirements has no unique(person_id) —
multiple rows per person are expected history. The batch projects the
NEWEST row by (created_at, id) (deterministic tie-break; the single
GET’s read leg orders by created_at alone and leaves equal-timestamp
ties to the planner).
Request: BatchGetWorkRequirementsRequest
{ "person_ids": ["uuid", "uuid"] }
Response (200): Vec<WorkRequirementStatusEntry> —
[{ person_id, on_file, required, exempt, status, sanction_level }].
POST /v1/work-requirements/{person_id}/activities
Log a single work-activity record (countable hours).
Minimum role: caseworker.
Request:
{
"activity_type": "unsubsidized_employment",
"hours_per_week": "30",
"effective_date": "2026-04-15",
"end_date": null
}
Used to drive ACF-199 WPR (work participation rate) — see canopy-reporting.
GET /v1/work-requirements/{person_id}/activities
List activity records for a person. Filterable by date range.
Minimum role: caseworker.
GET /v1/work-requirements/{person_id}/activities/summary?month=YYYY-MM
Aggregated hours for the target month (required ?month=YYYY-MM), broken down by activity type and classified core / non-core per 45 CFR 261.31. ACF-199 WPR source of truth. Pass ?detail=row to attach a per-work_activity_id drill-down under row_breakdown (#406).
Hours beyond a 45 CFR 261.31 countability cap (#1170) are excluded from core_hours: job search past cap_weeks_per_year ISO weeks in the federal fiscal year (Oct 1 – Sep 30) and vocational ed past cap_months_lifetime distinct calendar months, both read from the vocabulary. Usage is derived from the logged rows at summary time (no mutable counter); total_hours keeps the full prorated amount, each breakdown row itemizes its reclassified portion as cap_excess_hours, and cap_usage reports per-type usage against the cap.
Minimum role: caseworker.
Response (200): WorkActivitiesSummary (total_hours, core_hours, non_core_hours, activity_breakdown with per-type cap_excess_hours, optional row_breakdown, cap_usage). 400 on a malformed month.
POST /v1/work-requirements/activities/summary:batchGet
AIP-231 batch variant of the summary (#1252, ADR-001 Amendment 1 §B4): per-person monthly summaries for a bounded id set in ONE round-trip, backed server-side by ONE person_id = ANY($1) query — the set-based bulk read #320’s closing comment deferred, eliminating the ACF-199 extract’s per-adult GET N+1. Body: BatchActivitiesSummaryRequest (person_ids ≤ 500, month YYYY-MM). One WorkActivitiesSummary per id in first-occurrence request order, duplicates collapsed, zero-hours when a person has no overlapping activities (the single GET’s get-or-zero semantics); no detail=row drill-down (interactive single-person surface only). The #1170 cap reclassification applies per person exactly as on the single GET.
Minimum role: service caller (§B4 bulk reads are service-tier; the interactive per-person GET stays caseworker-reachable — the #1249 least-privilege posture).
Response (200): Vec<WorkActivitiesSummary>. 400 malformed month; 422 over the 500-id cap.
Time Limits
GET /v1/time-limits/{person_id}
Federal 60-month time-limit posture (months used, remaining, hardship-extension state). Surfaces denial state once months_used >= 60 and no hardship applies. Get-or-create: a person with no row on file gets a fresh row INSERTed (with federal_limit_months snapshotted from the parameter table, #441) and returned — kept deliberately for the interactive/determine surface (see the batch sibling below for the read-only alternative).
Minimum role: caseworker.
POST /v1/time-limits:batchGet
Get federal time-limit usage for a set of persons in one round-trip
(#1203, D5 row 5) — one person_id = ANY($1) set query replacing the
ACF-199 extract’s per-adult GET. 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
per-person GET above stays caseworker-reachable.
READ-ONLY: the single GET above is get-or-create — the same
read-that-writes hazard as the work-requirements GET, and its INSERT
additionally snapshots federal_limit_months from the parameter table.
The batch NEVER creates a row (pinned by row-count-unchanged tests) and
ships months_used ONLY — all the extract reads — so a synthesized
entry needs no parameter-table value at all: carrying no
federal_limit_months avoids fabricating a regulatory snapshot for a
person who has no row.
GET-OR-DEFAULT, exact-set: every requested UNIQUE id gets exactly one
entry. on_file: false ⇒ months_used: 0 (the column default a created
row would carry) — distinguishable from a real stored zero via on_file,
never a fabricated tracking claim. Under multi-row history the NEWEST row
by (created_at, id) is projected (the single GET’s read leg has no
ORDER BY at all — the batch is the deterministic surface).
Request: BatchGetTimeLimitsRequest
{ "person_ids": ["uuid", "uuid"] }
Response (200): Vec<TimeLimitStatusEntry> —
[{ person_id, on_file, months_used }].
Sanctions
GET /v1/tanf/sanctions/rollup
Aggregated active-sanction counts across the jurisdiction, grouped by sanction level + status. Feeds the supervisor dashboard (Stage 5 MR2 #496, FU-9).
Minimum role: supervisor.
Response (200): SanctionsRollup — total_sanctioned, level_1_count, level_2_count, level_3_count, expiring_soon_count.
Personal Responsibility (PAMMS 1345-1370)
POST /v1/personal-responsibilities/{application_id}
Record a personal responsibility agreement on an application (school attendance, immunizations, ICW cooperation, etc.).
Minimum role: caseworker.
GRG Payments (PAMMS 1210)
POST /v1/grg/payments
Record a grandparent-as-caregiver payment.
Minimum role: caseworker.
Request:
{
"grandparent_person_id": "uuid",
"grandchild_person_id": "uuid",
"payment_type": "msp",
"au_size": 1
}
payment_type ∈ msp ($100/month Monthly Subsidy Payment) / crisp (one-time Crisis Intervention Services Payment, 4×family-maximum). au_size is required for CRISP.
Response (201): the persisted GrgPayment row.
FTI Audit Log (IRC §6103(l)(7) / Pub 1075)
ADR-014 hash-chain integrity applies — previous_hash / event_hash columns serialised by pg_advisory_xact_lock(2) with canonical timestamp ordering. Chain breaks emit fti.audit_chain.breach_detected and force 503 from GET /v1/security/chain/status?family=fti&service=canopy-tanf on canopy-security (#1206 MR-3; a latched legacy v1 breach surfaces there as breached / legacy_breach_latched).
GET /v1/fti-audit-log
List FTI audit entries. IRS auditor access only.
Minimum role: fti_auditor (dedicated role; admins do NOT auto-hold it). A require_user_only route (#1425): service-class bearers are always 403; under enforce_user_only_routes only an exchanged per-target token carrying the role passes.
Query parameters: from, to, actor_id, event_type, limit, offset.
Overpayment Recovery (PAMMS 9000 / 42 USC 609(a)(1); 45 CFR 263.11)
Cross-program shape — same surface on canopy-snap (/v1/overpayments) and canopy-medicaid (/v1/overpayments), with per-service data isolation per ADR-001. Types shared from the canopy-overpayments crate. Ledger is the system of record; outstanding balance is derived (not stored). Claims auto-open from appeal.overpayment_assessed events (see canopy-appeals).
POST /v1/overpayments
File an overpayment claim.
Minimum role: caseworker (or service-class token).
Request:
{
"person_id": "uuid",
"household_id": "uuid",
"determination_id": "uuid",
"claim_amount_cents": 10000,
"claim_basis": "agency_error",
"error_type": "income-misreported",
"discovered_at": "2026-05-01",
"discovered_by": "uuid"
}
claim_basis ∈ agency_error / inadvertent_household_error / ipv. claim_amount_cents > 0 (rejected with HTTP 400 otherwise).
Response 201: the persisted OverpaymentClaim row (status begins at open).
GET /v1/overpayments[?status=…&limit=…&after_created_at=…&after_id=…]
One keyset page of claims WITH server-side ledger totals (#1222) — same envelope and semantics as the canopy-snap page (see the SNAP API page); the three services stay byte-identical.
status ∈ open / in_repayment / closed / written_off.
POST /v1/overpayments/{id}/repayment-plans
Attach a repayment plan. Rejected with HTTP 409 if the claim is closed or written_off.
Verification Discrepancies (#448)
Per-program counterpart to canopy-snap’s ievs_discrepancies. Backs the worker-portal #392 BFF action handler actions_tanf::resolve_discrepancy_tanf. Discrepancies cover income, work-requirement, and asset verification gaps — discriminated by discrepancy_type.
POST /v1/verification/discrepancies/{id}/resolve
Resolve a pending discrepancy. Sets resolution_status + resolution_notes + resolved_by (via EffectiveUser — the exchanged bearer’s sub, or the preserved 'system' sentinel for a bare service call; #1443 retired the verified-actor shape) + resolved_at = now(). Gated on resolution_status = 'pending' so a second resolve attempt returns 404.
Minimum role: service-or-exchanged (#1425): a service-class caller, or an exchanged user-context token (aud=canopy-tanf exactly, allowlisted azp, caseworker-or-above role) — canopy-web’s #392 BFF action sends the exchanged form when its exchanger is configured.
Request:
{
"resolution_status": "resolved_corrected",
"resolution_notes": "Worker verified $1,250/mo with employer call"
}
Response (200): the updated TanfDiscrepancy row.
Error Codes
| Code | Meaning |
|---|---|
400 |
Invalid input (negative amounts, malformed JSON, etc.) |
401 |
Missing or invalid JWT |
403 |
Insufficient role for the requested endpoint (e.g. redact requires data_steward) |
404 |
Person, application, determination, or overpayment not found |
409 |
Status transition not allowed (recoupment / repayment-plan against a claim in a terminal |
422 |
Semantically-invalid input (e.g., invalid |
503 |
FTI hash-chain breach detected — service refuses FTI-bearing writes until reviewed |
Events Published
-
tanf.determined(IDs + status + head-of-household recipientperson_id+program, no PII or FTI per ADR-004; consumed by canopy-notices to generate the NOA) -
determination.redacted(T2-6 #687) — a data steward crypto-shredded a determination’s snapshot; carries the actorsub+ reason, no plaintext -
tanf.case_closed(consumed by canopy-medicaid for TMA Phase 1) -
tanf.application_approved(consumed by canopy-medicaid for Express Lane eligibility) -
fti.audit_chain.breach_detected(Pub 1075 §9 reportable) -
tanf.overpayment_claimed(#1035) — staged in the same tx as an appeal-opened overpayment claim; routes to the 45 CFR 263.11 demand notice in canopy-notices AND acknowledges the assessment back to canopy-appeals (#1105)
application_id on tanf.determined and tanf.application_approved is the canopy-applications applications.id (the orchestrator-supplied id, same as the signed determination envelope), not the TANF-local tanf_applications.id — the local PK is unresolvable outside canopy-tanf under program isolation (ADR-001). This keeps notices / ELE / cargo xtask seed-verify referential integrity (e.g. canopy-medicaid’s ele_grant_events.source_application_id resolves against canopy_applications.applications.id). (Plan 4 G7.)
Subscribed Events
-
appeal.overpayment_assessed(filtersprogram == "tanf") — auto-opens anOverpaymentClaimrow in this service’s DB. Replaces the prior log-only handler in canopy-enrollment. -
appeal.overpayment_assessment_voided(#1105, same queue) — voids the claim stamped with the event’sassessment_idvia the stamped-storevoid_for_assessment(idempotent; no-claim is a no-op)