Plan: SNAP Eligibility — First Program Service

On this page

Status

Step Description Status

1

Database schema (applications, determinations, IEVS verification cache)

Done (2026-03-28)

2

Eligibility evaluation flow (call canopy-rules, produce determination)

Done (2026-03-28)

3

Determination signing (JWS with service key pair)

Done (2026-03-28)

4

API endpoints (submit application context, get determination)

Done (2026-03-28)

5

IEVS data isolation verification

Done (2026-03-28)

6

Integration tests proving full determination flow

Done (2026-03-28)

MR: !12
Epic: &31, &39
Branch: feature/snap-eligibility

Context

SNAP is the best first program service for three reasons:

  1. Simplest eligibility logic: gross income test (130% FPL), net income test (100% FPL), asset test, categorical eligibility. No FTI complexity (that’s TANF/Medicaid).

  2. Proves the full architecture end-to-end: canopy-eligibility calls canopy-snap, canopy-snap calls canopy-rules with snap-eligibility ruleset, produces a signed determination, returns it.

  3. IEVS isolation validates ADR-004: SNAP holds IEVS data under 7 USC §2025(e). This data must be physically unavailable to non-SNAP services. canopy-snap’s isolated database (postgres-snap:5433) ensures this by architecture.

This plan depends on:

Scope

In scope:

  • SNAP application context reception (household composition, income, assets, expenses from canopy-eligibility)

  • Rules engine call: snap-eligibility and snap-benefit-calculation rulesets

  • Signed determination production per ADR-002

  • IEVS verification data storage (isolated to canopy-snap per ADR-004)

  • Determination history (append-only per coding conventions)

  • SNAP-specific API endpoints

Out of scope:

  • IEVS federal hub integration (canopy-verification handles the actual IEVS query; canopy-snap stores the result)

  • Renewal/redetermination flow — separate plan

  • Benefit issuance — canopy-enrollment responsibility

  • SNAP-specific reporting (FNS-7176) — canopy-reporting plan

Design

Determination Flow

canopy-eligibility                 canopy-snap                    canopy-rules
       |                                |                              |
       |-- POST /v1/determine --------->|                              |
       |   { household_id,              |                              |
       |     application_id,            |                              |
       |     persons[], income[],       |                              |
       |     assets[], expenses[] }     |                              |
       |                                |-- POST /v1/evaluate -------->|
       |                                |   { ruleset: "snap-elig",    |
       |                                |     input: { gross, net,     |
       |                                |       hh_size, assets } }    |
       |                                |<-- { eligible: true,         |
       |                                |     basis: "..." }           |
       |                                |                              |
       |                                |-- POST /v1/evaluate -------->|
       |                                |   { ruleset: "snap-benefit", |
       |                                |     input: { net_income,     |
       |                                |       hh_size } }            |
       |                                |<-- { amount: 847.00,         |
       |                                |     unit: "monthly_usd" }    |
       |                                |                              |
       |                                |-- sign(determination) -------|
       |                                |                              |
       |<-- Determination { program: snap, status: approved,           |
       |      benefit_amount: 847.00, signature: "<JWS>" }             |

Database Schema

canopy-snap runs on its own PostgreSQL instance (postgres-snap:5433) per ADR-001.

