Plan: Intentional Program Violations (IPV) and Administrative Disqualification Hearings (ADH)

On this page

Status

Step Description Status

1

Database schema: ipv_cases, ipv_timeline_events tables

Done (2026-03-29)

2

Domain types, store layer, and disqualification penalty calculator

Done (2026-03-29)

3

ADH workflow: scheduling, notice enforcement, decision recording

Done (2026-03-29)

4

Waiver acceptance and court-referred disqualification paths

Done (2026-03-29)

5

Active disqualification check endpoint (consumed by canopy-snap)

Done (2026-03-29)

6

Event publishing (ipv.case_referred, ipv.adh_scheduled, ipv.disqualification_imposed)

Done (2026-03-29)

7

API endpoints and integration tests

Done (2026-03-29) — (unit tests; DB integration tests require devstack)

Epic: &41
Branch: feature/ipv-disqualification
Labels: type::feature, priority::high, program::cross-program, service::appeals, workflow::ready

Context

Intentional Program Violation (IPV) proceedings are a mandatory component of SNAP administration. When a state agency suspects that an individual has intentionally violated program rules — through fraud, misrepresentation, concealment of facts, or trafficking of benefits — it must initiate either an Administrative Disqualification Hearing (ADH) or refer the case to a court of appropriate jurisdiction.

Unlike fair hearings (which are household-initiated due process protections), IPV/ADH proceedings are agency-initiated enforcement actions. Both workflows live in canopy-appeals but follow entirely different lifecycles.

Regulatory basis

  • 7 CFR 273.16 — Disqualification for intentional program violations (governing regulation)

  • 7 CFR 273.16(b) — Administrative disqualification hearing (ADH) process: the state agency must provide written notice of the hearing at least 30 days in advance; the individual has the right to examine evidence, present witnesses, and cross-examine agency witnesses

  • 7 CFR 273.16(e) — Disqualification penalties:

    • First offense: 12-month disqualification from the program

    • Second offense: 24-month disqualification

    • Third offense: permanent disqualification

    • Trafficking (any offense): permanent disqualification (7 CFR 273.16(e)(1)(iv))

  • 7 CFR 273.16(f) — Court-imposed disqualification as an alternative to ADH: a court of appropriate jurisdiction may impose disqualification in lieu of the administrative hearing process

  • 7 CFR 273.16(i) — Claims for overissuance due to IPV: upon confirmation of IPV, the agency must establish an overissuance claim for the amount of benefits the individual received as a result of the violation

Key regulatory constraints

  1. The ADH is a separate proceeding from fair hearings under 7 CFR 273.15 — different purpose, different burden of proof, different outcome

  2. The individual may waive the ADH and accept disqualification with written consent (7 CFR 273.16(b)(4))

  3. If the individual does not appear at the ADH and fails to request a postponement, a default decision of IPV is entered (7 CFR 273.16(b)(12))

  4. Prior IPV count includes disqualifications from all programs, not just SNAP — cross-program tracking is mandatory (7 CFR 273.16(e)(1))

  5. During disqualification, the individual’s needs (income, resources, deductible expenses) are still counted for household eligibility, but they receive no benefits (7 CFR 273.16(b)(14))

  6. Overissuance claims must be established immediately upon IPV confirmation (7 CFR 273.16(i))

Scope

In scope:

  • ipv_cases and ipv_timeline_events tables in canopy-appeals database

  • IPV referral creation (agency-initiated)

  • ADH scheduling with 30-day advance notice enforcement

  • ADH decision recording (ipv_confirmed, ipv_not_confirmed, default_decision)

  • Waiver acceptance path (individual accepts disqualification without hearing)

  • Court-referred disqualification path

  • Disqualification penalty calculator (12/24/permanent based on offense number; trafficking = permanent)

  • Cross-program prior IPV offense counting

  • Active disqualification check endpoint (consumed by canopy-snap during eligibility determination)

  • Overissuance claim creation upon IPV confirmation

  • Event publishing (IDs only, no PII)

  • Integration tests with testcontainers-rs

Out of scope:

  • Fair hearings workflow (covered in fair-hearings-appeals plan — separate lifecycle)

  • Overissuance claim collection and repayment tracking (post-UAT; canopy-enrollment plan)

  • Investigation case management and evidence storage (post-UAT)

  • EBT transaction monitoring for trafficking detection (post-UAT)

  • TANF and Medicaid IPV variations (later phases; same infrastructure, different penalty schedules)

  • Worker portal UI for IPV case management (covered in worker-portal-snap plan)

