Plan: WIC Eligibility (canopy-wic)
On this page
- Status
- Context
- Scope
- Dependencies
- Design
- Steps
- Step 1: Database Migrations
- Step 2: Categorical and Income Eligibility Evaluation via canopy-rules
- Step 3: Nutritional Risk Assessment Recording
- Step 4: Food Package Assignment
- Step 5: Certification Period Tracking
- Step 6: Determination Signing per ADR-002
- Step 7: Event Publishing
- Step 8: Integration Tests
- Files Touched
- Verification
- Documentation Updates
Status
| Step | Description | Status |
|---|---|---|
1 |
Define WIC-specific tables: participants, certifications, nutritional risk assessments, food package assignments |
Done (2026-04-18) |
2 |
Implement WIC categorical and income eligibility evaluation via canopy-rules |
Done (2026-04-18) |
3 |
Implement nutritional risk assessment recording (anthropometric, biochemical, dietary, medical) |
Done (2026-04-18) |
4 |
Implement food package assignment based on participant category and nutritional risk |
Done (2026-04-18) |
5 |
Implement certification period tracking (infant, child, pregnant, postpartum, breastfeeding) |
Done (2026-04-18) |
6 |
Wire determination signing per ADR-002 |
Done (2026-04-18) |
7 |
Wire event publishing for |
Done (2026-04-18) |
8 |
Integration tests |
Done (2026-04-13) — |
Epic: &31
Branch: feature/wic-eligibility
Labels: type::feature, priority::medium, program::wic, service::wic, workflow::ready, federal-partner::fns
Context
The Special Supplemental Nutrition Program for Women, Infants, and Children (WIC) is authorized by Section 17 of the Child Nutrition Act of 1966 (42 USC 1786) and administered by FNS. WIC provides supplemental foods, nutrition education, breastfeeding support, and health care referrals to low-income pregnant, postpartum, and breastfeeding women, infants, and children up to age 5 who are at nutritional risk.
WIC eligibility requires:
-
Categorical eligibility — applicant must be a pregnant woman, postpartum woman (up to 6 months), breastfeeding woman (up to 1 year), infant (under 1 year), or child (ages 1-4)
-
Income test — household income at or below 185% FPL, or adjunctive eligibility through participation in SNAP, Medicaid, or TANF
-
Nutritional risk — must have at least one documented nutritional risk factor (anthropometric, biochemical, dietary, or medical)
-
Residency — must reside in the state
Per ADR-001, canopy-wic is an independent service with its own PostgreSQL database. Per ADR-002, WIC determinations are returned as signed JWS payloads via canopy-eligibility. Per ADR-003, income and categorical eligibility logic is in versioned JDM rulesets evaluated by canopy-rules. Nutritional risk assessment is recorded by clinical staff and stored in canopy-wic — it is not a rules engine decision.
Regulatory basis
-
42 USC 1786 — WIC program authorization
-
7 CFR Part 246 — WIC program regulations
-
7 CFR 246.7 — Eligibility criteria (categorical, income, nutritional risk, residency)
-
7 CFR 246.7(d) — Income eligibility standards (185% FPL)
-
7 CFR 246.7(e) — Adjunctive eligibility (SNAP, Medicaid, TANF participation)
-
7 CFR 246.9 — Fair hearing procedures
-
7 CFR 246.10 — Supplemental food requirements (food packages I-VII)
-
7 CFR 246.12 — Certification periods by participant category
Scope
In scope:
-
WIC categorical eligibility evaluation (pregnant, postpartum, breastfeeding, infant, child)
-
Income eligibility (185% FPL threshold or adjunctive eligibility via SNAP/Medicaid/TANF)
-
Nutritional risk assessment recording (clinical staff enters assessment; system stores and validates completeness)
-
Food package assignment based on participant category (food packages I-VII per 7 CFR 246.10)
-
Certification period tracking by participant category (7 CFR 246.12)
-
Determination signing via canopy-eligibility (ADR-002)
-
Event publishing:
wic.determination_completed,wic.certification_created(IDs only)
Out of scope:
-
WIC MIS (Management Information System) integration — separate plan when Phase 5 begins
-
eWIC card issuance and transaction processing (external vendor system)
-
Vendor authorization and monitoring
-
Nutrition education and breastfeeding support tracking
-
WIC federal reporting (FNS-798, FNS-648) — separate plan when Phase 5 begins
-
Food package inventory and procurement
Dependencies
This plan depends on:
-
persons-household-model (must be complete): household composition, demographics, pregnancy/breastfeeding status
-
rules-engine (must be complete): canopy-rules must evaluate WIC rulesets
-
determination-signing (must be complete): JWS signing infrastructure per ADR-002
-
reference-extensions (must be complete):
DeterminationStatusenum variants -
application-intake (must be complete): application creation and lifecycle management
-
eligibility-orchestrator (must be complete): WIC determination triggered via canopy-eligibility
Cross-program adjunctive eligibility
WIC adjunctive eligibility (7 CFR 246.7(e)) requires checking participation in SNAP, Medicaid, or TANF. Per ADR-001, canopy-wic cannot directly query canopy-snap, canopy-medicaid, or canopy-tanf databases. Instead:
-
canopy-eligibility provides a cross-program enrollment status API that canopy-wic calls to check if a household member is currently enrolled in SNAP, Medicaid, or TANF
-
This API returns only enrollment status (enrolled/not enrolled) and program name — no income data, no determination details, no FTI
Design
Eligibility evaluation flow
-
canopy-eligibility receives determination request for WIC program
-
canopy-eligibility calls canopy-wic
/v1/wic/evaluate -
canopy-wic retrieves household data from canopy-persons (demographics, pregnancy status, child age)
-
canopy-wic calls canopy-rules to evaluate WIC categorical and income rulesets
-
If income-ineligible, canopy-wic checks adjunctive eligibility via canopy-eligibility cross-program enrollment API
-
canopy-wic checks for documented nutritional risk assessment (must already be recorded by clinical staff)
-
canopy-wic stores determination in its own database
-
canopy-wic signs determination via canopy-eligibility signing infrastructure (ADR-002)
-
canopy-wic assigns food package based on participant category
-
canopy-wic returns signed determination to canopy-eligibility
-
canopy-wic publishes
wic.determination_completedevent
Database schema (canopy-wic database)
-- SPDX-License-Identifier: AGPL-3.0-or-later
-- Per ADR-001: canopy-wic owns this schema; no other service queries it directly
CREATE TABLE wic_participants (
id UUID PRIMARY KEY,
person_id UUID NOT NULL,
participant_category TEXT NOT NULL CHECK (participant_category IN (
'pregnant', 'postpartum', 'breastfeeding', 'infant', 'child'
)),
certification_start DATE NOT NULL,
certification_end DATE NOT NULL,
food_package TEXT NOT NULL CHECK (food_package IN (
'I', 'II', 'III', 'IV', 'V', 'VI', 'VII'
)),
status TEXT NOT NULL CHECK (status IN (
'active', 'expired', 'terminated', 'transferred'
)),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE wic_determinations (
id UUID PRIMARY KEY,
application_id UUID NOT NULL,
household_id UUID NOT NULL,
person_id UUID NOT NULL,
determination_status TEXT NOT NULL,
categorical_eligible BOOLEAN NOT NULL,
income_eligible BOOLEAN NOT NULL,
adjunctive_eligible BOOLEAN NOT NULL DEFAULT FALSE,
adjunctive_program TEXT, -- 'snap', 'medicaid', or 'tanf' if adjunctively eligible
nutritional_risk_documented BOOLEAN NOT NULL,
participant_category TEXT,
food_package TEXT,
effective_date DATE NOT NULL,
end_date DATE,
ruleset_version TEXT NOT NULL,
jws_token TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE wic_nutritional_risk_assessments (
id UUID PRIMARY KEY,
person_id UUID NOT NULL,
assessment_date DATE NOT NULL,
assessor_worker_id UUID NOT NULL,
anthropometric_risk BOOLEAN NOT NULL DEFAULT FALSE,
biochemical_risk BOOLEAN NOT NULL DEFAULT FALSE,
dietary_risk BOOLEAN NOT NULL DEFAULT FALSE,
medical_risk BOOLEAN NOT NULL DEFAULT FALSE,
risk_codes TEXT[] NOT NULL DEFAULT '{}', -- WIC risk factor codes
notes TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
Certification periods (7 CFR 246.12)
| Participant category | Certification period |
|---|---|
Pregnant woman |
Through pregnancy + 6 weeks postpartum |
Postpartum woman (non-breastfeeding) |
Up to 6 months after delivery |
Breastfeeding woman |
Up to infant’s first birthday |
Infant |
Up to first birthday (certified in 2 segments: birth-6 months, 6 months-1 year) |
Child (ages 1-4) |
Up to 1 year, renewable until 5th birthday |
Events
| Event | Payload fields |
|---|---|
|
|
|
|
Data restrictions
Per ADR-004, WIC does not handle FTI or IEVS data. Income data in WIC determinations is applicant-attested or verified through non-restricted sources. Adjunctive eligibility checks return only enrollment status, not income or determination details from other programs. Events contain only IDs, categories, and timestamps — no income data, no nutritional risk details, no PHI.
Steps
Step 1: Database Migrations
Files: services/canopy-wic/migrations/20260401000000_create_wic_tables.sql
Create the three WIC tables from the Design section (wic_participants, wic_determinations, wic_nutritional_risk_assessments) plus indexes for query performance:
-- SPDX-License-Identifier: AGPL-3.0-or-later
-- Per ADR-001: canopy-wic owns this schema; no other service queries it directly
-- Tables (see Design > Database Schema for full CREATE TABLE statements)
CREATE INDEX idx_wic_participants_person ON wic_participants(person_id);
CREATE INDEX idx_wic_participants_status ON wic_participants(status);
CREATE INDEX idx_wic_participants_category ON wic_participants(participant_category);
CREATE INDEX idx_wic_participants_cert_end ON wic_participants(certification_end);
CREATE INDEX idx_wic_determinations_application ON wic_determinations(application_id);
CREATE INDEX idx_wic_determinations_household ON wic_determinations(household_id);
CREATE INDEX idx_wic_determinations_person ON wic_determinations(person_id);
CREATE INDEX idx_wic_determinations_status ON wic_determinations(determination_status);
CREATE INDEX idx_wic_assessments_person ON wic_nutritional_risk_assessments(person_id);
CREATE INDEX idx_wic_assessments_date ON wic_nutritional_risk_assessments(assessment_date);
Run with sqlx migrate run on the postgres-wic instance.
Uncomment the migration runner in services/canopy-wic/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: Categorical and Income Eligibility Evaluation via canopy-rules
Files: services/canopy-wic/src/store/mod.rs, services/canopy-wic/src/store/models.rs, services/canopy-wic/src/store/determinations.rs, services/canopy-wic/src/store/participants.rs, services/canopy-wic/src/eligibility.rs, rulesets/{jurisdiction}/wic-eligibility.json
Store layer models
// services/canopy-wic/src/store/models.rs
// SPDX-License-Identifier: AGPL-3.0-or-later
use chrono::{DateTime, NaiveDate, Utc};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
pub struct WicParticipant {
pub id: Uuid,
pub person_id: Uuid,
pub participant_category: String, // pregnant, postpartum, breastfeeding, infant, child
pub certification_start: NaiveDate,
pub certification_end: NaiveDate,
pub food_package: String, // I, II, III, IV, V, VI, VII
pub status: String, // active, expired, terminated, transferred
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
pub struct WicDetermination {
pub id: Uuid,
pub application_id: Uuid,
pub household_id: Uuid,
pub person_id: Uuid,
pub determination_status: String,
pub categorical_eligible: bool,
pub income_eligible: bool,
pub adjunctive_eligible: bool,
pub adjunctive_program: Option<String>,
pub nutritional_risk_documented: bool,
pub participant_category: Option<String>,
pub food_package: Option<String>,
pub effective_date: NaiveDate,
pub end_date: Option<NaiveDate>,
pub ruleset_version: String,
pub jws_token: Option<String>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
pub struct WicNutritionalRiskAssessment {
pub id: Uuid,
pub person_id: Uuid,
pub assessment_date: NaiveDate,
pub assessor_worker_id: Uuid,
pub anthropometric_risk: bool,
pub biochemical_risk: bool,
pub dietary_risk: bool,
pub medical_risk: bool,
pub risk_codes: Vec<String>,
pub notes: Option<String>,
pub created_at: DateTime<Utc>,
}
Store query functions
// services/canopy-wic/src/store/determinations.rs
// SPDX-License-Identifier: AGPL-3.0-or-later
use sqlx::PgPool;
use uuid::Uuid;
use super::models::WicDetermination;
pub async fn create_determination(
pool: &PgPool,
det: &WicDetermination,
) -> Result<WicDetermination, sqlx::Error> {
sqlx::query_as::<_, WicDetermination>(
r#"INSERT INTO wic_determinations
(id, application_id, household_id, person_id, determination_status,
categorical_eligible, income_eligible, adjunctive_eligible, adjunctive_program,
nutritional_risk_documented, participant_category, food_package,
effective_date, end_date, ruleset_version, jws_token)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16)
RETURNING *"#,
)
.bind(det.id).bind(det.application_id).bind(det.household_id).bind(det.person_id)
.bind(&det.determination_status).bind(det.categorical_eligible)
.bind(det.income_eligible).bind(det.adjunctive_eligible)
.bind(&det.adjunctive_program).bind(det.nutritional_risk_documented)
.bind(&det.participant_category).bind(&det.food_package)
.bind(det.effective_date).bind(det.end_date)
.bind(&det.ruleset_version).bind(&det.jws_token)
.fetch_one(pool)
.await
}
pub async fn get_determination(
pool: &PgPool,
id: Uuid,
) -> Result<Option<WicDetermination>, sqlx::Error> {
sqlx::query_as::<_, WicDetermination>(
"SELECT * FROM wic_determinations WHERE id = $1",
)
.bind(id)
.fetch_optional(pool)
.await
}
pub async fn list_determinations_by_person(
pool: &PgPool,
person_id: Uuid,
) -> Result<Vec<WicDetermination>, sqlx::Error> {
sqlx::query_as::<_, WicDetermination>(
"SELECT * FROM wic_determinations WHERE person_id = $1 ORDER BY created_at DESC",
)
.bind(person_id)
.fetch_all(pool)
.await
}
// services/canopy-wic/src/store/participants.rs
// SPDX-License-Identifier: AGPL-3.0-or-later
use sqlx::PgPool;
use uuid::Uuid;
use super::models::WicParticipant;
pub async fn create_participant(
pool: &PgPool,
p: &WicParticipant,
) -> Result<WicParticipant, sqlx::Error> {
sqlx::query_as::<_, WicParticipant>(
r#"INSERT INTO wic_participants
(id, person_id, participant_category, certification_start, certification_end,
food_package, status)
VALUES ($1,$2,$3,$4,$5,$6,$7)
RETURNING *"#,
)
.bind(p.id).bind(p.person_id).bind(&p.participant_category)
.bind(p.certification_start).bind(p.certification_end)
.bind(&p.food_package).bind(&p.status)
.fetch_one(pool)
.await
}
pub async fn get_active_participant(
pool: &PgPool,
person_id: Uuid,
) -> Result<Option<WicParticipant>, sqlx::Error> {
sqlx::query_as::<_, WicParticipant>(
"SELECT * FROM wic_participants WHERE person_id = $1 AND status = 'active' ORDER BY certification_end DESC LIMIT 1",
)
.bind(person_id)
.fetch_optional(pool)
.await
}
Eligibility evaluation logic
// services/canopy-wic/src/eligibility.rs
// SPDX-License-Identifier: AGPL-3.0-or-later
use chrono::{NaiveDate, Utc};
use serde::{Deserialize, Serialize};
use sqlx::PgPool;
use uuid::Uuid;
use crate::errors::ApiError;
use crate::food_package::assign_food_package;
use crate::certification::compute_certification_end;
use crate::store;
use crate::store::models::{WicDetermination, WicParticipant};
/// Input received from canopy-eligibility via POST /v1/wic/evaluate.
#[derive(Debug, Clone, Deserialize)]
pub struct WicEvaluationRequest {
pub application_id: Uuid,
pub household_id: Uuid,
pub person_id: Uuid,
pub participant_category: String, // pregnant, postpartum, breastfeeding, infant, child
pub household_size: u32,
pub gross_monthly_income: f64,
pub jurisdiction: String,
}
/// Output returned to canopy-eligibility.
#[derive(Debug, Clone, Serialize)]
pub struct WicEvaluationResponse {
pub determination_id: Uuid,
pub determination_status: String,
pub categorical_eligible: bool,
pub income_eligible: bool,
pub adjunctive_eligible: bool,
pub nutritional_risk_documented: bool,
pub participant_category: Option<String>,
pub food_package: Option<String>,
pub effective_date: NaiveDate,
pub end_date: Option<NaiveDate>,
pub jws_token: Option<String>,
}
/// Adjunctive eligibility client trait.
/// Calls canopy-eligibility cross-program enrollment API.
/// Returns only enrolled/not-enrolled per ADR-001 — no income data,
/// no determination details from other programs.
#[trait_variant::make(Send)]
pub trait AdjunctiveClient: Send + Sync {
async fn check_enrollment(
&self,
person_id: Uuid,
program: &str,
) -> Result<bool, ApiError>;
}
/// Rules engine client trait. Evaluates JDM rulesets via canopy-rules.
#[trait_variant::make(Send)]
pub trait RulesClient: Send + Sync {
async fn evaluate(
&self,
rule_set_name: &str,
context_id: Uuid,
input: serde_json::Value,
) -> Result<serde_json::Value, ApiError>;
}
/// Determination signer trait per ADR-002.
pub trait DeterminationSigner: Send + Sync {
fn sign(&self, payload: &[u8]) -> Result<String, anyhow::Error>;
}
/// Core WIC eligibility evaluation.
///
/// Flow:
/// 1. Validate participant_category is one of: pregnant, postpartum, breastfeeding, infant, child
/// 2. Call canopy-rules with WIC categorical + income ruleset (185% FPL threshold)
/// 3. If income-ineligible, check adjunctive eligibility (SNAP, Medicaid, TANF enrollment)
/// 4. Check for documented nutritional risk assessment
/// 5. If all three pillars pass (categorical + income/adjunctive + nutritional risk): approved
/// 6. Assign food package, compute certification period, sign, persist
pub async fn evaluate(
db: &PgPool,
rules: &dyn RulesClient,
adjunctive: &dyn AdjunctiveClient,
signer: &dyn DeterminationSigner,
req: WicEvaluationRequest,
) -> Result<WicEvaluationResponse, ApiError> {
// 1. Validate participant category
let valid_categories = ["pregnant", "postpartum", "breastfeeding", "infant", "child"];
if !valid_categories.contains(&req.participant_category.as_str()) {
return Err(ApiError::Validation(format!(
"invalid participant_category: {}; must be one of: {}",
req.participant_category,
valid_categories.join(", ")
)));
}
// 2. Evaluate categorical + income eligibility via canopy-rules
let ruleset_name = format!("{}-wic-eligibility", req.jurisdiction);
let rules_output = rules.evaluate(
&ruleset_name,
req.application_id,
serde_json::json!({
"participant_category": req.participant_category,
"household_size": req.household_size,
"gross_monthly_income": req.gross_monthly_income,
}),
).await?;
let categorical_eligible = rules_output.get("categorical_eligible")
.and_then(|v| v.as_bool())
.unwrap_or(false);
let income_eligible = rules_output.get("income_eligible")
.and_then(|v| v.as_bool())
.unwrap_or(false);
let ruleset_version = rules_output.get("ruleset_version")
.and_then(|v| v.as_str())
.unwrap_or("unknown")
.to_string();
// 3. If income-ineligible, check adjunctive eligibility
let (adjunctive_eligible, adjunctive_program) = if !income_eligible {
check_adjunctive(adjunctive, req.person_id).await?
} else {
(false, None)
};
let effectively_income_eligible = income_eligible || adjunctive_eligible;
// 4. Check for documented nutritional risk assessment
let assessment = store::assessments::get_latest_assessment(db, req.person_id).await
.map_err(|e| ApiError::Internal(format!("failed to query assessments: {e}")))?;
let nutritional_risk_documented = assessment
.as_ref()
.map(|a| a.anthropometric_risk || a.biochemical_risk || a.dietary_risk || a.medical_risk)
.unwrap_or(false);
// 5. Determine overall eligibility
let approved = categorical_eligible && effectively_income_eligible && nutritional_risk_documented;
let status = if approved { "approved" } else { "denied" };
let now = Utc::now();
let effective_date = now.date_naive();
let (end_date, food_package, participant_category) = if approved {
let end = compute_certification_end(&req.participant_category, effective_date);
let pkg = assign_food_package(&req.participant_category);
(Some(end), Some(pkg), Some(req.participant_category.clone()))
} else {
(None, None, None)
};
// 6. Build determination, sign, persist
let det_id = Uuid::new_v4();
let mut det = WicDetermination {
id: det_id,
application_id: req.application_id,
household_id: req.household_id,
person_id: req.person_id,
determination_status: status.to_string(),
categorical_eligible,
income_eligible: effectively_income_eligible,
adjunctive_eligible,
adjunctive_program: adjunctive_program.clone(),
nutritional_risk_documented,
participant_category: participant_category.clone(),
food_package: food_package.clone(),
effective_date,
end_date,
ruleset_version,
jws_token: None,
created_at: now,
updated_at: now,
};
// Sign before persisting — unsigned determinations must never exist in the database
let payload = serde_json::to_vec(&det)
.map_err(|e| ApiError::Internal(format!("serialization failed: {e}")))?;
let token = signer.sign(&payload)
.map_err(|e| ApiError::Internal(format!("signing failed: {e}")))?;
det.jws_token = Some(token.clone());
let persisted = store::determinations::create_determination(db, &det).await
.map_err(|e| ApiError::Internal(format!("failed to persist determination: {e}")))?;
Ok(WicEvaluationResponse {
determination_id: persisted.id,
determination_status: persisted.determination_status,
categorical_eligible,
income_eligible: effectively_income_eligible,
adjunctive_eligible,
nutritional_risk_documented,
participant_category,
food_package,
effective_date,
end_date,
jws_token: Some(token),
})
}
/// Check adjunctive eligibility by querying canopy-eligibility cross-program
/// enrollment API for SNAP, Medicaid, and TANF. Returns on first match.
/// Per ADR-001 this returns only enrolled/not-enrolled — no income data.
async fn check_adjunctive(
client: &dyn AdjunctiveClient,
person_id: Uuid,
) -> Result<(bool, Option<String>), ApiError> {
for program in &["snap", "medicaid", "tanf"] {
match client.check_enrollment(person_id, program).await {
Ok(true) => return Ok((true, Some(program.to_string()))),
Ok(false) => continue,
Err(e) => {
tracing::warn!(person_id = %person_id, program, error = %e,
"adjunctive check failed; continuing to next program");
continue;
}
}
}
Ok((false, None))
}
WIC eligibility ruleset
The JDM ruleset rulesets/{jurisdiction}/wic-eligibility.json must evaluate:
-
Categorical eligibility:
participant_categoryis one ofpregnant,postpartum,breastfeeding,infant,child. This is a simple membership check. -
Income eligibility:
gross_monthly_income⇐ 185% FPL threshold forhousehold_size. The FPL thresholds are embedded in the ruleset as a lookup table, updated annually.
JSON input/output contract:
// Input
{
"participant_category": "pregnant",
"household_size": 3,
"gross_monthly_income": 2800.00
}
// Output
{
"categorical_eligible": true,
"income_eligible": true,
"fpl_threshold_185": 3256.25,
"ruleset_version": "wic-2026.1"
}
Error handling:
-
If canopy-rules returns a non-2xx status,
evaluate()returnsApiError::RulesEnginewith the status code logged. -
If the adjunctive check for all three programs fails (network errors), the determination proceeds with
adjunctive_eligible: false— the applicant can still qualify via direct income eligibility. Each failure is logged atwarnlevel.
Step 3: Nutritional Risk Assessment Recording
Files: services/canopy-wic/src/store/assessments.rs, services/canopy-wic/src/api/assessments.rs, services/canopy-wic/src/api/mod.rs
Nutritional risk assessment is recorded by clinical staff — it is NOT a rules engine decision. The system validates completeness (at least one risk type must be true) but does not evaluate clinical correctness.
Store functions
// services/canopy-wic/src/store/assessments.rs
// SPDX-License-Identifier: AGPL-3.0-or-later
use sqlx::PgPool;
use uuid::Uuid;
use super::models::WicNutritionalRiskAssessment;
pub async fn create_assessment(
pool: &PgPool,
a: &WicNutritionalRiskAssessment,
) -> Result<WicNutritionalRiskAssessment, sqlx::Error> {
sqlx::query_as::<_, WicNutritionalRiskAssessment>(
r#"INSERT INTO wic_nutritional_risk_assessments
(id, person_id, assessment_date, assessor_worker_id,
anthropometric_risk, biochemical_risk, dietary_risk, medical_risk,
risk_codes, notes)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10)
RETURNING *"#,
)
.bind(a.id).bind(a.person_id).bind(a.assessment_date)
.bind(a.assessor_worker_id)
.bind(a.anthropometric_risk).bind(a.biochemical_risk)
.bind(a.dietary_risk).bind(a.medical_risk)
.bind(&a.risk_codes).bind(&a.notes)
.fetch_one(pool)
.await
}
pub async fn get_latest_assessment(
pool: &PgPool,
person_id: Uuid,
) -> Result<Option<WicNutritionalRiskAssessment>, sqlx::Error> {
sqlx::query_as::<_, WicNutritionalRiskAssessment>(
"SELECT * FROM wic_nutritional_risk_assessments WHERE person_id = $1 ORDER BY assessment_date DESC LIMIT 1",
)
.bind(person_id)
.fetch_optional(pool)
.await
}
pub async fn get_assessment(
pool: &PgPool,
id: Uuid,
) -> Result<Option<WicNutritionalRiskAssessment>, sqlx::Error> {
sqlx::query_as::<_, WicNutritionalRiskAssessment>(
"SELECT * FROM wic_nutritional_risk_assessments WHERE id = $1",
)
.bind(id)
.fetch_optional(pool)
.await
}
API endpoint
// services/canopy-wic/src/api/assessments.rs
// SPDX-License-Identifier: AGPL-3.0-or-later
use axum::{extract::{Path, State}, Json};
use chrono::NaiveDate;
use uuid::Uuid;
use crate::errors::ApiError;
use crate::state::WicState;
use crate::store;
use crate::store::models::WicNutritionalRiskAssessment;
#[derive(Debug, serde::Deserialize)]
pub struct CreateAssessmentRequest {
pub person_id: Uuid,
pub assessment_date: NaiveDate,
pub assessor_worker_id: Uuid,
pub anthropometric_risk: bool,
pub biochemical_risk: bool,
pub dietary_risk: bool,
pub medical_risk: bool,
pub risk_codes: Vec<String>,
pub notes: Option<String>,
}
/// POST /v1/wic/assessments
///
/// Clinical staff records a nutritional risk assessment.
/// Validates that at least one risk type is documented.
pub async fn post_assessment(
State(state): State<WicState>,
Json(req): Json<CreateAssessmentRequest>,
) -> Result<Json<WicNutritionalRiskAssessment>, ApiError> {
// Validate: at least one risk type must be true
if !req.anthropometric_risk && !req.biochemical_risk
&& !req.dietary_risk && !req.medical_risk
{
return Err(ApiError::Validation(
"at least one nutritional risk type must be documented (anthropometric, biochemical, dietary, or medical)".to_string(),
));
}
// Validate: risk_codes must not be empty when a risk type is flagged
if req.risk_codes.is_empty() {
return Err(ApiError::Validation(
"risk_codes must contain at least one WIC risk factor code".to_string(),
));
}
let assessment = WicNutritionalRiskAssessment {
id: Uuid::new_v4(),
person_id: req.person_id,
assessment_date: req.assessment_date,
assessor_worker_id: req.assessor_worker_id,
anthropometric_risk: req.anthropometric_risk,
biochemical_risk: req.biochemical_risk,
dietary_risk: req.dietary_risk,
medical_risk: req.medical_risk,
risk_codes: req.risk_codes,
notes: req.notes,
created_at: chrono::Utc::now(),
};
let persisted = store::assessments::create_assessment(&state.db, &assessment).await
.map_err(|e| ApiError::Internal(format!("failed to persist assessment: {e}")))?;
Ok(Json(persisted))
}
/// GET /v1/wic/assessments/{id}
pub async fn get_assessment(
State(state): State<WicState>,
Path(id): Path<Uuid>,
) -> Result<Json<WicNutritionalRiskAssessment>, ApiError> {
let assessment = store::assessments::get_assessment(&state.db, id).await
.map_err(|e| ApiError::Internal(format!("query failed: {e}")))?
.ok_or(ApiError::NotFound(format!("assessment {id} not found")))?;
Ok(Json(assessment))
}
Error handling:
-
If no risk type is flagged (
anthropometric_risk,biochemical_risk,dietary_risk,medical_riskall false), returnApiError::Validationwith HTTP 422. -
If
risk_codesis empty, returnApiError::Validationwith HTTP 422. -
Unique constraint violations on
idmap toApiError::Conflict(HTTP 409). -
All
sqlx::Errorvariants map toApiError::Internal(HTTP 500) with database details logged but not returned in the response.
Step 4: Food Package Assignment
Files: services/canopy-wic/src/food_package.rs
Assign WIC food packages I-VII based on participant category per 7 CFR 246.10. Food package assignment is deterministic from the participant category — no rules engine call needed.
// services/canopy-wic/src/food_package.rs
// SPDX-License-Identifier: AGPL-3.0-or-later
/// Assign a WIC food package based on participant category per 7 CFR 246.10.
///
/// | Package | Category |
/// |---------|------------------------------------------------|
/// | I | Infants 0-5 months (fully breastfed) |
/// | II | Infants 0-5 months (partially breastfed/formula)|
/// | III | Infants 6-11 months (fully breastfed) |
/// | IV | Infants 6-11 months (partially breastfed/formula)|
/// | V | Children 1-4 |
/// | VI | Pregnant / postpartum (non-breastfeeding) |
/// | VII | Breastfeeding women |
///
/// This function assigns the default package for the category.
/// Infant sub-packages (I vs II, III vs IV) require breastfeeding
/// status from the evaluation request; the caller resolves this.
pub fn assign_food_package(participant_category: &str) -> String {
match participant_category {
"infant" => "IV".to_string(), // default: partially breastfed/formula
"child" => "V".to_string(),
"pregnant" => "VI".to_string(),
"postpartum" => "VI".to_string(),
"breastfeeding" => "VII".to_string(),
_ => "V".to_string(), // fallback; should never reach due to validation
}
}
/// Assign infant food package with breastfeeding detail.
/// Called when participant_category is "infant" and breastfeeding status is known.
pub fn assign_infant_food_package(fully_breastfed: bool, age_months: u32) -> String {
match (fully_breastfed, age_months < 6) {
(true, true) => "I".to_string(), // fully breastfed, 0-5 months
(false, true) => "II".to_string(), // partially/formula, 0-5 months
(true, false) => "III".to_string(), // fully breastfed, 6-11 months
(false, false) => "IV".to_string(), // partially/formula, 6-11 months
}
}
Error handling: assign_food_package is infallible. Invalid categories are caught upstream by evaluate() validation. The fallback to "V" exists as defensive programming but should never trigger.
Step 5: Certification Period Tracking
Files: services/canopy-wic/src/certification.rs
Compute certification end dates based on participant category per 7 CFR 246.12. Certification periods vary by category as documented in the Design section.
// services/canopy-wic/src/certification.rs
// SPDX-License-Identifier: AGPL-3.0-or-later
use chrono::{Months, NaiveDate};
/// Compute the certification end date based on participant category and
/// certification start date, per 7 CFR 246.12.
///
/// - Pregnant: through pregnancy + 6 weeks postpartum (approximated as 9 months from cert start)
/// - Postpartum (non-breastfeeding): 6 months from delivery (cert start = delivery date)
/// - Breastfeeding: up to infant's first birthday (approximated as 12 months from cert start)
/// - Infant: up to first birthday (12 months from cert start; certified in 2 segments)
/// - Child (1-4): 1 year from cert start, renewable until 5th birthday
pub fn compute_certification_end(
participant_category: &str,
certification_start: NaiveDate,
) -> NaiveDate {
match participant_category {
"pregnant" => certification_start + Months::new(9),
"postpartum" => certification_start + Months::new(6),
"breastfeeding" => certification_start + Months::new(12),
"infant" => certification_start + Months::new(6), // first segment; second segment issued at recertification
"child" => certification_start + Months::new(12),
_ => certification_start + Months::new(12), // defensive fallback
}
}
/// Validate whether a child participant is still within the eligible age
/// range (under 5 years old) for WIC certification renewal.
pub fn is_child_renewable(date_of_birth: NaiveDate, proposed_renewal_date: NaiveDate) -> bool {
let age_at_renewal = proposed_renewal_date.years_since(date_of_birth);
age_at_renewal.map(|years| years < 5).unwrap_or(false)
}
Error handling: compute_certification_end is infallible. Invalid categories are caught upstream. is_child_renewable returns false if the date arithmetic fails (e.g., invalid date of birth).
Step 6: Determination Signing per ADR-002
Files: services/canopy-wic/src/eligibility.rs (already wired in Step 2)
Determination signing follows the ADR-002 black-box determination contract. The DeterminationSigner trait is defined in Step 2. The concrete implementation is provided by canopy-signing (shared crate).
Signing flow within evaluate():
-
Serialize the
WicDeterminationstruct (withoutjws_token) to a canonical JSON byte array viaserde_json::to_vec. -
Call
signer.sign(&payload)which returns a detached JWS compact serialization string. -
Set
det.jws_token = Some(token)before persisting. -
The signed determination is stored in
wic_determinationsand returned to canopy-eligibility.
Invariant: an unsigned determination must NEVER exist in the database. If signing fails, evaluate() returns ApiError::Internal and the determination row is not inserted. This is enforced by the ordering in evaluate() — signer.sign() is called before store::determinations::create_determination().
Error handling:
-
signer.sign()failure (e.g., key unavailable, HSM timeout) returnsApiError::Internal. The error message is logged aterrorlevel; the HTTP response contains a generic error. -
serde_json::to_vecfailure (should not happen with valid structs) returnsApiError::Internal.
Step 7: Event Publishing
Files: services/canopy-wic/src/events.rs
Publish two events to the canopy.events topic exchange via lapin. Events contain only IDs, categories, and timestamps — no income data, no nutritional risk details, no PHI (per ADR-004 data restrictions).
// services/canopy-wic/src/events.rs
// SPDX-License-Identifier: AGPL-3.0-or-later
use chrono::{DateTime, NaiveDate, Utc};
use lapin::{BasicProperties, Channel};
use serde::Serialize;
use uuid::Uuid;
const EXCHANGE: &str = "canopy.events";
#[derive(Debug, Serialize)]
pub struct DeterminationCompletedEvent {
pub determination_id: Uuid,
pub application_id: Uuid,
pub determination_status: String,
pub completed_at: DateTime<Utc>,
}
#[derive(Debug, Serialize)]
pub struct CertificationCreatedEvent {
pub participant_id: Uuid,
pub person_id: Uuid,
pub participant_category: String,
pub certification_start: NaiveDate,
pub certification_end: NaiveDate,
}
pub async fn publish_determination_completed(
channel: &Channel,
event: &DeterminationCompletedEvent,
) -> Result<(), anyhow::Error> {
let payload = serde_json::to_vec(event)?;
channel
.basic_publish(
EXCHANGE,
"wic.determination_completed",
lapin::options::BasicPublishOptions::default(),
&payload,
BasicProperties::default()
.with_content_type("application/json".into())
.with_delivery_mode(2), // persistent
)
.await?
.await?;
Ok(())
}
pub async fn publish_certification_created(
channel: &Channel,
event: &CertificationCreatedEvent,
) -> Result<(), anyhow::Error> {
let payload = serde_json::to_vec(event)?;
channel
.basic_publish(
EXCHANGE,
"wic.certification_created",
lapin::options::BasicPublishOptions::default(),
&payload,
BasicProperties::default()
.with_content_type("application/json".into())
.with_delivery_mode(2), // persistent
)
.await?
.await?;
Ok(())
}
Event publishing is called after evaluate() succeeds and the determination is persisted. It is wired in the API handler:
// In services/canopy-wic/src/api/mod.rs evaluate handler (after evaluate() returns):
if response.determination_status == "approved" {
// Publish determination event
events::publish_determination_completed(&state.amqp_channel, &DeterminationCompletedEvent {
determination_id: response.determination_id,
application_id: req.application_id,
determination_status: response.determination_status.clone(),
completed_at: Utc::now(),
}).await.map_err(|e| {
tracing::error!(error = %e, "failed to publish determination event");
// Do NOT fail the request — event publishing is best-effort
}).ok();
// Publish certification event (only on approval)
if let (Some(ref category), Some(end_date)) = (&response.participant_category, response.end_date) {
events::publish_certification_created(&state.amqp_channel, &CertificationCreatedEvent {
participant_id: response.determination_id, // participant row ID
person_id: req.person_id,
participant_category: category.clone(),
certification_start: response.effective_date,
certification_end: end_date,
}).await.map_err(|e| {
tracing::error!(error = %e, "failed to publish certification event");
}).ok();
}
}
Error handling:
-
Event publishing failures are logged at
errorlevel but do NOT fail the determination request. The determination has already been signed and persisted — failing the HTTP response would leave the client unaware of a successful determination. -
serde_json::to_vecfailures are propagated throughanyhow::Error. -
lapinchannel errors (connection lost, exchange not declared) are logged. A separate health check monitors the AMQP connection.
Step 8: Integration Tests
Files: services/canopy-wic/tests/wic_tests.rs
All tests use a test database on postgres-wic (via testcontainers-rs) and mock HTTP servers for canopy-rules and canopy-eligibility cross-program API (via wiremock).
// services/canopy-wic/tests/wic_tests.rs
// SPDX-License-Identifier: AGPL-3.0-or-later
use canopy_wic::eligibility::{WicEvaluationRequest, WicEvaluationResponse};
use canopy_wic::store::models::{WicDetermination, WicNutritionalRiskAssessment};
/// 1. Approved: pregnant woman, income-eligible, nutritional risk documented.
/// Verifies all three eligibility pillars pass and food package VI is assigned.
#[tokio::test]
async fn wic_approved_pregnant_income_eligible() {
// Arrange: mock canopy-rules returns categorical=true, income=true
// insert nutritional risk assessment for person
// Act: POST /v1/wic/evaluate
// Assert:
// assert_eq!(response.determination_status, "approved");
// assert!(response.categorical_eligible);
// assert!(response.income_eligible);
// assert!(response.nutritional_risk_documented);
// assert_eq!(response.food_package, Some("VI".to_string()));
// assert!(response.jws_token.is_some());
}
/// 2. Approved via adjunctive eligibility: child, over-income, enrolled in SNAP.
/// Verifies adjunctive bypass of income test via cross-program enrollment API.
#[tokio::test]
async fn wic_approved_child_adjunctive_snap() {
// Arrange: mock canopy-rules returns categorical=true, income=false
// mock canopy-eligibility enrollment API returns snap=enrolled
// insert nutritional risk assessment for person
// Act: POST /v1/wic/evaluate
// Assert:
// assert_eq!(response.determination_status, "approved");
// assert!(response.adjunctive_eligible);
// assert_eq!(response.adjunctive_program, Some("snap".to_string()));
// assert_eq!(response.food_package, Some("V".to_string()));
}
/// 3. Denied: infant, income-ineligible, no adjunctive eligibility.
/// Verifies denial when income test fails and no adjunctive program.
#[tokio::test]
async fn wic_denied_infant_over_income_no_adjunctive() {
// Arrange: mock canopy-rules returns categorical=true, income=false
// mock canopy-eligibility enrollment API returns all=not enrolled
// insert nutritional risk assessment for person
// Act: POST /v1/wic/evaluate
// Assert:
// assert_eq!(response.determination_status, "denied");
// assert!(!response.income_eligible);
// assert!(!response.adjunctive_eligible);
// assert!(response.food_package.is_none());
}
/// 4. Denied: breastfeeding woman, income-eligible, NO nutritional risk documented.
/// Verifies denial when nutritional risk assessment is missing.
#[tokio::test]
async fn wic_denied_no_nutritional_risk() {
// Arrange: mock canopy-rules returns categorical=true, income=true
// do NOT insert any nutritional risk assessment
// Act: POST /v1/wic/evaluate
// Assert:
// assert_eq!(response.determination_status, "denied");
// assert!(response.categorical_eligible);
// assert!(response.income_eligible);
// assert!(!response.nutritional_risk_documented);
}
/// 5. Assessment validation: reject assessment with no risk types flagged.
/// Verifies the completeness validation on nutritional risk recording.
#[tokio::test]
async fn wic_assessment_rejects_no_risk_types() {
// Arrange: build assessment request with all risk types = false
// Act: POST /v1/wic/assessments
// Assert:
// assert_eq!(status, 422);
// assert!(body.contains("at least one nutritional risk type"));
}
/// 6. Certification period: verify correct end date for each participant category.
/// Tests compute_certification_end for all 5 categories.
#[tokio::test]
async fn wic_certification_periods_correct() {
// Arrange: certification_start = 2026-04-01
// Assert:
// pregnant: end = 2027-01-01 (9 months)
// postpartum: end = 2026-10-01 (6 months)
// breastfeeding: end = 2027-04-01 (12 months)
// infant: end = 2026-10-01 (6 months, first segment)
// child: end = 2027-04-01 (12 months)
}
/// 7. JWS signature verification: determination signature validates.
/// Reconstructs canonical payload and verifies against the signing key.
#[tokio::test]
async fn wic_determination_signature_verifies() {
// Arrange: run a full determination that returns approved
// Act: extract jws_token, reconstruct payload without jws_token field
// Assert:
// let verified = verifying_key.verify_detached(&payload, &jws_token);
// assert!(verified.is_ok());
}
/// 8. Event publishing: verify wic.determination_completed and
/// wic.certification_created events are published on approval.
#[tokio::test]
async fn wic_events_published_on_approval() {
// Arrange: set up AMQP test consumer bound to canopy.events exchange
// with routing keys wic.determination_completed, wic.certification_created
// Act: POST /v1/wic/evaluate with approved scenario
// Assert:
// let det_event: DeterminationCompletedEvent = consume_next().await;
// assert_eq!(det_event.determination_status, "approved");
// assert!(det_event.determination_id != Uuid::nil());
//
// let cert_event: CertificationCreatedEvent = consume_next().await;
// assert!(cert_event.participant_category == "pregnant");
// assert!(cert_event.certification_start <= cert_event.certification_end);
//
// // Verify no income data or PHI in events (ADR-004)
// let raw = serde_json::to_string(&det_event).unwrap();
// assert!(!raw.contains("income"));
// assert!(!raw.contains("risk_codes"));
}
Error handling in tests:
-
Each test runs in its own database transaction (rolled back after completion) or uses a fresh testcontainers PostgreSQL instance.
-
wiremockmock servers are scoped per test to avoid cross-test interference. -
Tests that verify event publishing use a dedicated AMQP test consumer with a short timeout (5 seconds) to detect missing events.
Files Touched
| File | Change |
|---|---|
|
New: wic_participants, wic_determinations, wic_nutritional_risk_assessments tables + indexes |
|
Modify: add |
|
New: |
|
New: |
|
New: |
|
New: |
|
New: |
|
New: |
|
New: |
|
Modify: add |
|
Modify: wire |
|
New: |
|
New: JDM ruleset with 185% FPL income thresholds, categorical membership check |
|
New: 8 integration tests covering approval, denial, adjunctive, nutritional risk, certification periods, signing, events |
|
Modify: uncomment migration runner, wire |
|
Modify: add chrono, lapin, reqwest, serde_json, wiremock (dev), testcontainers (dev) dependencies |
Verification
-
cargo nextest run -p canopy-wic— all tests pass -
Verify WIC determination returns signed JWS per ADR-002
-
Verify categorical eligibility correctly identifies all five participant categories
-
Verify income test evaluates against 185% FPL threshold
-
Verify adjunctive eligibility bypasses income test when SNAP/Medicaid/TANF enrollment confirmed
-
Verify nutritional risk assessment must be documented before determination can complete
-
Verify food package assignment matches participant category per 7 CFR 246.10
-
Verify certification periods match 7 CFR 246.12 requirements for each participant category
-
Verify no FTI, IEVS, or PHI in events or determination payloads
Documentation Updates
-
.claude/docs/services.md— add wic_participants, wic_determinations, wic_nutritional_risk_assessments tables; document WIC API routes -
.claude/CLAUDE.md— update canopy-wic feature status when implementation begins -
CHANGELOG.adoc— entry under== Unreleased -
docs/modules/ROOT/pages/plans/wic-eligibility.adoc— update status table steps to COMPLETE