Plan: SAVE (Systematic Alien Verification for Entitlements) Adapter

On this page

Status

Step Description Status

1

SaveAdapter trait with NoopSaveAdapter (deterministic test data) in canopy-verification

Done (2026-04-07) — (save.rs trait + noop_save.rs with #[cfg(feature = "noop-adapters")]; 9 SAVE-specific tests (24 total across canopy-verification including IEVS))

2

Internal API endpoints in canopy-verification for SAVE verify and additional verification

Done (2026-04-07) — (POST /internal/v1/save/verify + /additional-verification with NoopSaveAdapter; X-Service-Api-Key auth)

3

citizenship_verification table in canopy-snap isolated database

Done (2026-04-07) — (migration 20260330000001_create_citizenship_verification.sql exists)

4

SNAP-specific alien eligibility rules (7 CFR 273.4) in canopy-snap

Done (2026-04-07) — (alien_eligibility.rs: build_input + evaluate via rules engine; 14 unit tests)

5

JDM ruleset for alien category to eligibility mapping

Done (2026-04-07) — (rulesets/federal/snap-alien-eligibility.json: 12 decision table rules)

6

Integration tests

Done (2026-04-07) — (14 unit tests for build_input boundary cases; 7 integration tests via E2E)

Epic: &34, &40
Branch: feature/save-adapter
Labels: type::feature, priority::high, program::cross-program, service::verification, workflow::ready, compliance::ievs

Context

Regulatory basis

The Systematic Alien Verification for Entitlements (SAVE) program is a DHS service that allows federal, state, and local benefit-granting agencies to verify the immigration status of benefit applicants.

8 USC 1642 mandates that agencies administering SNAP, Medicaid, TANF, CHIP, and CCDF must verify immigration status through SAVE for all non-citizen applicants.

PRWORA 121 (Personal Responsibility and Work Opportunity Reconciliation Act of 1996, Section 121) established the requirement for states to verify immigration status as a condition of benefit eligibility.

7 CFR 273.4 defines the SNAP-specific citizenship and alien eligibility rules, including qualified alien categories, the 5-year bar for post-8/22/1996 LPRs, and exemptions for refugees, asylees, children, and elderly/disabled individuals.

Qualified alien categories under federal law:

  • Lawful Permanent Resident (LPR) — 5-year bar applies for entrants after 8/22/1996

  • Refugee (INA 207) — eligible from date of entry, no waiting period, for first 7 years

  • Asylee (INA 208) — eligible from date of grant, no waiting period, for first 7 years

  • Cuban/Haitian entrant — eligible from date of entry

  • Victims of trafficking (TVPA) — eligible from date of certification

  • Certain military (active duty, veterans, spouses/dependents) — exempt from 5-year bar

  • PRUCOL (Permanently Residing Under Color of Law) — state option; Georgia does not extend SNAP to PRUCOL

Architecture

  • canopy-verification provides the SaveAdapter trait interface and internal API endpoints

  • SAVE verification results are transient in canopy-verification — not persisted beyond the HTTP request lifecycle, per ADR-004

  • canopy-verification proxies the SAVE query; the calling program service (canopy-snap, canopy-tanf, canopy-medicaid) stores the verification outcome (pass/fail/pending) in its own isolated database

  • SAVE has a multi-step verification process:

    • Step 1 (Initial Verification) — automated query against DHS immigration records

    • Step 2 (Additional Verification) — automated secondary query when Step 1 is inconclusive

    • Step 3 (Manual DHS Review) — manual review by DHS when Steps 1 and 2 are inconclusive; agency submits G-845 form

  • The adapter must handle all three steps

  • No SAVE data is published to canopy.events — SAVE queries are synchronous request/response

Data use agreement requirement

Access to the SAVE system requires a signed Memorandum of Agreement (MOA) with USCIS. For UAT: NoopSaveAdapter provides deterministic responses without a live SAVE connection. For go-live: the MOA must be executed and the system must pass USCIS’s SAVE Program Verification Review.

Scope

In scope:

  • SaveAdapter trait with verify_immigration_status() and submit_additional_verification() methods

  • NoopSaveAdapter with deterministic responses based on last 2 digits of alien registration number

  • SaveVerificationRequest and SaveVerificationResponse structs in canopy-verification

  • Internal API endpoints: POST /internal/v1/save/verify and POST /internal/v1/save/additional-verification

  • citizenship_verification table in canopy-snap isolated database

  • SNAP-specific alien eligibility rules per 7 CFR 273.4 (5-year bar, refugee/asylee exemption, child exemption, elderly/disabled exemption)

  • JDM ruleset for alien category to eligibility mapping (rulesets/federal/snap-alien-eligibility.json)

  • Integration tests with NoopSaveAdapter and SNAP alien eligibility scenarios

Out of scope:

  • Live DHS SAVE API integration (requires executed MOA with USCIS)

  • TANF-specific alien eligibility rules (covered in tanf-eligibility plan)

  • Medicaid-specific alien eligibility rules (covered in medicaid-eligibility plan)

  • SAVE at renewal (covered in snap-renewals-certification plan)

  • FDSH integration (Medicaid-primary; covered in medicaid-eligibility plan)

  • G-845 form generation for Step 3 manual review (post-UAT)

Dependencies

This plan depends on:

  • reference-extensions (must be complete): VerificationSource::Save already exists in canopy-reference; DeterminationStatus::PendingVerification already exists

  • persons-household-model (must be complete): person table with date_of_birth, citizenship_status fields; household composition for child/elderly determination

  • snap-verification-ievs (parallel): same service pattern in canopy-verification; SAVE adapter follows the same internal API convention

Design

SaveAdapter trait

In services/canopy-verification/src/save.rs:

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

use anyhow::Result;
use chrono::NaiveDate;
use serde::{Deserialize, Serialize};

/// Request to verify immigration status via SAVE.
///
/// Fields correspond to the SAVE Initial Verification (Step 1) input.
/// At least one of `alien_registration_number`, `i94_number`, or
/// `passport_number` must be provided.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SaveVerificationRequest {
    /// USCIS Alien Registration Number (A-Number), 7-9 digits
    pub alien_registration_number: Option<String>,
    /// I-94 Arrival/Departure Record Number
    pub i94_number: Option<String>,
    /// Passport number (travel document)
    pub passport_number: Option<String>,
    /// Country of birth (ISO 3166-1 alpha-3)
    pub country_of_birth: String,
    /// Date of birth
    pub date_of_birth: NaiveDate,
    /// Legal first name as it appears on immigration documents
    pub first_name: String,
    /// Legal last name as it appears on immigration documents
    pub last_name: String,
}

