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

http://localhost:8014/v1

Authentication

Bearer token (Keycloak RS256 JWT)

Minimum role

Varies per endpoint

Swagger UI

http://localhost:8014/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 its aud is exactly canopy-tanf, its azp is on the authorized_exchanger_azps allowlist, and it carries a caseworker-or-above role. Used by POST /v1/determine and 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); with enforce_user_only_routes=true a 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; its require_service_caller rejects 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:

  1. Builds the assistance unit per PAMMS 1501 (composition rules)

  2. Evaluates GRG path (PAMMS 1210) when grandparent caregivers are present

  3. Runs work-requirement screening (45 CFR Part 261)

  4. Loads active sanction tier + lifecycle (tanf_work_requirements.sanction_level / sanction_expires_at; PAMMS 1351) and non_compliant personal-responsibility rows (PAMMS 1345-1370). These flow into the tanf-eligibility JDM ruleset as input.active_sanction_level / input.sanction_expired / input.personal_responsibility_failures / input.personal_responsibility_pending (#416).

  5. Runs gross + net income tests via canopy-rules (PAMMS 1615 thresholds)

  6. Checks federal 60-month time limit

  7. 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 the snapshot_hash signed into the determination; the snapshot is stored immutably in determination_snapshots. TANF is FTI-bearing (IRC §6103(l)(7)), so the snapshot’s creation additionally joins the ADR-014 fti_audit_log hash chain (resource_type='determination_snapshot') — the FTI-derived artifact at rest inherits the Pub 1075 §4 tamper-evidence + §9 breach pathway.

  8. 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): TanfDeterminationPageitems (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: falsemonths_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): SanctionsRolluptotal_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.

GET /v1/personal-responsibilities/{application_id}

List the responsibility records on an application.

Minimum role: caseworker.

PUT /v1/personal-responsibilities/status/{id}

Update a responsibility’s compliance status (pending / compliant / non_compliant / good_cause / exempt). Non-compliant + gating responsibilities drive denial / sanction in determine.

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_typemsp ($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.

GET /v1/grg/payments/{person_id}

List GRG payments for a person.

Minimum role: caseworker.

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.

GET /v1/fti-audit-log/{id}

Fetch a single entry (full event payload).

Minimum role: fti_auditor — same require_user_only posture as the list route.

GET /v1/fti-audit-log/summary

Aggregated counts by event type + actor over a date range. Used by Pub 1075 §9 quarterly review.

Minimum role: fti_auditor — same require_user_only posture as the list route.

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

statusopen / in_repayment / closed / written_off.

GET /v1/overpayments/{id}

Read a single claim.

POST /v1/overpayments/{id}/repayment-plans

Attach a repayment plan. Rejected with HTTP 409 if the claim is closed or written_off.

POST /v1/overpayments/{id}/recoupments

Append a recoupment ledger entry. Same TX semantics as canopy-snap — first non-zero recoupment flips status to in_repayment; reaching outstanding ≤ 0 flips to closed.

GET /v1/overpayments/{id}/ledger

Full ledger view + derived total_recouped_cents + outstanding_cents.

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 closed / written_off state)

422

Semantically-invalid input (e.g., invalid claim_basis enum value)

503

FTI hash-chain breach detected — service refuses FTI-bearing writes until reviewed

Events Published

  • tanf.determined (IDs + status + head-of-household recipient person_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 actor sub + 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)

NOTE
The 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 (filters program == "tanf") — auto-opens an OverpaymentClaim row 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’s assessment_id via the stamped-store void_for_assessment (idempotent; no-claim is a no-op)

Edit this page · default