Plan: Fair Hearings and Appeals
On this page
Status
| Step | Description | Status |
|---|---|---|
1 |
Database schema: appeal_requests, appeal_timeline_events tables |
Done (2026-04-20) |
2 |
Appeal intake endpoint with continued benefits evaluation |
Done (2026-04-20) |
3 |
90-day decision clock and timeline alert logic |
Done (2026-04-20) |
4 |
Hearing scheduling and decision recording |
Done (2026-04-20) |
5 |
Integration with canopy-notices (AppealAcknowledgment, ContinuedBenefitsNotice) |
Done (2026-04-20) — (appeal event subscribers wired in canopy-notices, appeal-acknowledgment Typst template created) |
6 |
API endpoints and integration tests |
Done (2026-04-20) — (unit tests; DB integration tests require testcontainers) |
Epic: &41
Branch: feature/fair-hearings-appeals
Context
Federal regulations guarantee fair hearing rights for all applicants and recipients of federally funded benefit programs. Failure to provide hearings, or failure to continue benefits pending a hearing decision, creates both legal exposure and federal compliance deficiencies.
Key requirements by program: - SNAP (7 CFR 273.15): Hearing request within 90 days of adverse action. Decision within 90 days of request. Recipients who request a hearing BEFORE the adverse action effective date must receive continued benefits at the prior level pending decision. Overpayment liability if agency prevails. - Medicaid (42 CFR 431.200-431.250): Same 90-day request window. Decision within 90 days. Continued benefits required. - TANF (45 CFR 205.10): State hearing procedures; similar rights.
This plan covers the SNAP fair hearing workflow for UAT. The same infrastructure supports TANF and Medicaid hearings (later phases add program-specific variations).
The hearing officer must be impartial and may not have participated in the original determination. This plan does not build a hearing officer assignment system (post-UAT); it tracks the assigned officer’s ID.
Scope
In scope:
-
appeal_requestsandappeal_timeline_eventstables -
POST /v1/appeals— file appeal request with continued benefits determination -
GET /v1/appeals/{id}— get appeal with full timeline -
GET /v1/appeals?household_id={id}— list appeals for household -
PUT /v1/appeals/{id}/schedule— schedule hearing -
PUT /v1/appeals/{id}/decision— record decision, trigger overpayment assessment if applicable -
PUT /v1/appeals/{id}/withdraw— withdraw appeal -
GET /v1/appeals/queue— worker queue of pending appeals -
90-day decision clock with alert events
-
Continued benefits: automatic grant when request before adverse action effective date
-
Overpayment calculation on agency-upheld decision
-
Integration with canopy-notices: AppealAcknowledgment and ContinuedBenefitsNotice
Out of scope:
-
Hearing officer assignment system (post-UAT)
-
Hearing transcript or document management (post-UAT)
-
Automated overpayment collection (post-UAT; canopy-enrollment plan)
-
TANF and Medicaid hearing variations (later phases)
Design
Database schema
CREATE TABLE appeal_requests (
id UUID PRIMARY KEY,
household_id UUID NOT NULL,
requestor_person_id UUID NOT NULL,
program TEXT NOT NULL, -- Program enum value
application_id UUID,
determination_id UUID NOT NULL, -- the determination being appealed
notice_id UUID, -- the NOA that triggered the appeal (optional)
request_date DATE NOT NULL,
request_method TEXT NOT NULL, -- 'phone', 'mail', 'in_person', 'online'
adverse_action_effective_date DATE, -- date of adverse action; used for continued benefits check
hearing_scheduled_date DATE,
hearing_officer_id UUID,
decision_due_date DATE NOT NULL, -- request_date + decision_clock_days (from jurisdiction.toml per program)
-- SNAP: 90 days (7 CFR 273.15). Medicaid: 90 days (42 CFR 431.244).
-- TANF: state-determined (Georgia: 90 days). CAPS/WIC: state-determined.
-- Load from jurisdiction.toml [appeals] decision_clock_days_snap = 90, etc.
decision_date DATE,
decision TEXT,
-- 'upheld_agency', 'reversed_household', 'withdrawn', 'dismissed'
decision_basis TEXT, -- narrative summary of hearing officer's decision
continued_benefits_eligible BOOLEAN GENERATED ALWAYS AS
(adverse_action_effective_date IS NOT NULL
AND request_date < adverse_action_effective_date) STORED,
continued_benefits_granted BOOLEAN NOT NULL DEFAULT false,
continued_benefits_start_date DATE,
continued_benefits_end_date DATE,
overpayment_amount NUMERIC(10,2), -- set when agency upheld and continued benefits were paid
overpayment_claim_id UUID,
status TEXT NOT NULL DEFAULT 'pending',
-- 'pending', 'scheduled', 'decided', 'withdrawn', 'dismissed'
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
active BOOLEAN NOT NULL DEFAULT true
);
CREATE INDEX appeals_household_idx ON appeal_requests (household_id);
CREATE INDEX appeals_status_idx ON appeal_requests (status, decision_due_date) WHERE status = 'pending';
CREATE TABLE appeal_timeline_events (
id UUID PRIMARY KEY,
appeal_id UUID NOT NULL REFERENCES appeal_requests(id),
event_type TEXT NOT NULL,
-- 'request_received', 'acknowledged', 'continued_benefits_granted',
-- 'hearing_scheduled', 'hearing_held', 'decision_issued',
-- 'overpayment_assessed', 'withdrawn', 'dismissed'
event_date TIMESTAMPTZ NOT NULL DEFAULT now(),
recorded_by UUID, -- worker person_id; null for system events
notes TEXT
);
Continued benefits logic
When an appeal request is received:
1. Check continued_benefits_eligible: computed column is true when request_date < adverse_action_effective_date
2. If eligible AND program is SNAP or Medicaid: automatically set continued_benefits_granted = true
3. Set continued_benefits_start_date = adverse_action_effective_date
4. Set continued_benefits_end_date = decision_due_date (provisional; updated when decision is issued)
5. Publish appeal.continued_benefits_granted event → canopy-enrollment must pause any scheduled benefit termination
6. canopy-notices generates ContinuedBenefitsNotice
When decision is issued:
- If decision = 'upheld_agency' AND continued_benefits_granted = true:
- Calculate overpayment_amount = sum of continued benefits paid between continued_benefits_start_date and decision_date
- Set continued_benefits_end_date = decision_date + 30 days (state grace period)
- Publish appeal.overpayment_assessed event
- canopy-notices generates OverpaymentNotice
- If decision = 'reversed_household':
- No overpayment
- canopy-eligibility must re-evaluate; original determination superseded
- Publish appeal.decision_reversed event
90-day decision clock
Background job (or event-driven via scheduler):
- Daily: find appeals where decision_due_date ⇐ today + 14 days AND status = 'pending' or 'scheduled'
- Publish appeal.decision_deadline_approaching event with days remaining
- If decision_due_date < today AND status not 'decided'/'withdrawn'/'dismissed': publish appeal.overdue
These events can trigger supervisor alerts in canopy-web.
Appeal request validation
On POST /v1/appeals:
1. Verify determination exists and belongs to the requesting household
2. Check request is within 90 days of the adverse action notice date (7 CFR 273.15(b))
3. If outside 90 days: reject with 422 and Problem Detail explaining the deadline
4. Compute continued_benefits_eligible
5. Auto-grant continued benefits if eligible
6. Create timeline event: request_received
7. Publish appeal.filed event → canopy-notices generates AppealAcknowledgment
Events published
// appeal.filed
{ "appeal_id": "uuid", "household_id": "uuid", "program": "snap", "request_date": "2026-07-15" }
// appeal.continued_benefits_granted
{ "appeal_id": "uuid", "household_id": "uuid", "program": "snap",
"start_date": "2026-07-20", "determination_id": "uuid" }
// appeal.decision_issued
{ "appeal_id": "uuid", "household_id": "uuid", "decision": "upheld_agency",
"determination_id": "uuid" }
// appeal.overpayment_assessed
{ "appeal_id": "uuid", "household_id": "uuid" }
// appeal.decision_reversed
{ "appeal_id": "uuid", "household_id": "uuid", "program": "snap" }
No benefit amounts, income, or personal data in any event payload.
API endpoint contract
| Method + Path | Description | Auth |
|---|---|---|
|
File appeal request; auto-grants continued benefits if eligible |
canopy-worker, canopy-applicant (own household) |
|
Get appeal with timeline events |
canopy-worker |
|
List all appeals for household |
canopy-worker |
|
Set hearing date and officer |
canopy-worker |
|
Record decision; triggers overpayment if upheld with continued benefits |
canopy-snap-supervisor |
|
Household withdraws appeal; terminates continued benefits |
canopy-worker, canopy-applicant (own) |
|
Worker queue sorted by decision_due_date |
canopy-worker |
CLI Commands (ADR-007)
Per ADR-007, the following canopy CLI commands must be added to tools/canopy-cli/ when this plan ships:
-
canopy appeal create— file an appeal request -
canopy appeal get <id>— get appeal with timeline events -
canopy appeal list --household-id <id>— list all appeals for a household -
canopy appeal schedule <id>— schedule a hearing -
canopy appeal decide <id>— record hearing decision -
canopy appeal withdraw <id>— withdraw an appeal -
canopy appeal queue— list pending appeals sorted by decision deadline
Steps
Step 1: Database migrations
Files: services/canopy-appeals/migrations/20260401000000_create_appeals_tables.sql, services/canopy-appeals/src/main.rs
Create appeal_requests and appeal_timeline_events tables using the SQL from the Design section.
Enable migrations in services/canopy-appeals/src/main.rs by uncommenting the migration runner.
Step 2: Domain types and store
Files: services/canopy-appeals/src/domain.rs, services/canopy-appeals/src/store.rs
Domain types: CreateAppealRequest, AppealResponse, ScheduleHearingRequest, RecordDecisionRequest.
Store layer: CRUD queries using sqlx.
Step 3: Continued benefits logic
Files: services/canopy-appeals/src/continued_benefits.rs (new)
Implement continued benefits determination and overpayment calculation. Overpayment calculation queries canopy-enrollment for benefit issuances in the continued period.
Step 4: 90-day clock alerting
Files: services/canopy-appeals/src/clock.rs (new), services/canopy-appeals/src/events.rs (update), services/canopy-appeals/src/main.rs (update)
Create services/canopy-appeals/src/clock.rs implementing the daily decision deadline check:
// SPDX-License-Identifier: AGPL-3.0-or-later
use canopy_mq::publisher::EventPublisher;
use chrono::{NaiveDate, Utc};
use sqlx::PgPool;
use uuid::Uuid;
pub struct DecisionClock {
pool: PgPool,
publisher: EventPublisher,
}
impl DecisionClock {
pub fn new(pool: PgPool, publisher: EventPublisher) -> Self {
Self { pool, publisher }
}
/// Run the daily clock check. Finds appeals approaching or past their decision deadline.
pub async fn run_daily_check(&self) -> Result<ClockCheckResult> {
let today = Utc::now().date_naive();
let approaching = self.find_approaching_deadline(today, 14).await?;
let overdue = self.find_overdue(today).await?;
for appeal in &approaching {
let days_remaining = (appeal.decision_due_date - today).num_days();
self.publisher.publish(
"appeal.decision_deadline_approaching",
&serde_json::json!({
"appeal_id": appeal.id,
"household_id": appeal.household_id,
"decision_due_date": appeal.decision_due_date,
"days_remaining": days_remaining
}),
).await?;
}
for appeal in &overdue {
self.publisher.publish(
"appeal.overdue",
&serde_json::json!({
"appeal_id": appeal.id,
"household_id": appeal.household_id,
"decision_due_date": appeal.decision_due_date
}),
).await?;
}
Ok(ClockCheckResult { approaching: approaching.len(), overdue: overdue.len() })
}
/// Find appeals where decision_due_date <= today + lookahead_days AND status IN ('pending', 'scheduled').
async fn find_approaching_deadline(&self, today: NaiveDate, lookahead_days: i64) -> Result<Vec<AppealRequest>> { /* sqlx query */ }
/// Find appeals where decision_due_date < today AND status NOT IN ('decided', 'withdrawn', 'dismissed').
async fn find_overdue(&self, today: NaiveDate) -> Result<Vec<AppealRequest>> { /* sqlx query */ }
}
pub struct ClockCheckResult {
pub approaching: usize,
pub overdue: usize,
}
Update services/canopy-appeals/src/events.rs to add event publishing functions for appeal.decision_deadline_approaching and appeal.overdue.
Per ADR-004: no personal data in event payloads — only appeal_id, household_id, dates.
Update services/canopy-appeals/src/main.rs to wire the clock as either:
-
A Tokio
tokio::time::intervaltask running every 24 hours (default, triggered at startup) -
An internal endpoint
POST /internal/v1/appeals/clock-checkthat runs the check on demand (for testing and manual triggers)
Both modes should be active: the interval task runs in production, the endpoint allows testing without waiting for the interval.
Step 5: API routes
Files: services/canopy-appeals/src/api/mod.rs (update), services/canopy-appeals/src/api/appeals.rs (new)
Create services/canopy-appeals/src/api/appeals.rs with route handlers for all endpoints from the Design section:
// SPDX-License-Identifier: AGPL-3.0-or-later
use axum::{Router, routing::{get, post, put}, extract::{Path, Query, State}, Json};
use canopy_api::AppState;
use uuid::Uuid;
pub fn routes() -> Router<AppState> {
Router::new()
.route("/v1/appeals", post(file_appeal))
.route("/v1/appeals", get(list_appeals)) // Query: household_id
.route("/v1/appeals/:id", get(get_appeal))
.route("/v1/appeals/:id/schedule", put(schedule_hearing))
.route("/v1/appeals/:id/decision", put(record_decision))
.route("/v1/appeals/:id/withdraw", put(withdraw_appeal))
.route("/v1/appeals/queue", get(appeals_queue))
}
Request types:
-
FileAppealRequest:{ household_id: Uuid, requestor_person_id: Uuid, program: String, determination_id: Uuid, notice_id: Option<Uuid>, request_date: NaiveDate, request_method: String } -
ScheduleHearingRequest:{ hearing_date: NaiveDate, hearing_officer_id: Uuid } -
RecordDecisionRequest:{ decision: String, decision_basis: String }— decision values:upheld_agency,reversed_household,dismissed -
AppealResponse: full appeal record withtimeline_events: Vec<AppealTimelineEvent>
The file_appeal handler must:
-
Validate the determination exists and belongs to the household (call canopy-eligibility or check local reference)
-
Check request is within 90 days of the adverse action notice date; return 422 with
ProblemDetailif outside window -
Compute
continued_benefits_eligible(request_date < adverse_action_effective_date) -
Auto-grant continued benefits if eligible for SNAP/Medicaid (call
ContinuedBenefitsService) -
Create
request_receivedtimeline event -
Publish
appeal.filedevent via canopy-mq -
Return 201 with the created appeal
The record_decision handler must:
-
Require
canopy-snap-supervisorrole -
If
upheld_agencyandcontinued_benefits_granted: calculate overpayment viaContinuedBenefitsService::calculate_overpayment -
If
reversed_household: publishappeal.decision_reversedevent -
Create
decision_issuedtimeline event -
Update appeal status to
decided
Auth: all endpoints require canopy-worker role minimum.
record_decision requires canopy-snap-supervisor.
Update services/canopy-appeals/src/api/mod.rs to merge appeal routes and the internal clock-check endpoint.
Step 6: Integration tests
Files: services/canopy-appeals/tests/appeals_test.rs (new)
Use testcontainers-rs with PostgreSQL and RabbitMQ containers.
Use canopy_test_lib for test harness setup.
// SPDX-License-Identifier: AGPL-3.0-or-later
use canopy_test_lib::{setup_test_db, setup_test_mq, mock_event_publisher};
#[tokio::test]
async fn test_appeal_before_effective_date_grants_continued_benefits() {
// Setup: determination with adverse_action_effective_date = today + 10
// POST /v1/appeals with request_date = today
// Assert: continued_benefits_eligible = true, continued_benefits_granted = true
// Assert: continued_benefits_start_date = adverse_action_effective_date
// Assert: appeal.continued_benefits_granted event published
// Assert: timeline event "continued_benefits_granted" created
}
#[tokio::test]
async fn test_appeal_after_effective_date_no_continued_benefits() {
// Setup: determination with adverse_action_effective_date = today - 5
// POST /v1/appeals with request_date = today
// Assert: continued_benefits_eligible = false, continued_benefits_granted = false
// Assert: no appeal.continued_benefits_granted event published
}
#[tokio::test]
async fn test_appeal_outside_90_day_window() {
// Setup: adverse action notice_date = today - 100
// POST /v1/appeals
// Assert: 422 response with ProblemDetail citing 7 CFR 273.15(b)
}
#[tokio::test]
async fn test_decision_upheld_calculates_overpayment() {
// Setup: appeal with continued_benefits_granted = true, 2 months of benefits paid
// PUT /v1/appeals/{id}/decision with decision = "upheld_agency"
// Assert: overpayment_amount = sum of continued benefit issuances
// Assert: appeal.overpayment_assessed event published
// Assert: timeline event "overpayment_assessed" created
}
#[tokio::test]
async fn test_decision_reversed_publishes_event() {
// PUT /v1/appeals/{id}/decision with decision = "reversed_household"
// Assert: appeal.decision_reversed event published
// Assert: no overpayment calculated
// Assert: timeline event "decision_issued" created
}
#[tokio::test]
async fn test_withdraw_appeal() {
// PUT /v1/appeals/{id}/withdraw
// Assert: status = "withdrawn"
// Assert: continued_benefits_end_date set if benefits were granted
// Assert: timeline event "withdrawn" created
}
#[tokio::test]
async fn test_90_day_clock_approaching_deadline() {
// Setup: appeal with decision_due_date = today + 10, status = "pending"
// Run DecisionClock::run_daily_check()
// Assert: appeal.decision_deadline_approaching event published with days_remaining = 10
}
#[tokio::test]
async fn test_90_day_clock_overdue() {
// Setup: appeal with decision_due_date = today - 3, status = "scheduled"
// Run DecisionClock::run_daily_check()
// Assert: appeal.overdue event published
}
#[tokio::test]
async fn test_appeals_queue_sorted_by_deadline() {
// Insert 3 appeals with different decision_due_dates
// GET /v1/appeals/queue
// Assert: sorted ascending by decision_due_date (most urgent first)
}
Each test must:
-
Run migrations via
sqlx::migrate!()on the test container -
Use
mock_event_publisherto capture and assert published events -
Verify no personal data or benefit amounts appear in published events (ADR-004)
Files Touched
| File | Change |
|---|---|
|
New: appeal_requests, appeal_timeline_events |
|
New: domain types |
|
New: database queries |
|
New: continued benefits and overpayment logic |
|
New: 90-day decision clock alerting |
|
Replace empty Router::new() with full route set |
|
Enable migrations; wire event publisher; wire clock task |
Verification
-
cargo nextest run -p canopy-appeals— all tests pass -
Appeal filed day before effective date →
continued_benefits_eligible = true, benefits continue -
Appeal filed day after effective date →
continued_benefits_eligible = false -
Decision upheld after continued benefits: overpayment = sum of continued issuances
-
90-day clock: appeal approaching deadline triggers
appeal.decision_deadline_approachingevent -
GET /v1/appeals/queue→ sorted by decision_due_date ascending (most urgent first)
Documentation Updates
-
.claude/docs/services.md— add appeals tables, events, endpoints -
CHANGELOG.adoc— entry under== Unreleased
Errata
2026-04-20 — continued-benefits overpayment formula
The initial landing of compute_overpayment used a simplified
monthly_benefit / 30 * days formula with an inline note ("In production,
this would query canopy-enrollment for actual issuances"). That was wrong
on two counts: SNAP allotments aren’t issued daily (monthly with
first-month proration per 7 CFR 274.2(b)), and it didn’t consider whether
the issuance actually reached the household (failed/reversed rows still
counted toward the overpayment).
Resolved via canopy-enrollment-household-issuances — compute_overpayment
now takes a list of IssuanceRecord fetched from canopy-enrollment’s new
GET /v1/households/{household_id}/issuances endpoint and sums the
allotment_amount of issuances in the continued-benefits window with
issuance_status = 'issued'. See that plan for the full semantic decisions
(whole-month vs partial-month, status filtering).