CREATE TABLE snap_applications (
    id UUID PRIMARY KEY,
    household_id UUID NOT NULL,
    application_id UUID NOT NULL,
    application_context JSONB NOT NULL,  -- the input from canopy-eligibility
    status TEXT NOT NULL DEFAULT 'received',
    created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE TABLE snap_determinations (
    id UUID PRIMARY KEY,
    application_id UUID NOT NULL REFERENCES snap_applications(id),
    household_id UUID NOT NULL,
    status TEXT NOT NULL,  -- approved, denied, pending_verification
    benefit_amount NUMERIC(10,2),
    benefit_unit TEXT,
    effective_date DATE,
    expiration_date DATE,
    renewal_date DATE,
    basis TEXT,
    signature TEXT NOT NULL,  -- detached JWS
    program_service_version TEXT NOT NULL,
    determined_at TIMESTAMPTZ NOT NULL,
    created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

-- IEVS data isolated to canopy-snap per ADR-004.
-- This data is authorized for SNAP only under 7 USC §2025(e).
CREATE TABLE ievs_verification_data (
    id UUID PRIMARY KEY,
    person_id UUID NOT NULL,
    data_source TEXT NOT NULL,  -- state_wage, unemployment_insurance, ssa_income
    match_result JSONB NOT NULL,
    verified_at TIMESTAMPTZ NOT NULL,
    created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

API Endpoints

Method Path Description

POST

/v1/determine

Receive application context, evaluate eligibility, return signed determination

GET

/v1/determinations/{id}

Retrieve a determination by ID

GET

/v1/determinations

List determinations (paginated)

The /v1/determine endpoint is the core of the black-box contract. It receives the full application context, evaluates internally, and returns only the determination — never raw IEVS data.

CLI Commands (ADR-007)

Per ADR-007, the following canopy CLI commands must be added to tools/canopy-cli/ when this plan ships:

  • canopy snap evaluate — submit application context for SNAP eligibility determination

  • canopy snap determination get <id> — retrieve a determination by ID

  • canopy snap determination list — list determinations (paginated)

Steps

Step 1: Database Migration

Files: services/canopy-snap/migrations/20260326000000_create_snap_tables.sql

Create snap_applications, snap_determinations, and ievs_verification_data tables using the SQL from the Design section above, plus the following indexes for query performance:

-- Tables (see Design > Database Schema for full CREATE TABLE statements)

CREATE INDEX idx_snap_applications_household ON snap_applications(household_id);
CREATE INDEX idx_snap_applications_application ON snap_applications(application_id);
CREATE INDEX idx_snap_applications_status ON snap_applications(status);
CREATE INDEX idx_snap_determinations_application ON snap_determinations(application_id);
CREATE INDEX idx_snap_determinations_household ON snap_determinations(household_id);
CREATE INDEX idx_snap_determinations_status ON snap_determinations(status);
CREATE INDEX idx_snap_determinations_determined_at ON snap_determinations(determined_at);
CREATE INDEX idx_ievs_verification_data_person ON ievs_verification_data(person_id);
CREATE INDEX idx_ievs_verification_data_source ON ievs_verification_data(data_source);

Run with sqlx migrate run on the postgres-snap instance (port 5433). Uncomment the migration runner in services/canopy-snap/src/main.rs.

Error handling: if the migration fails (e.g., table already exists), sqlx::migrate!() returns sqlx::migrate::MigrateError. The service should fail to start with a clear log message rather than silently proceeding with a stale schema.

Step 2: Store Layer

Files: services/canopy-snap/src/store/mod.rs, services/canopy-snap/src/store/models.rs

Model structs:

// services/canopy-snap/src/store/models.rs

use chrono::{DateTime, NaiveDate, Utc};
use rust_decimal::Decimal;
use serde::{Deserialize, Serialize};
use uuid::Uuid;

#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
pub struct SnapApplication {
    pub id: Uuid,
    pub household_id: Uuid,
    pub application_id: Uuid,
    pub application_context: serde_json::Value,
    pub status: String,
    pub created_at: DateTime<Utc>,
}

#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
pub struct SnapDetermination {
    pub id: Uuid,
    pub application_id: Uuid,
    pub household_id: Uuid,
    pub status: String,
    pub benefit_amount: Option<Decimal>,
    pub benefit_unit: Option<String>,
    pub effective_date: Option<NaiveDate>,
    pub expiration_date: Option<NaiveDate>,
    pub renewal_date: Option<NaiveDate>,
    pub basis: Option<String>,
    pub signature: String,
    pub program_service_version: String,
    pub determined_at: DateTime<Utc>,
    pub created_at: DateTime<Utc>,
}

#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
pub struct IevsVerificationRecord {
    pub id: Uuid,
    pub person_id: Uuid,
    pub data_source: String,
    pub match_result: serde_json::Value,
    pub verified_at: DateTime<Utc>,
    pub created_at: DateTime<Utc>,
}

Query functions:

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

pub mod models;

use models::{IevsVerificationRecord, SnapApplication, SnapDetermination};
use sqlx::PgPool;
use uuid::Uuid;

/// Pagination request used by list endpoints.
pub struct PageRequest {
    pub offset: i64,
    pub limit: i64,
}

pub async fn create_snap_application(
    pool: &PgPool,
    id: Uuid,
    household_id: Uuid,
    application_id: Uuid,
    application_context: serde_json::Value,
) -> Result<SnapApplication, sqlx::Error> {
    sqlx::query_as::<_, SnapApplication>(
        r#"INSERT INTO snap_applications (id, household_id, application_id, application_context, status)
           VALUES ($1, $2, $3, $4, 'received')
           RETURNING *"#,
    )
    .bind(id)
    .bind(household_id)
    .bind(application_id)
    .bind(application_context)
    .fetch_one(pool)
    .await
}

pub async fn create_snap_determination(
    pool: &PgPool,
    determination: &SnapDetermination,
) -> Result<SnapDetermination, sqlx::Error> {
    sqlx::query_as::<_, SnapDetermination>(
        r#"INSERT INTO snap_determinations
           (id, application_id, household_id, status, benefit_amount, benefit_unit,
            effective_date, expiration_date, renewal_date, basis, signature,
            program_service_version, determined_at)
           VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)
           RETURNING *"#,
    )
    .bind(determination.id)
    .bind(determination.application_id)
    .bind(determination.household_id)
    .bind(&determination.status)
    .bind(determination.benefit_amount)
    .bind(&determination.benefit_unit)
    .bind(determination.effective_date)
    .bind(determination.expiration_date)
    .bind(determination.renewal_date)
    .bind(&determination.basis)
    .bind(&determination.signature)
    .bind(&determination.program_service_version)
    .bind(determination.determined_at)
    .fetch_one(pool)
    .await
}

pub async fn get_determination(
    pool: &PgPool,
    id: Uuid,
) -> Result<Option<SnapDetermination>, sqlx::Error> {
    sqlx::query_as::<_, SnapDetermination>(
        "SELECT * FROM snap_determinations WHERE id = $1",
    )
    .bind(id)
    .fetch_optional(pool)
    .await
}

pub async fn list_determinations(
    pool: &PgPool,
    page: &PageRequest,
) -> Result<Vec<SnapDetermination>, sqlx::Error> {
    sqlx::query_as::<_, SnapDetermination>(
        "SELECT * FROM snap_determinations ORDER BY created_at DESC LIMIT $1 OFFSET $2",
    )
    .bind(page.limit)
    .bind(page.offset)
    .fetch_all(pool)
    .await
}

pub async fn create_ievs_record(
    pool: &PgPool,
    record: &IevsVerificationRecord,
) -> Result<IevsVerificationRecord, sqlx::Error> {
    sqlx::query_as::<_, IevsVerificationRecord>(
        r#"INSERT INTO ievs_verification_data (id, person_id, data_source, match_result, verified_at)
           VALUES ($1, $2, $3, $4, $5)
           RETURNING *"#,
    )
    .bind(record.id)
    .bind(record.person_id)
    .bind(&record.data_source)
    .bind(&record.match_result)
    .bind(record.verified_at)
    .fetch_one(pool)
    .await
}

Error handling: all store functions return sqlx::Error directly. Callers (the API layer) map these to ApiError::Internal with a logged message but no database details in the HTTP response. Unique constraint violations on id columns surface as sqlx::Error::Database with code 23505; callers should map these to ApiError::Conflict.

Step 3: Rules Engine Client

Files: services/canopy-snap/src/rules_client.rs

Follows the RulesEngineClient pattern from d:/code/craig/services/craig-cases/src/main.rs (lines 30-50).

// services/canopy-snap/src/rules_client.rs

use serde::{Deserialize, Serialize};
use uuid::Uuid;

use crate::errors::ApiError;

#[derive(Clone)]
pub struct RulesClient {
    client: reqwest::Client,
    base_url: String,
}

#[derive(Debug, Serialize)]
pub struct EvaluateRequest {
    pub rule_set_name: String,
    pub context_type: String,
    pub context_id: Uuid,
    pub input: serde_json::Value,
}

#[derive(Debug, Deserialize)]
pub struct EvaluateResponse {
    pub rule_set_name: String,
    pub output: serde_json::Value,
    pub evaluated_at: String,
}

impl RulesClient {
    pub fn new(base_url: &str) -> Self {
        Self {
            client: reqwest::Client::builder()
                .timeout(std::time::Duration::from_secs(10))
                .build()
                .expect("failed to build reqwest client"),
            base_url: base_url.trim_end_matches('/').to_string(),
        }
    }

    pub async fn evaluate(
        &self,
        rule_set_name: &str,
        context_type: &str,
        context_id: Uuid,
        input: serde_json::Value,
    ) -> Result<serde_json::Value, ApiError> {
        let url = format!("{}/v1/evaluate", self.base_url);
        let body = EvaluateRequest {
            rule_set_name: rule_set_name.to_string(),
            context_type: context_type.to_string(),
            context_id,
            input,
        };

        let response = self
            .client
            .post(&url)
            .json(&body)
            .send()
            .await
            .map_err(|e| ApiError::RulesEngine(format!("request failed: {e}")))?;

        if !response.status().is_success() {
            let status = response.status();
            let body = response.text().await.unwrap_or_default();
            return Err(ApiError::RulesEngine(format!(
                "rules engine returned {status}: {body}"
            )));
        }

        let eval_response: EvaluateResponse = response
            .json()
            .await
            .map_err(|e| ApiError::RulesEngine(format!("failed to parse response: {e}")))?;

        Ok(eval_response.output)
    }
}

JSON request sent to canopy-rules for eligibility evaluation:

{
  "rule_set_name": "us-oh-snap-eligibility",
  "context_type": "application",
  "context_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
  "input": {
    "gross_monthly_income": 2400.00,
    "household_size": 4,
    "countable_assets": 1200.00,
    "has_elderly_disabled_member": false,
    "is_categorically_eligible": false
  }
}

JSON response received from canopy-rules:

{
  "rule_set_name": "us-oh-snap-eligibility",
  "output": {
    "eligible": true,
    "gross_income_test_passed": true,
    "net_income_test_passed": true,
    "asset_test_passed": true,
    "basis": "Household passes gross income test (130% FPL), net income test (100% FPL), and asset test."
  },
  "evaluated_at": "2026-03-26T14:30:00Z"
}

JSON request for benefit calculation (only sent when eligible):

{
  "rule_set_name": "us-oh-snap-benefit-calculation",
  "context_type": "application",
  "context_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
  "input": {
    "net_monthly_income": 1800.00,
    "household_size": 4
  }
}

JSON response for benefit calculation:

{
  "rule_set_name": "us-oh-snap-benefit-calculation",
  "output": {
    "benefit_amount": 847.00,
    "benefit_unit": "monthly_usd",
    "max_allotment": 973.00,
    "expected_contribution": 126.00
  },
  "evaluated_at": "2026-03-26T14:30:01Z"
}

Error handling:

  • reqwest::Error (connection refused, timeout) maps to ApiError::RulesEngine with a message suitable for logging. The determination returns status: "pending_verification" rather than failing outright.

  • Non-2xx responses from canopy-rules (e.g., 404 for unknown ruleset, 422 for invalid input) are surfaced as ApiError::RulesEngine with the status code and body logged.

  • JSON deserialization failures indicate a contract mismatch between canopy-snap and canopy-rules; these are logged at error level with the raw response body.

Step 4: Determination Logic

Files: services/canopy-snap/src/determine.rs

The core determine() function orchestrates the full eligibility flow: persist application context, evaluate rules, build determination, sign, persist, return.

// services/canopy-snap/src/determine.rs

use chrono::Utc;
use rust_decimal::Decimal;
use sqlx::PgPool;
use uuid::Uuid;

use crate::errors::ApiError;
use crate::rules_client::RulesClient;
use crate::store;
use crate::store::models::{SnapApplication, SnapDetermination};

/// The application context received from canopy-eligibility.
#[derive(Debug, Clone, serde::Deserialize)]
pub struct ApplicationContext {
    pub application_id: Uuid,
    pub household_id: Uuid,
    pub applicant_person_id: Uuid,
    pub household_size: u32,
    pub income: Vec<IncomeRecord>,
    pub assets: Vec<AssetRecord>,
    pub expenses: Vec<ExpenseRecord>,
    pub has_elderly_disabled_member: bool,
    pub is_categorically_eligible: bool,
    pub jurisdiction: String,
}

#[derive(Debug, Clone, serde::Deserialize)]
pub struct IncomeRecord {
    pub source: String,
    pub amount: Decimal,
    pub frequency: String,  // monthly, biweekly, weekly, annual
}

impl IncomeRecord {
    pub fn monthly_amount(&self) -> Decimal {
        match self.frequency.as_str() {
            "monthly" => self.amount,
            "biweekly" => self.amount * Decimal::from(26) / Decimal::from(12),
            "weekly" => self.amount * Decimal::from(52) / Decimal::from(12),
            "annual" => self.amount / Decimal::from(12),
            _ => self.amount,  // default to treating as monthly
        }
    }
}

#[derive(Debug, Clone, serde::Deserialize)]
pub struct AssetRecord {
    pub asset_type: String,
    pub value: Decimal,
}

#[derive(Debug, Clone, serde::Deserialize)]
pub struct ExpenseRecord {
    pub expense_type: String,
    pub amount: Decimal,
    pub frequency: String,
}

/// Trait for signing determinations. Concrete implementation provided by canopy-signing.
pub trait DeterminationSigner: Send + Sync {
    fn sign(&self, determination: &SnapDetermination) -> Result<String, anyhow::Error>;
}

pub async fn determine(
    db: &PgPool,
    rules: &RulesClient,
    signer: &dyn DeterminationSigner,
    context: ApplicationContext,
) -> Result<SnapDetermination, ApiError> {
    // 1. Persist the application context (append-only)
    let app_id = Uuid::new_v4();
    let app = store::create_snap_application(
        db,
        app_id,
        context.household_id,
        context.application_id,
        serde_json::to_value(&context)
            .map_err(|e| ApiError::Internal(format!("failed to serialize context: {e}")))?,
    )
    .await
    .map_err(|e| ApiError::Internal(format!("failed to persist application: {e}")))?;

    // 2. Calculate gross monthly income
    let gross_income: Decimal = context.income.iter()
        .map(|i| i.monthly_amount())
        .sum();

    // 3. Calculate total countable assets
    let total_assets: Decimal = context.assets.iter()
        .map(|a| a.value)
        .sum();

    // 4. Call rules engine for eligibility evaluation
    let elig_output = rules.evaluate(
        &format!("{}-snap-eligibility", context.jurisdiction),
        "application",
        app.id,
        serde_json::json!({
            "gross_monthly_income": gross_income,
            "household_size": context.household_size,
            "countable_assets": total_assets,
            "has_elderly_disabled_member": context.has_elderly_disabled_member,
            "is_categorically_eligible": context.is_categorically_eligible,
        }),
    ).await?;

    let eligible = elig_output.get("eligible")
        .and_then(|v| v.as_bool())
        .unwrap_or(false);
    let basis = elig_output.get("basis")
        .and_then(|v| v.as_str())
        .map(String::from);

    // 5. If eligible, call benefit calculation
    let (benefit_amount, benefit_unit) = if eligible {
        let net_income = gross_income; // TODO: subtract allowable deductions
        let benefit_output = rules.evaluate(
            &format!("{}-snap-benefit-calculation", context.jurisdiction),
            "application",
            app.id,
            serde_json::json!({
                "net_monthly_income": net_income,
                "household_size": context.household_size,
            }),
        ).await?;

        let amount = benefit_output.get("benefit_amount")
            .and_then(|v| v.as_f64())
            .map(|v| Decimal::from_f64_retain(v).unwrap_or_default());
        let unit = benefit_output.get("benefit_unit")
            .and_then(|v| v.as_str())
            .map(String::from);

        (amount, unit)
    } else {
        (None, None)
    };

    // 6. Build the determination struct
    let now = Utc::now();
    let determination_id = Uuid::new_v4();
    let status = if eligible { "approved" } else { "denied" };

    let effective_date = if eligible {
        Some(now.date_naive())
    } else {
        None
    };

    let mut determination = SnapDetermination {
        id: determination_id,
        application_id: app.id,
        household_id: context.household_id,
        status: status.to_string(),
        benefit_amount,
        benefit_unit,
        effective_date,
        expiration_date: effective_date.map(|d| d + chrono::Months::new(6)),
        renewal_date: effective_date.map(|d| d + chrono::Months::new(5)),
        basis,
        signature: String::new(),  // placeholder before signing
        program_service_version: env!("CARGO_PKG_VERSION").to_string(),
        determined_at: now,
        created_at: now,
    };

    // 7. Sign the determination
    let signature = signer.sign(&determination)
        .map_err(|e| ApiError::Internal(format!("signing failed: {e}")))?;
    determination.signature = signature;

    // 8. Persist determination (append-only — never UPDATE, always INSERT)
    let persisted = store::create_snap_determination(db, &determination)
        .await
        .map_err(|e| ApiError::Internal(format!("failed to persist determination: {e}")))?;

    // 9. Return
    Ok(persisted)
}

Error handling specifics:

  • If create_snap_application fails, the entire determination fails with ApiError::Internal. No partial state is left because the application row was not committed.

  • If the rules engine call fails, determination returns status: "pending_verification" instead of propagating the error. This allows manual adjudication.

  • If signing fails, the determination is NOT persisted. An unsigned determination must never exist in the database.

  • If create_snap_determination fails after signing, the signed determination is lost. This is acceptable because determinations are idempotent — re-running determine() for the same application produces a new determination.

Step 5: API Routes

Files: services/canopy-snap/src/api/mod.rs, services/canopy-snap/src/api/determine.rs

The POST /v1/determine handler:

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

use axum::{extract::State, Json};
use crate::determine::{self, ApplicationContext};
use crate::errors::ApiError;
use crate::state::SnapState;
use crate::store::models::SnapDetermination;

/// POST /v1/determine
///
/// Receives the application context from canopy-eligibility,
/// evaluates SNAP eligibility via canopy-rules, produces a
/// signed determination, and returns it.
pub async fn post_determine(
    State(state): State<SnapState>,
    Json(context): Json<ApplicationContext>,
) -> Result<Json<SnapDetermination>, ApiError> {
    let determination = determine::determine(
        &state.db,
        &state.rules_client,
        state.signer.as_ref(),
        context,
    ).await?;

    Ok(Json(determination))
}

Request body (ApplicationContext) JSON example:

{
  "application_id": "b7e2f310-1234-4abc-9def-abcdef123456",
  "household_id": "c8f3a421-5678-4def-abcd-fedcba654321",
  "applicant_person_id": "d9a4b532-9abc-4012-3456-789abcdef012",
  "household_size": 4,
  "income": [
    { "source": "employment", "amount": 1200.00, "frequency": "biweekly" },
    { "source": "child_support", "amount": 300.00, "frequency": "monthly" }
  ],
  "assets": [
    { "asset_type": "checking_account", "value": 800.00 },
    { "asset_type": "vehicle", "value": 4500.00 }
  ],
  "expenses": [
    { "expense_type": "rent", "amount": 950.00, "frequency": "monthly" },
    { "expense_type": "childcare", "amount": 600.00, "frequency": "monthly" }
  ],
  "has_elderly_disabled_member": false,
  "is_categorically_eligible": false,
  "jurisdiction": "us-oh"
}

Response body (approved) JSON example:

{
  "id": "e0b5c643-def0-4123-4567-890abcdef345",
  "application_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
  "household_id": "c8f3a421-5678-4def-abcd-fedcba654321",
  "status": "approved",
  "benefit_amount": 847.00,
  "benefit_unit": "monthly_usd",
  "effective_date": "2026-03-26",
  "expiration_date": "2026-09-26",
  "renewal_date": "2026-08-26",
  "basis": "Household passes gross income test (130% FPL), net income test (100% FPL), and asset test.",
  "signature": "eyJhbGciOiJFUzI1NiIsImtpZCI6ImNhbm9weS1zbmFwLTIwMjYtMDMiLCJ0eXAiOiJjYW5vcHktZGV0ZXJtaW5hdGlvbitqd3QifQ..MEUCIQDx2n7K...",
  "program_service_version": "0.1.0",
  "determined_at": "2026-03-26T14:30:01Z",
  "created_at": "2026-03-26T14:30:01Z"
}

Response body (denied) JSON example:

{
  "id": "f1c6d754-0123-4234-5678-901bcdef0456",
  "application_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
  "household_id": "c8f3a421-5678-4def-abcd-fedcba654321",
  "status": "denied",
  "benefit_amount": null,
  "benefit_unit": null,
  "effective_date": null,
  "expiration_date": null,
  "renewal_date": null,
  "basis": "Household fails gross income test: $3,200/mo exceeds 130% FPL limit of $2,990/mo for household size 4.",
  "signature": "eyJhbGciOiJFUzI1NiIsImtpZCI6ImNhbm9weS1zbmFwLTIwMjYtMDMi...",
  "program_service_version": "0.1.0",
  "determined_at": "2026-03-26T14:31:00Z",
  "created_at": "2026-03-26T14:31:00Z"
}

Error response (422 — invalid input) JSON example:

{
  "error": "validation_error",
  "message": "household_size must be at least 1",
  "details": null
}

Error response (500 — rules engine unavailable) JSON example:

{
  "error": "internal_error",
  "message": "An internal error occurred. Please try again later.",
  "details": null
}

GET endpoints for determination retrieval:

// services/canopy-snap/src/api/determine.rs (continued)

use axum::extract::Path;
use axum::extract::Query;
use serde::Deserialize;
use uuid::Uuid;

#[derive(Debug, Deserialize)]
pub struct PaginationParams {
    pub offset: Option<i64>,
    pub limit: Option<i64>,
}

/// GET /v1/determinations/{id}
pub async fn get_determination(
    State(state): State<SnapState>,
    Path(id): Path<Uuid>,
) -> Result<Json<SnapDetermination>, ApiError> {
    let determination = crate::store::get_determination(&state.db, id)
        .await
        .map_err(|e| ApiError::Internal(format!("query failed: {e}")))?
        .ok_or(ApiError::NotFound(format!("determination {id} not found")))?;

    Ok(Json(determination))
}

/// GET /v1/determinations
pub async fn list_determinations(
    State(state): State<SnapState>,
    Query(params): Query<PaginationParams>,
) -> Result<Json<Vec<SnapDetermination>>, ApiError> {
    let page = crate::store::PageRequest {
        offset: params.offset.unwrap_or(0),
        limit: params.limit.unwrap_or(50).min(100),
    };
    let determinations = crate::store::list_determinations(&state.db, &page)
        .await
        .map_err(|e| ApiError::Internal(format!("query failed: {e}")))?;

    Ok(Json(determinations))
}

Route wiring in api/mod.rs:

use axum::{routing::{get, post}, Router};
use crate::state::SnapState;

pub mod determine;

pub fn routes() -> Router<SnapState> {
    Router::new()
        .route("/v1/determine", post(determine::post_determine))
        .route("/v1/determinations/:id", get(determine::get_determination))
        .route("/v1/determinations", get(determine::list_determinations))
}

Step 6: Integration Tests

Files: services/canopy-snap/tests/api/determine.rs

Full flow tests. Each test uses a test database on postgres-snap and a mock canopy-rules server (via wiremock).

// services/canopy-snap/tests/api/determine.rs

use canopy_snap::determine::ApplicationContext;
use canopy_snap::store::models::SnapDetermination;

/// Happy path: household that qualifies for SNAP receives an approved
/// determination with a positive benefit amount and a valid JWS signature.
#[tokio::test]
async fn snap_determination_approved() {
    // Arrange: mock canopy-rules to return eligible=true, benefit=847.00
    // Act: POST /v1/determine with qualifying context
    // Assert:
    //   assert_eq!(determination.status, "approved");
    //   assert!(determination.benefit_amount.unwrap() > Decimal::ZERO);
    //   assert!(determination.benefit_unit.as_deref() == Some("monthly_usd"));
    //   assert!(determination.effective_date.is_some());
    //   assert!(determination.expiration_date.is_some());
    //   assert!(!determination.signature.is_empty());
}

/// Denial: household exceeds gross income limit, rules engine returns eligible=false.
#[tokio::test]
async fn snap_determination_denied_over_income() {
    // Arrange: mock canopy-rules to return eligible=false,
    //   basis="Household fails gross income test"
    // Act: POST /v1/determine with over-income context
    // Assert:
    //   assert_eq!(determination.status, "denied");
    //   assert!(determination.benefit_amount.is_none());
    //   assert!(determination.benefit_unit.is_none());
    //   assert!(determination.basis.as_ref().unwrap().contains("gross income"));
    //   assert!(!determination.signature.is_empty());
}

/// Verify that the JWS signature on a determination can be verified
/// using the canopy-snap public key via canopy-signing's VerifyingKey.
#[tokio::test]
async fn determination_signature_verifies() {
    // Arrange: run a determination, extract the signature
    // Act: reconstruct canonical payload, verify with public key
    // Assert:
    //   let verified = verifying_key.verify_detached(&payload, &determination.signature);
    //   assert!(verified.unwrap());
}

/// Determinations are append-only: re-running determine() for the same
/// application creates a new row, never updates the existing one.
#[tokio::test]
async fn determination_is_append_only() {
    // Arrange: run determine() twice for the same application_id
    // Act: list determinations for that application
    // Assert:
    //   assert_eq!(determinations.len(), 2);
    //   assert_ne!(determinations[0].id, determinations[1].id);
    //   assert!(determinations[0].created_at <= determinations[1].created_at);
}

/// IEVS verification data is stored only in the canopy-snap database
/// (postgres-snap:5433) and is NOT accessible from the shared postgres instance.
/// This validates ADR-004 data isolation.
#[tokio::test]
async fn ievs_data_only_in_snap_database() {
    // Arrange: insert an IEVS record into postgres-snap
    // Act: attempt to query ievs_verification_data from the shared postgres pool
    // Assert:
    //   assert!(shared_pool_query.is_err());  // table does not exist in shared db
    //   let snap_record = snap_pool_query.unwrap();
    //   assert!(snap_record.is_some());  // exists in snap db
}

Files Touched

File Change

services/canopy-snap/migrations/20260326000000_create_snap_tables.sql

New: three tables plus indexes

services/canopy-snap/src/store/

New: models and query functions

services/canopy-snap/src/rules_client.rs

New: HTTP client for canopy-rules

services/canopy-snap/src/determine.rs

New: determination orchestration logic

services/canopy-snap/src/api/

Expanded: determine endpoint, determination queries

services/canopy-snap/src/main.rs

Wire store, rules client, migration runner

services/canopy-snap/Cargo.toml

Add reqwest, chrono, rust_decimal dependencies

Verification

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

  2. cargo xtask dev restart — canopy-snap migration runs on postgres-snap

  3. POST /v1/determine with test application context returns signed determination

  4. Verify: determination signature validates against canopy-snap’s public key

  5. Verify: ievs_verification_data table exists only in postgres-snap, not in shared postgres

  6. cargo clippy -p canopy-snap — -D warnings — clean

Documentation Updates

  • .claude/docs/services.md — add snap endpoint table

  • services/canopy-snap/migrations/COMPLIANCE.md — verify IEVS isolation documented

  • CHANGELOG.adoc — entry under == Unreleased

  • .claude/CLAUDE.md — update canopy-snap feature status from "stub" to "eligibility + determination"

Edit this page · default