Dependencies

This plan depends on:

  • reference-extensions (must be complete): DeterminationStatus::Disqualified variant must exist in canopy-reference enums

  • fair-hearings-appeals (parallel): IPV/ADH shares the canopy-appeals service and database but uses separate tables and a separate workflow; the two plans can be implemented in parallel with no schema conflicts

  • persons-household-model (must be complete): person_id and household_id foreign key targets must exist for IPV case referrals

  • notice-generation (parallel): AdministrativeDisqualificationNotice, DisqualificationImposedNotice, and OverpaymentNotice types must be added to canopy-notices; can be stubbed initially

Design

Database schema (canopy-appeals database)

-- SPDX-License-Identifier: AGPL-3.0-or-later

-- Intentional Program Violation cases (7 CFR 273.16)
-- Agency-initiated enforcement actions tracked through the ADH lifecycle.
CREATE TABLE ipv_cases (
    id UUID PRIMARY KEY,
    household_id UUID NOT NULL,
    person_id UUID NOT NULL,        -- the individual alleged to have committed IPV
    program TEXT NOT NULL,           -- 'snap', 'tanf', etc.
    allegation_type TEXT NOT NULL,   -- 'fraud', 'misrepresentation', 'concealment', 'trafficking'
    allegation_description TEXT NOT NULL,
    evidence_summary TEXT NOT NULL,
    referred_by UUID NOT NULL,       -- worker who referred the case
    referred_at TIMESTAMPTZ NOT NULL,
    overissuance_amount NUMERIC(10,2),  -- estimated overpayment due to IPV
    status TEXT NOT NULL DEFAULT 'referred',
    -- Status lifecycle:
    -- 'referred'          → initial referral by worker
    -- 'adh_scheduled'     → hearing date set
    -- 'adh_notice_sent'   → 30-day advance notice mailed (7 CFR 273.16(b))
    -- 'adh_completed'     → hearing held and decision recorded
    -- 'waiver_accepted'   → individual waived ADH, accepted disqualification (7 CFR 273.16(b)(4))
    -- 'court_referred'    → case sent to court in lieu of ADH (7 CFR 273.16(f))
    -- 'disqualified'      → disqualification imposed
    -- 'cleared'           → IPV not confirmed at ADH
    -- 'withdrawn'         → agency withdrew the referral
    adh_scheduled_date DATE,
    adh_notice_sent_at TIMESTAMPTZ,
    adh_decision TEXT,              -- 'ipv_confirmed', 'ipv_not_confirmed', 'default_decision'
    adh_decision_at TIMESTAMPTZ,
    disqualification_start_date DATE,
    disqualification_end_date DATE,  -- NULL for permanent disqualification
    disqualification_offense_number INTEGER,  -- 1st, 2nd, 3rd
    prior_ipv_count INTEGER NOT NULL DEFAULT 0,
    active BOOLEAN NOT NULL DEFAULT true,
    created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
    updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE INDEX ipv_cases_person_idx ON ipv_cases (person_id);
CREATE INDEX ipv_cases_household_idx ON ipv_cases (household_id);
CREATE INDEX ipv_cases_status_idx ON ipv_cases (status) WHERE active = true;

-- Timeline events for audit trail on IPV cases.
-- Every state transition and significant action is recorded.
CREATE TABLE ipv_timeline_events (
    id UUID PRIMARY KEY,
    ipv_case_id UUID NOT NULL REFERENCES ipv_cases(id),
    event_type TEXT NOT NULL,
    -- Event types:
    -- 'referred'                    → case created
    -- 'adh_scheduled'              → hearing date set
    -- 'adh_notice_sent'            → advance notice mailed
    -- 'adh_held'                   → hearing conducted
    -- 'adh_default'                → individual did not appear (7 CFR 273.16(b)(12))
    -- 'adh_decision'               → hearing officer decision recorded
    -- 'waiver_signed'              → individual signed waiver (7 CFR 273.16(b)(4))
    -- 'court_referred'             → case referred to court (7 CFR 273.16(f))
    -- 'disqualification_imposed'   → penalty applied
    -- 'disqualification_ended'     → penalty period expired
    -- 'overissuance_claim_created' → claim established (7 CFR 273.16(i))
    event_data JSONB,
    occurred_at TIMESTAMPTZ NOT NULL DEFAULT now(),
    recorded_by UUID  -- worker who recorded the event; null for system events
);

CREATE INDEX ipv_timeline_case_idx ON ipv_timeline_events (ipv_case_id, occurred_at);

Domain types

// SPDX-License-Identifier: AGPL-3.0-or-later

use chrono::{NaiveDate, Utc};
use rust_decimal::Decimal;
use serde::{Deserialize, Serialize};
use uuid::Uuid;

/// Allegation type for an IPV case (7 CFR 273.16(a)).
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum AllegationType {
    /// Intentional false statement or misrepresentation
    Fraud,
    /// Misrepresentation of identity, residence, or household composition
    Misrepresentation,
    /// Concealment of facts to obtain benefits
    Concealment,
    /// Selling, exchanging, or otherwise trafficking SNAP benefits (7 CFR 273.16(e)(1)(iv))
    Trafficking,
}

/// Status of an IPV case through the ADH lifecycle.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum IpvCaseStatus {
    Referred,
    AdhScheduled,
    AdhNoticeSent,
    AdhCompleted,
    WaiverAccepted,
    CourtReferred,
    Disqualified,
    Cleared,
    Withdrawn,
}

