Plan: SNAP IEVS Verification

On this page

Status

Step Description Status

1

IEVS match result and discrepancy tables in canopy-snap isolated database

Done (2026-04-09) — (tables in canopy-snap migrations: ievs_match_results, ievs_discrepancies)

2

IevsAdapter trait with NoopIevsAdapter (deterministic test data) and adapter stubs for Georgia DOL and SSA

Done (2026-04-09) — (ievs.rs trait + noop.rs with #[cfg(feature = "noop-adapters")]; SSN-suffix-based test data)

3

Verification workflow: match trigger, discrepancy detection, resolution tracking

Done (2026-04-09) — verification.rs runs IEVS matches post-determination, creates discrepancies, resolve_discrepancy handler

4

API endpoints for worker discrepancy review

Done (2026-04-09) — list_discrepancies, resolve_discrepancy, list_ievs_matches handlers in services/canopy-snap/src/api/verification_handler.rs; routed in api/mod.rs (GET /v1/snap/discrepancies, PUT /v1/snap/discrepancies/{id}/resolve, GET /v1/snap/ievs-matches).

5

Integration with eligibility evaluation flow (verification_items_required on determination)

Done (2026-04-06) — VerificationClient wired into determine_handler.rs via Extension; runs IEVS matches post-determination, populates verification_items_required on SnapDetermination. Issue #299 closed 2026-04-06.

6

Integration tests

Done (2026-04-09) — 3 IEVS tests (ievs_test.rs) + 4 SAVE tests (save_test.rs) + snap list/resolve tests

Epic: &34, &40
Issues: #299 (VerificationClient wiring into determination flow)
Branch: feature/snap-verification-ievs

Context

7 USC §2025(e) and 7 CFR 273.2(f)(9) require IEVS income verification at application and at renewal. Georgia must query: State Wage Records (SWR) from Georgia DOL, Unemployment Insurance (UI) from Georgia DOL, SSA SDX (State Data Exchange — SSI payment data), and SSA BENDEX (Benefit Exchange — Social Security benefit data).

Critical ADR-004 constraint: IEVS data is legally restricted to SNAP use only. Match results, raw responses, and discrepancy data must live exclusively in the canopy-snap isolated database (postgres-snap:5433). No IEVS data may appear in canopy.events, canopy-verification’s shared database, or any other service.

The architecture: - canopy-verification provides adapter trait interface (the hub) - canopy-snap calls canopy-verification’s internal API to initiate matches - Raw responses flow back to canopy-snap via HTTP response body - canopy-snap stores everything in its isolated database immediately - The match result never leaves the canopy-snap security boundary

For SNAP UAT: implement NoopIevsAdapter that returns deterministic test data based on SSN suffix. This allows full UAT without live federal hub access (which requires executed CMAs and security accreditation review).

Computer Matching Agreement requirement

Access to SSA SOLQ/BINDEX requires a Computer Matching Agreement (CMA) under the Computer Matching and Privacy Protection Act of 1988. Georgia must execute a SNAP-specific CMA with SSA (separate from the TANF CMA). CMA process typically takes 6-12 months. For UAT: NoopAdapter provides deterministic responses without CMA. For go-live: CMA must be executed and canopy-snap must pass SSA’s system security review.

Scope

In scope:

  • ievs_match_results table in canopy-snap (stores raw match data)

  • ievs_discrepancies table in canopy-snap (stores variance between self-reported and verified)

  • IevsAdapter trait with four methods (SWR, UI, SSA SDX, SSA BENDEX)

  • NoopIevsAdapter with deterministic responses (last 2 digits of SSN determine income tier for testing)

  • GeorgiaIevsAdapter stubs for SWR and UI (Georgia DOL API) — real endpoints TBD

  • SsaIevsAdapter stubs for SDX and BENDEX — real endpoints require CMA

  • Verification trigger: runs after application submission, results added to determination

  • Discrepancy detection: flag when verified income > self-reported income by threshold ($100/month)

  • Worker discrepancy resolution API

  • When discrepancy unresolved: verification_items_required populated on determination; status = PendingVerification

Out of scope:

  • Live Georgia DOL SWR/UI integration (requires API credentials and data use agreement)

  • Live SSA SDX/BENDEX integration (requires executed CMA)

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

  • SAVE (immigration verification; covered in a separate plan)

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

Design

Database schema (canopy-snap isolated database only)

CREATE TABLE ievs_match_results (
    id UUID PRIMARY KEY,
    application_id UUID NOT NULL,
    household_id UUID NOT NULL,
    person_id UUID NOT NULL,
    match_source TEXT NOT NULL,
    -- 'georgia_dol_swr', 'georgia_dol_ui', 'ssa_sdx', 'ssa_bendex'
    match_requested_at TIMESTAMPTZ NOT NULL DEFAULT now(),
    match_completed_at TIMESTAMPTZ,
    request_correlation_id TEXT,  -- adapter-provided request ID for audit
    -- Response data (never leave this database)
    verified_monthly_income NUMERIC(10,2),
    verified_income_type TEXT,   -- IncomeType enum value
    verified_frequency TEXT,     -- 'weekly', 'biweekly', 'monthly', 'quarterly', 'annual'
    match_status TEXT NOT NULL DEFAULT 'pending',
    -- 'pending', 'matched', 'no_match', 'error', 'timeout'
    error_detail TEXT,
    created_at TIMESTAMPTZ NOT NULL DEFAULT now()
    -- NOTE: raw_response is intentionally not stored in this table.
    -- The response summary fields above are sufficient for compliance and auditing.
    -- If raw response storage is required by IRS, add encrypted_raw_response BYTEA field.
);

CREATE INDEX ievs_results_application ON ievs_match_results (application_id);
CREATE INDEX ievs_results_person ON ievs_match_results (person_id);

CREATE TABLE ievs_discrepancies (
    id UUID PRIMARY KEY,
    application_id UUID NOT NULL,
    person_id UUID NOT NULL,
    match_result_id UUID NOT NULL REFERENCES ievs_match_results(id),
    income_type TEXT NOT NULL,
    self_reported_monthly_income NUMERIC(10,2),
    verified_monthly_income NUMERIC(10,2),
    variance_monthly NUMERIC(10,2) GENERATED ALWAYS AS
        (verified_monthly_income - COALESCE(self_reported_monthly_income, 0)) STORED,
    resolution_status TEXT NOT NULL DEFAULT 'pending',
    -- 'pending', 'confirmed_additional_income', 'corrected_ievs_error',
    -- 'resolved_household_explanation', 'aged_out'
    resolution_notes TEXT,
    resolved_by UUID,  -- worker person_id
    resolved_at TIMESTAMPTZ,
    created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

IevsAdapter trait

In services/canopy-verification/src/ievs.rs (or a new crates/canopy-ievs/ crate):

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

use chrono::NaiveDate;
use rust_decimal::Decimal;
use uuid::Uuid;
use anyhow::Result;

pub struct IevsMatchRequest {
    pub ssn: String,  // never logged, never published to event bus
    pub first_name: String,
    pub last_name: String,
    pub date_of_birth: NaiveDate,
    pub quarters: u8,  // how many prior quarters to query for SWR
}

pub struct WageRecord {
    pub employer_name: Option<String>,
    pub quarter: NaiveDate,
    pub wages: Decimal,
}

pub struct UiRecord {
    pub claim_status: UiClaimStatus,
    pub weekly_benefit_amount: Option<Decimal>,
    pub benefit_year_start: Option<NaiveDate>,
}

pub enum UiClaimStatus {
    ActiveClaim,
    NoClaim,
    ClaimExpired,
}

pub struct SsaSdxRecord {
    pub ssi_eligible: bool,
    pub monthly_ssi_amount: Option<Decimal>,
}

pub struct SsaBendexRecord {
    pub receives_social_security: bool,
    pub monthly_benefit_amount: Option<Decimal>,
    pub benefit_type: Option<String>,  // OASDI, disability, survivor
}

pub trait IevsAdapter: Send + Sync {
    /// Query state wage records (Georgia DOL quarterly wage data)
    async fn query_state_wage_records(
        &self,
        req: &IevsMatchRequest,
    ) -> Result<Vec<WageRecord>>;

    /// Query unemployment insurance claim status (Georgia DOL)
    async fn query_unemployment_insurance(
        &self,
        req: &IevsMatchRequest,
    ) -> Result<Option<UiRecord>>;

    /// Query SSI payment data (SSA State Data Exchange)
    async fn query_ssa_sdx(
        &self,
        req: &IevsMatchRequest,
    ) -> Result<Option<SsaSdxRecord>>;

    /// Query Social Security benefit data (SSA Benefit Exchange)
    async fn query_ssa_bendex(
        &self,
        req: &IevsMatchRequest,
    ) -> Result<Option<SsaBendexRecord>>;
}

NoopIevsAdapter

The Noop adapter produces deterministic test data based on the last 2 digits of the SSN: - 00-09: No income (no match) - 10-29: Wages only ($1,200/month) - 30-49: Wages higher than self-reported ($1,800/month — creates discrepancy for workers with self-reported $1,200) - 50-59: UI claim active ($400/week) - 60-79: SSI recipient ($943/month — 2026 SSI federal benefit rate) - 80-89: Social Security $1,100/month - 90-99: Complex: wages + UI (creates discrepancy scenario)

This enables reproducible UAT scenarios with known SSN suffixes without live data access.

canopy-verification API

canopy-verification exposes an internal endpoint that canopy-snap calls: POST /internal/v1/ievs/match — accepts IevsMatchRequest, returns IevsMatchResponse

This endpoint is internal-only (not exposed through the public API gateway). canopy-verification delegates to the configured IevsAdapter (NoopAdapter for UAT).

Verification flow in canopy-snap

After application submission and before determination finalization:

  1. For each household member, fetch SSN from canopy-persons (SSN is stored encrypted in canopy-persons — canopy-snap receives it only for the IEVS query, does not store it)

  2. Call canopy-verification’s IEVS endpoint for all four sources

  3. Store match results in ievs_match_results

  4. Compare verified income to self-reported income from application context

  5. If variance > $100/month per income source: create ievs_discrepancy record

  6. If any discrepancies pending: populate verification_items_required on determination with VerificationRequirement::GrossIncome (or relevant type)

  7. Set determination status to PendingVerification if unresolved discrepancies exist

  8. If expedited service applies: approve pending verification (verify within 45 days per 7 CFR 273.2(f)(9)(iv))

SNAP expedited with IEVS discrepancy: Benefits may be issued despite discrepancy. Set determination status to Approved, but include verification_items_required on determination. The discrepancy must be resolved within 45 days or the next certification action.

Steps

Step 1: Database migrations

Files: services/canopy-snap/migrations/20260327100000_ievs_tables.sql (new), services/canopy-snap/src/main.rs (update)

Create ievs_match_results and ievs_discrepancies tables using the SQL from the Design section above. These tables live in canopy-snap’s isolated database (postgres-snap:5433) per ADR-004 — IEVS data must never leave this boundary.

Include the indexes defined in the Design section:

CREATE INDEX ievs_results_application ON ievs_match_results (application_id);
CREATE INDEX ievs_results_person ON ievs_match_results (person_id);
CREATE INDEX ievs_discrepancies_application ON ievs_discrepancies (application_id);
CREATE INDEX ievs_discrepancies_person ON ievs_discrepancies (person_id);
CREATE INDEX ievs_discrepancies_status ON ievs_discrepancies (resolution_status)
    WHERE resolution_status = 'pending';

Ensure the migration runner is enabled in services/canopy-snap/src/main.rs.

Error handling: migration failure must halt service startup with a clear log message. Do not proceed with stale schema — IEVS compliance depends on these tables existing.

Step 2: IevsAdapter trait and NoopAdapter

Files: services/canopy-verification/src/ievs.rs (new), services/canopy-verification/src/noop.rs (new), services/canopy-verification/src/lib.rs (update)

Create services/canopy-verification/src/ievs.rs with the full IevsAdapter trait, request/response structs (IevsMatchRequest, WageRecord, UiRecord, UiClaimStatus, SsaSdxRecord, SsaBendexRecord) as defined in the Design section. All structs must derive Debug, Clone, Serialize, Deserialize.

Create services/canopy-verification/src/noop.rs implementing NoopIevsAdapter:

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

use crate::ievs::*;
use anyhow::Result;
use rust_decimal::Decimal;

pub struct NoopIevsAdapter;

impl IevsAdapter for NoopIevsAdapter {
    async fn query_state_wage_records(&self, req: &IevsMatchRequest) -> Result<Vec<WageRecord>> {
        let suffix = ssn_suffix(req);
        match suffix {
            0..=9 => Ok(vec![]),
            10..=29 => Ok(vec![wage_record(Decimal::new(1200_00, 2))]),
            30..=49 => Ok(vec![wage_record(Decimal::new(1800_00, 2))]),
            90..=99 => Ok(vec![wage_record(Decimal::new(1500_00, 2))]),
            _ => Ok(vec![]),
        }
    }
    // ... remaining methods follow the same SSN-suffix-based pattern from Design
}

fn ssn_suffix(req: &IevsMatchRequest) -> u8 {
    req.ssn[req.ssn.len()-2..].parse::<u8>().unwrap_or(0)
}

Update services/canopy-verification/src/lib.rs to export pub mod ievs; pub mod noop;.

Wire adapter selection via CANOPY_IEVS_ADAPTER environment variable in the canopy-verification service startup: - noop (default): NoopIevsAdapter - georgia_dol: stub GeorgiaIevsAdapter (methods return anyhow::bail!("Georgia DOL integration not yet configured")) - ssa: stub SsaIevsAdapter (methods return anyhow::bail!("SSA CMA not yet executed"))

Step 3: canopy-verification internal endpoint

Files: services/canopy-verification/src/api/ievs.rs (new), services/canopy-verification/src/api/mod.rs (update), services/canopy-verification/src/main.rs (update)

Create services/canopy-verification/src/api/ievs.rs with the internal IEVS match endpoint:

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

use axum::{Router, routing::post, extract::State, Json};
use canopy_verification::ievs::{IevsAdapter, IevsMatchRequest};

#[derive(Debug, Deserialize)]
pub struct IevsMatchHttpRequest {
    pub application_id: Uuid,
    pub person_id: Uuid,
    pub match_request: IevsMatchRequest,
    pub sources: Vec<String>,  // ["georgia_dol_swr", "georgia_dol_ui", "ssa_sdx", "ssa_bendex"]
}

#[derive(Debug, Serialize)]
pub struct IevsMatchHttpResponse {
    pub application_id: Uuid,
    pub person_id: Uuid,
    pub wage_records: Vec<WageRecord>,
    pub ui_record: Option<UiRecord>,
    pub sdx_record: Option<SsaSdxRecord>,
    pub bendex_record: Option<SsaBendexRecord>,
    pub match_status: String,
}

pub fn internal_routes<A: IevsAdapter + 'static>(adapter: A) -> Router {
    Router::new()
        .route("/internal/v1/ievs/match", post(handle_ievs_match::<A>))
        .with_state(Arc::new(adapter))
}

Authentication: require X-Service-Api-Key header (not JWT) validated against CANOPY_INTERNAL_API_KEY env var. Reject requests without a valid key with 401.

Logging: for every match attempt, log at INFO level with structured fields: application_id, person_id, sources_requested, timestamp, result_status. Never log SSN, name, or date of birth — these are in the IevsMatchRequest but must not appear in logs.

Update services/canopy-verification/src/api/mod.rs to merge internal routes. Update services/canopy-verification/src/main.rs to instantiate the configured IevsAdapter and pass it to the router.

Step 4: canopy-snap verification flow

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

Implement IevsVerifier that: 1. Calls canopy-verification with the four queries 2. Stores results 3. Detects discrepancies 4. Updates determination verification_items_required

Step 5: Discrepancy resolution endpoints

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

Create services/canopy-snap/src/api/verification.rs with route handlers:

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

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

pub fn routes() -> Router<AppState> {
    Router::new()
        .route("/v1/verification/discrepancies", get(list_discrepancies))
        .route("/v1/verification/discrepancies/:id/resolve", put(resolve_discrepancy))
        .route("/v1/verification/ievs-matches", get(list_ievs_matches))
}

#[derive(Debug, Deserialize)]
pub struct DiscrepancyQuery {
    pub application_id: Uuid,
}

#[derive(Debug, Deserialize)]
pub struct ResolveDiscrepancyRequest {
    pub resolution_status: String,  // "confirmed_additional_income", "corrected_ievs_error", "resolved_household_explanation"
    pub resolution_notes: String,
}

Create services/canopy-snap/src/store/verification.rs with sqlx query functions:

  • list_discrepancies_by_application(pool, application_id) → Vec<IevsDiscrepancy>

  • get_discrepancy(pool, id) → Option<IevsDiscrepancy>

  • resolve_discrepancy(pool, id, status, notes, resolved_by) → IevsDiscrepancy

  • list_match_results_by_application(pool, application_id) → Vec<IevsMatchResult>

All endpoints require canopy-worker role. The resolve_discrepancy handler must extract the worker’s person_id from JWT claims and record it as resolved_by.

Error handling: - 404 if discrepancy or match result not found - 422 if resolution_status is not one of the allowed enum values - When a discrepancy is resolved with confirmed_additional_income, the handler must trigger re-evaluation of the determination by calling the evaluation flow with the corrected income. Per ADR-001, this is an internal call within canopy-snap (no cross-database query).

Update services/canopy-snap/src/api/mod.rs to merge verification routes. Update services/canopy-snap/src/store/mod.rs to export pub mod verification;.

Step 6: Integration tests

Files: services/canopy-snap/tests/ievs_verification_test.rs (new), services/canopy-verification/tests/ievs_adapter_test.rs (new)

Create services/canopy-verification/tests/ievs_adapter_test.rs to test the NoopIevsAdapter in isolation:

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

use canopy_verification::ievs::*;
use canopy_verification::noop::NoopIevsAdapter;

#[tokio::test]
async fn test_noop_ssn_suffix_10_wages_only() {
    // SSN ending "10" -> wages $1,200/month, no UI, no SSI, no BENDEX
}

#[tokio::test]
async fn test_noop_ssn_suffix_30_high_wages() {
    // SSN ending "30" -> wages $1,800/month (creates discrepancy for $1,200 self-reported)
}

#[tokio::test]
async fn test_noop_ssn_suffix_00_no_match() {
    // SSN ending "00" -> no income records returned
}

Create services/canopy-snap/tests/ievs_verification_test.rs using testcontainers-rs with PostgreSQL:

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

use canopy_test_lib::setup_test_db;

#[tokio::test]
async fn test_clean_match_no_discrepancy() {
    // Setup: application with self-reported income $1,200/month
    // SSN suffix "10" -> NoopAdapter returns $1,200/month wages
    // Assert: no discrepancy created, determination status != PendingVerification
}

#[tokio::test]
async fn test_discrepancy_detected_pending_verification() {
    // Setup: application with self-reported income $1,200/month
    // SSN suffix "30" -> NoopAdapter returns $1,800/month wages
    // Assert: ievs_discrepancy record created with variance = $600
    // Assert: determination status = PendingVerification
    // Assert: verification_items_required includes GrossIncome
}

#[tokio::test]
async fn test_worker_resolves_discrepancy() {
    // Setup: existing discrepancy, status = pending
    // PUT /v1/verification/discrepancies/{id}/resolve with confirmed_additional_income
    // Assert: discrepancy resolved_by set, resolution_status updated
    // Assert: determination re-evaluated with corrected income
}

#[tokio::test]
async fn test_expedited_approved_despite_discrepancy() {
    // Setup: expedited household, SSN suffix "30" -> discrepancy
    // Assert: determination status = Approved (not PendingVerification)
    // Assert: verification_items_required still populated for 45-day follow-up
}

#[tokio::test]
async fn test_ievs_data_not_in_events() {
    // Verify that published events contain only IDs (application_id, person_id)
    // and never SSN, income amounts, or raw IEVS response data (ADR-004)
}

Each test must run migrations via sqlx::migrate!() on the test container. Use canopy_test_lib::mock_event_publisher to capture published events for assertion.

Files Touched

File Change

services/canopy-snap/migrations/YYYYMMDD_ievs_tables.sql

New: ievs_match_results, ievs_discrepancies

services/canopy-verification/src/ievs.rs (or new crate)

New: IevsAdapter trait, request/response types

services/canopy-verification/src/noop.rs

New: NoopIevsAdapter with deterministic test data

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

Add internal IEVS match endpoint

services/canopy-snap/src/verification.rs

New: IevsVerifier orchestration

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

Add discrepancy review endpoints

services/canopy-snap/src/evaluation.rs

Integrate IEVS verification into evaluation flow

Verification

  1. cargo nextest run -p canopy-snap — IEVS integration tests pass

  2. NoopAdapter SSN ending 30 → discrepancy detected, PendingVerification determination

  3. NoopAdapter SSN ending 10 → clean match, Approved determination

  4. Expedited household with discrepancy → Approved + verification_items_required set

  5. Worker resolves discrepancy → determination re-evaluated with corrected amount

  6. IEVS data confirmed absent from canopy.events (audit canopy-security log)

Documentation Updates

  • .claude/docs/services.md — add IEVS tables, canopy-verification internal endpoint

  • .claude/docs/security.md — document IEVS data isolation compliance pattern

  • CHANGELOG.adoc — entry under == Unreleased

Edit this page · default