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:
-
Simplest eligibility logic: gross income test (130% FPL), net income test (100% FPL), asset test, categorical eligibility. No FTI complexity (that’s TANF/Medicaid).
-
Proves the full architecture end-to-end: canopy-eligibility calls canopy-snap, canopy-snap calls canopy-rules with
snap-eligibilityruleset, produces a signed determination, returns it. -
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:
-
Person and Household Data Model — canopy-snap receives person/household data in the application context
-
Rules Engine — canopy-snap calls canopy-rules for eligibility evaluation
-
Determination Signing Infrastructure — canopy-snap signs its determinations
Scope
In scope:
-
SNAP application context reception (household composition, income, assets, expenses from canopy-eligibility)
-
Rules engine call:
snap-eligibilityandsnap-benefit-calculationrulesets -
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 toApiError::RulesEnginewith a message suitable for logging. The determination returnsstatus: "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::RulesEnginewith the status code and body logged. -
JSON deserialization failures indicate a contract mismatch between canopy-snap and canopy-rules; these are logged at
errorlevel 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_applicationfails, the entire determination fails withApiError::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_determinationfails after signing, the signed determination is lost. This is acceptable because determinations are idempotent — re-runningdetermine()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 |
|---|---|
|
New: three tables plus indexes |
|
New: models and query functions |
|
New: HTTP client for canopy-rules |
|
New: determination orchestration logic |
|
Expanded: determine endpoint, determination queries |
|
Wire store, rules client, migration runner |
|
Add reqwest, chrono, rust_decimal dependencies |
Verification
-
cargo nextest run -p canopy-snap— unit tests pass -
cargo xtask dev restart— canopy-snap migration runs on postgres-snap -
POST /v1/determinewith test application context returns signed determination -
Verify: determination signature validates against canopy-snap’s public key
-
Verify:
ievs_verification_datatable exists only in postgres-snap, not in shared postgres -
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"