/// ADH decision outcome.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum AdhDecision {
    /// IPV confirmed by hearing officer based on clear and convincing evidence
    IpvConfirmed,
    /// IPV not confirmed — insufficient evidence
    IpvNotConfirmed,
    /// Default decision — individual failed to appear (7 CFR 273.16(b)(12))
    DefaultDecision,
}

/// Request to create a new IPV referral.
pub struct CreateIpvReferralRequest {
    pub household_id: Uuid,
    pub person_id: Uuid,
    pub program: String,
    pub allegation_type: AllegationType,
    pub allegation_description: String,
    pub evidence_summary: String,
    pub referred_by: Uuid,
    pub overissuance_amount: Option<Decimal>,
}

/// Request to schedule an ADH date.
pub struct ScheduleAdhRequest {
    pub adh_date: NaiveDate,
}

/// Request to record an ADH decision.
pub struct RecordAdhDecisionRequest {
    pub decision: AdhDecision,
}

/// Request to record a waiver acceptance.
pub struct RecordWaiverRequest {
    pub waiver_signed_date: NaiveDate,
}

Disqualification penalty calculator

The penalty calculator determines the disqualification period based on offense number and allegation type per 7 CFR 273.16(e).

// SPDX-License-Identifier: AGPL-3.0-or-later

use chrono::{Months, NaiveDate};

/// Disqualification period result.
pub struct DisqualificationPeriod {
    /// Start date of disqualification
    pub start_date: NaiveDate,
    /// End date; None means permanent disqualification
    pub end_date: Option<NaiveDate>,
    /// Which offense number this represents (1, 2, 3+)
    pub offense_number: i32,
    /// Whether this is a permanent disqualification
    pub permanent: bool,
}

/// Calculate the disqualification period per 7 CFR 273.16(e).
///
/// Penalty schedule:
/// - 1st offense: 12 months (7 CFR 273.16(e)(1)(i))
/// - 2nd offense: 24 months (7 CFR 273.16(e)(1)(ii))
/// - 3rd+ offense: permanent (7 CFR 273.16(e)(1)(iii))
/// - Trafficking (any offense): permanent (7 CFR 273.16(e)(1)(iv))
///
/// `prior_ipv_count` includes disqualifications across ALL programs,
/// not just the program in the current case (7 CFR 273.16(e)(1)).
pub fn calculate_disqualification_period(
    start_date: NaiveDate,
    prior_ipv_count: i32,
    is_trafficking: bool,
) -> DisqualificationPeriod {
    let offense_number = prior_ipv_count + 1;

    // Trafficking is always permanent, regardless of offense number
    if is_trafficking {
        return DisqualificationPeriod {
            start_date,
            end_date: None,
            offense_number,
            permanent: true,
        };
    }

    match offense_number {
        1 => DisqualificationPeriod {
            start_date,
            end_date: Some(start_date + Months::new(12)),
            offense_number,
            permanent: false,
        },
        2 => DisqualificationPeriod {
            start_date,
            end_date: Some(start_date + Months::new(24)),
            offense_number,
            permanent: false,
        },
        _ => DisqualificationPeriod {
            start_date,
            end_date: None,
            offense_number,
            permanent: true,
        },
    }
}