/// SAVE verification response.
///
/// Maps to the three-step SAVE verification process.
/// `verification_status` indicates whether the case is resolved or
/// requires additional verification steps.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SaveVerificationResponse {
    /// SAVE-assigned case verification number (unique per query)
    pub case_number: String,
    /// Current step status
    pub verification_status: SaveVerificationStatus,
    /// SAVE response code (e.g., "IMMIGRATION STATUS VERIFIED",
    /// "INSTITUTE ADDITIONAL VERIFICATION", "DHS MANUAL REVIEW")
    pub save_response_code: String,
    /// Immigration status category if verified (e.g., "LPR", "REFUGEE",
    /// "ASYLEE", "CUBAN_HAITIAN_ENTRANT", "TRAFFICKING_VICTIM",
    /// "MILITARY", "PRUCOL", "UNDOCUMENTED")
    pub immigration_status_category: Option<String>,
    /// Plain-text eligibility statement from SAVE (informational only --
    /// the program service makes the eligibility determination, not SAVE)
    pub eligibility_statement: Option<String>,
    /// Whether lawful presence has been affirmatively verified
    pub lawful_presence_verified: bool,
}

/// Status of the SAVE verification case.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum SaveVerificationStatus {
    /// Step 1 complete: immigration status verified or definitively not found
    InitialVerification,
    /// Step 1 inconclusive: system recommends Step 2 additional verification
    AdditionalVerification,
    /// Steps 1-2 inconclusive: requires Step 3 manual DHS review (G-845)
    InstituteStep3,
    /// Step 3 in progress: DHS is reviewing the case
    CaseInContinuance,
}

/// Adapter trait for SAVE immigration status verification.
///
/// Implementations:
/// - `NoopSaveAdapter`: deterministic test responses (UAT)
/// - Future: `DhsSaveAdapter`: live DHS SAVE API (requires MOA)
pub trait SaveAdapter: Send + Sync {
    /// Perform initial SAVE verification (Step 1).
    /// If the response status is `AdditionalVerification`, the caller
    /// should invoke `submit_additional_verification` with the returned
    /// `case_number` for Step 2.
    async fn verify_immigration_status(
        &self,
        req: &SaveVerificationRequest,
    ) -> Result<SaveVerificationResponse>;

