Plan: canopy-tanf Work Activities List Endpoint + Aggregation
On this page
Status
| Step | Description | Status |
|---|---|---|
1 |
Store: add |
Done (2026-04-20) |
2 |
API: add |
Done (2026-04-20) |
3 |
API: add |
Done (2026-04-20) |
4 |
Reporting client: extend |
Done (2026-04-20) |
5 |
Reporting consumer: replace hardcoded |
Done (2026-04-20) |
6 |
Integration tests: POST an activity via existing create endpoint, GET the list, GET the summary for a given month, assert hours match expected calculation. Mirror the harness pattern in |
Done (2026-04-20) |
7 |
Plan sync: remove the "work hours placeholder" errata from |
Done (2026-04-20) |
Branch: feature/canopy-tanf-work-activities-list
Labels: type::feature, priority::high, program::tanf, service::tanf, service::reporting, federal-partner::acf, workflow::ready
Context
tanf_work_activities has stored rows since the original TANF migration
(services/canopy-tanf/migrations/20260325000000_create_tanf_tables.sql:106),
and POST /v1/work-requirements/{person_id}/activities exists
(services/canopy-tanf/src/api/work_requirement_handlers.rs:64). There is no
read path: no GET lists activities for a person, no endpoint aggregates hours
per month. create_work_activity at services/canopy-tanf/src/store/mod.rs:239
only writes.
Two consumers need the read path:
-
canopy-reportingcomputes ACF-199 work participation. Todayservices/canopy-reporting/src/reporting/tanf.rs:81-84uses hardcoded30/20hour placeholders. The fragilecanopy-reporting/src/clients.rsstub acknowledges this ("will return empty until that endpoint is added"). The ACF-199 WPR calculation is documented as "meaningless until real hours are wired" intanf-federal-reporting.adocStep 5. -
Worker portal doesn’t surface activity detail today, but the worker-portal-expansion plan’s TANF tab renders exemption/sanction/time-limit summaries without activity-level drill-down. A list endpoint unblocks that UI improvement as a follow-up.
Per-activity hours drive work-requirement compliance calculations under 45 CFR 261.31 (WPR) and PAMMS 2301 (Georgia TANF work plan). Without accurate hours, ACF-199 submissions to ACF are knowingly incorrect — an open compliance risk.
Scope
In scope:
-
One list endpoint, one summary endpoint, one store helper, one reporting consumer fix, tests for all of it.
-
Hour-aggregation math:
hours_per_week * overlap_weeks_in_monthwhereoverlap_weeks_in_month = (effective_date..end_date.unwrap_or(month_end)) ∩ (month_start..month_end) / 7. PAMMS-consistent (see Design). -
Core-vs-non-core classification is already defined in
services/canopy-reporting/src/reporting/tanf.rs:362-375asCORE_ACTIVITIES. Reuse that mapping from the summary endpoint.
Out of scope:
-
Adding new activity types. The existing set (
employment,job_search,community_service,education,vocational_training) is what the migration defines. -
Cross-person or household-level aggregation. ACF-199 rolls up from per-person data; the caller does the household math.
-
Write-side changes.
POST /activitiesstays as-is.
Dependencies
-
services/canopy-tanf/migrations/20260325000000_create_tanf_tables.sql— table exists. -
services/canopy-tanf/src/store/mod.rs:239—create_work_activityalready works. -
services/canopy-tanf/src/api/work_requirement_handlers.rs— existing handler module to extend. -
services/canopy-reporting/src/clients.rs—ServiceClients::get_tanf_work_requirementsexists; add a siblingget_tanf_work_activities. -
services/canopy-reporting/src/reporting/tanf.rs:362-375—CORE_ACTIVITIESconstant. -
No new migrations required.
Design
Store helper
// services/canopy-tanf/src/store/mod.rs
pub async fn list_work_activities_for_person(
db: &PgPool,
person_id: PersonId,
window: Option<(NaiveDate, NaiveDate)>,
) -> sqlx::Result<Vec<TanfWorkActivity>> {
match window {
Some((from, to)) => sqlx::query_as::<_, TanfWorkActivity>(
r#"SELECT a.*
FROM tanf_work_activities a
JOIN tanf_work_requirements r ON a.work_requirement_id = r.id
WHERE r.person_id = $1
AND a.effective_date <= $3
AND COALESCE(a.end_date, DATE '9999-12-31') >= $2
ORDER BY a.effective_date DESC"#,
)
.bind(person_id)
.bind(from)
.bind(to)
.fetch_all(db)
.await,
None => sqlx::query_as::<_, TanfWorkActivity>(
r#"SELECT a.*
FROM tanf_work_activities a
JOIN tanf_work_requirements r ON a.work_requirement_id = r.id
WHERE r.person_id = $1
ORDER BY a.effective_date DESC"#,
)
.bind(person_id)
.fetch_all(db)
.await,
}
}
Hour-aggregation formula
For a target month [month_start, month_end]:
activity_days_in_month = (
min(a.end_date.unwrap_or(month_end), month_end)
- max(a.effective_date, month_start)
) + 1
weeks_in_month_overlap = activity_days_in_month / 7.0
hours_for_month = a.hours_per_week * weeks_in_month_overlap
Negative activity_days_in_month (activity didn’t overlap month) → 0. Use
chrono::NaiveDate::signed_duration_since and rust_decimal arithmetic
throughout; no floats.
Summary endpoint response
GET /v1/work-requirements/{person_id}/activities/summary?month=2026-04
{
"person_id": "…",
"month": "2026-04",
"total_hours": "140.00",
"core_hours": "120.00",
"non_core_hours": "20.00",
"activity_breakdown": [
{ "activity_type": "employment", "hours": 120.00, "is_core": true },
{ "activity_type": "education", "hours": 20.00, "is_core": false }
]
}
activity_breakdown returns each activity type that contributed hours in the
window so auditors can trace WPR numbers back to source rows.
Reporting consumer
// services/canopy-reporting/src/reporting/tanf.rs
// Replace lines 81-84:
let summary = clients.get_tanf_work_activities_summary(person_id, month).await?;
total_work_hours += summary.total_hours;
core_activity_hours += summary.core_hours;
When the upstream returns 404 (no work requirement on file) or an empty list,
treat as total_hours = 0 and core_hours = 0 — exempt persons have no
hours, which is correct.
Steps
Step 1: Store helper
Files: services/canopy-tanf/src/store/mod.rs.
Add list_work_activities_for_person. Pair it with a unit test against
infrastructure_available() (pattern: existing create_work_activity tests).
Step 2: List endpoint
Files: services/canopy-tanf/src/api/work_requirement_handlers.rs,
services/canopy-tanf/src/api/mod.rs (route registration).
Handler signature:
#[utoipa::path(
get,
path = "/v1/work-requirements/{person_id}/activities",
params(
("person_id" = PersonId, Path, description = "Person whose activities to list"),
("from" = Option<NaiveDate>, Query, description = "Inclusive window start"),
("to" = Option<NaiveDate>, Query, description = "Inclusive window end"),
),
responses(
(status = 200, body = Vec<TanfWorkActivity>),
(status = 401), (status = 403), (status = 404),
),
security(("bearer_auth" = []))
)]
async fn list_activities(
State(state): State<AppState>,
Path(person_id): Path<PersonId>,
Query(q): Query<ActivityWindowQuery>,
claims: Claims,
) -> Result<Json<Vec<TanfWorkActivity>>, ApiError> { ... }
Step 3: Summary endpoint
Files: same.
Uses the Step 1 helper filtered to the target month, runs the aggregation
formula, returns WorkActivitiesSummary. New response struct lives in
services/canopy-tanf/src/domain.rs alongside TanfWorkActivity.
Step 4: Reporting client
Files: services/canopy-reporting/src/clients.rs.
Add get_tanf_work_activities_summary. Remove the
list_tanf_work_activities stub if it still returns Vec::new().
Step 5: Replace placeholders
Files: services/canopy-reporting/src/reporting/tanf.rs.
Delete the Decimal::from(30) / Decimal::from(20) literals at line
81-84. Call the Step 4 client method, aggregate into
total_work_hours / core_activity_hours.
Files Touched
| File | Change |
|---|---|
|
+list_work_activities_for_person + unit test |
|
+list_activities, +summary handlers |
|
Route registration + utoipa exposure |
|
+WorkActivitiesSummary response struct |
|
+get_tanf_work_activities_summary, -stub |
|
Replace placeholder hours with real summary |
|
New integration tests |
|
Errata removal |
|
Tier 5.5 row → Done |
|
Unreleased entry |
Verification
-
cargo nextest run -p canopy-tanf --test work_activities_test— all new tests pass. -
cargo nextest run -p canopy-reporting— no regressions. -
Manual: seed a work requirement + two activities (one core, one non-core) via
POST; callGET /summary?month=2026-04; assert both appear with correct aggregated hours. -
Manual: trigger ACF-199 generation against the seeded data; confirm
total_work_hoursmatches the summary endpoint output (no more 30/20 literals). -
cargo xtask validate— full battery green.
Documentation Updates
-
.claude/CLAUDE.md— canopy-tanf route count bumped 15 → 17, new plan listed -
.claude/docs/services.md— canopy-tanf row has no route count today; no change needed -
xref:api/canopy-tanf.adoc— this reference file does not exist (api/ only covers shared services); deferred with the rest of the per-program reference pages -
CHANGELOG.adoc—== Unreleased→=== Addedentry -
tanf-federal-reporting.adoc— moved the "work hours placeholder" errata entry to Resolved -
roadmap.adoc— Tier 5.5 row forcanopy-reporting/src/reporting/tanf.rs:81→ Done
Potential Improvements
These are orthogonal to the core WPR fix and can be follow-ups:
-
Per-row
activity_breakdown(not just per-type). The summary currently sums hours byactivity_type(one row per distinct type in the window). Auditors resolving an ACF-199 discrepancy may want per-work_activity_iddrill-down so they can trace a flagged hour count back to the individual logged row — useful when the same person has twoemploymententries with differenteffective_dateranges in the same month. -
Boundary rounding at the endpoint.
hours_per_week * days / 7produces a 28-fractional-digit Decimal (e.g.,128.57142857142857142857142857). The endpoint returns the raw value; clients comparing ratios hit a single-ulp tolerance because of rust_decimal’s 28-digit floor (see thesummary_splits_core_vs_non_coretest’s1e-20tolerance). Rounding to 2 fractional places at the JSON boundary would keep the math exact internally but produce clean, comparable strings for consumers. -
Exempt-family exclusion from denominator. The WPR formula per 45 CFR 261 counts only non-exempt work-eligible individuals in the denominator.
canopy-reportingalready filters onwork_requirements.exempt, but this plan only wired the numerator; the denominator-side filter lives inreporting/tanf.rsand still uses every adult. Tracked separately intanf-federal-reporting.adocerrata. -
Summary caching for month-end reporting runs. Every
cargo xtask report acf-199call fans out one summary HTTP call per adult. For a 100k-case state-wide run this is 100k sequential calls. A bulk endpoint (POST /v1/work-requirements/activities/summary { person_ids: […], month }) or an in-process cache keyed on(person_id, month)would cut ACF-199 wall-clock by ~20x. Premature until the reporting pipeline is stressed at real scale.
Tracked follow-ups (filed 2026-04-24 after audit of plan Errata + Potential Improvements sections across the repo):
-
#320 — Cache work-activity summaries for month-end reporting (from Potential Improvements)
Tracked follow-ups (filed 2026-05-04 during PI sweep):
-
#406 — Per-row activity_breakdown drill-down on summary endpoint
-
Boundary rounding at the endpoint — cosmetic; client-side rounding is sufficient. Single-ulp tolerance is documented in the test. Deferred indefinitely.
-
Exempt-family exclusion from denominator — already tracked in
tanf-federal-reporting.adocerrata; no separate issue needed.