ADH workflow enforcement

The ADH workflow enforces the following state machine:

referred → adh_scheduled → adh_notice_sent → adh_completed → disqualified
                                                            → cleared
         → waiver_accepted → disqualified
         → court_referred → disqualified
                          → cleared
         → withdrawn

Business rules enforced at each transition:

  1. referred → adh_scheduled: adh_scheduled_date must be set. No preconditions beyond the case existing.

  2. adh_scheduled → adh_notice_sent: adh_notice_sent_at must be set. The notice date must be at least 30 calendar days before adh_scheduled_date (7 CFR 273.16(b)). If adh_notice_sent_at is fewer than 30 days before adh_scheduled_date, the API returns 422 with a Problem Detail explaining the 30-day requirement.

  3. adh_notice_sent → adh_completed: adh_decision must be provided. If the individual did not appear and did not request postponement, adh_decision = default_decision is recorded (7 CFR 273.16(b)(12)). ADH notice must have been sent (adh_notice_sent_at IS NOT NULL); API returns 422 if notice was not sent.

  4. adh_completed (ipv_confirmed or default_decision) → disqualified: disqualification_start_date and disqualification_end_date (or NULL for permanent) are set. Prior IPV count is queried across all programs. Overissuance claim is created (7 CFR 273.16(i)).

  5. adh_completed (ipv_not_confirmed) → cleared: No disqualification. Case is closed.

  6. referred → waiver_accepted: Individual signs a written waiver accepting disqualification without a hearing (7 CFR 273.16(b)(4)). waiver_signed timeline event recorded.

  7. waiver_accepted → disqualified: Same penalty calculation as post-ADH disqualification.

  8. referred → court_referred: Case is sent to a court of appropriate jurisdiction (7 CFR 273.16(f)). Court outcome is recorded when available.

  9. referred → withdrawn: Agency withdraws the referral. No further action.

Active disqualification check

canopy-snap calls this endpoint during eligibility determination to check whether an individual is currently disqualified. Per 7 CFR 273.16(b)(14), a disqualified individual’s needs (income, resources) are still counted for the household, but the individual receives no benefits.

// SPDX-License-Identifier: AGPL-3.0-or-later

use chrono::NaiveDate;
use serde::Serialize;
use uuid::Uuid;

/// Response from the active disqualification check endpoint.
#[derive(Debug, Serialize)]
pub struct ActiveDisqualificationResponse {
    /// Whether the person has an active disqualification
    pub disqualified: bool,
    /// Program the disqualification applies to (if disqualified)
    pub program: Option<String>,
    /// End date of the disqualification; None means permanent
    pub disqualification_end_date: Option<NaiveDate>,
    /// The IPV case ID that imposed the disqualification
    pub ipv_case_id: Option<Uuid>,
}

The query checks ipv_cases where: - person_id matches - status = 'disqualified' - active = true - disqualification_start_date ⇐ today - disqualification_end_date IS NULL OR disqualification_end_date > today

Events published

All events are published to the canopy.events topic exchange via RabbitMQ (lapin 4). Event payloads contain only IDs and non-PII metadata — no names, addresses, income, or benefit amounts.

// Routing key: ipv.case_referred
// Published when: a worker creates an IPV referral
{
  "ipv_case_id": "uuid",
  "person_id": "uuid",
  "program": "snap"
}

// Routing key: ipv.adh_scheduled
// Published when: an ADH date is set
{
  "ipv_case_id": "uuid",
  "person_id": "uuid",
  "adh_date": "2026-08-15"
}

// Routing key: ipv.disqualification_imposed
// Published when: disqualification penalty is applied (after ADH, waiver, or court decision)
{
  "ipv_case_id": "uuid",
  "person_id": "uuid",
  "program": "snap",
  "offense_number": 1,
  "permanent": false
}

// Routing key: ipv.overissuance_claim_created
// Published when: overissuance claim is established upon IPV confirmation (7 CFR 273.16(i))
{
  "ipv_case_id": "uuid",
  "person_id": "uuid",
  "program": "snap"
}

