Plan: TANF Eligibility Service

On this page

Status

Step Description Status

1

Database migration: TANF application tables, income verification tables, work requirement tracking

Done (2026-04-09) — 9 tables, 9 indexes

2

FTI store layer: read/write FTI with automatic audit logging

Done (2026-04-09) — fti_audited wrapper on all fti_tax_data access

3

SSA SOLQ/BINDEX data store

Done (2026-04-09) — ssa_match_results CRUD

4

Rules client: evaluate tanf-eligibility, tanf-benefit-calculation, tanf-work-requirements rulesets

Done (2026-04-09) — TanfRulesClient with typed I/O structs for 3 rulesets

5

Determination endpoint: POST /v1/determine

Done (2026-04-09) — 11-step flow with FTI audit, time limits, rules evaluation

6

JWS determination signing

Done (2026-04-09) — EcdsaSigner + NoopSigner for UAT

7

Event publishing (FTI-scrubbed payloads)

Done (2026-04-09) — 3 event types with scrub_fti_fields defense-in-depth

8

Work requirement tracking API

Done (2026-04-09) — GET /v1/work-requirements/{person_id}, POST activities, GET time-limits, GET explanation

9

Integration tests

Done (2026-04-09) — 12 tests (determine, work requirements, time limits, FTI audit, RBAC 401/403)

Epic: &31
Branch: feature/tanf-eligibility

Context

canopy-tanf is the second program service implemented in Canopy, after canopy-snap. It is deliberately sequenced second because it introduces complexity that canopy-snap does not have:

  • FTI — TANF is authorized to receive IRS Federal Tax Information under IRC section 6103(l)(7). This means canopy-tanf holds data subject to IRS Publication 1075, requiring the FTI audit logging pattern from the fti-audit-logging plan.

  • SSA data — TANF uses SSA SOLQ/BINDEX under a Computer Matching Agreement separate from SNAP’s CMA.

  • Time limits — federal 60-month lifetime limit, state time limits, exemptions

  • Work requirements — participation rates, countable activities, exemptions, sanctions

  • Deprivation requirements — continued deprivation of parental support (absent parent, incapacity, unemployment)

canopy-tanf validates all four ADRs simultaneously:

  • ADR-001 (isolation): canopy-tanf has its own database, no cross-program data access

  • ADR-002 (determination contract): returns signed determination to canopy-eligibility, never raw data

  • ADR-003 (ruleset-as-data): calls canopy-rules with three TANF rulesets

  • ADR-004 (data tenancy): FTI isolated to canopy-tanf, FTI audit log maintained locally, events scrubbed

The FTI audit log migration stub already exists at services/canopy-tanf/migrations/20260325000001_create_fti_audit_log.sql. The TANF application tables migration is stubbed at services/canopy-tanf/migrations/20260325000000_create_tanf_tables.sql.

Scope

In scope:

  • TANF application data model: applications, household context, income, deprivation status

  • FTI data store: tax return summaries, wage data (IRC section 6103(l)(7))

  • SSA data store: SOLQ/BINDEX match results

  • TANF eligibility determination via canopy-rules rulesets

  • TANF benefit calculation via canopy-rules ruleset

  • Work requirement tracking: activities, hours, exemptions, sanctions

  • Time limit tracking: 60-month federal, state-specific

  • JWS-signed determination returned to canopy-eligibility

  • FTI audit logging on all FTI access paths

  • FTI-scrubbed event payloads

Out of scope:

  • Application intake flow (canopy-applications responsibility)

  • Person/household management (canopy-persons responsibility)

  • Notice generation (canopy-notices subscribes to determination events)

  • TANF case management (post-determination workflow, future plan)

  • TANF-MOE (Maintenance of Effort) reporting (canopy-reporting responsibility)

Design

Data Model

-- services/canopy-tanf/migrations/20260325000000_create_tanf_tables.sql
-- Replaces the stub.

