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
-
The ADH is a separate proceeding from fair hearings under 7 CFR 273.15 — different purpose, different burden of proof, different outcome
-
The individual may waive the ADH and accept disqualification with written consent (7 CFR 273.16(b)(4))
-
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))
-
Prior IPV count includes disqualifications from all programs, not just SNAP — cross-program tracking is mandatory (7 CFR 273.16(e)(1))
-
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))
-
Overissuance claims must be established immediately upon IPV confirmation (7 CFR 273.16(i))
Scope
In scope:
-
ipv_casesandipv_timeline_eventstables 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-appealsplan — 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-snapplan)
Dependencies
This plan depends on:
-
reference-extensions (must be complete):
DeterminationStatus::Disqualifiedvariant must exist incanopy-referenceenums -
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_idandhousehold_idforeign key targets must exist for IPV case referrals -
notice-generation (parallel):
AdministrativeDisqualificationNotice,DisqualificationImposedNotice, andOverpaymentNoticetypes 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:
-
referred → adh_scheduled:
adh_scheduled_datemust be set. No preconditions beyond the case existing. -
adh_scheduled → adh_notice_sent:
adh_notice_sent_atmust be set. The notice date must be at least 30 calendar days beforeadh_scheduled_date(7 CFR 273.16(b)). Ifadh_notice_sent_atis fewer than 30 days beforeadh_scheduled_date, the API returns 422 with a Problem Detail explaining the 30-day requirement. -
adh_notice_sent → adh_completed:
adh_decisionmust be provided. If the individual did not appear and did not request postponement,adh_decision = default_decisionis 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. -
adh_completed (ipv_confirmed or default_decision) → disqualified:
disqualification_start_dateanddisqualification_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)). -
adh_completed (ipv_not_confirmed) → cleared: No disqualification. Case is closed.
-
referred → waiver_accepted: Individual signs a written waiver accepting disqualification without a hearing (7 CFR 273.16(b)(4)).
waiver_signedtimeline event recorded. -
waiver_accepted → disqualified: Same penalty calculation as post-ADH disqualification.
-
referred → court_referred: Case is sent to a court of appropriate jurisdiction (7 CFR 273.16(f)). Court outcome is recorded when available.
-
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 |
|---|---|---|
|
Create IPV referral. Returns 201 with the created case. Publishes |
canopy-snap-supervisor |
|
List all IPV cases for a person (any status). Returns 200 with array. |
canopy-worker |
|
Get IPV case detail with full timeline. Returns 200. |
canopy-worker |
|
Schedule ADH date. Returns 200. Publishes |
canopy-snap-supervisor |
|
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 |
|
Record ADH decision (ipv_confirmed, ipv_not_confirmed, default_decision). Returns 200. Returns 422 if ADH notice was not yet sent. |
canopy-snap-supervisor |
|
Record individual’s written waiver acceptance (7 CFR 273.16(b)(4)). Returns 200. |
canopy-snap-supervisor |
|
Impose disqualification with calculated dates based on offense number and allegation type. Returns 200. Publishes |
canopy-snap-supervisor |
|
Check if person has an active disqualification. Returns 200 with |
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 intoipv_cases, insertreferredtimeline event -
get_ipv_case— select case with timeline events joined -
list_ipv_cases_for_person— select byperson_id -
update_ipv_case_status— update status with optimistic concurrency check onupdated_at -
create_timeline_event— insert intoipv_timeline_events -
count_prior_ipv_disqualifications— countipv_caseswhereperson_idmatches ANDstatus = 'disqualified'across ALL programs (cross-program tracking per 7 CFR 273.16(e)(1)) -
find_active_disqualification— query for active disqualification byperson_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, returnAppErrorwith 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_disqualificationsfor cross-program offense counting, then callcalculate_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
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 |
Status 201; |
2 |
Schedule ADH via |
|
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; |
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: |
|
8 |
Record ADH decision: |
|
9 |
Record ADH decision: |
|
10 |
Waiver acceptance via |
|
11 |
Impose disqualification: 1st offense, non-trafficking |
|
12 |
Impose disqualification: 2nd offense, non-trafficking (person has 1 prior IPV across any program) |
|
13 |
Impose disqualification: 3rd offense, non-trafficking |
|
14 |
Impose disqualification: 1st offense, trafficking allegation |
|
15 |
Cross-program prior IPV count: person has 1 SNAP disqualification and 1 TANF disqualification, new SNAP IPV case |
|
16 |
Active disqualification check: person with active disqualification |
|
17 |
Active disqualification check: person with expired disqualification |
Returns |
18 |
Active disqualification check: person with no disqualification history |
Returns |
19 |
Active disqualification check: person with permanent disqualification |
Returns |
20 |
Overissuance claim created upon IPV confirmation |
|
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-decisionon awithdrawncase) -
Cross-program counting: verify that TANF and Medicaid disqualifications are counted when calculating SNAP offense number
-
Permanent disqualification: verify that
disqualification_end_dateis NULL and the active check returnsdisqualified = trueindefinitely -
Concurrent IPV cases: verify that a person can have multiple IPV cases (one per program) and each is tracked independently
Files Touched
| File | Change |
|---|---|
|
New: ipv_cases, ipv_timeline_events tables with indexes |
|
New: module declaration for IPV submodule |
|
New: AllegationType, IpvCaseStatus, AdhDecision, request/response types |
|
New: CRUD queries for ipv_cases and ipv_timeline_events using sqlx |
|
New: calculate_disqualification_period() with unit tests |
|
New: ADH state transition validation, 30-day notice enforcement, cross-program offense counting |
|
New: Axum route handlers for all IPV endpoints |
|
New: RabbitMQ event publishers for IPV lifecycle events |
|
Modify: merge ipv_routes() into the canopy-appeals Router |
|
Modify: register IPV migration; wire IPV module |
|
New: 20+ integration test scenarios with boundary cases |
Verification
-
cargo nextest run -p canopy-appeals— all IPV tests pass -
Create an IPV referral, schedule ADH, send notice, record
ipv_confirmeddecision, impose disqualification → verify the full lifecycle produces correct status transitions and timeline events -
Verify 30-day notice enforcement: attempt to send notice 25 days before hearing → 422; send at 30 days → accepted
-
Verify cross-program offense counting: create disqualifications in SNAP and TANF for the same person → new IPV case correctly counts prior_ipv_count = 2
-
Verify trafficking = permanent on first offense: create IPV case with
allegation_type = 'trafficking'andprior_ipv_count = 0→ permanent disqualification -
Verify active disqualification check returns correct response for active, expired, permanent, and no-history cases
-
Verify that
ipv.disqualification_imposedevent is published with correctoffense_numberandpermanentflag -
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)"