Plan: SNAP ABAWD Work Requirements

On this page

Status

Step Description Status

1

ABAWD tracking and monthly activity tables in canopy-snap

Done (2026-03-28)

2

ABAWD identification ruleset (snap-abawd.json)

Done (2026-03-28)

3

3-month time limit tracking logic and 36-month window management

Done (2026-03-28)

4

Discretionary exemption allocation and waiver area management

Done (2026-03-28)

5

Integration with snap-eligibility evaluation flow

Done (2026-03-28)

6

API endpoints, event publishing, integration tests

Done (2026-03-28)

MR: !17
Epic: &33, &39
Branch: feature/snap-abawd

Context

7 USC §2015(o) and 7 CFR 273.24 establish the ABAWD work requirement. Able-Bodied Adults Without Dependents between ages 18–49 who do not meet work/training requirements are limited to 3 months of SNAP benefits in any 36-month period.

This is one of the most operationally complex SNAP requirements: - ABAWD identification requires evaluating multiple individual exemptions - The 36-month window is a rolling window, not a fixed period - Discretionary exemptions are allocated per fiscal year (12% of ABAWD caseload) - Waiver areas (high unemployment counties) can exempt all ABAWDs in the area - Time limit months must survive case closings and re-openings (they don’t reset when someone reapplies)

Regulatory detail: - 80 hours/month of qualifying activity (work, job search E&T, community service, self-employment) - Time limit: 3 months of SNAP receipt without qualifying activity in any 36-month rolling window - After exhausting 3 months: ineligible until 3 months of qualifying activity are completed - Discretionary exemptions: FNS allocates each state 12% of its ABAWD caseload; state distributes at will - Waiver areas: FNS may waive areas where unemployment rate exceeds 10% or where insufficient jobs; Georgia has had partial waivers historically

Scope

In scope:

  • abawd_tracking table — 36-month window, months used, exemption status per person

  • abawd_monthly_activity table — monthly work activity records

  • abawd_discretionary_exemptions table — discretionary exemption ledger by fiscal year

  • abawd_waiver_areas table — active waiver area codes/county codes

  • rulesets/georgia/snap-abawd.json — ABAWD identification and activity evaluation

  • Integration with snap-eligibility evaluation: ABAWD check runs as part of eligibility evaluation

  • AbawdNotice events at months 1 and 2 of 3-month window

  • API endpoints for worker-facing ABAWD management

  • Regaining eligibility after 3 months of qualifying activity

Out of scope:

  • E&T (Employment and Training) program administration — E&T is a separate ACF-funded program; Canopy tracks participation but does not administer E&T

  • Mandatory work registration — separate from ABAWD (applies to all able-bodied adults regardless of age); post-UAT scope

  • Workfare program administration — post-UAT scope

Design

Database schema (canopy-snap isolated database)

CREATE TABLE abawd_tracking (
    id UUID PRIMARY KEY,
    person_id UUID NOT NULL,
    household_id UUID NOT NULL,
    -- The 36-month tracking window. Reset when a new window starts after re-qualifying.
    window_start_date DATE NOT NULL,
    window_end_date DATE NOT NULL,  -- window_start_date + 36 months
    months_used INTEGER NOT NULL DEFAULT 0,  -- months of SNAP receipt without qualifying activity
    current_status TEXT NOT NULL DEFAULT 'tracking',
    -- 'exempt', 'tracking', 'time_limit_reached', 'regaining', 'waiver_area'
    exemption_type TEXT,
    -- 'pregnancy', 'disability_unfit', 'dependent_child_under_18',
    -- 'incapacitated_dependent', 'discretionary', 'waiver_area'
    exemption_expires DATE,
    discretionary_exemption_id UUID,  -- references abawd_discretionary_exemptions if applicable
    waiver_area_code TEXT,
    created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
    updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
    active BOOLEAN NOT NULL DEFAULT true
);

CREATE UNIQUE INDEX abawd_tracking_person_active ON abawd_tracking (person_id) WHERE active = true;