    /// Submit for additional verification (Step 2) or institute
    /// Step 3 manual DHS review.
    /// Called with the `case_number` from a prior Step 1 response.
    async fn submit_additional_verification(
        &self,
        case_number: &str,
    ) -> Result<SaveVerificationResponse>;
}

NoopSaveAdapter

The Noop adapter produces deterministic responses based on the last 2 digits of the alien_registration_number. If no alien_registration_number is provided, the adapter uses the last 2 digits of the i94_number (or returns an error if neither is present).

Last 2 digits Scenario Response

00-29

Lawful Permanent Resident, verified

InitialVerification, lawful_presence_verified = true, immigration_status_category = "LPR"

30-49

Refugee/asylee, verified

InitialVerification, lawful_presence_verified = true, immigration_status_category = "REFUGEE" (30-39) or "ASYLEE" (40-49)

50-59

Pending Step 2 (initial verification inconclusive)

AdditionalVerification, lawful_presence_verified = false, immigration_status_category = None

60-69

Pending Step 3 (additional verification inconclusive)

InstituteStep3, lawful_presence_verified = false, immigration_status_category = None

70-79

Not verified (immigration status does not match records)

InitialVerification, lawful_presence_verified = false, immigration_status_category = "UNDOCUMENTED"

80-99

Case in continuance (Step 3 manual review in progress)

CaseInContinuance, lawful_presence_verified = false, immigration_status_category = None

For submit_additional_verification:

  • If the original case ended in 50-59 (Step 2 pending), the additional verification returns InitialVerification with lawful_presence_verified = true, immigration_status_category = "LPR" (simulates successful Step 2 resolution).

  • If the original case ended in 60-69 (Step 3 pending), the additional verification returns InstituteStep3 with lawful_presence_verified = false (simulates escalation to Step 3).

  • All other case numbers return an error (invalid case for additional verification).

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

use anyhow::{bail, Result};

pub struct NoopSaveAdapter;

impl SaveAdapter for NoopSaveAdapter {
    async fn verify_immigration_status(
        &self,
        req: &SaveVerificationRequest,
    ) -> Result<SaveVerificationResponse> {
        let digits = extract_last_two_digits(req)?;
        let case_number = format!("SAVE-NOOP-{digits:02}");

        match digits {
            0..=29 => Ok(SaveVerificationResponse {
                case_number,
                verification_status: SaveVerificationStatus::InitialVerification,
                save_response_code: "IMMIGRATION STATUS VERIFIED".to_string(),
                immigration_status_category: Some("LPR".to_string()),
                eligibility_statement: Some(
                    "Lawful Permanent Resident status verified".to_string(),
                ),
                lawful_presence_verified: true,
            }),
            30..=39 => Ok(SaveVerificationResponse {
                case_number,
                verification_status: SaveVerificationStatus::InitialVerification,
                save_response_code: "IMMIGRATION STATUS VERIFIED".to_string(),
                immigration_status_category: Some("REFUGEE".to_string()),
                eligibility_statement: Some(
                    "Refugee status verified under INA 207".to_string(),
                ),
                lawful_presence_verified: true,
            }),
            40..=49 => Ok(SaveVerificationResponse {
                case_number,
                verification_status: SaveVerificationStatus::InitialVerification,
                save_response_code: "IMMIGRATION STATUS VERIFIED".to_string(),
                immigration_status_category: Some("ASYLEE".to_string()),
                eligibility_statement: Some(
                    "Asylee status verified under INA 208".to_string(),
                ),
                lawful_presence_verified: true,
            }),
            50..=59 => Ok(SaveVerificationResponse {
                case_number,
                verification_status: SaveVerificationStatus::AdditionalVerification,
                save_response_code: "INSTITUTE ADDITIONAL VERIFICATION".to_string(),
                immigration_status_category: None,
                eligibility_statement: None,
                lawful_presence_verified: false,
            }),
            60..=69 => Ok(SaveVerificationResponse {
                case_number,
                verification_status: SaveVerificationStatus::InstituteStep3,
                save_response_code: "INSTITUTE STEP 3 - DHS MANUAL REVIEW".to_string(),
                immigration_status_category: None,
                eligibility_statement: None,
                lawful_presence_verified: false,
            }),
            70..=79 => Ok(SaveVerificationResponse {
                case_number,
                verification_status: SaveVerificationStatus::InitialVerification,
                save_response_code: "IMMIGRATION STATUS NOT VERIFIED".to_string(),
                immigration_status_category: Some("UNDOCUMENTED".to_string()),
                eligibility_statement: Some(
                    "Immigration status does not match DHS records".to_string(),
                ),
                lawful_presence_verified: false,
            }),
            80..=99 => Ok(SaveVerificationResponse {
                case_number,
                verification_status: SaveVerificationStatus::CaseInContinuance,
                save_response_code: "CASE IN CONTINUANCE".to_string(),
                immigration_status_category: None,
                eligibility_statement: None,
                lawful_presence_verified: false,
            }),
            _ => bail!("unexpected digit value"),
        }
    }

