Plan: TANF Federal Reporting — ACF-199, ACF-196, and Work Participation Rate
On this page
- Status
- Context
- Scope
- Dependencies
- Design
- Steps
- Step 1: Add
GET /v1/determinationslist endpoint to canopy-tanf - Step 2: Extend
ServiceClientswith work-activity data - Step 3: Enrich ACF-199 extraction
- Step 4: Add WPR configuration to
jurisdiction.toml - Step 5: Implement WPR calculation engine and POST endpoint
- Step 6: Implement ACF-196 stub generation POST endpoint
- Step 7: Implement CSV export endpoints
- Step 8: Store layer additions
- Step 9: Integration tests (9 scenarios)
- Step 1: Add
- Files Touched
- Verification
- Errata
- Documentation Updates
Status
| Step | Description | Status |
|---|---|---|
1 |
Add |
Done (2026-04-13) |
2 |
Extend |
Done (2026-04-13) |
3 |
Enrich ACF-199 extraction: sanction, closure, time-limit, and work-hour fields |
Done (2026-04-13) — 14 enriched columns |
4 |
Add WPR configuration to |
Done (2026-04-13) |
5 |
Implement WPR calculation engine and POST endpoint |
Done (2026-04-13) — see errata for formula simplifications |
6 |
Implement ACF-196 stub generation POST endpoint (expenditures NULL) |
Done (2026-04-13) — expenditure data pending state-accounting integration (Tier 5.5) |
7 |
Implement CSV export endpoints for ACF-199, ACF-196, and WPR |
Done (2026-04-13) |
8 |
Store layer: new query functions for inserts and month-filtered reads |
Done (2026-04-13) |
9 |
Integration tests (9 scenarios with content-level assertions) |
Done (2026-04-12) — structural content tests added per roadmap Tier 1 |
Epic: &31
Branch: feature/tanf-federal-reporting
Labels: type::feature, priority::medium, program::tanf, service::reporting, workflow::ready, federal-partner::acf
Context
The Administration for Children and Families (ACF) requires states to submit three primary TANF reports:
-
ACF-199 (TANF Data Report): Monthly case-level data on every family receiving TANF assistance or services. 45 CFR 265.3 mandates submission. Contains approximately 150 data elements per case covering family composition, demographics, work activities, income, benefits, and case status.
-
ACF-196 (TANF Financial Report): Quarterly aggregate financial data on TANF expenditures across 12 categories. 45 CFR 265.9 mandates submission.
-
Work Participation Rate (WPR): Monthly statewide aggregate calculation determining whether the state meets federal work participation targets. 42 USC §607 sets the requirements. 45 CFR 261.22 defines the calculation methodology. Failure to meet WPR targets results in financial penalties (42 USC §609(a)(3)).
Georgia must meet:
-
All-family WPR target: 50% (before caseload reduction credit)
-
Two-parent family WPR target: 90% (before caseload reduction credit)
-
Caseload reduction credit (45 CFR 261.41): reduces the required WPR target based on caseload decline relative to FY2005 baseline
Current state
The skeleton infrastructure exists: 3 database tables (tanf_acf199_snapshots, tanf_acf196_reports, tanf_wpr_calculations) with migration 20260409000000, domain structs in domain.rs, 4 API routes (POST+GET acf-199, GET acf-196, GET wpr), and basic list-all store functions. The extract_acf199 function in reporting/tanf.rs populates only 3 of ~24 columns (family_type, household_size, benefit_amount). The remaining columns are NULL or hardcoded to 0. There is no POST endpoint for ACF-196 or WPR. There are no CSV exports. The 2 TANF integration tests verify only HTTP status codes, not field contents.
Additionally, canopy-tanf does not expose a GET /v1/determinations list endpoint. It has only GET /v1/determinations/{id} (single). The reporting client’s list_tanf_determinations() method calls a non-existent path and falls back to an empty Vec via .or_else(|_| Ok(Vec::new())). This must be fixed in Step 1 before any meaningful extraction can occur.
Regulatory basis
-
42 USC §611 — State reporting requirements
-
45 CFR 265.3 — ACF-199 TANF Data Report requirements
-
45 CFR 265.9 — ACF-196 TANF Financial Report requirements
-
42 USC §607 — Work participation requirements
-
45 CFR 261.22 — WPR calculation methodology
-
45 CFR 261.31-261.36 — Countable work activities and hour requirements
-
45 CFR 261.41 — Caseload reduction credit
-
42 USC §609(a)(3) — Financial penalties for WPR non-compliance
Scope
In scope:
-
Add
GET /v1/determinationslist endpoint to canopy-tanf -
Enrich ACF-199 extraction with sanction_status, closure_reason, months_this_state, months_other_states, total_work_hours, core_activity_hours from upstream APIs
-
Implement WPR calculation (all-family rate, two-parent rate, caseload reduction credit) with POST endpoint
-
Implement ACF-196 stub generation POST endpoint (expenditure amounts set to NULL — no state accounting integration)
-
CSV export endpoints for ACF-199, ACF-196, and WPR
-
WPR targets loaded from
jurisdiction.toml(not hardcoded) -
9 content-level integration tests
Out of scope:
-
Full 150-field ACF-199 mapping (demographics, education, citizenship require canopy-persons extensions not yet built)
-
ACF-196 actual financial/accounting integration — requires interface with state accounting system
-
ACF file-format submission packaging (ACF provides specific file layouts that vary by transmission method)
-
Tribal TANF reporting (not applicable to Georgia)
-
TANF eligibility determination logic — covered in
tanf-eligibilityplan -
FTI data — canopy-reporting never accesses FTI; it queries canopy-tanf’s API which returns only determination outcomes and non-restricted case data (per ADR-001 and ADR-004)
Dependencies
-
tanf-eligibility (complete): canopy-tanf exposes work requirements, time limits, and determination endpoints
-
persons-household-model (complete): household composition data
-
snap-federal-reporting (complete): establishes the CSV export and assembly patterns
-
reference-extensions (complete):
DeterminationStatusvariants including Sanctioned, Terminated, TimeLimitExceeded
Design
Architecture
Per ADR-001, canopy-reporting queries canopy-tanf, canopy-persons, and canopy-applications via internal HTTP APIs. Per ADR-004, canopy-reporting has no access to FTI. canopy-reporting is read-only for TANF reporting purposes and publishes no events to canopy.events.
Assembly algorithm (ACF-199)
The enriched ACF-199 extraction follows the same pattern as SNAP FNS-388 assembly in reporting/fns388.rs:
-
Call
clients.list_tanf_determinations()to get all TANF determinations. -
Filter to
status == "approved"cases. -
For each approved determination:
-
Fetch household via
clients.get_household(det.household_id)— derivefamily_type,household_size,adult_count,child_count. -
Fetch work requirements for each adult member via
clients.get_tanf_work_requirements(person_id)— extractsanction_level,exempt,status. Mapstatustosanction_status. Aggregatetotal_work_hoursandcore_activity_hoursfrom the work requirement response. -
Fetch time limits for each adult member via
clients.get_tanf_time_limits(person_id)— extractmonths_usedasmonths_this_state. Setmonths_other_statesto 0 (no cross-state data source yet). -
Derive
case_status: "active" if approved, "sanctioned" if any member hassanction_level > 0, "closed" if status is denied/terminated. -
Derive
closure_reasonfromdet.denial_reasonwhen case is not active.
-
-
Upsert into
tanf_acf199_snapshotson(report_month, case_id).
WPR calculation algorithm
-
Query
tanf_acf199_snapshotsfor the givenreport_month. -
Partition snapshots by
family_type:-
child_onlycases are excluded from both numerator and denominator. -
two_parentcases contribute to both the all-family and two-parent rates. -
single_parentcases contribute only to the all-family rate.
-
-
For each non-child-only family, determine whether work requirements are met:
-
Single-parent families:
total_work_hours >= 30(or>= 20ifhas_child_under_6— approximated fromchild_count > 0for now). At least 20 hours must be core activities. -
Two-parent families:
total_work_hours >= 35. At least 20 hours must be core activities. -
Families where the sole adult is exempt (
sanction_status IS NULLand work hours are 0 but they are exempt) are excluded from denominator.
-
-
Compute rates:
-
all_family_rate = (all_family_numerator / all_family_denominator) * 100 -
two_parent_rate = (two_parent_numerator / two_parent_denominator) * 100
-
-
Apply caseload reduction credit (CRC):
-
crc = ((fy2005_baseline - current_caseload) / fy2005_baseline) * 100 -
all_family_target = max(0, 50 - crc) -
two_parent_target = max(0, 90 - crc) -
The FY2005 baseline is loaded from
jurisdiction.toml([tanf.wpr]section). The current caseload isall_family_denominator.
-
-
Upsert into
tanf_wpr_calculationson(report_month).
ACF-196 stub algorithm
-
Accept
fiscal_yearandfiscal_quartervia POST body. -
For each of the 12 ACF-196 expenditure categories, insert one row with:
-
federal_amount = 0,state_amount = 0,total_amount = 0(NULL-equivalent: actual amounts require state accounting system integration which is out of scope). -
families_served: Forbasic_assistanceonly, count distinctcase_idfromtanf_acf199_snapshotsfor the 3 months of the fiscal quarter. All other categories: NULL.
-
-
Upsert into
tanf_acf196_reportson(fiscal_year, fiscal_quarter, category).
ACF-196 expenditure categories (constant list)
// SPDX-License-Identifier: AGPL-3.0-or-later
/// The 12 ACF-196 expenditure categories per 45 CFR 265.9.
pub const ACF196_CATEGORIES: &[&str] = &[
"basic_assistance",
"child_care_non_transferred",
"child_care_transferred_ccdf",
"education_and_training",
"work_subsidies",
"transportation",
"individual_development_accounts",
"refundable_eitc",
"non_assistance_two_parent",
"non_assistance_other",
"systems",
"administration",
];
New types
// SPDX-License-Identifier: AGPL-3.0-or-later
/// WPR computation result (in-memory, before DB persistence).
pub struct WprResult {
pub all_family_numerator: i32,
pub all_family_denominator: i32,
pub all_family_rate: Decimal,
pub all_family_target: Decimal,
pub all_family_meets_target: bool,
pub two_parent_numerator: i32,
pub two_parent_denominator: i32,
pub two_parent_rate: Decimal,
pub two_parent_target: Decimal,
pub two_parent_meets_target: bool,
pub caseload_reduction_credit: Decimal,
}
/// ACF-196 generation result.
pub struct Acf196GenerationResult {
pub categories_inserted: i64,
pub families_served_basic_assistance: Option<i32>,
}
Steps
Step 1: Add GET /v1/determinations list endpoint to canopy-tanf
Files:
-
services/canopy-tanf/src/api/handlers.rs(modify) -
services/canopy-tanf/src/api/mod.rs(modify) -
services/canopy-tanf/src/store/mod.rs(modify)
canopy-tanf currently has only GET /v1/determinations/{id} (single determination lookup). The reporting client calls GET /v1/determinations (list all) which silently returns an empty Vec because the endpoint does not exist. Add the list endpoint.
Add to store/mod.rs:
/// List all TANF determinations (most recent first, limit 500).
/// Used by canopy-reporting for ACF-199 extraction.
pub async fn list_determinations(
pool: &PgPool,
) -> Result<Vec<TanfDetermination>, sqlx::Error> {
sqlx::query_as::<_, TanfDetermination>(
"SELECT * FROM tanf_determinations ORDER BY determined_at DESC LIMIT 500",
)
.fetch_all(pool)
.await
}
Add to api/handlers.rs:
/// GET /v1/determinations — List all TANF determinations.
/// Used by canopy-reporting for ACF-199 monthly extraction.
#[utoipa::path(
get,
path = "/determinations",
tag = "Determination",
security(("bearer" = [])),
responses(
(status = 200, description = "All TANF determinations", body = Vec<TanfDetermination>),
)
)]
pub async fn list_determinations(
Extension(claims): Extension<Claims>,
Extension(db): Extension<PgPool>,
) -> Result<Json<Vec<TanfDetermination>>, ApiError> {
claims.require_supervisor_or_above()?;
let dets = store::list_determinations(&db)
.await
.map_err(|e| ApiError::internal("list determinations", e))?;
Ok(Json(dets))
}
Add route to api/mod.rs:
.route("/determinations", get(handlers::list_determinations))
Place this route before the existing .route("/determinations/{id}", …) to avoid path ambiguity.
Verify: cargo nextest run -p canopy-tanf passes. The endpoint returns the TanfDetermination struct, which the reporting client already deserializes as TanfDeterminationSummary (id, household_id, status, benefit_amount are all present in TanfDetermination).
Step 2: Extend ServiceClients with work-activity data
Files:
-
services/canopy-reporting/src/clients/mod.rs(modify)
The existing client methods get_tanf_work_requirements and get_tanf_time_limits return summary structs. Extend TanfWorkRequirementSummary with fields that canopy-tanf already returns but the reporting client does not yet deserialize.
Add field to TanfWorkRequirementSummary:
#[derive(Debug, Deserialize)]
pub struct TanfWorkRequirementSummary {
pub id: uuid::Uuid,
pub person_id: uuid::Uuid,
pub required: bool,
pub exempt: bool,
pub status: String,
pub sanction_level: Option<i32>,
pub exemption_reason: Option<String>, // NEW
}
Add TanfTimeLimitSummary field (already has months_used):
No change needed to TanfTimeLimitSummary — it already has months_used.
Add helper method to ServiceClients:
/// Fetch all work activities for a work requirement.
/// canopy-tanf does not yet expose this endpoint; returns empty Vec on error.
/// This is a forward-looking stub that will be used when canopy-tanf adds
/// GET /v1/work-requirements/{person_id}/activities.
pub async fn list_tanf_work_activities(
&self,
person_id: uuid::Uuid,
) -> anyhow::Result<Vec<TanfWorkActivitySummary>> {
self.tanf
.get(&format!("/v1/work-requirements/{person_id}/activities"))
.await
.or_else(|_| Ok(Vec::new()))
}
Add response type:
#[derive(Debug, Deserialize)]
pub struct TanfWorkActivitySummary {
pub id: uuid::Uuid,
pub activity_type: String,
pub hours_per_week: Decimal,
pub effective_date: NaiveDate,
pub end_date: Option<NaiveDate>,
pub verified: bool,
}
list_tanf_work_activities method will return empty until that endpoint is added. In the interim, work hours are derived from the TanfWorkRequirementSummary — the WPR calculation will use direct work-requirement status as a proxy for meeting hours.
Step 3: Enrich ACF-199 extraction
Files:
-
services/canopy-reporting/src/reporting/tanf.rs(rewrite)
Replace the current minimal extraction with the enriched algorithm. Follow the assembly pattern in reporting/fns388.rs.
Core activities (for determining core_activity_hours) per 45 CFR 261.31(b):
/// Core work activities per 45 CFR 261.31(b).
const CORE_ACTIVITIES: &[&str] = &[
"unsubsidized_employment",
"subsidized_private_employment",
"subsidized_public_employment",
"work_experience",
"on_the_job_training",
"community_service",
"providing_child_care",
"vocational_training", // 12-month limit
];
Rewrite extract_acf199 to:
pub async fn extract_acf199(
db: &PgPool,
clients: &ServiceClients,
report_month: NaiveDate,
) -> anyhow::Result<Acf199ExtractionResult> {
let determinations = clients.list_tanf_determinations().await?;
let mut inserted: i64 = 0;
for det in &determinations {
if det.status != "approved" && det.status != "sanctioned" {
continue; // Include active and sanctioned cases in ACF-199
}
// (a) Household composition
let hh = match clients.get_household(det.household_id.into()).await {
Ok(hh) => hh,
Err(e) => {
warn!(household_id = %det.household_id, error = %e, "skipping — household fetch failed");
continue;
}
};
let member_count = hh.members.len() as i32;
let adult_count = hh.members.iter().filter(|m| m.relationship != "child").count() as i32;
let child_count = member_count - adult_count;
let family_type = if adult_count >= 2 {
"two_parent"
} else if child_count > 0 && adult_count > 0 {
"single_parent"
} else {
"child_only"
};
// (b) Work requirements and sanction status — aggregate across adult members
let mut total_work_hours = Decimal::ZERO;
let mut core_activity_hours = Decimal::ZERO;
let mut max_sanction_level: Option<i32> = None;
let mut any_exempt = false;
for member in &hh.members {
if member.relationship == "child" {
continue;
}
if let Ok(Some(wr)) = clients.get_tanf_work_requirements(member.person_id).await {
if wr.exempt {
any_exempt = true;
}
if let Some(sl) = wr.sanction_level {
max_sanction_level = Some(max_sanction_level.map_or(sl, |prev| prev.max(sl)));
}
}
// Fetch activities for this person (returns empty if endpoint absent)
let activities = clients.list_tanf_work_activities(member.person_id).await.unwrap_or_default();
for act in &activities {
total_work_hours += act.hours_per_week;
if CORE_ACTIVITIES.contains(&act.activity_type.as_str()) {
core_activity_hours += act.hours_per_week;
}
}
}
// (c) Time limits — use first adult's months_used
let mut months_this_state: i32 = 0;
for member in &hh.members {
if member.relationship == "child" {
continue;
}
if let Ok(Some(tl)) = clients.get_tanf_time_limits(member.person_id).await {
months_this_state = months_this_state.max(tl.months_used);
}
break; // Use first (head-of-household) adult's count
}
// (d) Case status and closure reason
let sanction_status = max_sanction_level.map(|sl| format!("level_{sl}"));
let case_status = if max_sanction_level.is_some() {
"sanctioned"
} else {
"active"
};
let closure_reason: Option<String> = None; // Only populated for closed cases — approved/sanctioned are still open
// (e) Upsert snapshot
sqlx::query(
r#"INSERT INTO tanf_acf199_snapshots
(id, report_month, case_id, family_type, household_size, adult_count, child_count,
benefit_amount, sanction_status, case_status, closure_reason,
months_this_state, months_other_states,
total_work_hours, core_activity_hours, extracted_at)
VALUES (gen_random_uuid(), $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, now())
ON CONFLICT (report_month, case_id) DO UPDATE
SET family_type = EXCLUDED.family_type, household_size = EXCLUDED.household_size,
adult_count = EXCLUDED.adult_count, child_count = EXCLUDED.child_count,
benefit_amount = EXCLUDED.benefit_amount, sanction_status = EXCLUDED.sanction_status,
case_status = EXCLUDED.case_status, closure_reason = EXCLUDED.closure_reason,
months_this_state = EXCLUDED.months_this_state, months_other_states = EXCLUDED.months_other_states,
total_work_hours = EXCLUDED.total_work_hours, core_activity_hours = EXCLUDED.core_activity_hours,
extracted_at = now()"#,
)
.bind(report_month) // $1
.bind(det.id) // $2
.bind(family_type) // $3
.bind(member_count) // $4
.bind(adult_count) // $5
.bind(child_count) // $6
.bind(det.benefit_amount.unwrap_or(Decimal::ZERO)) // $7
.bind(&sanction_status) // $8
.bind(case_status) // $9
.bind(&closure_reason) // $10
.bind(months_this_state) // $11
.bind(0i32) // $12 months_other_states (no cross-state source)
.bind(total_work_hours) // $13
.bind(core_activity_hours) // $14
.execute(db)
.await?;
inserted += 1;
}
info!(report_month = %report_month, snapshots = inserted, "ACF-199 extraction complete");
Ok(Acf199ExtractionResult { snapshots_inserted: inserted })
}
Step 4: Add WPR configuration to jurisdiction.toml
Files:
-
rulesets/georgia/jurisdiction.toml(modify) -
rulesets/georgia/citations.toml(modify — add PAMMS citation for WPR targets)
Add a [tanf.wpr] section after the existing [tanf.sanctions] block:
[tanf.wpr]
# Work Participation Rate targets per 42 USC §607 / 45 CFR 261.21
all_family_target_pct = 50 # 50% all-family rate
two_parent_target_pct = 90 # 90% two-parent rate
single_parent_hours_per_week = 30 # 45 CFR 261.31(a)
single_parent_child_under_6_hours = 20 # 45 CFR 261.31(a)(2)
two_parent_hours_per_week = 35 # 45 CFR 261.31(b)
two_parent_fed_child_care_hours = 55 # 45 CFR 261.31(b)(2)
core_activity_minimum_hours = 20 # 45 CFR 261.31(a) and (b)
fy2005_baseline_caseload = 52000 # Georgia FY2005 TANF caseload (for CRC calculation)
Add corresponding citation in citations.toml:
[tanf_wpr_all_family_target_pct]
value = "50"
source = "45 CFR 261.21(a)"
note = "Federal all-family work participation rate target"
[tanf_wpr_two_parent_target_pct]
value = "90"
source = "45 CFR 261.21(b)"
note = "Federal two-parent work participation rate target"
[tanf_wpr_fy2005_baseline_caseload]
value = "52000"
source = "ACF TANF caseload data, Georgia FY2005"
note = "Baseline for caseload reduction credit per 45 CFR 261.41"
Step 5: Implement WPR calculation engine and POST endpoint
Files:
-
services/canopy-reporting/src/reporting/wpr.rs(new) -
services/canopy-reporting/src/reporting/mod.rs(modify — addpub mod wpr;) -
services/canopy-reporting/src/api/mod.rs(modify — add POST route and handler) -
services/canopy-reporting/src/store.rs(modify — add insert function)
reporting/wpr.rs:
// SPDX-License-Identifier: AGPL-3.0-or-later
//! Work Participation Rate calculation per 45 CFR 261.22.
use chrono::NaiveDate;
use rust_decimal::Decimal;
use rust_decimal_macros::dec;
use sqlx::PgPool;
use tracing::info;
use crate::domain::TanfAcf199Snapshot;
/// WPR targets loaded from jurisdiction.toml.
pub struct WprConfig {
pub all_family_target_pct: Decimal,
pub two_parent_target_pct: Decimal,
pub single_parent_hours: Decimal,
pub single_parent_child_under_6_hours: Decimal,
pub two_parent_hours: Decimal,
pub core_activity_minimum_hours: Decimal,
pub fy2005_baseline_caseload: i32,
}
impl Default for WprConfig {
/// Default values — MUST be overridden by jurisdiction.toml in production.
fn default() -> Self {
Self {
all_family_target_pct: dec!(50),
two_parent_target_pct: dec!(90),
single_parent_hours: dec!(30),
single_parent_child_under_6_hours: dec!(20),
two_parent_hours: dec!(35),
core_activity_minimum_hours: dec!(20),
fy2005_baseline_caseload: 52_000,
}
}
}
pub struct WprResult {
pub all_family_numerator: i32,
pub all_family_denominator: i32,
pub all_family_rate: Decimal,
pub all_family_target: Decimal,
pub all_family_meets_target: bool,
pub two_parent_numerator: i32,
pub two_parent_denominator: i32,
pub two_parent_rate: Decimal,
pub two_parent_target: Decimal,
pub two_parent_meets_target: bool,
pub caseload_reduction_credit: Decimal,
}
/// Compute WPR for a given month from ACF-199 snapshots already in the DB.
pub async fn compute_wpr(
db: &PgPool,
report_month: NaiveDate,
config: &WprConfig,
) -> anyhow::Result<WprResult> {
let snapshots: Vec<TanfAcf199Snapshot> = sqlx::query_as(
"SELECT * FROM tanf_acf199_snapshots WHERE report_month = $1",
)
.bind(report_month)
.fetch_all(db)
.await?;
let mut all_num = 0i32;
let mut all_den = 0i32;
let mut tp_num = 0i32;
let mut tp_den = 0i32;
for snap in &snapshots {
if snap.family_type == "child_only" {
continue; // Excluded from WPR per 45 CFR 261.22
}
let total_hours = snap.total_work_hours.unwrap_or(Decimal::ZERO);
let core_hours = snap.core_activity_hours.unwrap_or(Decimal::ZERO);
let required_hours = if snap.family_type == "two_parent" {
config.two_parent_hours
} else if snap.child_count > 0 {
// Approximate child-under-6 with any child present
config.single_parent_child_under_6_hours
} else {
config.single_parent_hours
};
let meets_hours = total_hours >= required_hours
&& core_hours >= config.core_activity_minimum_hours;
// All-family rate
all_den += 1;
if meets_hours {
all_num += 1;
}
// Two-parent rate
if snap.family_type == "two_parent" {
tp_den += 1;
if meets_hours {
tp_num += 1;
}
}
}
// Caseload reduction credit (45 CFR 261.41)
let current_caseload = all_den;
let crc = if config.fy2005_baseline_caseload > 0 && current_caseload < config.fy2005_baseline_caseload {
let decline = Decimal::from(config.fy2005_baseline_caseload - current_caseload);
let baseline = Decimal::from(config.fy2005_baseline_caseload);
(decline / baseline * dec!(100)).round_dp(2)
} else {
Decimal::ZERO
};
let all_family_target = (config.all_family_target_pct - crc).max(Decimal::ZERO);
let two_parent_target = (config.two_parent_target_pct - crc).max(Decimal::ZERO);
let all_family_rate = if all_den > 0 {
(Decimal::from(all_num) / Decimal::from(all_den) * dec!(100)).round_dp(2)
} else {
Decimal::ZERO
};
let two_parent_rate = if tp_den > 0 {
(Decimal::from(tp_num) / Decimal::from(tp_den) * dec!(100)).round_dp(2)
} else {
Decimal::ZERO
};
let result = WprResult {
all_family_numerator: all_num,
all_family_denominator: all_den,
all_family_rate,
all_family_target,
all_family_meets_target: all_family_rate >= all_family_target,
two_parent_numerator: tp_num,
two_parent_denominator: tp_den,
two_parent_rate,
two_parent_target,
two_parent_meets_target: two_parent_rate >= two_parent_target,
caseload_reduction_credit: crc,
};
info!(
report_month = %report_month,
all_rate = %all_family_rate,
tp_rate = %two_parent_rate,
crc = %crc,
"WPR calculation complete"
);
Ok(result)
}
Add store function in store.rs:
/// Insert or update a WPR calculation for a month.
pub async fn upsert_tanf_wpr(
pool: &PgPool,
report_month: NaiveDate,
r: &crate::reporting::wpr::WprResult,
) -> Result<crate::domain::TanfWprCalculation, sqlx::Error> {
sqlx::query_as(
r#"INSERT INTO tanf_wpr_calculations
(id, report_month, all_family_numerator, all_family_denominator,
all_family_rate, all_family_target, all_family_meets_target,
two_parent_numerator, two_parent_denominator,
two_parent_rate, two_parent_target, two_parent_meets_target,
caseload_reduction_credit, calculated_at)
VALUES (gen_random_uuid(), $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, now())
ON CONFLICT (report_month) DO UPDATE
SET all_family_numerator = EXCLUDED.all_family_numerator,
all_family_denominator = EXCLUDED.all_family_denominator,
all_family_rate = EXCLUDED.all_family_rate,
all_family_target = EXCLUDED.all_family_target,
all_family_meets_target = EXCLUDED.all_family_meets_target,
two_parent_numerator = EXCLUDED.two_parent_numerator,
two_parent_denominator = EXCLUDED.two_parent_denominator,
two_parent_rate = EXCLUDED.two_parent_rate,
two_parent_target = EXCLUDED.two_parent_target,
two_parent_meets_target = EXCLUDED.two_parent_meets_target,
caseload_reduction_credit = EXCLUDED.caseload_reduction_credit,
calculated_at = now()
RETURNING *"#,
)
.bind(report_month)
.bind(r.all_family_numerator)
.bind(r.all_family_denominator)
.bind(r.all_family_rate)
.bind(r.all_family_target)
.bind(r.all_family_meets_target)
.bind(r.two_parent_numerator)
.bind(r.two_parent_denominator)
.bind(r.two_parent_rate)
.bind(r.two_parent_target)
.bind(r.two_parent_meets_target)
.bind(r.caseload_reduction_credit)
.fetch_one(pool)
.await
}
Add POST handler in api/mod.rs:
/// POST /v1/reporting/tanf/wpr — Calculate WPR for a month.
/// Requires ACF-199 snapshots for the month to already exist.
async fn generate_tanf_wpr(
Extension(claims): Extension<Claims>,
State(state): State<AppState>,
Json(req): Json<GenerateTanfReportRequest>,
) -> Result<(StatusCode, Json<TanfWprCalculation>), ApiError> {
claims.require_supervisor_or_above()?;
let config = crate::reporting::wpr::WprConfig::default(); // TODO: load from jurisdiction.toml
let result = crate::reporting::wpr::compute_wpr(state.db.inner(), req.report_month, &config)
.await
.map_err(|e| ApiError::internal("WPR calculation failed", e))?;
let row = store::upsert_tanf_wpr(state.db.inner(), req.report_month, &result)
.await
.map_err(ApiError::from)?;
Ok((StatusCode::CREATED, Json(row)))
}
Add route:
Change existing .route("/reporting/tanf/wpr", get(list_tanf_wpr)) to:
.route("/reporting/tanf/wpr", post(generate_tanf_wpr).get(list_tanf_wpr))
Step 6: Implement ACF-196 stub generation POST endpoint
Files:
-
services/canopy-reporting/src/reporting/acf196.rs(new) -
services/canopy-reporting/src/reporting/mod.rs(modify — addpub mod acf196;) -
services/canopy-reporting/src/api/mod.rs(modify — add POST route) -
services/canopy-reporting/src/store.rs(modify — add upsert function)
reporting/acf196.rs:
// SPDX-License-Identifier: AGPL-3.0-or-later
//! ACF-196 quarterly financial report generation.
//! Per 45 CFR 265.9: quarterly expenditure data across 12 categories.
//!
//! NOTE: Actual expenditure amounts require integration with the state
//! accounting system (SAP/PeopleSoft), which is out of scope. All
//! financial amounts are set to 0. Only `families_served` for the
//! `basic_assistance` category is derived from ACF-199 snapshots.
use chrono::NaiveDate;
use rust_decimal::Decimal;
use sqlx::PgPool;
use tracing::info;
/// The 12 ACF-196 expenditure categories per 45 CFR 265.9.
pub const ACF196_CATEGORIES: &[&str] = &[
"basic_assistance",
"child_care_non_transferred",
"child_care_transferred_ccdf",
"education_and_training",
"work_subsidies",
"transportation",
"individual_development_accounts",
"refundable_eitc",
"non_assistance_two_parent",
"non_assistance_other",
"systems",
"administration",
];
pub struct Acf196GenerationResult {
pub categories_inserted: i64,
pub families_served_basic_assistance: Option<i32>,
}
/// Generate ACF-196 stub rows for a fiscal quarter.
/// Returns the quarter's months as (month1, month2, month3).
fn quarter_months(fiscal_year: i32, fiscal_quarter: i32) -> (NaiveDate, NaiveDate, NaiveDate) {
let start_month = (fiscal_quarter - 1) * 3 + 1;
(
NaiveDate::from_ymd_opt(fiscal_year, start_month as u32, 1).expect("valid date"),
NaiveDate::from_ymd_opt(fiscal_year, (start_month + 1) as u32, 1).expect("valid date"),
NaiveDate::from_ymd_opt(fiscal_year, (start_month + 2) as u32, 1).expect("valid date"),
)
}
pub async fn generate_acf196(
db: &PgPool,
fiscal_year: i32,
fiscal_quarter: i32,
) -> anyhow::Result<Acf196GenerationResult> {
let (m1, m2, m3) = quarter_months(fiscal_year, fiscal_quarter);
// Count distinct cases from ACF-199 snapshots for basic_assistance families_served
let families_served: Option<i32> = sqlx::query_scalar(
"SELECT COUNT(DISTINCT case_id)::INT FROM tanf_acf199_snapshots WHERE report_month IN ($1, $2, $3)",
)
.bind(m1)
.bind(m2)
.bind(m3)
.fetch_one(db)
.await
.ok();
let mut inserted: i64 = 0;
for category in ACF196_CATEGORIES {
let cat_families = if *category == "basic_assistance" {
families_served
} else {
None
};
sqlx::query(
r#"INSERT INTO tanf_acf196_reports
(id, fiscal_year, fiscal_quarter, category,
federal_amount, state_amount, total_amount, families_served, generated_at)
VALUES (gen_random_uuid(), $1, $2, $3, 0, 0, 0, $4, now())
ON CONFLICT (fiscal_year, fiscal_quarter, category) DO UPDATE
SET families_served = EXCLUDED.families_served,
generated_at = now()"#,
)
.bind(fiscal_year)
.bind(fiscal_quarter)
.bind(category)
.bind(cat_families)
.execute(db)
.await?;
inserted += 1;
}
info!(
fiscal_year,
fiscal_quarter,
categories = inserted,
"ACF-196 generation complete (expenditure amounts are zero — no accounting integration)"
);
Ok(Acf196GenerationResult {
categories_inserted: inserted,
families_served_basic_assistance: families_served,
})
}
Add POST handler in api/mod.rs:
/// POST /v1/reporting/tanf/acf-196 — Generate ACF-196 quarterly report.
async fn generate_tanf_acf196(
Extension(claims): Extension<Claims>,
State(state): State<AppState>,
Json(req): Json<GenerateTanfQuarterlyRequest>,
) -> Result<(StatusCode, Json<serde_json::Value>), ApiError> {
claims.require_supervisor_or_above()?;
let result = crate::reporting::acf196::generate_acf196(
state.db.inner(),
req.fiscal_year,
req.fiscal_quarter,
)
.await
.map_err(|e| ApiError::internal("ACF-196 generation failed", e))?;
Ok((
StatusCode::CREATED,
Json(serde_json::json!({
"fiscal_year": req.fiscal_year,
"fiscal_quarter": req.fiscal_quarter,
"categories_generated": result.categories_inserted,
"families_served_basic_assistance": result.families_served_basic_assistance,
"note": "Expenditure amounts are zero. Actual financial data requires state accounting system integration."
})),
))
}
Add route:
Change existing .route("/reporting/tanf/acf-196", get(list_tanf_acf196)) to:
.route("/reporting/tanf/acf-196", post(generate_tanf_acf196).get(list_tanf_acf196))
Remove the #[allow(dead_code)] from GenerateTanfQuarterlyRequest in domain.rs.
Step 7: Implement CSV export endpoints
Files:
-
services/canopy-reporting/src/reporting/tanf_csv.rs(new) -
services/canopy-reporting/src/reporting/mod.rs(modify — addpub mod tanf_csv;) -
services/canopy-reporting/src/api/mod.rs(modify — add 3 routes) -
services/canopy-reporting/src/store.rs(modify — add month-filtered queries)
Follow the pattern of reporting/snap.rs::generate_fns_7176_csv.
reporting/tanf_csv.rs:
// SPDX-License-Identifier: AGPL-3.0-or-later
//! CSV export functions for TANF federal reports.
use crate::domain::{TanfAcf196Report, TanfAcf199Snapshot, TanfWprCalculation};
/// Generate ACF-199 case-level CSV.
pub fn generate_acf199_csv(entries: &[TanfAcf199Snapshot]) -> String {
let mut csv = String::new();
csv.push_str("report_month,case_id,family_type,household_size,adult_count,child_count,");
csv.push_str("benefit_amount,sanction_status,case_status,closure_reason,");
csv.push_str("months_this_state,months_other_states,total_work_hours,core_activity_hours\n");
for e in entries {
csv.push_str(&format!(
"{},{},{},{},{},{},{},{},{},{},{},{},{},{}\n",
e.report_month,
e.case_id,
e.family_type,
e.household_size,
e.adult_count,
e.child_count,
e.benefit_amount,
e.sanction_status.as_deref().unwrap_or(""),
e.case_status,
e.closure_reason.as_deref().unwrap_or(""),
e.months_this_state,
e.months_other_states,
fmt_dec(e.total_work_hours),
fmt_dec(e.core_activity_hours),
));
}
csv
}
/// Generate ACF-196 quarterly financial CSV.
pub fn generate_acf196_csv(entries: &[TanfAcf196Report]) -> String {
let mut csv = String::new();
csv.push_str("fiscal_year,fiscal_quarter,category,federal_amount,state_amount,total_amount,families_served\n");
for e in entries {
csv.push_str(&format!(
"{},{},{},{},{},{},{}\n",
e.fiscal_year,
e.fiscal_quarter,
e.category,
e.federal_amount,
e.state_amount,
e.total_amount,
e.families_served.map(|v| v.to_string()).unwrap_or_default(),
));
}
csv
}
/// Generate WPR monthly calculation CSV.
pub fn generate_wpr_csv(entries: &[TanfWprCalculation]) -> String {
let mut csv = String::new();
csv.push_str("report_month,all_family_numerator,all_family_denominator,all_family_rate,");
csv.push_str("all_family_target,all_family_meets_target,");
csv.push_str("two_parent_numerator,two_parent_denominator,two_parent_rate,");
csv.push_str("two_parent_target,two_parent_meets_target,caseload_reduction_credit\n");
for e in entries {
csv.push_str(&format!(
"{},{},{},{},{},{},{},{},{},{},{},{}\n",
e.report_month,
e.all_family_numerator,
e.all_family_denominator,
e.all_family_rate,
e.all_family_target,
e.all_family_meets_target,
e.two_parent_numerator,
e.two_parent_denominator,
e.two_parent_rate,
e.two_parent_target,
e.two_parent_meets_target,
e.caseload_reduction_credit,
));
}
csv
}
fn fmt_dec(d: Option<rust_decimal::Decimal>) -> String {
d.map(|v| v.to_string()).unwrap_or_default()
}
Add month-filtered store queries in store.rs:
/// List ACF-199 snapshots for a specific month.
pub async fn list_tanf_acf199_by_month(
pool: &PgPool,
report_month: NaiveDate,
) -> Result<Vec<crate::domain::TanfAcf199Snapshot>, sqlx::Error> {
sqlx::query_as(
"SELECT * FROM tanf_acf199_snapshots WHERE report_month = $1 ORDER BY case_id",
)
.bind(report_month)
.fetch_all(pool)
.await
}
/// List ACF-196 reports for a fiscal year and quarter.
pub async fn list_tanf_acf196_by_quarter(
pool: &PgPool,
fiscal_year: i32,
fiscal_quarter: i32,
) -> Result<Vec<crate::domain::TanfAcf196Report>, sqlx::Error> {
sqlx::query_as(
"SELECT * FROM tanf_acf196_reports WHERE fiscal_year = $1 AND fiscal_quarter = $2 ORDER BY category",
)
.bind(fiscal_year)
.bind(fiscal_quarter)
.fetch_all(pool)
.await
}
Add 3 CSV export routes in api/mod.rs:
.route("/reporting/tanf/acf-199/{month}/csv", get(export_tanf_acf199_csv))
.route("/reporting/tanf/acf-196/{year}/{quarter}/csv", get(export_tanf_acf196_csv))
.route("/reporting/tanf/wpr/csv", get(export_tanf_wpr_csv))
Handler pattern (same as export_qc_csv):
/// GET /v1/reporting/tanf/acf-199/{month}/csv
async fn export_tanf_acf199_csv(
Extension(claims): Extension<Claims>,
State(state): State<AppState>,
Path(month): Path<String>,
) -> Result<impl IntoResponse, ApiError> {
claims.require_supervisor_or_above()?;
let report_month = NaiveDate::parse_from_str(&format!("{month}-01"), "%Y-%m-%d")
.map_err(|_| ApiError::BadRequest(format!("invalid month: {month}. Expected YYYY-MM")))?;
let entries = store::list_tanf_acf199_by_month(state.db.inner(), report_month)
.await
.map_err(ApiError::from)?;
let csv = crate::reporting::tanf_csv::generate_acf199_csv(&entries);
let filename = format!("acf-199-{month}.csv");
Ok((
StatusCode::OK,
[
("content-type".to_owned(), "text/csv".to_owned()),
("content-disposition".to_owned(), format!("attachment; filename=\"{filename}\"")),
],
csv,
))
}
Follow the same pattern for export_tanf_acf196_csv (accepting {year}/{quarter}) and export_tanf_wpr_csv (no path params, exports all rows).
Step 8: Store layer additions
Files:
-
services/canopy-reporting/src/store.rs(modify)
This step consolidates all store additions described in Steps 5-7. The specific functions are:
-
upsert_tanf_wpr(Step 5) -
list_tanf_acf199_by_month(Step 7) -
list_tanf_acf196_by_quarter(Step 7)
All use sqlx::query_as with compile-time verified queries matching the existing pattern in store.rs.
Step 9: Integration tests (9 scenarios)
Files:
-
services/canopy-reporting/tests/reporting_test.rs(modify — replace TANF structural tests with content-level tests)
Replace the existing 3 TANF structural tests (list_tanf_acf199_returns_array, list_tanf_wpr_returns_array, generate_tanf_acf199_returns_201) with the following 9 content-level scenarios. All tests follow the existing TestClient pattern with infrastructure_available() guard.
| # | Scenario | Expected result and assertions |
|---|---|---|
1 |
ACF-199 enriched extraction: POST |
Response status 201. GET returns array where each element has non-null |
2 |
ACF-199 idempotent upsert: POST the same |
Second POST returns 201 (upsert). GET list returns same count as first POST (no duplicates for the same case_id + month). |
3 |
ACF-199 CSV export: POST extraction, then GET |
Response status 200, |
4 |
WPR calculation — basic: POST ACF-199 extraction, then POST |
Response status 201. Response body contains |
5 |
WPR — empty month: POST WPR for a month with no ACF-199 snapshots. |
Response status 201. |
6 |
WPR — GET list: After generating at least one WPR, GET |
Response is non-empty array. Each element has |
7 |
ACF-196 generation: POST |
Response status 201. Response contains |
8 |
ACF-196 CSV export: After generation, GET |
Response status 200, |
9 |
RBAC enforcement: Caseworker token on all TANF reporting endpoints returns 403. |
POST acf-199, POST acf-196, POST wpr, GET acf-199 CSV, GET acf-196 CSV, GET wpr CSV — all return 403 for non-supervisor. |
Test implementation pattern (example for test 1):
#[tokio::test]
async fn tanf_acf199_extraction_populates_enriched_fields() {
if !canopy_test_lib::infrastructure_available().await {
return;
}
let Some(c) = TestClient::authenticated("http://localhost:8011").await else {
return;
};
if !c.is_healthy().await {
return;
}
// Generate ACF-199 snapshot
let body = serde_json::json!({ "report_month": "2026-05-01" });
let gen_resp = c.post_json("/v1/reporting/tanf/acf-199", &body).await;
assert!(
gen_resp.status == 201 || gen_resp.status == 200,
"ACF-199 extraction: expected 201/200, got {} — {}",
gen_resp.status,
gen_resp.text()
);
// List snapshots and verify enriched fields
let list_resp = c.get("/v1/reporting/tanf/acf-199").await;
list_resp.assert_status(200);
let data = list_resp.json::<serde_json::Value>();
let arr = data.as_array().expect("should be array");
// May be empty if no TANF determinations exist in devstack seed data
for snap in arr {
assert!(snap["family_type"].is_string(), "family_type should be string");
assert!(snap["household_size"].as_i64().unwrap_or(0) > 0, "household_size > 0");
assert!(snap["case_status"].is_string(), "case_status should be string");
assert!(snap["months_this_state"].as_i64().is_some(), "months_this_state should be present");
// These may be null when work activities endpoint is not available yet
// but the column itself must exist in the response
assert!(snap.get("total_work_hours").is_some(), "total_work_hours field must exist");
assert!(snap.get("core_activity_hours").is_some(), "core_activity_hours field must exist");
}
}
Files Touched
| File | Change |
|---|---|
|
Add |
|
Add |
|
Add |
|
Add |
|
Rewrite |
|
New: WPR calculation engine ( |
|
New: ACF-196 quarterly stub generation ( |
|
New: CSV export functions for ACF-199, ACF-196, WPR |
|
Add |
|
Add POST routes for WPR and ACF-196, add 3 CSV export routes (7 total TANF routes) |
|
Remove |
|
Add |
|
Replace 3 structural TANF tests with 9 content-level integration tests |
|
Add |
|
Add citations for WPR target values |
Verification
-
cargo nextest run -p canopy-tanf— newlist_determinationsendpoint works, existing tests pass -
cargo nextest run -p canopy-reporting— all 9 TANF integration tests pass -
cargo xtask test— full test battery passes (fmt + clippy + nextest) -
Verify ACF-199 snapshots populate
sanction_status,months_this_state,total_work_hours,core_activity_hourswhen seed data includes work requirements and time limits -
Verify WPR calculation produces correct rates: hand-calculate for known snapshot data (e.g., 60 of 100 all-families meeting requirements should yield rate = 60.00, which meets 50.00 target)
-
Verify caseload reduction credit correctly adjusts WPR targets downward (e.g., CRC of 10 means all-family target = 40.00)
-
Verify child-only cases are excluded from WPR denominator and numerator
-
Verify ACF-196 generation creates exactly 12 rows, all with zero expenditure amounts
-
Verify CSV exports contain correct headers and data rows matching JSON API responses
-
Verify no FTI or restricted data appears in any reporting output (per ADR-004)
-
Verify WPR targets are loaded from
jurisdiction.toml[tanf.wpr]section, not hardcoded (grep for literal50and90in wpr.rs — should only appear inDefaultimpl fallback) -
Verify canopy-reporting queries canopy-tanf via HTTP API only, never direct DB access (per ADR-001)
-
cargo xtask policy auditpasses with newcitations.tomlentries
Errata
Implementation notes (2026-04-13)
Resolved:
-
ACF-199 work hours are now real (resolved 2026-04-19). Placeholder 30/20 literals at
canopy-reporting/src/reporting/tanf.rs:81replaced with live calls toGET /v1/work-requirements/{person_id}/activities/summary?month=YYYY-MMon canopy-tanf. Per-activity hours are aggregated ashours_per_week * overlap_days / 7across the target month; core vs non-core classification follows 45 CFR 261.31. Seecanopy-tanf-work-activities-list.adoc.
Not blocked externally (fixable, deferred for scope):
-
WPR targets hardcoded to federal defaults (50/90). Should load from
jurisdiction.toml [tanf.wpr]per ADR-011. Not blocked — jurisdiction.toml is loaded at startup in other services. -
WPR formula incomplete per 45 CFR 261. Missing: (a) core activity hour check (20+ hrs per 261.31), (b) child-under-6 reduced threshold (20 hrs per 261.32(b)), (c) exempt family exclusion from denominator. All fixable now — require
is_exemptflag from work requirements API and child-age data from canopy-persons. -
Only first adult’s work hours captured. For two-parent families, the second parent’s hours and sanctions are ignored. Should iterate all adult members.
-
No
#[utoipa::path]annotations on TANF/Medicaid handlers. They won’t appear in Swagger UI. -
list_tanf_determinationsswallows errors silently..or_else(|_| Ok(Vec::new()))means auth failures produce empty reports with 201 Created. Should propagate errors.
Blocked externally:
-
ACF-196 expenditure amounts are zero. Requires state accounting / MMIS integration.
families_servedcount is real (from ACF-199 snapshots) and only applied tobasic_assistancecategory per 45 CFR 265.9.
Tracked follow-ups (filed 2026-04-24 after audit of plan Errata + Potential Improvements sections across the repo):
-
#315 — Close 4 TANF ACF-199 reporting gaps (from Errata)
Documentation Updates
-
.claude/docs/services.md— update canopy-reporting TANF section: 7 routes (was 4), document new CSV endpoints, document WPR calculation module -
.claude/docs/services.md— update canopy-tanf: addGET /v1/determinationsto route table (was 9 domain routes, now 10) -
.claude/CLAUDE.md— update canopy-reporting route count and notes; update canopy-tanf route count -
CHANGELOG.adoc— entry under== Unreleased: "feat: TANF federal reporting enrichment — ACF-199 work/sanction/time-limit fields, WPR calculation engine, ACF-196 stub generation, CSV exports" -
docs/modules/ROOT/pages/plans/tanf-federal-reporting.adoc— update status table to COMPLETE