Plan: TANF Eligibility Service
On this page
- Status
- Context
- Scope
- Design
- Steps
- Step 1: Database Migration
- Step 2: Store Layer — FTI Data
- Step 3: Store Layer — SSA Data, Applications, Snapshots
- Step 4: Store Layer — Time Limits and Work Requirements
- Step 5: Rules Client
- Step 6: Determination Endpoint
- Step 7: Signing Integration
- Step 8: Event Publishing
- Step 9: Additional API Endpoints
- Step 10: Tests
- Files Touched
- Verification
- Documentation Updates
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: |
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 |
|---|---|
|
Income tests (gross and net income limits as % of FPL), deprivation verification, citizenship/residency, household composition requirements |
|
Standard of need, payment standard, benefit amount = max(0, payment_standard - countable_income), minimum benefit floor |
|
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 |
|---|---|
|
Replace stub with full TANF schema |
|
Wire migrations, signing key, FTI audit logger, rules client |
|
New: module declarations |
|
New: FtiTaxData, TanfApplication, TanfHouseholdSnapshot, TanfIncome, SsaMatchResult, TanfTimeLimit, TanfWorkRequirement, TanfWorkActivity, TanfDetermination |
|
New: read_fti_tax_data, insert_fti_tax_data (FTI-audited) |
|
New: get_ssa_match_results, insert_ssa_match_result |
|
New: create_application, update_application_status, get_application |
|
New: create_household_snapshot |
|
New: income CRUD |
|
New: get_time_limit, upsert_time_limit, is_time_limit_exceeded |
|
New: get_work_requirement, upsert_work_requirement, log_work_activity, get_work_activities |
|
New: handle_determine (full determination flow) |
|
New: TanfRulesClient, input/output types, evaluate_eligibility, calculate_benefit, evaluate_work_requirements |
|
New: FTI audit logger wiring (per fti-audit-logging plan) |
|
Wire all routes |
|
New: get_determination, get_determination_explanation |
|
New: get_work_requirements, log_activity |
|
New: get_time_limits |
|
Implement FTI-scrubbed event publishers |
|
Add canopy-signing, reqwest, chrono, rust_decimal |
|
New: TANF eligibility ruleset (JDM) |
|
New: TANF benefit calculation ruleset (JDM) |
|
New: TANF work requirements ruleset (JDM) |
Verification
-
cargo nextest run -p canopy-tanf— unit tests pass -
cargo xtask dev restart— migrations run, tables created -
cargo nextest run -p canopy-tanf --profile integration— determination flow, FTI audit, work requirements pass -
Manual: POST
/v1/determinewith test ApplicationContext, verify signed determination -
Manual: query FTI audit log, verify access was logged
-
Manual: inspect
tanf.determinedevent in RabbitMQ, confirm no FTI fields -
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