    async fn submit_additional_verification(
        &self,
        case_number: &str,
    ) -> Result<SaveVerificationResponse> {
        let digits: u8 = case_number
            .rsplit('-')
            .next()
            .and_then(|s| s.parse().ok())
            .unwrap_or(0);

        match digits {
            50..=59 => Ok(SaveVerificationResponse {
                case_number: case_number.to_string(),
                verification_status: SaveVerificationStatus::InitialVerification,
                save_response_code: "IMMIGRATION STATUS VERIFIED".to_string(),
                immigration_status_category: Some("LPR".to_string()),
                eligibility_statement: Some(
                    "Lawful Permanent Resident status verified via Step 2"
                        .to_string(),
                ),
                lawful_presence_verified: true,
            }),
            60..=69 => Ok(SaveVerificationResponse {
                case_number: case_number.to_string(),
                verification_status: SaveVerificationStatus::InstituteStep3,
                save_response_code: "INSTITUTE STEP 3 - DHS MANUAL REVIEW".to_string(),
                immigration_status_category: None,
                eligibility_statement: None,
                lawful_presence_verified: false,
            }),
            _ => bail!(
                "case {case_number} is not eligible for additional verification"
            ),
        }
    }
}

/// Extract the last 2 digits from the alien registration number or I-94 number.
fn extract_last_two_digits(req: &SaveVerificationRequest) -> Result<u8> {
    let number = req
        .alien_registration_number
        .as_deref()
        .or(req.i94_number.as_deref())
        .ok_or_else(|| {
            anyhow::anyhow!(
                "at least one of alien_registration_number or i94_number is required"
            )
        })?;
    let last_two = &number[number.len().saturating_sub(2)..];
    last_two
        .parse::<u8>()
        .map(|n| n % 100)
        .map_err(|e| anyhow::anyhow!("failed to parse last 2 digits: {e}"))
}

Database schema (canopy-snap isolated database only)

No new tables in canopy-verification. SAVE query results are transient in canopy-verification per ADR-004 — the raw response is returned to the calling program service and not persisted.

Each program service stores its own citizenship_verification record. The schema below is for canopy-snap; other program services (canopy-tanf, canopy-medicaid) will create equivalent tables in their own isolated databases.

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

