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_requests and appeal_timeline_events tables

  • 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

POST /v1/appeals

File appeal request; auto-grants continued benefits if eligible

canopy-worker, canopy-applicant (own household)

GET /v1/appeals/{id}

Get appeal with timeline events

canopy-worker

GET /v1/appeals?household_id={id}

List all appeals for household

canopy-worker

PUT /v1/appeals/{id}/schedule

Set hearing date and officer

canopy-worker

PUT /v1/appeals/{id}/decision

Record decision; triggers overpayment if upheld with continued benefits

canopy-snap-supervisor

PUT /v1/appeals/{id}/withdraw

Household withdraws appeal; terminates continued benefits

canopy-worker, canopy-applicant (own)

GET /v1/appeals/queue

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::interval task running every 24 hours (default, triggered at startup)

  • An internal endpoint POST /internal/v1/appeals/clock-check that 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 with timeline_events: Vec<AppealTimelineEvent>

The file_appeal handler must:

  1. Validate the determination exists and belongs to the household (call canopy-eligibility or check local reference)

  2. Check request is within 90 days of the adverse action notice date; return 422 with ProblemDetail if outside window

  3. Compute continued_benefits_eligible (request_date < adverse_action_effective_date)

  4. Auto-grant continued benefits if eligible for SNAP/Medicaid (call ContinuedBenefitsService)

  5. Create request_received timeline event

  6. Publish appeal.filed event via canopy-mq

  7. Return 201 with the created appeal

The record_decision handler must:

  1. Require canopy-snap-supervisor role

  2. If upheld_agency and continued_benefits_granted: calculate overpayment via ContinuedBenefitsService::calculate_overpayment

  3. If reversed_household: publish appeal.decision_reversed event

  4. Create decision_issued timeline event

  5. 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:

  1. Run migrations via sqlx::migrate!() on the test container

  2. Use mock_event_publisher to capture and assert published events

  3. Verify no personal data or benefit amounts appear in published events (ADR-004)

Files Touched

File Change

services/canopy-appeals/migrations/YYYYMMDD_appeals.sql

New: appeal_requests, appeal_timeline_events

services/canopy-appeals/src/domain.rs

New: domain types

services/canopy-appeals/src/store.rs

New: database queries

services/canopy-appeals/src/continued_benefits.rs

New: continued benefits and overpayment logic

services/canopy-appeals/src/clock.rs

New: 90-day decision clock alerting

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

Replace empty Router::new() with full route set

services/canopy-appeals/src/main.rs

Enable migrations; wire event publisher; wire clock task

Verification

  1. cargo nextest run -p canopy-appeals — all tests pass

  2. Appeal filed day before effective date → continued_benefits_eligible = true, benefits continue

  3. Appeal filed day after effective date → continued_benefits_eligible = false

  4. Decision upheld after continued benefits: overpayment = sum of continued issuances

  5. 90-day clock: appeal approaching deadline triggers appeal.decision_deadline_approaching event

  6. 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-issuancescompute_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).

Edit this page · default