-- TANF applications received for determination.
CREATE TABLE tanf_applications (
    id UUID PRIMARY KEY,
    application_id UUID NOT NULL,       -- reference to canopy-applications
    household_id UUID NOT NULL,         -- reference to canopy-persons
    applicant_person_id UUID NOT NULL,  -- reference to canopy-persons
    status TEXT NOT NULL DEFAULT 'pending',  -- pending, in_progress, determined, error
    received_at TIMESTAMPTZ NOT NULL DEFAULT now(),
    determined_at TIMESTAMPTZ,
    created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
    updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

-- TANF-specific household context snapshot at time of application.
-- Copied from canopy-persons at determination time so the determination
-- is reproducible even if canopy-persons data changes later.
CREATE TABLE tanf_household_snapshots (
    id UUID PRIMARY KEY,
    tanf_application_id UUID NOT NULL REFERENCES tanf_applications(id),
    household_id UUID NOT NULL,
    household_size INTEGER NOT NULL,
    dependent_children INTEGER NOT NULL,
    head_of_household_person_id UUID NOT NULL,
    deprivation_type TEXT,  -- absent_parent, incapacity, unemployment, death
    deprivation_verified BOOLEAN NOT NULL DEFAULT false,
    snapshot_at TIMESTAMPTZ NOT NULL DEFAULT now(),
    created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

-- Income records relevant to TANF determination.
-- Includes both self-reported and FTI-verified income.
CREATE TABLE tanf_income (
    id UUID PRIMARY KEY,
    tanf_application_id UUID NOT NULL REFERENCES tanf_applications(id),
    person_id UUID NOT NULL,
    income_type TEXT NOT NULL,
    amount NUMERIC(10,2) NOT NULL,
    frequency TEXT NOT NULL,
    source TEXT NOT NULL,          -- self_report, fti, ssa_solq, employer
    verification_status TEXT NOT NULL DEFAULT 'unverified',
    created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

-- FTI data received from IRS (IRC section 6103(l)(7)).
-- Access to this table MUST be wrapped with FTI audit logging.
CREATE TABLE fti_tax_data (
    id UUID PRIMARY KEY,
    tanf_application_id UUID NOT NULL REFERENCES tanf_applications(id),
    person_id UUID NOT NULL,
    tax_year INTEGER NOT NULL,
    filing_status TEXT,
    adjusted_gross_income NUMERIC(10,2),
    wages_salaries_tips NUMERIC(10,2),
    self_employment_income NUMERIC(10,2),
    received_at TIMESTAMPTZ NOT NULL DEFAULT now(),
    created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

-- SSA SOLQ/BINDEX match results.
CREATE TABLE ssa_match_results (
    id UUID PRIMARY KEY,
    tanf_application_id UUID NOT NULL REFERENCES tanf_applications(id),
    person_id UUID NOT NULL,
    match_type TEXT NOT NULL,       -- solq, bindex
    ssn_verified BOOLEAN,
    benefits_status TEXT,           -- title_ii, ssi, both, none
    monthly_benefit_amount NUMERIC(10,2),
    match_date DATE NOT NULL,
    created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

-- TANF time limit tracking per person.
CREATE TABLE tanf_time_limits (
    id UUID PRIMARY KEY,
    person_id UUID NOT NULL,
    months_used INTEGER NOT NULL DEFAULT 0,
    federal_limit_months INTEGER NOT NULL DEFAULT 60,
    state_limit_months INTEGER,
    exempt BOOLEAN NOT NULL DEFAULT false,
    exemption_reason TEXT,
    last_counted_month DATE,
    created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
    updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

-- Work requirement tracking.
CREATE TABLE tanf_work_requirements (
    id UUID PRIMARY KEY,
    person_id UUID NOT NULL,
    tanf_application_id UUID REFERENCES tanf_applications(id),
    required BOOLEAN NOT NULL DEFAULT true,
    exempt BOOLEAN NOT NULL DEFAULT false,
    exemption_reason TEXT,  -- age, disability, caring_for_infant, domestic_violence
    status TEXT NOT NULL DEFAULT 'pending',  -- pending, compliant, non_compliant, sanctioned
    sanction_level INTEGER DEFAULT 0,  -- progressive sanctions: 0, 1, 2, 3
    created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
    updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

-- Work activity log: hours and activities reported.
CREATE TABLE tanf_work_activities (
    id UUID PRIMARY KEY,
    work_requirement_id UUID NOT NULL REFERENCES tanf_work_requirements(id),
    activity_type TEXT NOT NULL,     -- employment, job_search, community_service, education, vocational_training
    hours_per_week NUMERIC(5,1) NOT NULL,
    effective_date DATE NOT NULL,
    end_date DATE,
    verified BOOLEAN NOT NULL DEFAULT false,
    created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

-- TANF determinations produced by this service.
-- Stored locally as the program service's record.
-- The signed version is returned to canopy-eligibility.
CREATE TABLE tanf_determinations (
    id UUID PRIMARY KEY,
    tanf_application_id UUID NOT NULL REFERENCES tanf_applications(id),
    household_id UUID NOT NULL,
    status TEXT NOT NULL,             -- approved, denied, pending_verification
    benefit_amount NUMERIC(10,2),
    benefit_unit TEXT DEFAULT 'monthly_usd',
    effective_date DATE,
    expiration_date DATE,
    renewal_date DATE,
    basis TEXT,
    denial_reason TEXT,
    program_service_version TEXT NOT NULL,
    determined_at TIMESTAMPTZ NOT NULL DEFAULT now(),
    signature TEXT NOT NULL,          -- detached JWS
    created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

-- Indexes
CREATE INDEX idx_tanf_applications_application ON tanf_applications(application_id);
CREATE INDEX idx_tanf_applications_household ON tanf_applications(household_id);
CREATE INDEX idx_tanf_income_application ON tanf_income(tanf_application_id);
CREATE INDEX idx_fti_tax_data_application ON fti_tax_data(tanf_application_id);
CREATE INDEX idx_fti_tax_data_person ON fti_tax_data(person_id);
CREATE INDEX idx_ssa_match_results_application ON ssa_match_results(tanf_application_id);
CREATE INDEX idx_tanf_time_limits_person ON tanf_time_limits(person_id);
CREATE INDEX idx_tanf_work_requirements_person ON tanf_work_requirements(person_id);
CREATE INDEX idx_tanf_determinations_application ON tanf_determinations(tanf_application_id);

API Endpoints

Method Path Description

POST

/v1/determine

Accept ApplicationContext from canopy-eligibility, run TANF eligibility determination, return signed Determination. This is the ADR-002 black-box endpoint.

GET

/v1/determinations/{id}

Get a stored TANF determination by ID.

GET

/v1/determinations/{id}/explanation

Human-readable explanation of the determination basis (per ADR-002 consequence — narrative, not data).

GET

/v1/work-requirements/{person_id}

Get current work requirement status for a person.

POST

/v1/work-requirements/{person_id}/activities

Log a work activity.

GET

/v1/time-limits/{person_id}

Get time limit status for a person.

GET

/v1/fti-audit-log

FTI audit log query (restricted to fti_auditor role, per fti-audit-logging plan).

Determination Flow

POST /v1/determine (from canopy-eligibility)
│
├── 1. Parse ApplicationContext, create tanf_applications row
│
├── 2. Fetch household data from canopy-persons
│      GET /v1/households/{household_id}
│      GET /v1/persons/{id} for each member
│      Store snapshot in tanf_household_snapshots
│
├── 3. Fetch/verify income data
│   ├── 3a. Self-reported income from canopy-persons (via ApplicationContext IDs)
│   ├── 3b. FTI verification (if available) — audit-logged read from fti_tax_data
│   └── 3c. SSA SOLQ/BINDEX match (if available) — read from ssa_match_results
│   Store all in tanf_income
│
├── 4. Check time limits
│      Read tanf_time_limits for applicant
│      If federal 60-month limit exceeded and not exempt → deny
│
├── 5. Check deprivation requirement
│      Verify continued deprivation (absent parent, incapacity, unemployment)
│      If no qualifying deprivation → deny
│
├── 6. Evaluate eligibility via canopy-rules
│      POST /v1/evaluate to canopy-rules with:
│        ruleset: "tanf-eligibility"
│        input: { household_size, income, deprivation, time_limit_status }
│      Receive: { eligible: bool, denial_reasons: [] }
│
├── 7. If eligible, calculate benefit via canopy-rules
│      POST /v1/evaluate to canopy-rules with:
│        ruleset: "tanf-benefit-calculation"
│        input: { household_size, countable_income, state_max_benefit }
│      Receive: { benefit_amount, effective_date, expiration_date }
│
├── 8. Check work requirements via canopy-rules
│      POST /v1/evaluate to canopy-rules with:
│        ruleset: "tanf-work-requirements"
│        input: { person_age, disability_status, child_ages, current_activities }
│      Receive: { required: bool, exempt: bool, exemption_reason }
│      Store/update tanf_work_requirements
│
├── 9. Build Determination struct
│      Sign with ECDSA P-256 (DeterminationSigner)
│      Store in tanf_determinations
│
├── 10. Publish tanf.determined event (FTI-scrubbed payload)
│       Payload: { application_id, household_id, status, determined_at }
│       NO income amounts, NO FTI fields, NO SSA data
│
└── 11. Return signed Determination to canopy-eligibility

Rulesets

Three JDM ruleset files in rulesets/georgia/:

Ruleset Purpose

tanf-eligibility.json

Income tests (gross and net income limits as % of FPL), deprivation verification, citizenship/residency, household composition requirements

tanf-benefit-calculation.json

Standard of need, payment standard, benefit amount = max(0, payment_standard - countable_income), minimum benefit floor

tanf-work-requirements.json

Who is required to participate, exemption categories, countable activities, minimum hours (20/30 per week), progressive sanctions

FTI Access Pattern

Every function that reads or writes fti_tax_data uses the fti_audited wrapper from the fti-audit-logging plan:

pub async fn read_fti_tax_data(
    db: &DbPool,
    audit_logger: &dyn FtiAuditLogger,
    tanf_application_id: Uuid,
    person_id: Uuid,
    request_context: &RequestContext,
) -> Result<Vec<FtiTaxData>, TanfError> {
    fti_audited(
        audit_logger,
        FtiAuditEntry {
            accessed_by: request_context.user_id.clone(),
            purpose_code: FtiPurposeCode::TanfElig,
            data_elements: vec!["agi".into(), "filing_status".into(), "wages".into()],
            originating_system: "canopy-tanf".into(),
            action: FtiAction::Read,
            resource_type: "fti_tax_data".into(),
            resource_id: None,
            request_id: Some(request_context.request_id),
            ip_address: request_context.ip_address.clone(),
            success: true,  // updated by wrapper
        },
        sqlx::query_as::<_, FtiTaxData>(
            "SELECT * FROM fti_tax_data WHERE tanf_application_id = $1 AND person_id = $2"
        )
        .bind(tanf_application_id)
        .bind(person_id)
        .fetch_all(db.inner()),
    )
    .await
}

Events

Published to canopy.events with FTI-scrubbed payloads:

  • tanf.determined{ application_id, household_id, status, determined_at }

  • tanf.work_requirement_updated{ person_id, status, updated_at }

  • tanf.time_limit_warning{ person_id, months_remaining, warned_at }

NO income amounts, NO FTI fields, NO SSA match data in event payloads.

Steps

Step 1: Database Migration

Files: services/canopy-tanf/migrations/20260325000000_create_tanf_tables.sql

Replace the stub migration with the full schema from the Design section. The FTI audit log migration (20260325000001) is updated by the fti-audit-logging plan.

Uncomment migration runner in services/canopy-tanf/src/main.rs.

Step 2: Store Layer — FTI Data

Files: services/canopy-tanf/src/store/mod.rs (new), services/canopy-tanf/src/store/models.rs (new), services/canopy-tanf/src/store/fti.rs (new)

Define Rust structs for fti_tax_data table. Implement CRUD with fti_audited wrapper on every operation.

// services/canopy-tanf/src/store/models.rs

#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
pub struct FtiTaxData {
    pub id: Uuid,
    pub tanf_application_id: Uuid,
    pub person_id: Uuid,
    pub tax_year: i32,
    pub filing_status: Option<String>,
    pub adjusted_gross_income: Option<Decimal>,
    pub wages_salaries_tips: Option<Decimal>,
    pub self_employment_income: Option<Decimal>,
    pub received_at: DateTime<Utc>,
    pub created_at: DateTime<Utc>,
}

#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
pub struct TanfApplication {
    pub id: Uuid,
    pub application_id: Uuid,
    pub household_id: Uuid,
    pub applicant_person_id: Uuid,
    pub status: String,
    pub received_at: DateTime<Utc>,
    pub determined_at: Option<DateTime<Utc>>,
    pub created_at: DateTime<Utc>,
    pub updated_at: DateTime<Utc>,
}

#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
pub struct TanfHouseholdSnapshot {
    pub id: Uuid,
    pub tanf_application_id: Uuid,
    pub household_id: Uuid,
    pub household_size: i32,
    pub dependent_children: i32,
    pub head_of_household_person_id: Uuid,
    pub deprivation_type: Option<String>,
    pub deprivation_verified: bool,
    pub snapshot_at: DateTime<Utc>,
    pub created_at: DateTime<Utc>,
}

#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
pub struct TanfIncome {
    pub id: Uuid,
    pub tanf_application_id: Uuid,
    pub person_id: Uuid,
    pub income_type: String,
    pub amount: Decimal,
    pub frequency: String,
    pub source: String,
    pub verification_status: String,
    pub created_at: DateTime<Utc>,
}

#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
pub struct SsaMatchResult {
    pub id: Uuid,
    pub tanf_application_id: Uuid,
    pub person_id: Uuid,
    pub match_type: String,
    pub ssn_verified: Option<bool>,
    pub benefits_status: Option<String>,
    pub monthly_benefit_amount: Option<Decimal>,
    pub match_date: NaiveDate,
    pub created_at: DateTime<Utc>,
}

#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
pub struct TanfTimeLimit {
    pub id: Uuid,
    pub person_id: Uuid,
    pub months_used: i32,
    pub federal_limit_months: i32,  // 60
    pub state_limit_months: Option<i32>,
    pub exempt: bool,
    pub exemption_reason: Option<String>,
    pub last_counted_month: Option<NaiveDate>,
    pub created_at: DateTime<Utc>,
    pub updated_at: DateTime<Utc>,
}

#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
pub struct TanfWorkRequirement {
    pub id: Uuid,
    pub person_id: Uuid,
    pub tanf_application_id: Option<Uuid>,
    pub required: bool,
    pub exempt: bool,
    pub exemption_reason: Option<String>,
    pub status: String,
    pub sanction_level: Option<i32>,
    pub created_at: DateTime<Utc>,
    pub updated_at: DateTime<Utc>,
}

#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
pub struct TanfWorkActivity {
    pub id: Uuid,
    pub work_requirement_id: Uuid,
    pub activity_type: String,
    pub hours_per_week: Decimal,
    pub effective_date: NaiveDate,
    pub end_date: Option<NaiveDate>,
    pub verified: bool,
    pub created_at: DateTime<Utc>,
}

#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
pub struct TanfDetermination {
    pub id: Uuid,
    pub tanf_application_id: Uuid,
    pub household_id: Uuid,
    pub status: String,
    pub benefit_amount: Option<Decimal>,
    pub benefit_unit: Option<String>,
    pub effective_date: Option<NaiveDate>,
    pub expiration_date: Option<NaiveDate>,
    pub renewal_date: Option<NaiveDate>,
    pub basis: Option<String>,
    pub denial_reason: Option<String>,
    pub program_service_version: String,
    pub determined_at: DateTime<Utc>,
    pub signature: String,
    pub created_at: DateTime<Utc>,
}

FTI-wrapped store functions:

// services/canopy-tanf/src/store/fti.rs

use canopy_common::fti_audit::{fti_audited, FtiPurposeCode, FtiAction, FtiAuditError};
use super::models::FtiTaxData;

/// Read FTI tax data with automatic audit logging.
/// Every access to fti_tax_data MUST use fti_audited.
pub async fn read_fti_tax_data(
    pool: &PgPool,
    tanf_application_id: Uuid,
    person_id: Uuid,
    accessed_by: &str,
    request_id: Option<Uuid>,
    ip_address: Option<&str>,
) -> Result<Vec<FtiTaxData>, FtiAuditError> {
    fti_audited(
        pool,
        accessed_by,
        FtiPurposeCode::TanfEligibility,
        &["adjusted_gross_income", "filing_status", "wages_salaries_tips"],
        "canopy-tanf",
        FtiAction::Read,
        "fti_tax_data",
        None,
        request_id,
        ip_address,
        || async {
            sqlx::query_as::<_, FtiTaxData>(
                "SELECT id, tanf_application_id, person_id, tax_year,
                        filing_status, adjusted_gross_income, wages_salaries_tips,
                        self_employment_income, received_at, created_at
                 FROM fti_tax_data
                 WHERE tanf_application_id = $1 AND person_id = $2"
            )
            .bind(tanf_application_id)
            .bind(person_id)
            .fetch_all(pool)
            .await
            .map_err(FtiAuditError::Database)
        },
    )
    .await
}

/// Write FTI tax data with automatic audit logging.
pub async fn insert_fti_tax_data(
    pool: &PgPool,
    data: &FtiTaxData,
    accessed_by: &str,
    request_id: Option<Uuid>,
    ip_address: Option<&str>,
) -> Result<(), FtiAuditError> {
    fti_audited(
        pool,
        accessed_by,
        FtiPurposeCode::TanfEligibility,
        &["adjusted_gross_income", "filing_status", "wages_salaries_tips", "self_employment_income"],
        "canopy-tanf",
        FtiAction::Write,
        "fti_tax_data",
        Some(data.id),
        request_id,
        ip_address,
        || async {
            sqlx::query(
                "INSERT INTO fti_tax_data
                    (id, tanf_application_id, person_id, tax_year, filing_status,
                     adjusted_gross_income, wages_salaries_tips, self_employment_income, received_at)
                 VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)"
            )
            .bind(data.id)
            .bind(data.tanf_application_id)
            .bind(data.person_id)
            .bind(data.tax_year)
            .bind(&data.filing_status)
            .bind(data.adjusted_gross_income)
            .bind(data.wages_salaries_tips)
            .bind(data.self_employment_income)
            .bind(data.received_at)
            .execute(pool)
            .await
            .map(|_| ())
            .map_err(FtiAuditError::Database)
        },
    )
    .await
}

Step 3: Store Layer — SSA Data, Applications, Snapshots

Files: services/canopy-tanf/src/store/ssa.rs (new), services/canopy-tanf/src/store/applications.rs (new), services/canopy-tanf/src/store/snapshots.rs (new), services/canopy-tanf/src/store/income.rs (new)

// services/canopy-tanf/src/store/applications.rs

pub async fn create_application(
    pool: &PgPool,
    application_id: Uuid,
    household_id: Uuid,
    applicant_person_id: Uuid,
) -> Result<TanfApplication, sqlx::Error> {
    sqlx::query_as::<_, TanfApplication>(
        "INSERT INTO tanf_applications (id, application_id, household_id, applicant_person_id)
         VALUES ($1, $2, $3, $4)
         RETURNING *"
    )
    .bind(Uuid::now_v7())
    .bind(application_id)
    .bind(household_id)
    .bind(applicant_person_id)
    .fetch_one(pool)
    .await
}

pub async fn update_application_status(
    pool: &PgPool,
    id: Uuid,
    status: &str,
) -> Result<TanfApplication, sqlx::Error> {
    let determined_at = if status == "determined" { Some(Utc::now()) } else { None };
    sqlx::query_as::<_, TanfApplication>(
        "UPDATE tanf_applications
         SET status = $1, determined_at = $2, updated_at = now()
         WHERE id = $3
         RETURNING *"
    )
    .bind(status)
    .bind(determined_at)
    .bind(id)
    .fetch_one(pool)
    .await
}

pub async fn get_application(pool: &PgPool, id: Uuid) -> Result<Option<TanfApplication>, sqlx::Error> {
    sqlx::query_as::<_, TanfApplication>("SELECT * FROM tanf_applications WHERE id = $1")
        .bind(id)
        .fetch_optional(pool)
        .await
}
// services/canopy-tanf/src/store/snapshots.rs

pub async fn create_household_snapshot(
    pool: &PgPool,
    tanf_application_id: Uuid,
    household_id: Uuid,
    household_size: i32,
    dependent_children: i32,
    head_of_household_person_id: Uuid,
    deprivation_type: Option<&str>,
    deprivation_verified: bool,
) -> Result<TanfHouseholdSnapshot, sqlx::Error> {
    sqlx::query_as::<_, TanfHouseholdSnapshot>(
        "INSERT INTO tanf_household_snapshots
            (id, tanf_application_id, household_id, household_size, dependent_children,
             head_of_household_person_id, deprivation_type, deprivation_verified)
         VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
         RETURNING *"
    )
    .bind(Uuid::now_v7())
    .bind(tanf_application_id)
    .bind(household_id)
    .bind(household_size)
    .bind(dependent_children)
    .bind(head_of_household_person_id)
    .bind(deprivation_type)
    .bind(deprivation_verified)
    .fetch_one(pool)
    .await
}
// services/canopy-tanf/src/store/ssa.rs

pub async fn get_ssa_match_results(
    pool: &PgPool,
    tanf_application_id: Uuid,
    person_id: Uuid,
) -> Result<Vec<SsaMatchResult>, sqlx::Error> {
    sqlx::query_as::<_, SsaMatchResult>(
        "SELECT * FROM ssa_match_results
         WHERE tanf_application_id = $1 AND person_id = $2"
    )
    .bind(tanf_application_id)
    .bind(person_id)
    .fetch_all(pool)
    .await
}

pub async fn insert_ssa_match_result(
    pool: &PgPool,
    result: &SsaMatchResult,
) -> Result<(), sqlx::Error> {
    sqlx::query(
        "INSERT INTO ssa_match_results
            (id, tanf_application_id, person_id, match_type, ssn_verified,
             benefits_status, monthly_benefit_amount, match_date)
         VALUES ($1, $2, $3, $4, $5, $6, $7, $8)"
    )
    .bind(result.id)
    .bind(result.tanf_application_id)
    .bind(result.person_id)
    .bind(&result.match_type)
    .bind(result.ssn_verified)
    .bind(&result.benefits_status)
    .bind(result.monthly_benefit_amount)
    .bind(result.match_date)
    .execute(pool)
    .await?;
    Ok(())
}

Step 4: Store Layer — Time Limits and Work Requirements

Files: services/canopy-tanf/src/store/time_limits.rs (new), services/canopy-tanf/src/store/work_requirements.rs (new)

// services/canopy-tanf/src/store/time_limits.rs

pub async fn get_time_limit(
    pool: &PgPool,
    person_id: Uuid,
) -> Result<Option<TanfTimeLimit>, sqlx::Error> {
    sqlx::query_as::<_, TanfTimeLimit>(
        "SELECT * FROM tanf_time_limits WHERE person_id = $1"
    )
    .bind(person_id)
    .fetch_optional(pool)
    .await
}

pub async fn upsert_time_limit(
    pool: &PgPool,
    person_id: Uuid,
    months_used: i32,
    exempt: bool,
    exemption_reason: Option<&str>,
    last_counted_month: Option<NaiveDate>,
) -> Result<TanfTimeLimit, sqlx::Error> {
    sqlx::query_as::<_, TanfTimeLimit>(
        "INSERT INTO tanf_time_limits (id, person_id, months_used, exempt, exemption_reason, last_counted_month)
         VALUES ($1, $2, $3, $4, $5, $6)
         ON CONFLICT (person_id) DO UPDATE SET
             months_used = $3, exempt = $4, exemption_reason = $5,
             last_counted_month = $6, updated_at = now()
         RETURNING *"
    )
    .bind(Uuid::now_v7())
    .bind(person_id)
    .bind(months_used)
    .bind(exempt)
    .bind(exemption_reason)
    .bind(last_counted_month)
    .fetch_one(pool)
    .await
}

/// Check if the person has exceeded the federal 60-month time limit.
pub fn is_time_limit_exceeded(time_limit: &TanfTimeLimit) -> bool {
    !time_limit.exempt && time_limit.months_used >= time_limit.federal_limit_months
}
// services/canopy-tanf/src/store/work_requirements.rs

pub async fn get_work_requirement(
    pool: &PgPool,
    person_id: Uuid,
) -> Result<Option<TanfWorkRequirement>, sqlx::Error> {
    sqlx::query_as::<_, TanfWorkRequirement>(
        "SELECT * FROM tanf_work_requirements WHERE person_id = $1 ORDER BY created_at DESC LIMIT 1"
    )
    .bind(person_id)
    .fetch_optional(pool)
    .await
}

pub async fn upsert_work_requirement(
    pool: &PgPool,
    person_id: Uuid,
    tanf_application_id: Option<Uuid>,
    required: bool,
    exempt: bool,
    exemption_reason: Option<&str>,
    status: &str,
    sanction_level: i32,
) -> Result<TanfWorkRequirement, sqlx::Error> {
    sqlx::query_as::<_, TanfWorkRequirement>(
        "INSERT INTO tanf_work_requirements
            (id, person_id, tanf_application_id, required, exempt, exemption_reason, status, sanction_level)
         VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
         RETURNING *"
    )
    .bind(Uuid::now_v7())
    .bind(person_id)
    .bind(tanf_application_id)
    .bind(required)
    .bind(exempt)
    .bind(exemption_reason)
    .bind(status)
    .bind(sanction_level)
    .fetch_one(pool)
    .await
}

pub async fn log_work_activity(
    pool: &PgPool,
    work_requirement_id: Uuid,
    activity_type: &str,
    hours_per_week: Decimal,
    effective_date: NaiveDate,
    end_date: Option<NaiveDate>,
) -> Result<TanfWorkActivity, sqlx::Error> {
    sqlx::query_as::<_, TanfWorkActivity>(
        "INSERT INTO tanf_work_activities
            (id, work_requirement_id, activity_type, hours_per_week, effective_date, end_date)
         VALUES ($1, $2, $3, $4, $5, $6)
         RETURNING *"
    )
    .bind(Uuid::now_v7())
    .bind(work_requirement_id)
    .bind(activity_type)
    .bind(hours_per_week)
    .bind(effective_date)
    .bind(end_date)
    .fetch_one(pool)
    .await
}

pub async fn get_work_activities(
    pool: &PgPool,
    work_requirement_id: Uuid,
) -> Result<Vec<TanfWorkActivity>, sqlx::Error> {
    sqlx::query_as::<_, TanfWorkActivity>(
        "SELECT * FROM tanf_work_activities
         WHERE work_requirement_id = $1
         ORDER BY effective_date DESC"
    )
    .bind(work_requirement_id)
    .fetch_all(pool)
    .await
}

Step 5: Rules Client

Files: services/canopy-tanf/src/rules_client.rs (new)

Implement HTTP client for canopy-rules with three ruleset evaluations:

pub struct TanfRulesClient {
    http: reqwest::Client,
    rules_base_url: String,
}

/// Input for the tanf-eligibility ruleset.
#[derive(Debug, Serialize)]
pub struct TanfEligibilityInput {
    pub household_size: i32,
    pub dependent_children: i32,
    pub gross_income: Decimal,
    pub net_income: Decimal,
    pub deprivation_type: Option<String>,
    pub deprivation_verified: bool,
    pub citizenship_verified: bool,
    pub residency_verified: bool,
    pub time_limit_months_used: i32,
    pub time_limit_exempt: bool,
}

/// Output from the tanf-eligibility ruleset.
#[derive(Debug, Deserialize)]
pub struct TanfEligibilityOutput {
    pub eligible: bool,
    pub denial_reasons: Vec<String>,
    pub gross_income_test_passed: bool,
    pub net_income_test_passed: bool,
    pub deprivation_test_passed: bool,
}

/// Input for the tanf-benefit-calculation ruleset.
#[derive(Debug, Serialize)]
pub struct TanfBenefitInput {
    pub household_size: i32,
    pub countable_income: Decimal,
    pub state_max_benefit: Decimal,
    pub payment_standard: Decimal,
}

/// Output from the tanf-benefit-calculation ruleset.
#[derive(Debug, Deserialize)]
pub struct TanfBenefitOutput {
    pub benefit_amount: Decimal,
    pub effective_date: NaiveDate,
    pub expiration_date: NaiveDate,
    pub calculation_basis: String,
}

/// Input for the tanf-work-requirements ruleset.
#[derive(Debug, Serialize)]
pub struct WorkRequirementsInput {
    pub person_age: i32,
    pub disability_status: Option<String>,
    pub youngest_child_age_months: Option<i32>,
    pub domestic_violence_waiver: bool,
    pub current_activities: Vec<WorkActivityInput>,
}

#[derive(Debug, Serialize)]
pub struct WorkActivityInput {
    pub activity_type: String,
    pub hours_per_week: Decimal,
}

/// Output from the tanf-work-requirements ruleset.
#[derive(Debug, Deserialize)]
pub struct WorkRequirementsOutput {
    pub required: bool,
    pub exempt: bool,
    pub exemption_reason: Option<String>,
    pub hours_met: bool,
    pub minimum_hours_required: Decimal,
    pub total_hours_reported: Decimal,
}

impl TanfRulesClient {
    pub fn new(http: reqwest::Client, rules_base_url: String) -> Self {
        Self { http, rules_base_url }
    }

    /// Evaluate TANF eligibility via the tanf-eligibility ruleset.
    pub async fn evaluate_eligibility(
        &self,
        input: TanfEligibilityInput,
    ) -> Result<TanfEligibilityOutput, RulesError> {
        let url = format!("{}/v1/evaluate", self.rules_base_url);
        let body = serde_json::json!({
            "ruleset": "tanf-eligibility",
            "input": input,
        });
        let resp = self.http.post(&url).json(&body).send().await?;
        if !resp.status().is_success() {
            let status = resp.status();
            let text = resp.text().await.unwrap_or_default();
            return Err(RulesError::EvaluationFailed { status, body: text });
        }
        let output: TanfEligibilityOutput = resp.json().await?;
        Ok(output)
    }

    /// Calculate TANF benefit via the tanf-benefit-calculation ruleset.
    pub async fn calculate_benefit(
        &self,
        input: TanfBenefitInput,
    ) -> Result<TanfBenefitOutput, RulesError> {
        let url = format!("{}/v1/evaluate", self.rules_base_url);
        let body = serde_json::json!({
            "ruleset": "tanf-benefit-calculation",
            "input": input,
        });
        let resp = self.http.post(&url).json(&body).send().await?;
        if !resp.status().is_success() {
            let status = resp.status();
            let text = resp.text().await.unwrap_or_default();
            return Err(RulesError::EvaluationFailed { status, body: text });
        }
        let output: TanfBenefitOutput = resp.json().await?;
        Ok(output)
    }

    /// Evaluate work requirements via the tanf-work-requirements ruleset.
    pub async fn evaluate_work_requirements(
        &self,
        input: WorkRequirementsInput,
    ) -> Result<WorkRequirementsOutput, RulesError> {
        let url = format!("{}/v1/evaluate", self.rules_base_url);
        let body = serde_json::json!({
            "ruleset": "tanf-work-requirements",
            "input": input,
        });
        let resp = self.http.post(&url).json(&body).send().await?;
        if !resp.status().is_success() {
            let status = resp.status();
            let text = resp.text().await.unwrap_or_default();
            return Err(RulesError::EvaluationFailed { status, body: text });
        }
        let output: WorkRequirementsOutput = resp.json().await?;
        Ok(output)
    }
}

/// Error type for canopy-rules client.
#[derive(Debug, thiserror::Error)]
pub enum RulesError {
    #[error("HTTP request failed: {0}")]
    Http(#[from] reqwest::Error),
    #[error("Rules evaluation failed: status={status}, body={body}")]
    EvaluationFailed { status: reqwest::StatusCode, body: String },
}

Step 6: Determination Endpoint

Files: services/canopy-tanf/src/determine.rs (new), services/canopy-tanf/src/api/mod.rs

Implement the POST /v1/determine handler following the determination flow from the Design section. This is the core of canopy-tanf — the black-box determination endpoint per ADR-002.

// services/canopy-tanf/src/determine.rs

/// POST /v1/determine
/// Accept ApplicationContext from canopy-eligibility, run TANF determination.
pub async fn handle_determine(
    claims: Extension<Claims>,
    State(state): State<AppState>,
    Json(context): Json<ApplicationContext>,
) -> Result<Json<SignedDetermination>, ApiError> {
    // Step 1: Create tanf_applications row
    let app = store::applications::create_application(
        state.db.inner(),
        context.application_id,
        context.household_id,
        context.applicant_person_id,
    ).await.map_err(ApiError::internal)?;

    // Step 2: Fetch household data and create snapshot
    let household = state.persons_client.get_household(context.household_id).await?;
    let members = state.persons_client.get_household_members(context.household_id).await?;
    let snapshot = store::snapshots::create_household_snapshot(
        state.db.inner(), app.id, household.id,
        members.len() as i32,
        members.iter().filter(|m| m.is_dependent_child).count() as i32,
        household.head_of_household_id,
        household.deprivation_type.as_deref(),
        household.deprivation_verified,
    ).await.map_err(ApiError::internal)?;

    // Step 3: Fetch/verify income (FTI access is audit-logged)
    let fti_data = store::fti::read_fti_tax_data(
        state.db.inner(), app.id, context.applicant_person_id,
        &claims.sub, Some(context.request_id), claims.ip_address.as_deref(),
    ).await?;

    let ssa_data = store::ssa::get_ssa_match_results(
        state.db.inner(), app.id, context.applicant_person_id,
    ).await.map_err(ApiError::internal)?;

    // Step 4: Check time limits
    let time_limit = store::time_limits::get_time_limit(
        state.db.inner(), context.applicant_person_id,
    ).await.map_err(ApiError::internal)?;
    let time_limit_months = time_limit.as_ref().map(|tl| tl.months_used).unwrap_or(0);
    let time_limit_exempt = time_limit.as_ref().map(|tl| tl.exempt).unwrap_or(false);

    // Step 5: Deprivation check (from snapshot)
    // If no qualifying deprivation, will be caught by rules engine

    // Step 6: Evaluate eligibility via canopy-rules
    let eligibility_input = TanfEligibilityInput {
        household_size: snapshot.household_size,
        dependent_children: snapshot.dependent_children,
        gross_income: calculate_gross_income(&fti_data, &ssa_data),
        net_income: calculate_net_income(&fti_data, &ssa_data),
        deprivation_type: snapshot.deprivation_type.clone(),
        deprivation_verified: snapshot.deprivation_verified,
        citizenship_verified: true,  // from ApplicationContext
        residency_verified: true,     // from ApplicationContext
        time_limit_months_used: time_limit_months,
        time_limit_exempt: time_limit_exempt,
    };
    let eligibility_result = state.rules_client.evaluate_eligibility(eligibility_input).await?;

    // Step 7: If eligible, calculate benefit
    let benefit_result = if eligibility_result.eligible {
        let benefit_input = TanfBenefitInput {
            household_size: snapshot.household_size,
            countable_income: calculate_net_income(&fti_data, &ssa_data),
            state_max_benefit: Decimal::from(277),  // Georgia TANF max for family of 3
            payment_standard: Decimal::from(277),
        };
        Some(state.rules_client.calculate_benefit(benefit_input).await?)
    } else {
        None
    };

    // Step 8: Check work requirements
    let work_input = WorkRequirementsInput {
        person_age: calculate_age(&context.applicant_dob),
        disability_status: context.disability_status.clone(),
        youngest_child_age_months: context.youngest_child_age_months,
        domestic_violence_waiver: context.domestic_violence_waiver.unwrap_or(false),
        current_activities: vec![],  // populated from existing work_activities
    };
    let work_result = state.rules_client.evaluate_work_requirements(work_input).await?;

    // Store work requirement
    store::work_requirements::upsert_work_requirement(
        state.db.inner(), context.applicant_person_id, Some(app.id),
        work_result.required, work_result.exempt,
        work_result.exemption_reason.as_deref(),
        if work_result.exempt { "exempt" } else if work_result.hours_met { "compliant" } else { "pending" },
        0,
    ).await.map_err(ApiError::internal)?;

    // Step 9: Build and sign determination
    let status = if eligibility_result.eligible { "approved" } else { "denied" };
    let determination = build_determination(
        app.id, context.household_id, status,
        benefit_result.as_ref(), &eligibility_result,
    );
    let signed = state.signer.sign(&determination)?;

    // Store determination locally
    store::determinations::insert_determination(state.db.inner(), &determination, &signed.signature)
        .await.map_err(ApiError::internal)?;

    // Step 10: Publish tanf.determined event (FTI-scrubbed)
    events::publish_tanf_determined(
        &state.publisher, context.application_id, context.household_id, status,
    ).await.map_err(ApiError::internal)?;

    // Step 11: Return signed determination
    Ok(Json(signed))
}

Wire the DeterminationSigner (from determination-signing plan) to sign the determination before returning it.

Step 7: Signing Integration

Files: services/canopy-tanf/src/main.rs, services/canopy-tanf/Cargo.toml

Load the TANF signing key from CANOPY_TANF_SIGNING_KEY environment variable at startup. Create EcdsaDeterminationSigner and add it to the service state.

// In main.rs:
let signing_key = std::env::var("CANOPY_TANF_SIGNING_KEY")
    .context("CANOPY_TANF_SIGNING_KEY not set")?;
let signer = EcdsaDeterminationSigner::from_pem(&signing_key)?;

Step 8: Event Publishing

Files: services/canopy-tanf/src/events.rs

Implement FTI-scrubbed event publishing:

use canopy_common::fti_audit::scrub_fti_fields;
use canopy_mq::{EventEnvelope, Publisher};

/// Published to canopy.events when TANF determination completes.
/// Contains NO FTI -- only IDs and status per ADR-004.
#[derive(Debug, Serialize)]
pub struct TanfDeterminedEvent {
    pub application_id: Uuid,
    pub household_id: Uuid,
    pub status: String,
    pub determined_at: DateTime<Utc>,
}

/// Published when work requirement status changes.
#[derive(Debug, Serialize)]
pub struct WorkRequirementUpdatedEvent {
    pub person_id: Uuid,
    pub status: String,
    pub updated_at: DateTime<Utc>,
}

/// Published when a person is approaching their time limit.
#[derive(Debug, Serialize)]
pub struct TimeLimitWarningEvent {
    pub person_id: Uuid,
    pub months_remaining: i32,
    pub warned_at: DateTime<Utc>,
}

pub async fn publish_tanf_determined(
    publisher: &Publisher,
    application_id: Uuid,
    household_id: Uuid,
    status: &str,
) -> Result<(), lapin::Error> {
    let payload = serde_json::json!({
        "application_id": application_id,
        "household_id": household_id,
        "status": status,
        "determined_at": Utc::now(),
    });
    // Defense-in-depth: scrub even though we constructed a clean payload
    let mut payload = payload;
    scrub_fti_fields(&mut payload);

    let envelope = EventEnvelope::new("canopy-tanf", "tanf.determined", payload);
    publisher.publish(&envelope).await
}

pub async fn publish_work_requirement_updated(
    publisher: &Publisher,
    person_id: Uuid,
    status: &str,
) -> Result<(), lapin::Error> {
    let mut payload = serde_json::json!({
        "person_id": person_id,
        "status": status,
        "updated_at": Utc::now(),
    });
    scrub_fti_fields(&mut payload);

    let envelope = EventEnvelope::new("canopy-tanf", "tanf.work_requirement_updated", payload);
    publisher.publish(&envelope).await
}

pub async fn publish_time_limit_warning(
    publisher: &Publisher,
    person_id: Uuid,
    months_remaining: i32,
) -> Result<(), lapin::Error> {
    let mut payload = serde_json::json!({
        "person_id": person_id,
        "months_remaining": months_remaining,
        "warned_at": Utc::now(),
    });
    scrub_fti_fields(&mut payload);

    let envelope = EventEnvelope::new("canopy-tanf", "tanf.time_limit_warning", payload);
    publisher.publish(&envelope).await
}

Step 9: Additional API Endpoints

Files: services/canopy-tanf/src/api/work_requirements.rs (new), services/canopy-tanf/src/api/time_limits.rs (new), services/canopy-tanf/src/api/determinations.rs (new)

// services/canopy-tanf/src/api/determinations.rs

/// GET /v1/determinations/{id}
pub async fn get_determination(
    claims: Extension<Claims>,
    Path(id): Path<Uuid>,
    State(state): State<AppState>,
) -> Result<Json<TanfDetermination>, ApiError> {
    let det = store::determinations::get_determination(state.db.inner(), id)
        .await.map_err(ApiError::internal)?;
    match det {
        Some(d) => Ok(Json(d)),
        None => Err(ApiError::not_found("tanf_determination", id)),
    }
}

/// GET /v1/determinations/{id}/explanation
/// Returns human-readable narrative, not raw data (per ADR-002).
pub async fn get_determination_explanation(
    claims: Extension<Claims>,
    Path(id): Path<Uuid>,
    State(state): State<AppState>,
) -> Result<Json<DeterminationExplanation>, ApiError> {
    let det = store::determinations::get_determination(state.db.inner(), id)
        .await.map_err(ApiError::internal)?
        .ok_or_else(|| ApiError::not_found("tanf_determination", id))?;

    Ok(Json(DeterminationExplanation {
        id: det.id,
        status: det.status.clone(),
        narrative: det.basis.clone().unwrap_or_default(),
        denial_reason: det.denial_reason.clone(),
    }))
}

#[derive(Debug, Serialize)]
pub struct DeterminationExplanation {
    pub id: Uuid,
    pub status: String,
    pub narrative: String,
    pub denial_reason: Option<String>,
}
// services/canopy-tanf/src/api/work_requirements.rs

/// GET /v1/work-requirements/{person_id}
pub async fn get_work_requirements(
    claims: Extension<Claims>,
    Path(person_id): Path<Uuid>,
    State(state): State<AppState>,
) -> Result<Json<Option<TanfWorkRequirement>>, ApiError> {
    let req = store::work_requirements::get_work_requirement(state.db.inner(), person_id)
        .await.map_err(ApiError::internal)?;
    Ok(Json(req))
}

/// POST /v1/work-requirements/{person_id}/activities
#[derive(Debug, Deserialize)]
pub struct LogActivityRequest {
    pub activity_type: String,
    pub hours_per_week: Decimal,
    pub effective_date: NaiveDate,
    pub end_date: Option<NaiveDate>,
}

pub async fn log_activity(
    claims: Extension<Claims>,
    Path(person_id): Path<Uuid>,
    State(state): State<AppState>,
    Json(req): Json<LogActivityRequest>,
) -> Result<Json<TanfWorkActivity>, ApiError> {
    let work_req = store::work_requirements::get_work_requirement(state.db.inner(), person_id)
        .await.map_err(ApiError::internal)?
        .ok_or_else(|| ApiError::not_found("work_requirement", person_id))?;

    let activity = store::work_requirements::log_work_activity(
        state.db.inner(), work_req.id,
        &req.activity_type, req.hours_per_week, req.effective_date, req.end_date,
    ).await.map_err(ApiError::internal)?;

    // Publish event (no FTI in this payload)
    events::publish_work_requirement_updated(&state.publisher, person_id, &work_req.status)
        .await.map_err(ApiError::internal)?;

    Ok(Json(activity))
}
// services/canopy-tanf/src/api/time_limits.rs

/// GET /v1/time-limits/{person_id}
pub async fn get_time_limits(
    claims: Extension<Claims>,
    Path(person_id): Path<Uuid>,
    State(state): State<AppState>,
) -> Result<Json<Option<TanfTimeLimit>>, ApiError> {
    let tl = store::time_limits::get_time_limit(state.db.inner(), person_id)
        .await.map_err(ApiError::internal)?;
    Ok(Json(tl))
}

Step 10: Tests

Files: services/canopy-tanf/tests/determine.rs (new), services/canopy-tanf/tests/fti_audit.rs (new), services/canopy-tanf/tests/work_requirements.rs (new)

Integration tests

// services/canopy-tanf/tests/determine.rs

#[tokio::test]
async fn tanf_determination_full_flow_approved() {
    // Setup: testcontainers Postgres + RabbitMQ, run migrations
    //        mock canopy-persons (returns household with 3 members, 2 dependent children)
    //        mock canopy-rules (returns eligible=true, benefit_amount=277)
    //        seed FTI data for the applicant
    // Act: POST /v1/determine with ApplicationContext
    // Assert:
    //   - Response status 200
    //   - Returned determination has status = "approved"
    //   - Returned determination has valid JWS signature
    //   - tanf_applications row exists with status = "determined"
    //   - tanf_determinations row exists with benefit_amount = 277
    //   - tanf_household_snapshots row exists with correct snapshot
}

#[tokio::test]
async fn tanf_determination_with_fti_audit() {
    // Setup: testcontainers, seed FTI data
    // Act: POST /v1/determine
    // Assert:
    //   - fti_audit_log has at least 1 entry
    //   - Entry has purpose_code = "TANF_ELIG"
    //   - Entry has data_elements_accessed containing "adjusted_gross_income"
    //   - Entry has originating_system = "canopy-tanf"
    //   - Entry has success = true
}

#[tokio::test]
async fn tanf_time_limit_exceeded_denied() {
    // Setup: testcontainers, seed tanf_time_limits with months_used = 60, exempt = false
    // Act: POST /v1/determine
    // Assert:
    //   - Determination status = "denied"
    //   - denial_reason contains "time_limit"
}

#[tokio::test]
async fn tanf_time_limit_tracked() {
    // Setup: testcontainers
    // Act: upsert time limit with months_used = 48
    // Assert: GET /v1/time-limits/{person_id} returns months_used = 48
    // Act: upsert with months_used = 49
    // Assert: GET returns months_used = 49
}

#[tokio::test]
async fn tanf_deprivation_not_verified_denied() {
    // Setup: testcontainers, household with deprivation_verified = false
    // Act: POST /v1/determine
    // Assert: determination denied, denial_reason contains "deprivation"
}

#[tokio::test]
async fn fti_audit_entry_created_for_every_access() {
    // Setup: testcontainers, seed FTI data for 3 household members
    // Act: POST /v1/determine (reads FTI for each member)
    // Assert: fti_audit_log has 3 entries, one per member
}

#[tokio::test]
async fn tanf_events_contain_no_fti_fields() {
    // Setup: testcontainers + RabbitMQ, subscribe to canopy.events
    // Act: POST /v1/determine
    // Assert: captured "tanf.determined" event payload:
    //   - Contains: application_id, household_id, status, determined_at
    //   - Does NOT contain: adjusted_gross_income, wages, filing_status, fti_*, agi
}

#[tokio::test]
async fn work_requirements_evaluated() {
    // Setup: testcontainers, mock canopy-rules returns required=true, exempt=false
    // Act: POST /v1/determine
    // Assert: tanf_work_requirements row created with required=true, status="pending"
}

#[tokio::test]
async fn determination_signature_verifiable() {
    // Setup: testcontainers, generate ECDSA P-256 key pair
    // Act: POST /v1/determine
    // Assert: returned signature verifies with the public key using DeterminationVerifier
}
// services/canopy-tanf/tests/work_requirements.rs

#[tokio::test]
async fn log_work_activity() {
    // Setup: testcontainers, create work requirement for person
    // Act: POST /v1/work-requirements/{person_id}/activities
    //      { activity_type: "employment", hours_per_week: 30, effective_date: "2026-01-15" }
    // Assert: response contains the activity with correct fields
    //         GET /v1/work-requirements/{person_id} returns the requirement
}

#[tokio::test]
async fn work_requirement_exempt_caring_for_infant() {
    // Setup: testcontainers, mock canopy-rules returns exempt=true, exemption_reason="caring_for_infant"
    // Act: POST /v1/determine with youngest_child_age_months = 6
    // Assert: work requirement has exempt=true, exemption_reason="caring_for_infant"
}

Files Touched

File Change

services/canopy-tanf/migrations/20260325000000_create_tanf_tables.sql

Replace stub with full TANF schema

services/canopy-tanf/src/main.rs

Wire migrations, signing key, FTI audit logger, rules client

services/canopy-tanf/src/store/mod.rs

New: module declarations

services/canopy-tanf/src/store/models.rs

New: FtiTaxData, TanfApplication, TanfHouseholdSnapshot, TanfIncome, SsaMatchResult, TanfTimeLimit, TanfWorkRequirement, TanfWorkActivity, TanfDetermination

services/canopy-tanf/src/store/fti.rs

New: read_fti_tax_data, insert_fti_tax_data (FTI-audited)

services/canopy-tanf/src/store/ssa.rs

New: get_ssa_match_results, insert_ssa_match_result

services/canopy-tanf/src/store/applications.rs

New: create_application, update_application_status, get_application

services/canopy-tanf/src/store/snapshots.rs

New: create_household_snapshot

services/canopy-tanf/src/store/income.rs

New: income CRUD

services/canopy-tanf/src/store/time_limits.rs

New: get_time_limit, upsert_time_limit, is_time_limit_exceeded

services/canopy-tanf/src/store/work_requirements.rs

New: get_work_requirement, upsert_work_requirement, log_work_activity, get_work_activities

services/canopy-tanf/src/determine.rs

New: handle_determine (full determination flow)

services/canopy-tanf/src/rules_client.rs

New: TanfRulesClient, input/output types, evaluate_eligibility, calculate_benefit, evaluate_work_requirements

services/canopy-tanf/src/fti_audit.rs

New: FTI audit logger wiring (per fti-audit-logging plan)

services/canopy-tanf/src/api/mod.rs

Wire all routes

services/canopy-tanf/src/api/determinations.rs

New: get_determination, get_determination_explanation

services/canopy-tanf/src/api/work_requirements.rs

New: get_work_requirements, log_activity

services/canopy-tanf/src/api/time_limits.rs

New: get_time_limits

services/canopy-tanf/src/events.rs

Implement FTI-scrubbed event publishers

services/canopy-tanf/Cargo.toml

Add canopy-signing, reqwest, chrono, rust_decimal

rulesets/georgia/tanf-eligibility.json

New: TANF eligibility ruleset (JDM)

rulesets/georgia/tanf-benefit-calculation.json

New: TANF benefit calculation ruleset (JDM)

rulesets/georgia/tanf-work-requirements.json

New: TANF work requirements ruleset (JDM)

Verification

  1. cargo nextest run -p canopy-tanf — unit tests pass

  2. cargo xtask dev restart — migrations run, tables created

  3. cargo nextest run -p canopy-tanf --profile integration — determination flow, FTI audit, work requirements pass

  4. Manual: POST /v1/determine with test ApplicationContext, verify signed determination

  5. Manual: query FTI audit log, verify access was logged

  6. Manual: inspect tanf.determined event in RabbitMQ, confirm no FTI fields

  7. Manual: verify determination signature with canopy-eligibility’s verifier

Documentation Updates

  • .claude/docs/services.md — add canopy-tanf endpoints, events, tables

  • CHANGELOG.adoc — entry under == Unreleased

  • .claude/docs/security.md — document TANF FTI handling, Pub 1075 compliance

Edit this page · default