CREATE TABLE abawd_monthly_activity (
    id UUID PRIMARY KEY,
    person_id UUID NOT NULL,
    abawd_tracking_id UUID NOT NULL REFERENCES abawd_tracking(id),
    benefit_month DATE NOT NULL,  -- first day of month
    hours_worked SMALLINT NOT NULL DEFAULT 0,
    hours_job_search SMALLINT NOT NULL DEFAULT 0,
    hours_training SMALLINT NOT NULL DEFAULT 0,
    hours_community_service SMALLINT NOT NULL DEFAULT 0,
    hours_self_employment SMALLINT NOT NULL DEFAULT 0,
    total_hours SMALLINT GENERATED ALWAYS AS (
        hours_worked + hours_job_search + hours_training +
        hours_community_service + hours_self_employment
    ) STORED,
    -- NOTE: The 80-hour threshold is the federal default (7 CFR 273.24).
    -- Do NOT hardcode in SQL — qualifying_month must be computed in application
    -- code using the threshold from jurisdiction.toml [snap.abawd] qualifying_hours_per_month.
    -- The GENERATED ALWAYS AS columns below use 80 as a placeholder; in production,
    -- replace with a view or application-level computation that reads the config value.
    qualifying_month BOOLEAN GENERATED ALWAYS AS (
        hours_worked + hours_job_search + hours_training +
        hours_community_service + hours_self_employment >= 80
    ) STORED,
    counts_against_limit BOOLEAN GENERATED ALWAYS AS (
        hours_worked + hours_job_search + hours_training +
        hours_community_service + hours_self_employment < 80
    ) STORED,
    snap_received BOOLEAN NOT NULL DEFAULT true,  -- did this person receive SNAP this month?
    reported_by TEXT NOT NULL DEFAULT 'self_attestation',
    verified BOOLEAN NOT NULL DEFAULT false,
    created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE UNIQUE INDEX abawd_activity_month ON abawd_monthly_activity (person_id, benefit_month);

CREATE TABLE abawd_discretionary_exemptions (
    id UUID PRIMARY KEY,
    fiscal_year SMALLINT NOT NULL,  -- e.g., 2026
    quota_allocated INTEGER NOT NULL,  -- 12% of state ABAWD caseload
    quota_used INTEGER NOT NULL DEFAULT 0,
    quota_remaining INTEGER GENERATED ALWAYS AS (quota_allocated - quota_used) STORED,
    created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE TABLE abawd_discretionary_exemption_grants (
    id UUID PRIMARY KEY,
    exemption_pool_id UUID NOT NULL REFERENCES abawd_discretionary_exemptions(id),
    person_id UUID NOT NULL,
    fiscal_year SMALLINT NOT NULL,
    granted_at TIMESTAMPTZ NOT NULL DEFAULT now(),
    granted_by UUID NOT NULL,  -- worker person_id
    reason TEXT,
    active BOOLEAN NOT NULL DEFAULT true
);

CREATE TABLE abawd_waiver_areas (
    id UUID PRIMARY KEY,
    jurisdiction TEXT NOT NULL DEFAULT 'georgia',
    area_code TEXT NOT NULL,  -- FIPS county code or custom area identifier
    area_name TEXT NOT NULL,
    waiver_start_date DATE NOT NULL,
    waiver_end_date DATE,  -- null if ongoing
    fns_waiver_approval_number TEXT,
    created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
    active BOOLEAN NOT NULL DEFAULT true
);

ABAWD identification ruleset

New file: rulesets/georgia/snap-abawd.json

Input:

{
  "person": {
    "age": 25,
    "is_pregnant": false,
    "disability_unfit_for_employment": false,
    "dependent_children_under_18": 0,
    "incapacitated_dependents": 0,
    "waiver_area_code": null
  }
}

Output:

{
  "is_abawd": true,
  "exemption_type": null
}

Decision table: - Age < 18 → not ABAWD - Age >= 50 → not ABAWD - Pregnant → not ABAWD (exemption_type: "pregnancy") - Physically/mentally unfit for employment → not ABAWD (exemption_type: "disability_unfit") - Responsible for dependent child under 18 → not ABAWD (exemption_type: "dependent_child_under_18") - Responsible for incapacitated dependent → not ABAWD (exemption_type: "incapacitated_dependent") - In FNS-approved waiver area → ABAWD but exempt (exemption_type: "waiver_area") - Holds discretionary exemption for current fiscal year → ABAWD but exempt (exemption_type: "discretionary") - Otherwise → ABAWD, not exempt

Note: "physically/mentally unfit for employment" is a lower threshold than "disabled" — a person does not need an SSI/disability determination to meet this exemption. Medical professional statement is sufficient.

Time limit tracking logic

The 36-month window is a rolling window. Implementation:

  1. On each monthly benefit issuance:

    • Load the person’s active abawd_tracking record

    • Load their abawd_monthly_activity for the benefit month

    • If qualifying_month = false AND snap_received = true: increment months_used

    • If months_used >= 3: set status to time_limit_reached, publish abawd.time_limit_reached event

    • If months_used == 1: publish abawd.warning_month_1

    • If months_used == 2: publish abawd.warning_month_2

  2. Regaining eligibility:

    • Once time_limit_reached, person must complete 3 months of qualifying activity (consecutive or not, within any 36-month window)

    • Track qualifying months in abawd_monthly_activity with snap_received = false

    • After 3 qualifying months: set status back to tracking, reset months_used = 0, start new window

  3. Window reset:

    • After a complete 36-month window with fewer than 3 months used, start a new window

    • window_end_date passes with months_used < 3 → create new tracking record with new window

Discretionary exemption quota management

Each fiscal year (October 1 start), FNS calculates 12% of Georgia’s ABAWD caseload and allocates it as discretionary exemptions. Workers grant exemptions from the pool.

If quota_remaining = 0, worker cannot grant additional exemptions for the fiscal year. Workers must document reason for each exemption grant. Granted exemptions convert the person’s ABAWD status to exempt for the current certification period.

Integration with snap-eligibility

In the eligibility evaluation flow, after categorical eligibility pre-screen: 1. For each household member age 18-49, run snap-abawd.json ruleset 2. If ABAWD and time_limit_reached: set person as ineligible for this benefit month 3. If household has no eligible members after ABAWD exclusion: determine status = AbawdExceeded 4. Record abawd_month_count on the Determination struct

Steps

Step 1: Migrations

Files: services/canopy-snap/migrations/20260327000000_abawd_tables.sql, services/canopy-snap/src/main.rs

Create all four ABAWD tables (abawd_tracking, abawd_monthly_activity, abawd_discretionary_exemptions, abawd_discretionary_exemption_grants, abawd_waiver_areas) using the SQL from the Design section above in a single migration file.

Add the following additional indexes for query performance:

CREATE INDEX idx_abawd_tracking_household ON abawd_tracking (household_id);
CREATE INDEX idx_abawd_tracking_status ON abawd_tracking (current_status) WHERE active = true;
CREATE INDEX idx_abawd_activity_tracking ON abawd_monthly_activity (abawd_tracking_id);
CREATE INDEX idx_abawd_discretionary_grants_pool ON abawd_discretionary_exemption_grants (exemption_pool_id) WHERE active = true;
CREATE INDEX idx_abawd_waiver_areas_active ON abawd_waiver_areas (area_code) WHERE active = true;

Run with sqlx migrate run on the postgres-snap instance (port 5433). Uncomment the migration runner in services/canopy-snap/src/main.rs (the boot.db.run_migrations(&sqlx::migrate!()).await?; line).

Error handling: if the migration fails, sqlx::migrate!() returns sqlx::migrate::MigrateError. The service must fail to start with a clear log message rather than proceeding with a stale schema.

Step 2: ABAWD identification ruleset

Files: rulesets/georgia/snap-abawd.json (new), rulesets/georgia/jurisdiction.toml (update)

Create rulesets/georgia/snap-abawd.json as a zen-engine JDM decision table implementing the full ABAWD identification logic from the Design section. The ruleset accepts the person input object and returns { is_abawd: bool, exemption_type: Option<String> }.

Decision table rows (evaluated in order, first match wins):

  1. age < 18{ is_abawd: false, exemption_type: null }

  2. age >= 50{ is_abawd: false, exemption_type: null }

  3. is_pregnant == true{ is_abawd: false, exemption_type: "pregnancy" }

  4. disability_unfit_for_employment == true{ is_abawd: false, exemption_type: "disability_unfit" }

  5. dependent_children_under_18 > 0{ is_abawd: false, exemption_type: "dependent_child_under_18" }

  6. incapacitated_dependents > 0{ is_abawd: false, exemption_type: "incapacitated_dependent" }

  7. waiver_area_code != null{ is_abawd: true, exemption_type: "waiver_area" }

  8. Default → { is_abawd: true, exemption_type: null }

Update jurisdiction.toml to add the [snap.abawd] section with qualifying_hours_per_month = 80 (the 7 CFR 273.24 default). This value is used by application code instead of hardcoding in SQL; validate that abawd_monthly_activity.qualifying_month computation in application code reads from this config.

Verify the ruleset loads correctly using canopy_rules::Engine::evaluate("snap-abawd", &input) in a unit test.

Step 3: Time limit tracking service

Files: services/canopy-snap/src/abawd.rs (new)

Implement AbawdTracker struct with methods: - evaluate_abawd_status(person_id, household_id) → runs snap-abawd.json via canopy-rules - record_monthly_activity(person_id, benefit_month, hours) → upsert to abawd_monthly_activity - process_benefit_month(person_id, benefit_month) → runs time limit logic - grant_discretionary_exemption(person_id, fiscal_year, granted_by, reason) → checks quota, grants - check_waiver_area(address_fips_code) → checks abawd_waiver_areas

Step 4: API endpoints

Files: services/canopy-snap/src/api/abawd.rs (new), services/canopy-snap/src/api/mod.rs (update)

Create services/canopy-snap/src/api/abawd.rs with the following route handlers:

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

use axum::{Router, routing::{get, post}, extract::{Path, Query, State}, Json};
use canopy_api::AppState;
use uuid::Uuid;

pub fn routes() -> Router<AppState> {
    Router::new()
        .route("/v1/abawd/:person_id", get(get_abawd_status))
        .route("/v1/abawd/:person_id/activity", post(record_monthly_activity))
        .route("/v1/abawd/:person_id/exemption", post(grant_discretionary_exemption))
        .route("/v1/abawd/exemptions/quota", get(get_quota_status))
        .route("/v1/abawd/waiver-areas", get(list_waiver_areas))
}

Request/response types:

  • RecordActivityRequest: { benefit_month: NaiveDate, hours_worked: i16, hours_job_search: i16, hours_training: i16, hours_community_service: i16, hours_self_employment: i16, reported_by: String }

  • GrantExemptionRequest: { fiscal_year: i16, reason: String } (granted_by extracted from JWT claims via canopy_auth::Claims)

  • QuotaQuery: { fiscal_year: i16 }

  • AbawdStatusResponse: { tracking: AbawdTracking, recent_activity: Vec<AbawdMonthlyActivity> }

  • QuotaStatusResponse: { fiscal_year: i16, quota_allocated: i32, quota_used: i32, quota_remaining: i32 }

All endpoints require canopy-worker role minimum. POST …​/exemption requires canopy-snap-supervisor (granting exemptions is a supervisory action).

Error handling: - 404 if no active abawd_tracking record for person_id - 409 if grant_discretionary_exemption called when quota_remaining = 0 - 422 if record_monthly_activity called with benefit_month in the future - Use canopy_api::ProblemDetail for all error responses with regulatory references

Update services/canopy-snap/src/api/mod.rs to merge abawd routes: Router::new().merge(abawd::routes())

Step 5: Snap-eligibility integration

Files: services/canopy-snap/src/evaluation.rs (update), services/canopy-snap/src/events.rs (update), services/canopy-snap/src/main.rs (update)

Update services/canopy-snap/src/evaluation.rs to integrate ABAWD checks into the eligibility evaluation flow. After categorical eligibility pre-screen, add:

/// Run ABAWD evaluation for each household member aged 18-49.
/// Returns the list of person_ids that are ABAWD-excluded for this benefit month.
pub async fn evaluate_abawd_members(
    pool: &PgPool,
    abawd_tracker: &AbawdTracker,
    household_members: &[HouseholdMember],
    benefit_month: NaiveDate,
) -> Result<Vec<AbawdEvaluation>> {
    // For each member age 18-49:
    // 1. Call abawd_tracker.evaluate_abawd_status(person_id, household_id)
    // 2. If is_abawd && !exempt: call abawd_tracker.process_benefit_month(person_id, benefit_month)
    // 3. If time_limit_reached: mark person as ineligible for this month
    // 4. Return AbawdEvaluation { person_id, is_abawd, exempt, months_used, excluded }
}

Add abawd_month_count: Option<i32> and abawd_excluded_members: Vec<Uuid> fields to the SnapDetermination model in services/canopy-snap/src/store/models.rs. If all household members are ABAWD-excluded, set determination status to AbawdExceeded.

Update services/canopy-snap/src/events.rs to publish ABAWD warning events via canopy-mq:

use canopy_mq::publisher::EventPublisher;

pub async fn publish_abawd_warning(
    publisher: &EventPublisher,
    event_type: &str,  // "abawd.warning_month_1", "abawd.warning_month_2", "abawd.time_limit_reached"
    person_id: Uuid,
    household_id: Uuid,
    months_used: i32,
) -> Result<()> {
    // Publish to canopy.events topic exchange with routing key = event_type
    // Payload: { person_id, household_id, months_used, benefit_month }
    // Per ADR-004: no income, SSN, or IEVS data in events
}

Wire the EventPublisher in main.rs by extracting it from boot.mq_health or creating from the boot connection pool. Pass the publisher to the evaluation flow as a dependency.

Step 6: Integration tests

Files: services/canopy-snap/tests/abawd_test.rs (new)

Use testcontainers-rs to spin up a PostgreSQL container with the canopy-snap migration applied. Use canopy_test_lib for test harness setup (database pool, mock event publisher).

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

use canopy_test_lib::{setup_test_db, mock_event_publisher};
use canopy_snap::abawd::AbawdTracker;

#[tokio::test]
async fn test_age_55_not_abawd() { /* age 55 -> evaluate_abawd_status returns is_abawd=false */ }

#[tokio::test]
async fn test_dependent_child_not_abawd() { /* age 35 + dependent child -> is_abawd=false, exemption_type="dependent_child_under_18" */ }

#[tokio::test]
async fn test_abawd_month_1_warning() { /* age 35, no activity, month 1 -> months_used=1, warning event published */ }

#[tokio::test]
async fn test_abawd_month_3_exhausted() { /* 3 consecutive months without qualifying activity -> status=time_limit_reached, determination=AbawdExceeded */ }

#[tokio::test]
async fn test_discretionary_exemption_granted() { /* grant exemption -> status=exempt, determination approved */ }

#[tokio::test]
async fn test_discretionary_exemption_quota_exhausted() { /* quota_remaining=0 -> grant returns 409 Conflict */ }

#[tokio::test]
async fn test_waiver_area_exempt() { /* FIPS code in abawd_waiver_areas -> exempt */ }

#[tokio::test]
async fn test_regaining_eligibility() { /* after time_limit_reached, 3 qualifying months -> status back to tracking, months_used reset */ }

Each test must:

  1. Set up the database with migration applied via sqlx::migrate!()

  2. Insert test data (person records, abawd_tracking records, activity records as needed)

  3. Call the relevant AbawdTracker method

  4. Assert the expected database state and event publications

  5. Verify that no IEVS or income data appears in published events (ADR-004 compliance)

Files Touched

File Change

services/canopy-snap/migrations/YYYYMMDD_abawd_tables.sql

New: all four ABAWD tables

rulesets/georgia/snap-abawd.json

New: ABAWD identification ruleset

services/canopy-snap/src/abawd.rs

New: AbawdTracker and time limit logic

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

Add ABAWD management endpoints

services/canopy-snap/src/evaluation.rs

Integrate ABAWD check; update determination output

services/canopy-snap/tests/abawd_test.rs

New: integration tests

Verification

  1. cargo nextest run -p canopy-snap — all tests pass including ABAWD scenarios

  2. UAT scenario: age 35, no work, 3 consecutive months → AbawdExceeded on month 4 determination

  3. UAT scenario: month 1 → abawd.warning_month_1 event published; canopy-notices generates AbawdNotice

  4. UAT scenario: discretionary exemption granted → determination approved despite no qualifying activity

  5. FNS-7176 QC extract includes abawd_household: true and months_used for ABAWD cases

Documentation Updates

  • .claude/docs/services.md — add ABAWD tables and endpoints

  • CHANGELOG.adoc — entry under == Unreleased

Edit this page · default