// Routing key: ipv.case_cleared
// Published when: ADH determines IPV not confirmed
{
  "ipv_case_id": "uuid",
  "person_id": "uuid",
  "program": "snap"
}

API endpoint contract

Method + Path Description Auth

POST /v1/ipv/cases

Create IPV referral. Returns 201 with the created case. Publishes ipv.case_referred event.

canopy-snap-supervisor

GET /v1/ipv/cases?person_id={id}

List all IPV cases for a person (any status). Returns 200 with array.

canopy-worker

GET /v1/ipv/cases/{id}

Get IPV case detail with full timeline. Returns 200.

canopy-worker

PUT /v1/ipv/cases/{id}/schedule-adh

Schedule ADH date. Returns 200. Publishes ipv.adh_scheduled event.

canopy-snap-supervisor

PUT /v1/ipv/cases/{id}/send-notice

Mark ADH notice as sent. Returns 200. Returns 422 if notice date is fewer than 30 days before hearing date (7 CFR 273.16(b)).

canopy-snap-supervisor

PUT /v1/ipv/cases/{id}/record-decision

Record ADH decision (ipv_confirmed, ipv_not_confirmed, default_decision). Returns 200. Returns 422 if ADH notice was not yet sent.

canopy-snap-supervisor

PUT /v1/ipv/cases/{id}/waiver

Record individual’s written waiver acceptance (7 CFR 273.16(b)(4)). Returns 200.

canopy-snap-supervisor

PUT /v1/ipv/cases/{id}/impose-disqualification

Impose disqualification with calculated dates based on offense number and allegation type. Returns 200. Publishes ipv.disqualification_imposed event. Creates overissuance claim and publishes ipv.overissuance_claim_created event.

canopy-snap-supervisor

GET /v1/ipv/disqualifications/active?person_id={id}

Check if person has an active disqualification. Returns 200 with ActiveDisqualificationResponse. Used by canopy-snap during eligibility determination to exclude disqualified individuals from benefits while still counting their needs for the household (7 CFR 273.16(b)(14)).

canopy-worker, canopy-internal

All error responses use RFC 9457 Problem Details format. Common error cases:

  • 404 — IPV case not found

  • 409 — Invalid status transition (e.g., trying to record a decision on a withdrawn case)

  • 422 — Validation failure (e.g., ADH notice not sent when recording decision; notice fewer than 30 days before hearing)

Steps

Step 1: Database migrations

Files:

  • services/canopy-appeals/migrations/YYYYMMDD_ipv_cases.sql (new)

Create ipv_cases and ipv_timeline_events tables as defined in the Design section. Ensure migrations run in services/canopy-appeals/src/main.rs alongside the existing appeal_requests migration (if implemented).

Step 2: Domain types and store layer

Files:

  • services/canopy-appeals/src/ipv/mod.rs (new)

  • services/canopy-appeals/src/ipv/domain.rs (new) — AllegationType, IpvCaseStatus, AdhDecision, request/response types

  • services/canopy-appeals/src/ipv/store.rs (new) — CRUD queries using sqlx with compile-time verification

Domain types as shown in the Design section. Store layer:

  • create_ipv_case — insert into ipv_cases, insert referred timeline event

  • get_ipv_case — select case with timeline events joined

  • list_ipv_cases_for_person — select by person_id

  • update_ipv_case_status — update status with optimistic concurrency check on updated_at

  • create_timeline_event — insert into ipv_timeline_events

  • count_prior_ipv_disqualifications — count ipv_cases where person_id matches AND status = 'disqualified' across ALL programs (cross-program tracking per 7 CFR 273.16(e)(1))

  • find_active_disqualification — query for active disqualification by person_id

Step 3: Disqualification penalty calculator

Files:

  • services/canopy-appeals/src/ipv/penalties.rs (new) — calculate_disqualification_period()

