Plan: SNAP Special Household Situations — Drug Felon, Fleeing Felon, and Striker Rules

On this page

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:

  1. Calculate eligibility using current income (during the strike)

  2. Calculate eligibility using pre-strike income (what the household earned before the strike)

  3. 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_screenings table 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-eligibility and snap-deduction-calculation plans

  • Categorical eligibility bypass — covered in snap-categorical-eligibility plan

Dependencies

This plan depends on:

  • reference-extensions (must be complete): DeterminationStatus::Disqualified variant; IncomeType::StrikeBenefits variant for pre-strike income comparison

  • persons-household-model (must be complete): person_id, household_id associations; 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

drug_felony_conviction

BOOLEAN

Per household member; self-attested

drug_felony_conviction_date

DATE (nullable)

Only if drug_felony_conviction = true; used to determine if conviction is on or after 8/22/1996

fleeing_felony_prosecution

BOOLEAN

Per household member; self-attested

probation_parole_violation

BOOLEAN

Per household member; self-attested

striker_status

BOOLEAN

Per household member; self-attested

pre_strike_income

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:

  1. Fetch application attestation data for all household members

  2. For each household member, run disqualification screenings:

    1. screen_drug_felon() with jurisdiction policy from jurisdiction.toml

    2. screen_fleeing_felon() (federal rule, no jurisdiction variation)

    3. screen_striker() with pre-strike income comparison

  3. Disqualified members are excluded from the SNAP household for benefit calculation

  4. If all household members are disqualified, the entire application is denied

  5. If any striker screening returns Denied, the household is denied

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

/v1/snap/disqualification-screenings?household_id={id}

List all disqualification screenings for a household. Returns screening type, result, and review status. Requires canopy-worker role.

PUT

/v1/snap/disqualification-screenings/{id}/review

Worker reviews self-attestation and records screening_result (eligible, disqualified, pending_verification, exempt). Requires canopy-snap-supervisor role.

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) — add FleeingFelonInput, 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.

Step 10: Integration tests

Files:

  • services/canopy-snap/tests/disqualification_tests.rs (new)

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

services/canopy-applications/migrations/YYYYMMDD_disqualification_attestation.sql

New: per-member attestation columns for drug felony, fleeing felony, probation violation, striker status

services/canopy-snap/migrations/YYYYMMDD_snap_disqualification_screenings.sql

New: snap_disqualification_screenings table with indexes

services/canopy-snap/src/disqualifications.rs

New: DrugFelonyPolicy, DrugFelonInput, DrugFelonResult, screen_drug_felon(), FleeingFelonInput, ScreeningResult, screen_fleeing_felon()

services/canopy-snap/src/striker.rs

New: StrikerInput, StrikerResult, screen_striker() with pre-strike income comparison

services/canopy-snap/src/determine.rs

Modify: insert disqualification screening gate before income/asset tests

services/canopy-snap/src/routes.rs

Modify: add GET and PUT disqualification screening endpoints

rulesets/federal/snap-disqualifications.json

New: federal disqualification decision table (drug felon, fleeing felon, striker)

rulesets/georgia/snap-disqualifications.json

New: Georgia drug felon policy override (trafficking_only)

rulesets/georgia/jurisdiction.toml

Modify: add [snap.disqualifications] section with drug_felony_policy and drug_treatment_exemption

services/canopy-snap/tests/disqualification_tests.rs

New: 15+ integration test scenarios with boundary cases

Verification

  1. cargo nextest run -p canopy-snap — all disqualification screening tests pass

  2. Verify Georgia drug felon policy: trafficking conviction disqualifies, possession conviction does not

  3. Verify jurisdiction with full_prohibition: any drug felony disqualifies

  4. Verify jurisdiction with no_prohibition: no drug felony disqualifies

  5. Verify conviction date boundary: on 8/22/1996 disqualifies, before 8/22/1996 does not

  6. Verify fleeing felon screening has no jurisdiction variation (federal mandatory rule)

  7. Verify striker pre-strike income comparison: household denied when pre-strike income exceeds gross income limit even though current income qualifies

  8. Verify disqualified members are excluded from household for benefit calculation but remaining members proceed

  9. Verify all screening events contain only IDs and screening results, no PII or conviction details

  10. 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

Edit this page · default