Plan: WIC Eligibility (canopy-wic)

On this page

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 wic.determination_completed and wic.certification_created

Done (2026-04-18)

8

Integration tests

Done (2026-04-13) — services/canopy-wic/tests/wic_test.rs + 6 unit tests

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:

  1. 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)

  2. Income test — household income at or below 185% FPL, or adjunctive eligibility through participation in SNAP, Medicaid, or TANF

  3. Nutritional risk — must have at least one documented nutritional risk factor (anthropometric, biochemical, dietary, or medical)

  4. 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): DeterminationStatus enum 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

  1. canopy-eligibility receives determination request for WIC program

  2. canopy-eligibility calls canopy-wic /v1/wic/evaluate

  3. canopy-wic retrieves household data from canopy-persons (demographics, pregnancy status, child age)

  4. canopy-wic calls canopy-rules to evaluate WIC categorical and income rulesets

  5. If income-ineligible, canopy-wic checks adjunctive eligibility via canopy-eligibility cross-program enrollment API

  6. canopy-wic checks for documented nutritional risk assessment (must already be recorded by clinical staff)

  7. canopy-wic stores determination in its own database

  8. canopy-wic signs determination via canopy-eligibility signing infrastructure (ADR-002)

  9. canopy-wic assigns food package based on participant category

  10. canopy-wic returns signed determination to canopy-eligibility

  11. canopy-wic publishes wic.determination_completed event

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

wic.determination_completed

determination_id, application_id, determination_status, completed_at

wic.certification_created

participant_id, person_id, participant_category, certification_start, certification_end

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_category is one of pregnant, postpartum, breastfeeding, infant, child. This is a simple membership check.

  • Income eligibility: gross_monthly_income ⇐ 185% FPL threshold for household_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() returns ApiError::RulesEngine with 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 at warn level.

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_risk all false), return ApiError::Validation with HTTP 422.

  • If risk_codes is empty, return ApiError::Validation with HTTP 422.

  • Unique constraint violations on id map to ApiError::Conflict (HTTP 409).

  • All sqlx::Error variants map to ApiError::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():

  1. Serialize the WicDetermination struct (without jws_token) to a canonical JSON byte array via serde_json::to_vec.

  2. Call signer.sign(&payload) which returns a detached JWS compact serialization string.

  3. Set det.jws_token = Some(token) before persisting.

  4. The signed determination is stored in wic_determinations and 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) returns ApiError::Internal. The error message is logged at error level; the HTTP response contains a generic error.

  • serde_json::to_vec failure (should not happen with valid structs) returns ApiError::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 error level 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_vec failures are propagated through anyhow::Error.

  • lapin channel 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.

  • wiremock mock 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

services/canopy-wic/migrations/20260401000000_create_wic_tables.sql

New: wic_participants, wic_determinations, wic_nutritional_risk_assessments tables + indexes

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

Modify: add pub mod models; pub mod determinations; pub mod participants; pub mod assessments;

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

New: WicParticipant, WicDetermination, WicNutritionalRiskAssessment structs

services/canopy-wic/src/store/determinations.rs

New: create_determination, get_determination, list_determinations_by_person

services/canopy-wic/src/store/participants.rs

New: create_participant, get_active_participant

services/canopy-wic/src/store/assessments.rs

New: create_assessment, get_latest_assessment, get_assessment

services/canopy-wic/src/eligibility.rs

New: evaluate(), check_adjunctive(), WicEvaluationRequest, WicEvaluationResponse, AdjunctiveClient, RulesClient, DeterminationSigner traits

services/canopy-wic/src/food_package.rs

New: assign_food_package(), assign_infant_food_package()

services/canopy-wic/src/certification.rs

New: compute_certification_end(), is_child_renewable()

services/canopy-wic/src/events.rs

Modify: add DeterminationCompletedEvent, CertificationCreatedEvent, publish_determination_completed(), publish_certification_created()

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

Modify: wire /v1/wic/evaluate, /v1/wic/assessments, /v1/wic/assessments/{id} routes; add event publishing after evaluation

services/canopy-wic/src/api/assessments.rs

New: post_assessment(), get_assessment() handlers

rulesets/{jurisdiction}/wic-eligibility.json

New: JDM ruleset with 185% FPL income thresholds, categorical membership check

services/canopy-wic/tests/wic_tests.rs

New: 8 integration tests covering approval, denial, adjunctive, nutritional risk, certification periods, signing, events

services/canopy-wic/src/main.rs

Modify: uncomment migration runner, wire WicState with DB pool, AMQP channel, rules client, adjunctive client, signer

services/canopy-wic/Cargo.toml

Modify: add chrono, lapin, reqwest, serde_json, wiremock (dev), testcontainers (dev) dependencies

Verification

  1. cargo nextest run -p canopy-wic — all tests pass

  2. Verify WIC determination returns signed JWS per ADR-002

  3. Verify categorical eligibility correctly identifies all five participant categories

  4. Verify income test evaluates against 185% FPL threshold

  5. Verify adjunctive eligibility bypasses income test when SNAP/Medicaid/TANF enrollment confirmed

  6. Verify nutritional risk assessment must be documented before determination can complete

  7. Verify food package assignment matches participant category per 7 CFR 246.10

  8. Verify certification periods match 7 CFR 246.12 requirements for each participant category

  9. 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

Edit this page · default