Plan: SNAP Special Household Situations — Drug Felon, Fleeing Felon, and Striker Rules
On this page
- Status
- Context
- Scope
- Dependencies
- Design
- Application fields (canopy-applications)
- Disqualification screenings table (canopy-snap database)
- jurisdiction.toml additions
- Drug felon screening logic
- Fleeing felon / probation violator screening logic
- Striker pre-strike income comparison logic
- Wiring into snap-eligibility determination flow
- JDM rulesets
- Events
- API endpoints
- Steps
- Step 1: Application attestation fields
- Step 2: Disqualification screenings table
- Step 3: Drug felon screening logic
- Step 4: Fleeing felon / probation violator screening
- Step 5: Striker pre-strike income comparison
- Step 6: JDM rulesets
- Step 7: Jurisdiction configuration
- Step 8: Wire into determination flow
- Step 9: API endpoints
- Step 10: Integration tests
- Integration Tests
- Files Touched
- Verification
- Documentation Updates
Status
| Step | Description | Status |
|---|---|---|
1 |
Add self-attestation application fields to canopy-applications (per household member) |
Done (2026-03-28) |
2 |
Create snap_disqualification_screenings table in canopy-snap |
Done (2026-03-28) |
3 |
Implement drug felon screening logic with jurisdiction-aware policy |
Done (2026-03-28) |
4 |
Implement fleeing felon / probation violator screening logic |
Done (2026-03-28) |
5 |
Implement striker pre-strike income comparison logic |
Done (2026-03-28) |
6 |
Create federal and Georgia JDM rulesets for disqualification screening |
Done (2026-03-28) |
7 |
Add jurisdiction.toml drug felon policy configuration |
Done (2026-03-28) |
8 |
Wire screening into snap-eligibility determination flow (before income/asset tests) |
Done (2026-03-28) |
9 |
API endpoints for screening review |
Done (2026-03-28) |
10 |
Integration tests |
Done (2026-03-28) |
MR: !17
Epic: &33, &39
Branch: feature/snap-special-situations
Labels: type::compliance, priority::high, program::snap, service::rules, workflow::ready, federal-partner::fns
Context
Federal SNAP regulations define three categories of household members who are categorically disqualified from SNAP participation regardless of income or asset status. These disqualifications are evaluated per individual household member, not per household. Disqualified members are excluded from the household for benefit calculation, but remaining members may still be eligible.
Drug felon prohibition (7 CFR 273.11(m))
Individuals convicted of a state or federal drug felony on or after August 22, 1996 are ineligible for SNAP under the default federal rule. However, the 2014 Farm Bill (Section 4008) gave states the option to:
-
Keep the full prohibition (default)
-
Opt out entirely (no drug felony restriction)
-
Modify the restriction (e.g., restrict only drug trafficking, not possession)
Georgia has exercised a partial opt-out under Georgia Code 49-4-186: Georgia restricts SNAP eligibility only for drug trafficking convictions, not drug possession convictions. States may also offer exemptions for individuals who have completed or are participating in a drug treatment program.
Fleeing felon / probation violator prohibition (7 CFR 273.11(n))
Individuals actively fleeing prosecution, custody, or confinement for a felony are categorically ineligible. Individuals violating a condition of probation or parole imposed under federal or state law are also ineligible. This is a federal mandatory rule with no state opt-out.
Striker household rules (7 CFR 273.11(e))
A "striker" is an individual participating in a strike as defined under the National Labor Relations Act. Striker households are subject to a pre-strike income comparison test:
-
Calculate eligibility using current income (during the strike)
-
Calculate eligibility using pre-strike income (what the household earned before the strike)
-
The household is eligible only if they would have been eligible using pre-strike income
This prevents households from becoming SNAP-eligible solely because a strike reduced their income.
Non-striking household members are not affected by this rule; only the striker’s income is replaced in the comparison.
The StrikeBenefits income type (from the reference-extensions plan) is used to classify current strike-period income.
Scope
In scope:
-
Self-attestation application fields for drug felony, fleeing felony, probation violation, and striker status (per household member)
-
snap_disqualification_screeningstable in canopy-snap for recording screening results -
Drug felon screening with jurisdiction-configurable policy (
full_prohibition,trafficking_only,no_prohibition,modified) -
Fleeing felon and probation violator categorical disqualification (no state variation)
-
Striker pre-strike income comparison test
-
JDM rulesets for disqualification screening (federal and Georgia)
-
API endpoints for worker review of self-attestation screenings
-
Events for screening completion (IDs only, no PII)
-
Integration tests for all three disqualification types and jurisdiction policy variations
Out of scope:
-
Law enforcement cross-referencing for fleeing felon verification (post-UAT enhancement)
-
Drug treatment program tracking and exemption management (post-UAT; for UAT, exemption_reason is a free-text field set by worker)
-
Automated conviction record lookup (post-UAT)
-
Striker union status verification beyond self-attestation
-
Income/asset tests — covered in
snap-eligibilityandsnap-deduction-calculationplans -
Categorical eligibility bypass — covered in
snap-categorical-eligibilityplan
Dependencies
This plan depends on:
-
reference-extensions (must be complete):
DeterminationStatus::Disqualifiedvariant;IncomeType::StrikeBenefitsvariant for pre-strike income comparison -
persons-household-model (must be complete):
person_id,household_idassociations; income records per person -
application-intake (must be complete): application model with per-member attestation fields
-
snap-eligibility (parallel): this plan provides a pre-screening gate that snap-eligibility calls before income/asset tests
Design
Application fields (canopy-applications)
The following self-attestation fields are added per household member on the application. These are collected during application intake. Verification is a separate process and is not required before initial screening.
| Field | Type | Notes |
|---|---|---|
|
BOOLEAN |
Per household member; self-attested |
|
DATE (nullable) |
Only if drug_felony_conviction = true; used to determine if conviction is on or after 8/22/1996 |
|
BOOLEAN |
Per household member; self-attested |
|
BOOLEAN |
Per household member; self-attested |
|
BOOLEAN |
Per household member; self-attested |
|
NUMERIC(10,2) (nullable) |
Only if striker_status = true; monthly income before the strike began |
Disqualification screenings table (canopy-snap database)
-- SPDX-License-Identifier: AGPL-3.0-or-later
CREATE TABLE snap_disqualification_screenings (
id UUID PRIMARY KEY,
household_id UUID NOT NULL,
person_id UUID NOT NULL,
application_id UUID NOT NULL,
screening_type TEXT NOT NULL,
-- 'drug_felony', 'fleeing_felony', 'probation_violation', 'striker'
self_attested BOOLEAN NOT NULL,
self_attested_date DATE,
conviction_date DATE, -- drug felony only
pre_strike_income NUMERIC(10,2), -- striker only
screening_result TEXT NOT NULL DEFAULT 'pending',
-- 'eligible', 'disqualified', 'pending_verification', 'exempt'
exemption_reason TEXT, -- for drug felony: state opt-out, time served, etc.
screened_at TIMESTAMPTZ NOT NULL DEFAULT now(),
screened_by UUID, -- worker who reviewed
active BOOLEAN NOT NULL DEFAULT true,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX snap_disqual_screening_household_idx
ON snap_disqualification_screenings (household_id, person_id);
CREATE INDEX snap_disqual_screening_type_idx
ON snap_disqualification_screenings (screening_type, screening_result)
WHERE active = true;
jurisdiction.toml additions
[snap.disqualifications]
# Drug felony policy (7 CFR 273.11(m))
# Values: "full_prohibition", "trafficking_only", "no_prohibition", "modified"
# Georgia: partial opt-out under Georgia Code §49-4-186
drug_felony_policy = "trafficking_only"
# Whether drug treatment program completion is accepted as an exemption
# Georgia: true (confirm current policy before implementation)
drug_treatment_exemption = true
Drug felon screening logic
// SPDX-License-Identifier: AGPL-3.0-or-later
use chrono::NaiveDate;
/// Drug felony policy configured per jurisdiction in jurisdiction.toml.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum DrugFelonyPolicy {
/// Federal default: all drug felonies on or after 8/22/1996 disqualify.
FullProhibition,
/// Only drug trafficking convictions disqualify (e.g., Georgia).
TraffickingOnly,
/// State has opted out entirely; no drug felony restriction.
NoProhibition,
/// State has a custom modification (details in jurisdiction.toml).
Modified,
}
/// Federal cutoff date: convictions on or after this date trigger the prohibition.
const DRUG_FELONY_CUTOFF: NaiveDate =
match NaiveDate::from_ymd_opt(1996, 8, 22) {
Some(d) => d,
None => unreachable!(),
};
/// Input for drug felon screening of a single household member.
pub struct DrugFelonInput {
pub person_id: uuid::Uuid,
pub has_conviction: bool,
pub conviction_date: Option<NaiveDate>,
/// Whether the conviction is for trafficking (vs. possession or other).
/// Determined by worker review or self-attestation detail.
pub is_trafficking: bool,
/// Whether the individual has completed a drug treatment program.
pub completed_treatment: bool,
}
/// Result of the drug felon screening.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum DrugFelonResult {
/// No disqualification applies.
Eligible,
/// Disqualified under drug felon prohibition.
Disqualified,
/// Exempt (e.g., treatment completion, conviction before cutoff).
Exempt { reason: String },
}
/// Screen a household member for drug felon disqualification.
///
/// Applies the jurisdiction-specific policy from jurisdiction.toml.
pub fn screen_drug_felon(
input: &DrugFelonInput,
policy: &DrugFelonyPolicy,
treatment_exemption_enabled: bool,
) -> DrugFelonResult {
if !input.has_conviction {
return DrugFelonResult::Eligible;
}
// Conviction before federal cutoff date: not subject to prohibition
if let Some(date) = input.conviction_date {
if date < DRUG_FELONY_CUTOFF {
return DrugFelonResult::Exempt {
reason: "Conviction predates 8/22/1996 federal cutoff".to_string(),
};
}
}
match policy {
DrugFelonyPolicy::NoProhibition => DrugFelonResult::Eligible,
DrugFelonyPolicy::TraffickingOnly => {
if !input.is_trafficking {
return DrugFelonResult::Eligible;
}
if treatment_exemption_enabled && input.completed_treatment {
return DrugFelonResult::Exempt {
reason: "Completed drug treatment program".to_string(),
};
}
DrugFelonResult::Disqualified
}
DrugFelonyPolicy::FullProhibition => {
if treatment_exemption_enabled && input.completed_treatment {
return DrugFelonResult::Exempt {
reason: "Completed drug treatment program".to_string(),
};
}
DrugFelonResult::Disqualified
}
DrugFelonyPolicy::Modified => {
// Modified policies require jurisdiction-specific JDM ruleset
// evaluation; this branch delegates to zen-engine.
// For the Rust reference implementation, treat as full prohibition
// with treatment exemption check.
if treatment_exemption_enabled && input.completed_treatment {
return DrugFelonResult::Exempt {
reason: "Completed drug treatment program".to_string(),
};
}
DrugFelonResult::Disqualified
}
}
}
Fleeing felon / probation violator screening logic
// SPDX-License-Identifier: AGPL-3.0-or-later
/// Input for fleeing felon / probation violator screening.
pub struct FleeingFelonInput {
pub person_id: uuid::Uuid,
pub fleeing_felony_prosecution: bool,
pub probation_parole_violation: bool,
}
/// Screen a household member for fleeing felon / probation violator
/// disqualification.
///
/// 7 CFR 273.11(n): categorical prohibition, no state opt-out.
pub fn screen_fleeing_felon(input: &FleeingFelonInput) -> ScreeningResult {
if input.fleeing_felony_prosecution {
return ScreeningResult::Disqualified {
reason: "Fleeing prosecution, custody, or confinement for a felony".to_string(),
};
}
if input.probation_parole_violation {
return ScreeningResult::Disqualified {
reason: "Violating a condition of probation or parole".to_string(),
};
}
ScreeningResult::Eligible
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ScreeningResult {
Eligible,
Disqualified { reason: String },
}
Striker pre-strike income comparison logic
// SPDX-License-Identifier: AGPL-3.0-or-later
use rust_decimal::Decimal;
/// Input for striker household screening.
pub struct StrikerInput {
pub person_id: uuid::Uuid,
pub is_striker: bool,
/// Monthly income the striker earned before the strike began.
pub pre_strike_income: Option<Decimal>,
/// Current monthly income for the striker (during the strike).
pub current_income: Decimal,
}
/// Result of the striker pre-strike income comparison.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum StrikerResult {
/// Not a striker; no special screening required.
NotApplicable,
/// Pre-strike income makes household eligible (would have qualified
/// even before the strike).
Eligible,
/// Pre-strike income makes household ineligible (household only qualifies
/// because strike reduced income).
Denied,
/// Missing pre-strike income data; cannot complete comparison.
PendingVerification,
}
/// Evaluate the striker pre-strike income comparison test.
///
/// 7 CFR 273.11(e): Replace the striker's current income with their
/// pre-strike income, then re-evaluate eligibility. If the household
/// would NOT have been eligible with pre-strike income, deny.
///
/// `gross_income_limit` is the 130% FPL gross income limit for the
/// household size.
pub fn screen_striker(
input: &StrikerInput,
household_gross_income_excluding_striker: Decimal,
gross_income_limit: Decimal,
) -> StrikerResult {
if !input.is_striker {
return StrikerResult::NotApplicable;
}
let pre_strike = match input.pre_strike_income {
Some(income) => income,
None => return StrikerResult::PendingVerification,
};
// Household gross income with pre-strike income substituted
let hypothetical_gross = household_gross_income_excluding_striker + pre_strike;
if hypothetical_gross <= gross_income_limit {
StrikerResult::Eligible
} else {
StrikerResult::Denied
}
}
Wiring into snap-eligibility determination flow
The disqualification screenings are evaluated before income and asset tests in the canopy-snap/src/determine.rs flow:
-
Fetch application attestation data for all household members
-
For each household member, run disqualification screenings:
-
screen_drug_felon()with jurisdiction policy fromjurisdiction.toml -
screen_fleeing_felon()(federal rule, no jurisdiction variation) -
screen_striker()with pre-strike income comparison
-
-
Disqualified members are excluded from the SNAP household for benefit calculation
-
If all household members are disqualified, the entire application is denied
-
If any striker screening returns
Denied, the household is denied -
Remaining eligible members proceed to income/asset tests and deduction pipeline
JDM rulesets
-
rulesets/federal/snap-disqualifications.json— federal decision table encoding the three disqualification categories. Inputs: attestation fields, conviction date, striker income. Outputs: screening_result per member. -
rulesets/georgia/snap-disqualifications.json— Georgia-specific override for drug felon policy (trafficking_only). Inherits fleeing felon and striker rules from the federal ruleset unchanged.
The JDM rulesets must produce results identical to the Rust reference implementation. The Rust code is the authoritative reference; JDM rulesets are the production evaluation path via zen-engine.
Events
{
"event": "disqualification.screening_completed",
"screening_id": "uuid",
"household_id": "uuid",
"person_id": "uuid",
"screening_type": "drug_felony",
"result": "disqualified"
}
Events contain only identifiers and screening results.
No PII, no conviction details, no income amounts are included in events.
Published to the canopy.events topic exchange via RabbitMQ.
API endpoints
| Method | Endpoint | Description |
|---|---|---|
GET |
|
List all disqualification screenings for a household. Returns screening type, result, and review status. Requires |
PUT |
|
Worker reviews self-attestation and records screening_result (eligible, disqualified, pending_verification, exempt). Requires |
Both endpoints return RFC 9457 Problem Details for errors.
Steps
Step 1: Application attestation fields
Files:
-
services/canopy-applications/migrations/YYYYMMDD_disqualification_attestation.sql(new) — add per-member attestation columns
Add drug_felony_conviction, drug_felony_conviction_date, fleeing_felony_prosecution, probation_parole_violation, striker_status, and pre_strike_income to the application member table.
Step 2: Disqualification screenings table
Files:
-
services/canopy-snap/migrations/YYYYMMDD_snap_disqualification_screenings.sql(new)
Create snap_disqualification_screenings table with indexes as shown in the Design section.
Step 3: Drug felon screening logic
Files:
-
services/canopy-snap/src/disqualifications.rs(new) —DrugFelonyPolicy,DrugFelonInput,DrugFelonResult,screen_drug_felon()
Implement jurisdiction-aware drug felon screening.
Load drug_felony_policy and drug_treatment_exemption from jurisdiction.toml at startup.
All monetary values use rust_decimal::Decimal.
No unwrap() in any code path.
Step 4: Fleeing felon / probation violator screening
Files:
-
services/canopy-snap/src/disqualifications.rs(modify) — addFleeingFelonInput,ScreeningResult,screen_fleeing_felon()
Federal mandatory rule with no jurisdiction variation.
Step 5: Striker pre-strike income comparison
Files:
-
services/canopy-snap/src/striker.rs(new) —StrikerInput,StrikerResult,screen_striker()
Implement the pre-strike income comparison test.
The striker’s current income is replaced with pre_strike_income and the household gross income is re-evaluated against the 130% FPL limit.
Step 6: JDM rulesets
Files:
-
rulesets/federal/snap-disqualifications.json(new) -
rulesets/georgia/snap-disqualifications.json(new)
Federal ruleset encodes all three screening categories.
Georgia ruleset overrides drug felon policy to trafficking_only.
Fleeing felon and striker rules are unchanged from federal.
Step 7: Jurisdiction configuration
Files:
-
rulesets/georgia/jurisdiction.toml(modify) — add[snap.disqualifications]section
Add drug_felony_policy = "trafficking_only" and drug_treatment_exemption = true.
Step 8: Wire into determination flow
Files:
-
services/canopy-snap/src/determine.rs(modify)
Insert disqualification screening gate before income/asset tests.
Disqualified members are excluded from benefit calculation.
If all members are disqualified or a striker screening returns Denied, the determination result is Denied with appropriate denial_reason_codes.
Step 9: API endpoints
Files:
-
services/canopy-snap/src/routes.rs(modify) — add disqualification screening endpoints
Implement GET /v1/snap/disqualification-screenings and PUT /v1/snap/disqualification-screenings/{id}/review.
Both use RFC 9457 Problem Details for error responses.
Role-based access: listing requires canopy-worker, review requires canopy-snap-supervisor.
Integration Tests
All tests use testcontainers-rs for PostgreSQL.
All tests use cargo nextest run -p canopy-snap.
Test scenarios
| # | Scenario | Expected result |
|---|---|---|
1 |
Drug felony (Georgia): trafficking conviction on or after 8/22/1996 |
Disqualified |
2 |
Drug felony (Georgia): possession conviction (not trafficking) |
Eligible (Georgia trafficking_only policy) |
3 |
Drug felony: jurisdiction with full_prohibition, any drug felony on or after 8/22/1996 |
Disqualified |
4 |
Drug felony: jurisdiction with no_prohibition, any drug felony |
Eligible regardless of conviction type |
5 |
Drug felony: conviction date before 8/22/1996 |
Exempt (predates federal cutoff) |
6 |
Drug felony: completed treatment program with treatment_exemption_enabled |
Exempt (treatment completion) |
7 |
Fleeing felony: self-attested fleeing prosecution |
Disqualified |
8 |
Probation violation: self-attested probation/parole violation |
Disqualified |
9 |
Striker: pre-strike income ($3,000/month) exceeds gross income limit for household size |
Denied (even though current strike income qualifies) |
10 |
Striker: pre-strike income ($1,200/month) within gross income limit for household size |
Eligible |
11 |
Striker: missing pre-strike income data |
PendingVerification |
12 |
Non-striker household members are unaffected by striker screening |
Eligible (only striker’s income is compared) |
13 |
Household with one disqualified member and two eligible members |
Disqualified member excluded; remaining members proceed to income/asset tests |
14 |
All household members disqualified |
Entire application denied |
15 |
Screening event published with IDs only, no conviction details or income amounts |
Event payload contains only screening_id, household_id, person_id, screening_type, result |
Boundary tests
-
Drug felony conviction date exactly on 8/22/1996: subject to prohibition (on-or-after is inclusive)
-
Drug felony conviction date of 8/21/1996: exempt (before cutoff)
-
Striker with pre-strike income exactly equal to gross income limit: eligible (boundary is inclusive: ⇐)
-
Striker with pre-strike income $0.01 over gross income limit: denied
-
Household with multiple screening types on the same member (drug felon AND striker): both screenings evaluated independently
Files Touched
| File | Change |
|---|---|
|
New: per-member attestation columns for drug felony, fleeing felony, probation violation, striker status |
|
New: snap_disqualification_screenings table with indexes |
|
New: DrugFelonyPolicy, DrugFelonInput, DrugFelonResult, screen_drug_felon(), FleeingFelonInput, ScreeningResult, screen_fleeing_felon() |
|
New: StrikerInput, StrikerResult, screen_striker() with pre-strike income comparison |
|
Modify: insert disqualification screening gate before income/asset tests |
|
Modify: add GET and PUT disqualification screening endpoints |
|
New: federal disqualification decision table (drug felon, fleeing felon, striker) |
|
New: Georgia drug felon policy override (trafficking_only) |
|
Modify: add [snap.disqualifications] section with drug_felony_policy and drug_treatment_exemption |
|
New: 15+ integration test scenarios with boundary cases |
Verification
-
cargo nextest run -p canopy-snap— all disqualification screening tests pass -
Verify Georgia drug felon policy: trafficking conviction disqualifies, possession conviction does not
-
Verify jurisdiction with
full_prohibition: any drug felony disqualifies -
Verify jurisdiction with
no_prohibition: no drug felony disqualifies -
Verify conviction date boundary: on 8/22/1996 disqualifies, before 8/22/1996 does not
-
Verify fleeing felon screening has no jurisdiction variation (federal mandatory rule)
-
Verify striker pre-strike income comparison: household denied when pre-strike income exceeds gross income limit even though current income qualifies
-
Verify disqualified members are excluded from household for benefit calculation but remaining members proceed
-
Verify all screening events contain only IDs and screening results, no PII or conviction details
-
Hand-calculate a household with one drug felon, one striker, and one clean member; verify Canopy produces the correct per-member screening results and household-level determination
Documentation Updates
-
.claude/docs/services.md— add snap_disqualification_screenings table; add disqualification screening description to canopy-snap service -
.claude/CLAUDE.md— update canopy-snap feature status: "Disqualification screenings: drug felon (jurisdiction-aware), fleeing felon, striker pre-strike income comparison" -
CHANGELOG.adoc— entry under== Unreleased -
docs/modules/ROOT/pages/plans/snap-special-situations.adoc— update status table steps to COMPLETE