Implement the penalty calculator as shown in the Design section. Unit tests in the same file (#[cfg(test)] block):

  • 1st offense non-trafficking → 12 months

  • 2nd offense non-trafficking → 24 months

  • 3rd offense non-trafficking → permanent

  • 1st offense trafficking → permanent

  • 2nd offense trafficking → permanent

Step 4: ADH workflow and notice enforcement

Files:

  • services/canopy-appeals/src/ipv/workflow.rs (new)

Implement state transition validation:

  • Validate 30-day advance notice rule: adh_notice_sent_at + 30 days ⇐ adh_scheduled_date. If violated, return AppError with 422 status and Problem Detail body.

  • Validate that ADH notice was sent before recording a decision.

  • On IPV confirmation (or default decision): query count_prior_ipv_disqualifications for cross-program offense counting, then call calculate_disqualification_period.

  • On waiver acceptance: same penalty calculation path.

Step 5: API routes

Files:

  • services/canopy-appeals/src/ipv/api.rs (new)

  • services/canopy-appeals/src/api/mod.rs (modify) — merge IPV routes into the appeals Router

Implement all endpoints listed in the API endpoint contract section. Each endpoint:

  • Extracts and validates request body

  • Calls store layer

  • Publishes appropriate event via RabbitMQ

  • Returns JSON response with appropriate status code

Wire into the existing canopy-appeals Router:

// SPDX-License-Identifier: AGPL-3.0-or-later

use axum::{Router, routing::{get, post, put}};

pub fn ipv_routes() -> Router<AppState> {
    Router::new()
        .route("/v1/ipv/cases", post(create_ipv_referral).get(list_ipv_cases))
        .route("/v1/ipv/cases/{id}", get(get_ipv_case))
        .route("/v1/ipv/cases/{id}/schedule-adh", put(schedule_adh))
        .route("/v1/ipv/cases/{id}/send-notice", put(send_notice))
        .route("/v1/ipv/cases/{id}/record-decision", put(record_decision))
        .route("/v1/ipv/cases/{id}/waiver", put(record_waiver))
        .route("/v1/ipv/cases/{id}/impose-disqualification", put(impose_disqualification))
        .route("/v1/ipv/disqualifications/active", get(check_active_disqualification))
}

Step 6: Event publishing

Files:

  • services/canopy-appeals/src/ipv/events.rs (new)

Publish events to canopy.events topic exchange via lapin 4. Event payloads as defined in the Design section — IDs only, no PII.

Routing keys:

  • ipv.case_referred

  • ipv.adh_scheduled

  • ipv.disqualification_imposed

  • ipv.overissuance_claim_created

  • ipv.case_cleared

Step 7: Integration tests

Files:

  • services/canopy-appeals/tests/ipv_tests.rs (new)

Integration Tests

All tests use testcontainers-rs for PostgreSQL. Run with cargo nextest run -p canopy-appeals.

Test scenarios

# Scenario Expected result

1

Create IPV referral via POST /v1/ipv/cases

Status 201; status = 'referred'; timeline event referred recorded; ipv.case_referred event published

2

Schedule ADH via PUT /v1/ipv/cases/{id}/schedule-adh

adh_scheduled_date set; status = 'adh_scheduled'; ipv.adh_scheduled event published

3

ADH notice sent 25 days before hearing (fewer than 30 days)

422 response with Problem Detail: "ADH notice must be sent at least 30 days before the hearing date per 7 CFR 273.16(b)"

4

ADH notice sent 30 days before hearing (exactly 30 days)

200 response; adh_notice_sent_at set; status = 'adh_notice_sent'

5

ADH notice sent 45 days before hearing (more than 30 days)

200 response; accepted (30-day minimum is met)

6

Record ADH decision without notice having been sent

422 response with Problem Detail: "ADH notice must be sent before recording a decision"

7

Record ADH decision: ipv_confirmed

status = 'adh_completed'; adh_decision = 'ipv_confirmed'; adh_decision_at set

8

Record ADH decision: default_decision (no-show)

status = 'adh_completed'; adh_decision = 'default_decision'; IPV confirmed per 7 CFR 273.16(b)(12)

9

Record ADH decision: ipv_not_confirmed

status = 'cleared'; case closed; ipv.case_cleared event published

10

Waiver acceptance via PUT /v1/ipv/cases/{id}/waiver

status = 'waiver_accepted'; waiver_signed timeline event recorded

11

Impose disqualification: 1st offense, non-trafficking

disqualification_offense_number = 1; disqualification_end_date = start + 12 months; permanent = false

12

Impose disqualification: 2nd offense, non-trafficking (person has 1 prior IPV across any program)

disqualification_offense_number = 2; disqualification_end_date = start + 24 months

13

Impose disqualification: 3rd offense, non-trafficking

disqualification_end_date = NULL; permanent = true

14

Impose disqualification: 1st offense, trafficking allegation

disqualification_end_date = NULL; permanent = true regardless of offense number (7 CFR 273.16(e)(1)(iv))

15

Cross-program prior IPV count: person has 1 SNAP disqualification and 1 TANF disqualification, new SNAP IPV case

prior_ipv_count = 2; offense number = 3; permanent disqualification

16

Active disqualification check: person with active disqualification

GET /v1/ipv/disqualifications/active?person_id={id} returns disqualified = true with end_date and ipv_case_id

17

Active disqualification check: person with expired disqualification

Returns disqualified = false (end_date has passed)

18

Active disqualification check: person with no disqualification history

Returns disqualified = false

19

Active disqualification check: person with permanent disqualification

Returns disqualified = true with disqualification_end_date = null

20

Overissuance claim created upon IPV confirmation

ipv.overissuance_claim_created event published; overissuance_amount set on the case

Boundary tests

  • 30-day notice boundary: notice sent at exactly 30 days 0 hours before hearing → accepted; notice sent at 29 days 23 hours 59 minutes → rejected (date comparison, not timestamp)

  • Status transition enforcement: verify that invalid transitions return 409 (e.g., record-decision on a withdrawn case)

  • Cross-program counting: verify that TANF and Medicaid disqualifications are counted when calculating SNAP offense number

  • Permanent disqualification: verify that disqualification_end_date is NULL and the active check returns disqualified = true indefinitely

  • Concurrent IPV cases: verify that a person can have multiple IPV cases (one per program) and each is tracked independently

Files Touched

File Change

services/canopy-appeals/migrations/YYYYMMDD_ipv_cases.sql

New: ipv_cases, ipv_timeline_events tables with indexes

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

New: module declaration for IPV submodule

services/canopy-appeals/src/ipv/domain.rs

New: AllegationType, IpvCaseStatus, AdhDecision, request/response types

services/canopy-appeals/src/ipv/store.rs

New: CRUD queries for ipv_cases and ipv_timeline_events using sqlx

services/canopy-appeals/src/ipv/penalties.rs

New: calculate_disqualification_period() with unit tests

services/canopy-appeals/src/ipv/workflow.rs

New: ADH state transition validation, 30-day notice enforcement, cross-program offense counting

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

New: Axum route handlers for all IPV endpoints

services/canopy-appeals/src/ipv/events.rs

New: RabbitMQ event publishers for IPV lifecycle events

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

Modify: merge ipv_routes() into the canopy-appeals Router

services/canopy-appeals/src/main.rs

Modify: register IPV migration; wire IPV module

services/canopy-appeals/tests/ipv_tests.rs

New: 20+ integration test scenarios with boundary cases

Verification

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

  2. Create an IPV referral, schedule ADH, send notice, record ipv_confirmed decision, impose disqualification → verify the full lifecycle produces correct status transitions and timeline events

  3. Verify 30-day notice enforcement: attempt to send notice 25 days before hearing → 422; send at 30 days → accepted

  4. Verify cross-program offense counting: create disqualifications in SNAP and TANF for the same person → new IPV case correctly counts prior_ipv_count = 2

  5. Verify trafficking = permanent on first offense: create IPV case with allegation_type = 'trafficking' and prior_ipv_count = 0 → permanent disqualification

  6. Verify active disqualification check returns correct response for active, expired, permanent, and no-history cases

  7. Verify that ipv.disqualification_imposed event is published with correct offense_number and permanent flag

  8. Verify default decision path: record decision with default_decision → IPV confirmed, disqualification can be imposed

Documentation Updates

  • .claude/docs/services.md — add ipv_cases and ipv_timeline_events tables; add IPV events; add IPV API endpoints to canopy-appeals service section

  • .claude/CLAUDE.md — update canopy-appeals feature status: "IPV/ADH workflow implemented; disqualification penalty enforcement; cross-program tracking"

  • CHANGELOG.adoc — entry under == Unreleased: "Add IPV case tracking and Administrative Disqualification Hearing workflow to canopy-appeals (7 CFR 273.16)"

Edit this page · default