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) — ( |
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 |
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-verificationprovides theSaveAdaptertrait 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:
-
SaveAdaptertrait withverify_immigration_status()andsubmit_additional_verification()methods -
NoopSaveAdapterwith deterministic responses based on last 2 digits of alien registration number -
SaveVerificationRequestandSaveVerificationResponsestructs in canopy-verification -
Internal API endpoints:
POST /internal/v1/save/verifyandPOST /internal/v1/save/additional-verification -
citizenship_verificationtable 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-eligibilityplan) -
Medicaid-specific alien eligibility rules (covered in
medicaid-eligibilityplan) -
SAVE at renewal (covered in
snap-renewals-certificationplan) -
FDSH integration (Medicaid-primary; covered in
medicaid-eligibilityplan) -
G-845 form generation for Step 3 manual review (post-UAT)
Dependencies
This plan depends on:
-
reference-extensions (must be complete):
VerificationSource::Savealready exists incanopy-reference;DeterminationStatus::PendingVerificationalready exists -
persons-household-model (must be complete):
persontable withdate_of_birth,citizenship_statusfields; 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 |
|
30-49 |
Refugee/asylee, verified |
|
50-59 |
Pending Step 2 (initial verification inconclusive) |
|
60-69 |
Pending Step 3 (additional verification inconclusive) |
|
70-79 |
Not verified (immigration status does not match records) |
|
80-99 |
Case in continuance (Step 3 manual review in progress) |
|
For submit_additional_verification:
-
If the original case ended in 50-59 (Step 2 pending), the additional verification returns
InitialVerificationwithlawful_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
InstituteStep3withlawful_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 |
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 |
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:
-
For each non-citizen household member, call
POST /internal/v1/save/verifyvia canopy-verification -
Store the outcome in
citizenship_verificationstable in canopy-snap’s database -
If
verification_statusispending_step_2orpending_step_3: set determination toPendingVerificationwithverification_items_requiredincludingVerificationRequirement::CitizenshipStatus -
If
verification_statusisverifiedandlawful_presence_verified = true: rundetermine_snap_alien_eligibility()with the verified immigration category -
If the alien eligibility check returns
eligible = false: deny with the reason and citation -
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) —SaveAdaptertrait,SaveVerificationRequest,SaveVerificationResponse,SaveVerificationStatus -
services/canopy-verification/src/noop_save.rs(new) —NoopSaveAdapterwith 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).
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 |
|
2 |
NoopSaveAdapter: alien_registration_number ending |
|
3 |
NoopSaveAdapter: alien_registration_number ending |
|
4 |
SNAP alien eligibility: refugee with entry date < 7 years ago |
|
5 |
SNAP alien eligibility: LPR admitted 8/23/1996, less than 5 years residence |
|
6 |
SNAP alien eligibility: LPR admitted 8/23/1996, 5+ years residence |
|
7 |
SNAP alien eligibility: child under 18, qualified alien (LPR, post-PRWORA) |
|
8 |
SAVE data confirmed absent from |
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_verificationwith case ending 55 — verify resolves to verified LPR -
NoopSaveAdapter:
submit_additional_verificationwith case ending 65 — verify escalates to Step 3 -
NoopSaveAdapter:
submit_additional_verificationwith case ending 00 — verify returns error (not eligible for additional verification)
Files Touched
| File | Change |
|---|---|
|
New: SaveAdapter trait, SaveVerificationRequest, SaveVerificationResponse, SaveVerificationStatus |
|
New: NoopSaveAdapter with deterministic test data based on alien_registration_number suffix |
|
New: POST /internal/v1/save/verify, POST /internal/v1/save/additional-verification |
|
Modify: add SAVE routes to router |
|
New: citizenship_verifications table in canopy-snap isolated database |
|
New: AlienEligibilityInput, AlienEligibilityResult, determine_snap_alien_eligibility() with 10 rules from 7 CFR 273.4 |
|
New: JDM ruleset for alien category to eligibility mapping |
|
New: 8+ integration test scenarios with boundary cases |
Verification
-
cargo nextest run -p canopy-verification— SaveAdapter and NoopSaveAdapter unit tests pass -
cargo nextest run -p canopy-snap— alien eligibility integration tests pass -
NoopSaveAdapter alien_registration_number ending
00→ verified LPR -
NoopSaveAdapter alien_registration_number ending
55→ pending Step 2;submit_additional_verificationresolves to verified -
NoopSaveAdapter alien_registration_number ending
75→ not verified,UNDOCUMENTED -
SNAP alien eligibility: refugee with entry < 7 years ago → eligible
-
SNAP alien eligibility: LPR post-PRWORA with < 5 years → barred; with >= 5 years → eligible
-
SNAP alien eligibility: child under 18 → eligible regardless of entry date
-
SAVE data confirmed absent from
canopy.events(no PII, no SAVE response codes in event payloads) -
POST /internal/v1/save/verifyreturns401without service JWT -
POST /internal/v1/save/verifyreturns403with non-internal JWT role -
POST /internal/v1/save/verifyreturns400when no alien_registration_number, i94_number, or passport_number provided -
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