-- Citizenship verification outcomes for SNAP applicants.
-- Stores the result of SAVE queries; raw SAVE responses are NOT stored.
-- Only the verification outcome (pass/fail/pending) and category are persisted.
CREATE TABLE citizenship_verifications (
    id UUID PRIMARY KEY,
    application_id UUID NOT NULL,
    person_id UUID NOT NULL,
    -- SAVE case verification number (unique per SAVE query)
    verification_case_number TEXT NOT NULL,
    -- Current verification status
    verification_status TEXT NOT NULL DEFAULT 'pending',
    -- 'verified', 'unverified', 'pending_step_2', 'pending_step_3',
    -- 'case_in_continuance'
    -- Qualified alien category if verified
    -- (e.g., 'LPR', 'REFUGEE', 'ASYLEE', 'CUBAN_HAITIAN_ENTRANT',
    --  'TRAFFICKING_VICTIM', 'MILITARY', 'PRUCOL', 'UNDOCUMENTED')
    alien_eligibility_category TEXT,
    -- SAVE response code (text, e.g., "IMMIGRATION STATUS VERIFIED")
    save_response_code TEXT NOT NULL,
    -- Whether lawful presence was affirmatively verified
    lawful_presence_verified BOOLEAN NOT NULL DEFAULT FALSE,
    -- Timestamp when verification was completed (NULL if pending)
    verified_at TIMESTAMPTZ,
    created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE INDEX citizenship_verifications_application
    ON citizenship_verifications (application_id);
CREATE INDEX citizenship_verifications_person
    ON citizenship_verifications (person_id);
CREATE INDEX citizenship_verifications_case_number
    ON citizenship_verifications (verification_case_number);

SNAP alien eligibility rules (7 CFR 273.4)

In services/canopy-snap/src/alien_eligibility.rs:

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

use anyhow::Result;
use chrono::{NaiveDate, Utc};

/// Input for SNAP alien eligibility determination per 7 CFR 273.4.
pub struct AlienEligibilityInput {
    /// Immigration status category from SAVE (e.g., "LPR", "REFUGEE")
    pub immigration_status_category: String,
    /// Date the person entered the U.S. or was granted qualified status
    pub qualified_status_date: NaiveDate,
    /// Person's date of birth
    pub date_of_birth: NaiveDate,
    /// Whether the person is disabled (SSI, SSDI, or state-determined)
    pub is_disabled: bool,
    /// Whether the person has active-duty military service or is a
    /// veteran, or spouse/dependent of such
    pub is_military_connected: bool,
}

/// Result of the SNAP alien eligibility check.
pub struct AlienEligibilityResult {
    /// Whether the person is eligible for SNAP based on alien status
    pub eligible: bool,
    /// Reason for eligibility or ineligibility
    pub reason: String,
    /// Regulatory citation supporting the determination
    pub citation: String,
}

/// Determine SNAP eligibility based on alien/immigration status.
///
/// Rules per 7 CFR 273.4:
///
/// 1. U.S. citizens are always eligible (not checked here -- this
///    function is only called for non-citizens after SAVE verification).
/// 2. Undocumented immigrants are categorically ineligible.
/// 3. Refugees and asylees are eligible from date of entry/grant
///    for the first 7 years (no 5-year bar).
/// 4. Cuban/Haitian entrants and trafficking victims are eligible
///    from date of entry/certification (no 5-year bar).
/// 5. Military-connected qualified aliens are exempt from the 5-year bar.
/// 6. Children under 18 who are otherwise qualified aliens are eligible
///    regardless of entry date (no 5-year bar per 7 CFR 273.4(a)(6)).
/// 7. Elderly persons born before 8/22/1996 who are lawfully residing
///    are eligible (7 CFR 273.4(a)(6)).
/// 8. Disabled qualified aliens receiving SSI/SSDI are eligible
///    (7 CFR 273.4(a)(6)).
/// 9. LPRs admitted after 8/22/1996: 5-year bar before SNAP eligibility.
/// 10. LPRs admitted before 8/22/1996: eligible (grandfathered).
pub fn determine_snap_alien_eligibility(
    input: &AlienEligibilityInput,
) -> Result<AlienEligibilityResult> {
    let today = Utc::now().date_naive();
    let prwora_date = NaiveDate::from_ymd_opt(1996, 8, 22)
        .expect("valid date");
    let age = (today - input.date_of_birth).num_days() / 365;
    let years_qualified =
        (today - input.qualified_status_date).num_days() as f64 / 365.25;

    // Rule 2: Undocumented -- categorical prohibition
    if input.immigration_status_category == "UNDOCUMENTED" {
        return Ok(AlienEligibilityResult {
            eligible: false,
            reason: "Undocumented immigration status; categorically \
                     ineligible for SNAP"
                .to_string(),
            citation: "7 CFR 273.4(a)".to_string(),
        });
    }

    // Rule 3: Refugees -- eligible for 7 years from entry
    if input.immigration_status_category == "REFUGEE" {
        return if years_qualified <= 7.0 {
            Ok(AlienEligibilityResult {
                eligible: true,
                reason: "Refugee; eligible for 7 years from date of entry"
                    .to_string(),
                citation: "7 CFR 273.4(a)(1)".to_string(),
            })
        } else {
            // After 7 years, treated as LPR for eligibility purposes
            Ok(AlienEligibilityResult {
                eligible: true,
                reason: "Refugee with 7+ years of qualified status; \
                         eligible as qualified alien"
                    .to_string(),
                citation: "7 CFR 273.4(a)(6)".to_string(),
            })
        };
    }

    // Rule 3: Asylees -- eligible for 7 years from grant date
    if input.immigration_status_category == "ASYLEE" {
        return if years_qualified <= 7.0 {
            Ok(AlienEligibilityResult {
                eligible: true,
                reason: "Asylee; eligible for 7 years from date of grant"
                    .to_string(),
                citation: "7 CFR 273.4(a)(2)".to_string(),
            })
        } else {
            Ok(AlienEligibilityResult {
                eligible: true,
                reason: "Asylee with 7+ years of qualified status; \
                         eligible as qualified alien"
                    .to_string(),
                citation: "7 CFR 273.4(a)(6)".to_string(),
            })
        };
    }

    // Rule 4: Cuban/Haitian entrants and trafficking victims
    if input.immigration_status_category == "CUBAN_HAITIAN_ENTRANT"
        || input.immigration_status_category == "TRAFFICKING_VICTIM"
    {
        return Ok(AlienEligibilityResult {
            eligible: true,
            reason: format!(
                "{}; eligible from date of entry/certification",
                input.immigration_status_category
            ),
            citation: "7 CFR 273.4(a)(3)".to_string(),
        });
    }

    // Rule 5: Military-connected -- exempt from 5-year bar
    if input.is_military_connected {
        return Ok(AlienEligibilityResult {
            eligible: true,
            reason: "Military-connected qualified alien; exempt from \
                     5-year bar"
                .to_string(),
            citation: "7 CFR 273.4(a)(4)".to_string(),
        });
    }

    // Rule 6: Children under 18 -- no 5-year bar
    if age < 18 {
        return Ok(AlienEligibilityResult {
            eligible: true,
            reason: "Child under 18; qualified alien exempt from \
                     5-year bar"
                .to_string(),
            citation: "7 CFR 273.4(a)(6)".to_string(),
        });
    }

    // Rule 7: Elderly (born before 8/22/1996) lawfully residing
    if input.date_of_birth < prwora_date
        && input.immigration_status_category == "LPR"
    {
        return Ok(AlienEligibilityResult {
            eligible: true,
            reason: "Elderly LPR born before 8/22/1996; eligible as \
                     lawfully residing"
                .to_string(),
            citation: "7 CFR 273.4(a)(6)".to_string(),
        });
    }

    // Rule 8: Disabled qualified aliens
    if input.is_disabled {
        return Ok(AlienEligibilityResult {
            eligible: true,
            reason: "Disabled qualified alien; eligible".to_string(),
            citation: "7 CFR 273.4(a)(6)".to_string(),
        });
    }

    // Rule 9-10: LPR 5-year bar
    if input.immigration_status_category == "LPR" {
        if input.qualified_status_date <= prwora_date {
            // Rule 10: LPR admitted before 8/22/1996 -- grandfathered
            return Ok(AlienEligibilityResult {
                eligible: true,
                reason: "LPR admitted on or before 8/22/1996; \
                         grandfathered"
                    .to_string(),
                citation: "7 CFR 273.4(a)(6)".to_string(),
            });
        }

        // Rule 9: LPR admitted after 8/22/1996 -- 5-year bar
        return if years_qualified >= 5.0 {
            Ok(AlienEligibilityResult {
                eligible: true,
                reason: "LPR with 5+ years of qualified status; \
                         5-year bar satisfied"
                    .to_string(),
                citation: "7 CFR 273.4(a)(6)".to_string(),
            })
        } else {
            Ok(AlienEligibilityResult {
                eligible: false,
                reason: format!(
                    "LPR admitted after 8/22/1996 with {:.1} years of \
                     qualified status; 5-year bar not yet satisfied",
                    years_qualified
                ),
                citation: "8 USC 1613; 7 CFR 273.4(a)(6)".to_string(),
            })
        };
    }

    // Default: unrecognized category -- deny with explanation
    Ok(AlienEligibilityResult {
        eligible: false,
        reason: format!(
            "Immigration status category '{}' is not a recognized \
             qualified alien category for SNAP",
            input.immigration_status_category
        ),
        citation: "7 CFR 273.4(a)".to_string(),
    })
}

API endpoints

Both endpoints are internal (service-to-service). Authentication: JWT with canopy-internal role. Content-Type: application/json. Errors: RFC 9457 Problem Details (application/problem+json).

POST /internal/v1/save/verify

Initiate SAVE Step 1 verification.

Auth: Service-to-service JWT with canopy-internal role (not worker or applicant JWT).
Request body: SaveVerificationRequest (JSON)
Success response: 200 OK with SaveVerificationResponse (JSON)
Error responses:

Status Condition

400 Bad Request

Missing required fields (no alien_registration_number, i94_number, or passport_number provided)

401 Unauthorized

Missing or invalid service JWT

403 Forbidden

JWT does not contain canopy-internal role

502 Bad Gateway

SAVE upstream service error (live adapter only)

503 Service Unavailable

SAVE upstream service unreachable (live adapter only)

POST /internal/v1/save/additional-verification

Submit for SAVE Step 2/3 additional verification.

Auth: Service-to-service JWT with canopy-internal role.
Request body:

{
    "case_number": "string (SAVE case verification number from Step 1)"
}

Success response: 200 OK with SaveVerificationResponse (JSON)
Error responses:

Status Condition

400 Bad Request

Invalid or missing case_number

401 Unauthorized

Missing or invalid service JWT

403 Forbidden

JWT does not contain canopy-internal role

404 Not Found

Case number not found or not eligible for additional verification

502 Bad Gateway

SAVE upstream service error (live adapter only)

Events

No events are published by this plan. SAVE queries are synchronous request/response. No SAVE data appears in the canopy.events RabbitMQ exchange.

The determination result (which includes the alien eligibility outcome as a pass/fail status — not raw SAVE data) is published as part of the existing determination.completed event via canopy-snap. That event contains only IDs, status codes, and timestamps per ADR-004.

Wiring into snap-eligibility

The SAVE verification is called during the canopy-snap determination flow after application submission:

  1. For each non-citizen household member, call POST /internal/v1/save/verify via canopy-verification

  2. Store the outcome in citizenship_verifications table in canopy-snap’s database

  3. If verification_status is pending_step_2 or pending_step_3: set determination to PendingVerification with verification_items_required including VerificationRequirement::CitizenshipStatus

  4. If verification_status is verified and lawful_presence_verified = true: run determine_snap_alien_eligibility() with the verified immigration category

  5. If the alien eligibility check returns eligible = false: deny with the reason and citation

  6. If expedited service applies (7 CFR 273.2(j)): approve pending SAVE verification; verification must be completed within 30 days

Steps

Step 1: SaveAdapter trait and NoopSaveAdapter

Files:

  • services/canopy-verification/src/save.rs (new) — SaveAdapter trait, SaveVerificationRequest, SaveVerificationResponse, SaveVerificationStatus

  • services/canopy-verification/src/noop_save.rs (new) — NoopSaveAdapter with deterministic responses

Wire NoopSaveAdapter as the default in canopy-verification via environment variable: CANOPY_SAVE_ADAPTER=noop (default) or =dhs_save (future live adapter).

Step 2: Internal API endpoints

Files:

  • services/canopy-verification/src/api/save.rs (new) — POST /internal/v1/save/verify, POST /internal/v1/save/additional-verification

  • services/canopy-verification/src/api/mod.rs (modify) — add SAVE routes to router

Both endpoints require canopy-internal role in the JWT claims. Log every SAVE verification attempt with: application_id (from request header), person_id (from request header), timestamp, verification status. Never log alien registration numbers, I-94 numbers, passport numbers, or other PII.

Step 3: citizenship_verification table

Files:

  • services/canopy-snap/migrations/YYYYMMDD_citizenship_verifications.sql (new)

Create citizenship_verifications table in canopy-snap’s isolated database.

Step 4: SNAP alien eligibility rules

Files:

  • services/canopy-snap/src/alien_eligibility.rs (new) — AlienEligibilityInput, AlienEligibilityResult, determine_snap_alien_eligibility()

Implement the 10 rules from 7 CFR 273.4 as shown in the Design section. All date calculations use chrono::NaiveDate. All functions return Result<T> using anyhow::Context. No unwrap() in any code path (the expect("valid date") for the PRWORA constant is acceptable since it is a compile-time-known value).

Step 5: JDM ruleset

Files:

  • rulesets/federal/snap-alien-eligibility.json (new) — JDM decision table encoding the 7 CFR 273.4 rules

The JDM ruleset encodes the same logic as the Rust function but as a data-driven decision table evaluated by zen-engine. This enables jurisdiction customization (e.g., states that extend SNAP to PRUCOL aliens) without code changes. The Rust implementation serves as the reference; the JDM ruleset must produce identical results.

Input fields: immigration_status_category, years_qualified, age, is_disabled, is_military_connected, qualified_before_prwora. Output fields: eligible (bool), reason (string), citation (string).

Step 6: Integration tests

Files:

  • services/canopy-snap/tests/alien_eligibility_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

NoopSaveAdapter: alien_registration_number ending 00 (LPR)

InitialVerification, lawful_presence_verified = true, immigration_status_category = "LPR"

2

NoopSaveAdapter: alien_registration_number ending 55 (Step 2 pending)

AdditionalVerification, lawful_presence_verified = false

3

NoopSaveAdapter: alien_registration_number ending 75 (not verified)

InitialVerification, lawful_presence_verified = false, immigration_status_category = "UNDOCUMENTED"

4

SNAP alien eligibility: refugee with entry date < 7 years ago

eligible = true, citation = 7 CFR 273.4(a)(1)

5

SNAP alien eligibility: LPR admitted 8/23/1996, less than 5 years residence

eligible = false, reason includes "5-year bar not yet satisfied"

6

SNAP alien eligibility: LPR admitted 8/23/1996, 5+ years residence

eligible = true, reason includes "5-year bar satisfied"

7

SNAP alien eligibility: child under 18, qualified alien (LPR, post-PRWORA)

eligible = true, reason includes "Child under 18", citation = 7 CFR 273.4(a)(6)

8

SAVE data confirmed absent from canopy.events exchange (audit canopy-security subscriber log)

No SAVE-related events in the exchange; no PII in any published event

Boundary tests (required by QC standards)

  • LPR admitted exactly on 8/22/1996 (the PRWORA date itself) — verify grandfathered (admitted on or before)

  • LPR admitted 8/23/1996 with exactly 5.0 years of qualified status — verify eligible (boundary is inclusive: >=)

  • Refugee with exactly 7.0 years since entry — verify still eligible (boundary is inclusive: ⇐)

  • Child who turns 18 today — verify ineligible for child exemption (age >= 18)

  • Disabled LPR admitted after 8/22/1996 with less than 5 years — verify eligible (disability exempts from 5-year bar)

  • NoopSaveAdapter: submit_additional_verification with case ending 55 — verify resolves to verified LPR

  • NoopSaveAdapter: submit_additional_verification with case ending 65 — verify escalates to Step 3

  • NoopSaveAdapter: submit_additional_verification with case ending 00 — verify returns error (not eligible for additional verification)

Files Touched

File Change

services/canopy-verification/src/save.rs

New: SaveAdapter trait, SaveVerificationRequest, SaveVerificationResponse, SaveVerificationStatus

services/canopy-verification/src/noop_save.rs

New: NoopSaveAdapter with deterministic test data based on alien_registration_number suffix

services/canopy-verification/src/api/save.rs

New: POST /internal/v1/save/verify, POST /internal/v1/save/additional-verification

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

Modify: add SAVE routes to router

services/canopy-snap/migrations/YYYYMMDD_citizenship_verifications.sql

New: citizenship_verifications table in canopy-snap isolated database

services/canopy-snap/src/alien_eligibility.rs

New: AlienEligibilityInput, AlienEligibilityResult, determine_snap_alien_eligibility() with 10 rules from 7 CFR 273.4

rulesets/federal/snap-alien-eligibility.json

New: JDM ruleset for alien category to eligibility mapping

services/canopy-snap/tests/alien_eligibility_tests.rs

New: 8+ integration test scenarios with boundary cases

Verification

  1. cargo nextest run -p canopy-verification — SaveAdapter and NoopSaveAdapter unit tests pass

  2. cargo nextest run -p canopy-snap — alien eligibility integration tests pass

  3. NoopSaveAdapter alien_registration_number ending 00 → verified LPR

  4. NoopSaveAdapter alien_registration_number ending 55 → pending Step 2; submit_additional_verification resolves to verified

  5. NoopSaveAdapter alien_registration_number ending 75 → not verified, UNDOCUMENTED

  6. SNAP alien eligibility: refugee with entry < 7 years ago → eligible

  7. SNAP alien eligibility: LPR post-PRWORA with < 5 years → barred; with >= 5 years → eligible

  8. SNAP alien eligibility: child under 18 → eligible regardless of entry date

  9. SAVE data confirmed absent from canopy.events (no PII, no SAVE response codes in event payloads)

  10. POST /internal/v1/save/verify returns 401 without service JWT

  11. POST /internal/v1/save/verify returns 403 with non-internal JWT role

  12. POST /internal/v1/save/verify returns 400 when no alien_registration_number, i94_number, or passport_number provided

  13. Error responses conform to RFC 9457 Problem Details format

Documentation Updates

  • .claude/docs/services.md — add SaveAdapter trait, canopy-verification SAVE endpoints, citizenship_verifications table in canopy-snap

  • .claude/docs/security.md — document SAVE data transience in canopy-verification per ADR-004; document that SAVE PII (alien registration numbers, passport numbers) is never logged or published to events

  • .claude/CLAUDE.md — update canopy-verification feature status: "NoopSaveAdapter implemented; SAVE internal endpoints"; update canopy-snap: "alien eligibility rules (7 CFR 273.4)"

  • CHANGELOG.adoc — entry under == Unreleased

  • docs/modules/ROOT/pages/plans/save-adapter.adoc — update status table steps to COMPLETE

Edit this page · default