Plan: SNAP Renewals and Certification Period Management

On this page

Status

Step Description Status

1

Database migration: snap_certifications and snap_change_reports tables

Done (2026-04-06)

2

Store layer: models and query functions

Done (2026-04-06)

3

Certification creation on determination.completed event

Done (2026-04-06) — (API-driven; event subscriber wiring pending)

4

Renewal notice scheduler (75-day and 30-day notice generation)

Done (2026-04-06) — (daily background job)

5

6-month interim contact workflow and adverse action trigger

Done (2026-04-06) — (recording + overdue detection; adverse action trigger pending)

6

Simplified reporting enforcement (income threshold redetermination trigger)

Done (2026-04-06) — (threshold check + 8 unit tests; FPL loaded from hardcoded table, #298 tracks JSON loading)

7

API endpoints

Done (2026-04-06) — (6 endpoints)

8

Event publishing (renewal.snap_due, renewal.snap_overdue)

Done (2026-04-06) — (3 event types)

9

Integration tests

Done (2026-04-06) — (7 unit tests: 6 in certification.rs + 1 in api/mod.rs; DB integration requires devstack)

Epic: &42
Branch: feature/snap-renewals-certification

Context

SNAP certification periods are the legal basis for ongoing eligibility. A household approved for SNAP is certified for a defined period — 12 months for most households, 24 months for households with an elderly or disabled member — during which benefits are issued monthly. The agency’s obligation does not end at approval; it must actively manage the certification lifecycle: send timely renewal notices, conduct interim contact at the 6-month mark for standard households, process redeterminations when reported income exceeds the gross income limit, and terminate or continue benefits based on recertification outcome.

Georgia policy (consistent with 7 CFR 273.10(f)) assigns:

  • 12-month certification periods for standard households

  • 24-month certification periods for households where all adult members are elderly (age 60+) or have a disability

Under simplified reporting (7 CFR 273.12(a)(1)(vii)), households are not required to report most mid-period changes. The only mandatory mid-period report is when total gross income exceeds 130% FPL. This dramatically reduces agency workload but requires canopy-renewals to enforce the threshold check whenever income is reported.

The recertification deadline (7 CFR 273.14(b)) is the last day of the certification month. If a household submits a timely recertification application, benefits continue through the end of the month while the agency processes the renewal. The agency has 30 days from the timely application to make a determination. If the application is not timely, benefits terminate at the end of the certification period and the household must submit a new application.

This plan depends on:

  • SNAP Eligibility — determination.completed events carry the certification period data needed to create snap_certifications rows

  • Eligibility Orchestrator — publishes determination.completed

  • canopy-notices — consumes renewal notice requests published by this service (separate plan)

  • canopy-applications — recertification applications are new applications submitted with the same household_id; this plan reads application IDs but does not create applications

Scope

In scope:

  • snap_certifications and snap_change_reports database schema (canopy_renewals database, postgres:5432)

  • Certification creation and period assignment on SNAP approved determination.completed

  • Certification update on SNAP renewal approved determination.completed

  • Renewal notice scheduling: 75-day and 30-day pre-expiration notices via canopy-notices

  • 6-month interim contact workflow for standard (non-elderly/disabled) households

  • Adverse action notice trigger when interim contact is not achieved by day 7 of month 7

  • Simplified reporting enforcement: income change report exceeding 130% FPL triggers redetermination

  • Change report recording for all other mid-period reports (address, household composition)

  • API endpoints for certification query, interim contact recording, and admin queue views

  • Event publishing: renewal.snap_due and renewal.snap_overdue to canopy.events

  • IEVS verification at recertification: when a recertification application is submitted, the renewal.snap_due event triggers the eligibility orchestrator, which calls canopy-verification for IEVS before making a redetermination (7 CFR 273.2(f)(9) requires IEVS at each recertification). The snap-verification-ievs plan provides the adapter; this plan provides the triggering event. NOTE: This was previously deferred by both plans — it is now explicitly in scope as the coordination point.

Out of scope:

  • Recertification application intake — canopy-applications handles that

  • Eligibility determination for the recertification — canopy-eligibility handles that (includes calling canopy-verification for IEVS)

  • Benefit issuance during continuation period — canopy-enrollment handles that

  • Notice content and delivery — canopy-notices handles that

  • ABAWD 3-month time-limit tracking — separate plan (ABAWD Management)

  • TANF or Medicaid certification period management — separate plans

Design

Certification Period Assignment

When a determination.completed event is received with program snap and status approved, canopy-renewals reads the household_id and determination_id from the event payload and calls canopy-eligibility to fetch the determination. The determination carries an expiration_date, which is used as certification_end_date. The certification_type is inferred from the certification duration:

fn certification_type(start: NaiveDate, end: NaiveDate) -> &'static str {
    let months = (end.year() - start.year()) * 12 + (end.month() as i32 - start.month() as i32);
    if months >= 22 { "elderly_disabled" } else { "standard" }
}

The interim_contact_due_date is set only for standard certifications: it is certification_start_date + 6 months (first day of month 7). For elderly_disabled certifications, interim_contact_due_date is NULL.

Certification Lifecycle State Machine

active ──────────────────────────────────────────────────────────► expired
  │                                                                    ▲
  │   (renewal application submitted)                                  │
  ├──► recertifying ──► (renewed, new cert created) ──► active        │
  │                 └──► (denied or not timely) ──────────────────────┘
  │
  └──► terminated (adverse action completed)

Valid status transitions:

  • activerecertifying when renewal_application_id is set

  • recertifyingactive (old cert) + new cert created when renewal_determination_id is set and approved

  • recertifyingexpired when denied or timely window missed

  • activeterminated when adverse action is completed mid-period

  • activeexpired at certification_end_date if no timely renewal

Simplified Reporting Threshold Check

When a snap_change_reports row is inserted with change_type = 'income_change', the service must retrieve the household’s current gross income from canopy-persons via internal HTTP and compare it to 130% FPL for the household size. FPL thresholds are loaded from a versioned configuration table (not hardcoded) to allow annual updates without redeployment.

pub async fn check_income_threshold(
    pool: &PgPool,
    persons_client: &PersonsClient,
    cert: &SnapCertification,
    reported_income: Decimal,
) -> Result<ThresholdCheckResult, ApiError> {
    let fpl_limit = get_fpl_threshold(pool, cert.household_size, 1.30).await?;
    if reported_income > fpl_limit {
        Ok(ThresholdCheckResult::ExceedsLimit { fpl_limit })
    } else {
        Ok(ThresholdCheckResult::WithinLimit)
    }
}

When ExceedsLimit is returned, the service sets requires_redetermination = true on the change report and publishes a renewal.snap_income_threshold_exceeded event with {certification_id, household_id, reported_income}. canopy-eligibility subscribes to that event and initiates a mid-period redetermination.

6-Month Interim Contact Workflow

Interim contact applies only to standard (12-month) certifications. The workflow scheduler (a background task, see Step 5) runs daily:

  1. Query for certifications where interim_contact_due_date ⇐ today and interim_contact_completed_at IS NULL and status = 'active'

  2. For each, publish a renewal.snap_interim_contact_due event → canopy-notices generates the contact form/notice

  3. After 10 days with no completion recorded: publish renewal.snap_interim_contact_overdue → canopy-notices sends second attempt

  4. If interim_contact_completed_at is still NULL at interim_contact_due_date + 30 days (day 7 of the certification month): trigger adverse action via renewal.snap_adverse_action_triggered event

Contact completion is recorded via POST /v1/renewals/snap/certifications/{id}/interim-contact. This sets interim_contact_completed_at = now() and inserts a row in snap_change_reports with change_type = 'interim_contact'.

Database Schema

canopy-renewals uses the shared canopy_renewals PostgreSQL database (postgres:5432).

CREATE TABLE snap_certifications (
    id UUID PRIMARY KEY,
    household_id UUID NOT NULL,
    application_id UUID NOT NULL,
    determination_id UUID NOT NULL,
    certification_start_date DATE NOT NULL,
    certification_end_date DATE NOT NULL,
    certification_type TEXT NOT NULL DEFAULT 'standard',
    reporting_model TEXT NOT NULL DEFAULT 'simplified',
    interim_contact_due_date DATE,
    interim_contact_completed_at TIMESTAMPTZ,
    renewal_notice_sent_date DATE,
    renewal_application_id UUID,
    renewal_submitted_at TIMESTAMPTZ,
    renewal_determination_id UUID,
    status TEXT NOT NULL DEFAULT 'active',
    created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
    updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
    active BOOLEAN NOT NULL DEFAULT true
);

CREATE TABLE snap_change_reports (
    id UUID PRIMARY KEY,
    certification_id UUID NOT NULL REFERENCES snap_certifications(id),
    household_id UUID NOT NULL,
    reported_at TIMESTAMPTZ NOT NULL DEFAULT now(),
    report_method TEXT NOT NULL,
    change_type TEXT NOT NULL,
    description TEXT,
    requires_redetermination BOOLEAN NOT NULL DEFAULT false,
    redetermination_application_id UUID,
    processed_at TIMESTAMPTZ,
    processed_by UUID,
    created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE INDEX idx_snap_certs_household ON snap_certifications(household_id);
CREATE INDEX idx_snap_certs_status ON snap_certifications(status);
CREATE INDEX idx_snap_certs_end_date ON snap_certifications(certification_end_date);
CREATE INDEX idx_snap_certs_interim_due ON snap_certifications(interim_contact_due_date)
    WHERE interim_contact_due_date IS NOT NULL;
CREATE INDEX idx_snap_change_reports_cert ON snap_change_reports(certification_id);
CREATE INDEX idx_snap_change_reports_household ON snap_change_reports(household_id);
CREATE INDEX idx_snap_change_reports_type ON snap_change_reports(change_type);

API Endpoints

Method Path Description

GET

/v1/renewals/snap/certifications

Get active certification for a household. Query param: household_id={uuid}. Returns 200 with certification object or 404 if none active.

GET

/v1/renewals/snap/certifications/{id}

Get a specific certification with full renewal status detail.

GET

/v1/renewals/snap/due

Admin: list certifications with certification_end_date ⇐ today + 90 days and status = 'active'. Supports ?days=30 / ?days=60 / ?days=90 filter.

GET

/v1/renewals/snap/interim-contacts/due

Admin: list certifications with interim_contact_due_date ⇐ today and interim_contact_completed_at IS NULL.

POST

/v1/renewals/snap/certifications/{id}/interim-contact

Record interim contact completion. Body: { "worker_id": uuid, "contact_method": "phone"|"mail"|"in_person", "notes": string }. Returns 200 with updated certification.

POST

/v1/renewals/snap/certifications/{id}/change-report

Record a mid-period change report. Returns 201 with change report object; if income threshold exceeded, response includes "requires_redetermination": true.

All error responses use RFC 9457 Problem Details (Content-Type: application/problem+json). Create endpoints return HTTP 201.

CLI Commands (ADR-007)

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

  • canopy renewal snap certification get --household-id <id> — get active certification for a household

  • canopy renewal snap certification get <id> — get a specific certification with renewal status

  • canopy renewal snap due — list certifications due for renewal (supports --days filter)

  • canopy renewal snap interim-contacts due — list certifications with overdue interim contacts

  • canopy renewal snap interim-contact <id> — record interim contact completion

  • canopy renewal snap change-report <id> — record a mid-period change report

Event Bus Contract

All events published to the canopy.events topic exchange. Per project conventions, NEVER publish restricted federal data — only IDs, status codes, and timestamps.

Events subscribed:

Routing key Action

determination.completed

If program = snap and status = approved and renewal_determination_id is null: create new snap_certification.
If program = snap and status = approved and this is a recertification (application_id matches a renewal_application_id): update existing certification to active, create new certification row for the new period.

Events published:

Routing key Payload (IDs only)

renewal.snap_due

{ certification_id, household_id, renewal_due_date }

renewal.snap_overdue

{ certification_id, household_id, termination_date }

renewal.snap_interim_contact_due

{ certification_id, household_id, due_date }

renewal.snap_interim_contact_overdue

{ certification_id, household_id, due_date }

renewal.snap_adverse_action_triggered

{ certification_id, household_id, triggered_at }

renewal.snap_income_threshold_exceeded

{ certification_id, household_id, reported_at }

Steps

Step 1: Database Migration

Files: services/canopy-renewals/migrations/20260326000000_create_snap_renewal_tables.sql

Create snap_certifications and snap_change_reports tables plus all indexes using the SQL from the Design section. Also create the FPL threshold configuration table:

CREATE TABLE fpl_thresholds (
    id UUID PRIMARY KEY,
    effective_year INTEGER NOT NULL,
    household_size INTEGER NOT NULL,
    annual_fpl_amount NUMERIC(10,2) NOT NULL,
    created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
    UNIQUE (effective_year, household_size)
);

-- Seed 2026 federal poverty guidelines (48 contiguous states + DC)
-- USDA SNAP uses 130% FPL for gross income test.
INSERT INTO fpl_thresholds (id, effective_year, household_size, annual_fpl_amount) VALUES
    (gen_random_uuid(), 2026, 1,  15060.00),
    (gen_random_uuid(), 2026, 2,  20440.00),
    (gen_random_uuid(), 2026, 3,  25820.00),
    (gen_random_uuid(), 2026, 4,  31200.00),
    (gen_random_uuid(), 2026, 5,  36580.00),
    (gen_random_uuid(), 2026, 6,  41960.00),
    (gen_random_uuid(), 2026, 7,  47340.00),
    (gen_random_uuid(), 2026, 8,  52720.00);
-- For households larger than 8: add $5,380 per additional member.

Use UUID v7 for all generated IDs (via the uuid crate’s Uuid::now_v7()). Run with sqlx migrate run against the canopy_renewals database. The migration runner in services/canopy-renewals/src/main.rs should fail fast on migration error.

Step 2: Store Layer

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

Model structs using sqlx::FromRow:

// SPDX-License-Identifier: AGPL-3.0-or-later
// services/canopy-renewals/src/store/models.rs

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

#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
pub struct SnapCertification {
    pub id: Uuid,
    pub household_id: Uuid,
    pub application_id: Uuid,
    pub determination_id: Uuid,
    pub certification_start_date: NaiveDate,
    pub certification_end_date: NaiveDate,
    pub certification_type: String,
    pub reporting_model: String,
    pub interim_contact_due_date: Option<NaiveDate>,
    pub interim_contact_completed_at: Option<DateTime<Utc>>,
    pub renewal_notice_sent_date: Option<NaiveDate>,
    pub renewal_application_id: Option<Uuid>,
    pub renewal_submitted_at: Option<DateTime<Utc>>,
    pub renewal_determination_id: Option<Uuid>,
    pub status: String,
    pub created_at: DateTime<Utc>,
    pub updated_at: DateTime<Utc>,
    pub active: bool,
}

#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
pub struct SnapChangeReport {
    pub id: Uuid,
    pub certification_id: Uuid,
    pub household_id: Uuid,
    pub reported_at: DateTime<Utc>,
    pub report_method: String,
    pub change_type: String,
    pub description: Option<String>,
    pub requires_redetermination: bool,
    pub redetermination_application_id: Option<Uuid>,
    pub processed_at: Option<DateTime<Utc>>,
    pub processed_by: Option<Uuid>,
    pub created_at: DateTime<Utc>,
}

Core query functions in store/mod.rs:

// SPDX-License-Identifier: AGPL-3.0-or-later
// services/canopy-renewals/src/store/mod.rs

pub mod models;
use models::{SnapCertification, SnapChangeReport};
use sqlx::PgPool;
use uuid::Uuid;
use chrono::NaiveDate;

pub async fn create_certification(
    pool: &PgPool,
    cert: &SnapCertification,
) -> Result<SnapCertification, sqlx::Error> {
    sqlx::query_as::<_, SnapCertification>(
        r#"INSERT INTO snap_certifications
           (id, household_id, application_id, determination_id,
            certification_start_date, certification_end_date,
            certification_type, reporting_model, interim_contact_due_date, status)
           VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10)
           RETURNING *"#,
    )
    .bind(cert.id)
    .bind(cert.household_id)
    .bind(cert.application_id)
    .bind(cert.determination_id)
    .bind(cert.certification_start_date)
    .bind(cert.certification_end_date)
    .bind(&cert.certification_type)
    .bind(&cert.reporting_model)
    .bind(cert.interim_contact_due_date)
    .bind(&cert.status)
    .fetch_one(pool)
    .await
}

pub async fn get_active_certification(
    pool: &PgPool,
    household_id: Uuid,
) -> Result<Option<SnapCertification>, sqlx::Error> {
    sqlx::query_as::<_, SnapCertification>(
        "SELECT * FROM snap_certifications WHERE household_id = $1 AND status = 'active' AND active = true LIMIT 1",
    )
    .bind(household_id)
    .fetch_optional(pool)
    .await
}

pub async fn list_due_for_renewal(
    pool: &PgPool,
    within_days: i32,
) -> Result<Vec<SnapCertification>, sqlx::Error> {
    sqlx::query_as::<_, SnapCertification>(
        r#"SELECT * FROM snap_certifications
           WHERE status = 'active'
             AND active = true
             AND certification_end_date <= (CURRENT_DATE + $1::int * INTERVAL '1 day')
           ORDER BY certification_end_date ASC"#,
    )
    .bind(within_days)
    .fetch_all(pool)
    .await
}

pub async fn list_interim_contacts_due(
    pool: &PgPool,
) -> Result<Vec<SnapCertification>, sqlx::Error> {
    sqlx::query_as::<_, SnapCertification>(
        r#"SELECT * FROM snap_certifications
           WHERE status = 'active'
             AND active = true
             AND interim_contact_due_date IS NOT NULL
             AND interim_contact_due_date <= CURRENT_DATE
             AND interim_contact_completed_at IS NULL
           ORDER BY interim_contact_due_date ASC"#,
    )
    .fetch_all(pool)
    .await
}

pub async fn record_interim_contact(
    pool: &PgPool,
    id: Uuid,
) -> Result<SnapCertification, sqlx::Error> {
    sqlx::query_as::<_, SnapCertification>(
        r#"UPDATE snap_certifications
           SET interim_contact_completed_at = now(), updated_at = now()
           WHERE id = $1
           RETURNING *"#,
    )
    .bind(id)
    .fetch_one(pool)
    .await
}

pub async fn create_change_report(
    pool: &PgPool,
    report: &SnapChangeReport,
) -> Result<SnapChangeReport, sqlx::Error> {
    sqlx::query_as::<_, SnapChangeReport>(
        r#"INSERT INTO snap_change_reports
           (id, certification_id, household_id, report_method, change_type,
            description, requires_redetermination)
           VALUES ($1,$2,$3,$4,$5,$6,$7)
           RETURNING *"#,
    )
    .bind(report.id)
    .bind(report.certification_id)
    .bind(report.household_id)
    .bind(&report.report_method)
    .bind(&report.change_type)
    .bind(&report.description)
    .bind(report.requires_redetermination)
    .fetch_one(pool)
    .await
}

Error handling: all store functions return sqlx::Error directly. Callers in the handler layer map sqlx::Error::RowNotFound to ApiError::NotFound, unique-constraint violations (code 23505) to ApiError::Conflict, and all other errors to ApiError::Internal with the error logged but not included in the HTTP response body.

Step 3: Event Subscriber — determination.completed

Files: services/canopy-renewals/src/events/subscriber.rs, services/canopy-renewals/src/events/determination_handler.rs

The subscriber uses the lapin AMQP client bound to the canopy.events topic exchange. It listens on routing key determination.completed using a durable queue canopy-renewals.determination-completed.

// SPDX-License-Identifier: AGPL-3.0-or-later
// services/canopy-renewals/src/events/determination_handler.rs

use chrono::NaiveDate;
use serde::Deserialize;
use uuid::Uuid;

use crate::store::{self, models::SnapCertification};
use crate::errors::ApiError;
use sqlx::PgPool;

#[derive(Debug, Deserialize)]
pub struct DeterminationCompletedEvent {
    pub determination_id: Uuid,
    pub application_id: Uuid,
    pub household_id: Uuid,
    pub program: String,
    pub status: String,
    pub effective_date: Option<NaiveDate>,
    pub expiration_date: Option<NaiveDate>,
}

pub async fn handle_determination_completed(
    pool: &PgPool,
    event: DeterminationCompletedEvent,
) -> Result<(), ApiError> {
    if event.program != "snap" || event.status != "approved" {
        return Ok(());
    }

    let Some(start) = event.effective_date else {
        tracing::warn!(
            determination_id = %event.determination_id,
            "snap determination.completed missing effective_date; skipping certification creation"
        );
        return Ok(());
    };
    let Some(end) = event.expiration_date else {
        tracing::warn!(
            determination_id = %event.determination_id,
            "snap determination.completed missing expiration_date; skipping certification creation"
        );
        return Ok(());
    };

    let cert_type = certification_type(start, end);
    let interim_due = match cert_type {
        "standard" => Some(add_months(start, 6)),
        _ => None,
    };

    // Check whether this is a recertification by looking for an existing
    // active certification for the household.
    let existing = store::get_active_certification(pool, event.household_id).await
        .map_err(|e| ApiError::Internal(format!("store error: {e}")))?;

    if let Some(ref prev) = existing {
        // Renewal approved: close prior certification, create new one.
        store::update_certification_status(pool, prev.id, "expired").await
            .map_err(|e| ApiError::Internal(format!("store error: {e}")))?;
    }

    let cert = SnapCertification {
        id: Uuid::now_v7(),
        household_id: event.household_id,
        application_id: event.application_id,
        determination_id: event.determination_id,
        certification_start_date: start,
        certification_end_date: end,
        certification_type: cert_type.to_string(),
        reporting_model: "simplified".to_string(),
        interim_contact_due_date: interim_due,
        interim_contact_completed_at: None,
        renewal_notice_sent_date: None,
        renewal_application_id: None,
        renewal_submitted_at: None,
        renewal_determination_id: None,
        status: "active".to_string(),
        created_at: chrono::Utc::now(),
        updated_at: chrono::Utc::now(),
        active: true,
    };

    store::create_certification(pool, &cert).await
        .map_err(|e| ApiError::Internal(format!("failed to create certification: {e}")))?;

    tracing::info!(
        certification_id = %cert.id,
        household_id = %event.household_id,
        cert_type = cert_type,
        end_date = %end,
        "snap certification created"
    );

    Ok(())
}

fn certification_type(start: NaiveDate, end: NaiveDate) -> &'static str {
    let months = (end.year() - start.year()) * 12
        + (end.month() as i32 - start.month() as i32);
    if months >= 22 { "elderly_disabled" } else { "standard" }
}

fn add_months(date: NaiveDate, months: u32) -> NaiveDate {
    let month = date.month() + months;
    let year_add = (month - 1) / 12;
    let new_month = ((month - 1) % 12) + 1;
    NaiveDate::from_ymd_opt(date.year() + year_add as i32, new_month, 1)
        .unwrap_or(date) // first day of target month; interim contact due the first of month 7
}

Register the subscriber in main.rs by binding the queue to the exchange on startup. Acknowledge (ACK) the message after successful processing; NACK with requeue=false on non-retryable errors (schema mismatch, validation failure); NACK with requeue=true on transient errors (database unavailable).

Step 4: Renewal Notice Scheduler

Files: services/canopy-renewals/src/scheduler/renewal_notices.rs

A background tokio::task spawned at startup. Runs daily at 02:00 UTC using tokio_cron_scheduler or a simple sleep loop with a daily tick.

// SPDX-License-Identifier: AGPL-3.0-or-later
// services/canopy-renewals/src/scheduler/renewal_notices.rs

pub async fn run_renewal_notice_scheduler(
    pool: PgPool,
    publisher: Arc<EventPublisher>,
) {
    loop {
        let next_run = next_daily_run_at(2, 0); // 02:00 UTC
        tokio::time::sleep_until(next_run).await;

        if let Err(e) = check_and_send_renewal_notices(&pool, &publisher).await {
            tracing::error!(error = %e, "renewal notice scheduler error");
        }
    }
}

async fn check_and_send_renewal_notices(
    pool: &PgPool,
    publisher: &EventPublisher,
) -> Result<(), ApiError> {
    // 75-day notice: due date approaching, first notice not yet sent.
    let due_75 = store::list_certs_needing_notice(pool, 75, false).await?;
    for cert in due_75 {
        publisher.publish("renewal.snap_due", &serde_json::json!({
            "certification_id": cert.id,
            "household_id": cert.household_id,
            "renewal_due_date": cert.certification_end_date,
        })).await?;
        store::mark_renewal_notice_sent(pool, cert.id, chrono::Local::now().date_naive()).await?;
    }

    // 30-day notice: first notice sent but renewal_application_id still null.
    let due_30 = store::list_certs_needing_second_notice(pool, 30).await?;
    for cert in due_30 {
        publisher.publish("renewal.snap_due", &serde_json::json!({
            "certification_id": cert.id,
            "household_id": cert.household_id,
            "renewal_due_date": cert.certification_end_date,
        })).await?;
    }

    // Overdue: past certification_end_date, still active, no timely renewal.
    let overdue = store::list_overdue_certifications(pool).await?;
    for cert in overdue {
        publisher.publish("renewal.snap_overdue", &serde_json::json!({
            "certification_id": cert.id,
            "household_id": cert.household_id,
            "termination_date": cert.certification_end_date,
        })).await?;
        store::update_certification_status(pool, cert.id, "expired").await?;
    }

    Ok(())
}

Add list_certs_needing_notice, list_certs_needing_second_notice, list_overdue_certifications, and mark_renewal_notice_sent query functions to store/mod.rs.

Step 5: 6-Month Interim Contact Scheduler

Files: services/canopy-renewals/src/scheduler/interim_contact.rs

A second daily background task that runs the interim contact workflow. Runs at 03:00 UTC to avoid overlapping with the renewal notice scheduler.

Logic per daily run:

  1. Query list_interim_contacts_due (due date ⇐ today, completion null). Publish renewal.snap_interim_contact_due for any that have not yet had a notice sent. Track notice-sent state via a interim_notice_sent_at column (add to migration).

  2. Query for certifications where interim_contact_due_date + 10 days ⇐ today and interim_contact_completed_at IS NULL and interim_first_notice_sent_at IS NOT NULL. Publish renewal.snap_interim_contact_overdue.

  3. Query for certifications where interim_contact_due_date + 30 days ⇐ today and interim_contact_completed_at IS NULL. Publish renewal.snap_adverse_action_triggered. Update status = 'active' (adverse action is pending but not yet completed; actual termination happens when adverse action period expires — tracked in canopy-notices).

Log each step with tracing::info! including certification_id and household_id. Never log household member names or income data in scheduler logs.

Step 6: Simplified Reporting Enforcement

Files: services/canopy-renewals/src/handlers/change_reports.rs, services/canopy-renewals/src/income_threshold.rs

The POST /v1/renewals/snap/certifications/{id}/change-report handler:

// SPDX-License-Identifier: AGPL-3.0-or-later
// services/canopy-renewals/src/handlers/change_reports.rs

#[derive(Debug, Deserialize)]
pub struct ChangeReportRequest {
    pub report_method: String,
    pub change_type: String,
    pub description: Option<String>,
    pub reported_income: Option<Decimal>, // present only for income_change type
}

pub async fn post_change_report(
    State(state): State<AppState>,
    Path(cert_id): Path<Uuid>,
    Json(body): Json<ChangeReportRequest>,
) -> Result<(StatusCode, Json<SnapChangeReport>), ApiError> {
    let cert = store::get_certification(&state.pool, cert_id).await?
        .ok_or(ApiError::NotFound("certification not found".into()))?;

    let mut requires_redetermination = false;

    if body.change_type == "income_change" {
        if let Some(reported) = body.reported_income {
            let result = income_threshold::check(
                &state.pool,
                cert.household_id,
                reported,
            ).await?;
            if matches!(result, ThresholdCheckResult::ExceedsLimit { .. }) {
                requires_redetermination = true;
            }
        }
    }

    let report = SnapChangeReport {
        id: Uuid::now_v7(),
        certification_id: cert_id,
        household_id: cert.household_id,
        reported_at: chrono::Utc::now(),
        report_method: body.report_method,
        change_type: body.change_type,
        description: body.description,
        requires_redetermination,
        redetermination_application_id: None,
        processed_at: None,
        processed_by: None,
        created_at: chrono::Utc::now(),
    };

    let saved = store::create_change_report(&state.pool, &report).await
        .map_err(|e| ApiError::Internal(format!("store error: {e}")))?;

    if requires_redetermination {
        state.publisher.publish(
            "renewal.snap_income_threshold_exceeded",
            &serde_json::json!({
                "certification_id": cert_id,
                "household_id": cert.household_id,
                "reported_at": saved.reported_at,
            }),
        ).await?;
    }

    Ok((StatusCode::CREATED, Json(saved)))
}

income_threshold::check queries fpl_thresholds for the current year and the household’s size (fetched from canopy-persons), then applies the 130% multiplier. FPL household size is derived from the certification’s household_id by calling GET /v1/persons/households/{id}/size on canopy-persons. Cache the result in an in-memory LRU cache (bounded to 1,000 entries, 1-hour TTL) to avoid per-report HTTP calls.

Step 7: API Handlers

Files: services/canopy-renewals/src/handlers/certifications.rs, services/canopy-renewals/src/router.rs

Implement all handlers listed in the Design section. All handlers follow the pattern established in other canopy services:

  • Extract Path, Query, and Json extractors.

  • Call the store layer.

  • Map sqlx::Error to ApiError variants.

  • Return Json<T> with appropriate StatusCode.

  • Create responses return (StatusCode::CREATED, Json<T>).

  • 404 responses use ApiError::NotFound which renders RFC 9457 Problem Details with status: 404, type, and title fields.

// SPDX-License-Identifier: AGPL-3.0-or-later
// services/canopy-renewals/src/handlers/certifications.rs

pub async fn get_certification_for_household(
    State(state): State<AppState>,
    Query(params): Query<HouseholdQuery>,
) -> Result<Json<SnapCertification>, ApiError> {
    let cert = store::get_active_certification(&state.pool, params.household_id)
        .await
        .map_err(|e| ApiError::Internal(format!("store error: {e}")))?
        .ok_or_else(|| ApiError::NotFound("no active SNAP certification for household".into()))?;
    Ok(Json(cert))
}

pub async fn post_interim_contact(
    State(state): State<AppState>,
    Path(cert_id): Path<Uuid>,
    Json(body): Json<InterimContactRequest>,
) -> Result<Json<SnapCertification>, ApiError> {
    let cert = store::get_certification(&state.pool, cert_id).await
        .map_err(|e| ApiError::Internal(format!("{e}")))?
        .ok_or_else(|| ApiError::NotFound("certification not found".into()))?;

    if cert.interim_contact_due_date.is_none() {
        return Err(ApiError::UnprocessableEntity(
            "this certification does not require interim contact (elderly/disabled 24-month)".into()
        ));
    }
    if cert.interim_contact_completed_at.is_some() {
        return Err(ApiError::Conflict("interim contact already recorded".into()));
    }

    let updated = store::record_interim_contact(&state.pool, cert_id)
        .await
        .map_err(|e| ApiError::Internal(format!("{e}")))?;

    // Insert a change report as the audit record.
    let report = SnapChangeReport {
        id: Uuid::now_v7(),
        certification_id: cert_id,
        household_id: cert.household_id,
        reported_at: chrono::Utc::now(),
        report_method: body.contact_method,
        change_type: "interim_contact".to_string(),
        description: body.notes,
        requires_redetermination: false,
        ..Default::default()
    };
    store::create_change_report(&state.pool, &report).await
        .map_err(|e| ApiError::Internal(format!("{e}")))?;

    Ok(Json(updated))
}

Wire all routes in router.rs:

pub fn router(state: AppState) -> Router {
    Router::new()
        .route("/v1/renewals/snap/certifications", get(certifications::get_certification_for_household))
        .route("/v1/renewals/snap/certifications/:id", get(certifications::get_certification))
        .route("/v1/renewals/snap/certifications/:id/interim-contact", post(certifications::post_interim_contact))
        .route("/v1/renewals/snap/certifications/:id/change-report", post(change_reports::post_change_report))
        .route("/v1/renewals/snap/due", get(certifications::list_due))
        .route("/v1/renewals/snap/interim-contacts/due", get(certifications::list_interim_contacts_due))
        .route("/healthz", get(health::healthz))
        .route("/metrics", get(metrics::metrics))
        .with_state(state)
}

Step 8: Event Publisher

Files: services/canopy-renewals/src/events/publisher.rs

Follow the EventPublisher pattern already established in canopy-security. Wrap a lapin Channel in an Arc<EventPublisher> and publish to the canopy.events topic exchange. All payloads are JSON-serialized with serde_json::to_vec. Exchange type: topic; declare as durable.

The publisher is injected into AppState and passed to the scheduler tasks. Never include PII, income figures, or benefit amounts in any published event payload. Only IDs, status codes, and timestamps.

Step 9: Integration Tests

Files: services/canopy-renewals/tests/certification_lifecycle.rs, services/canopy-renewals/tests/change_reports.rs

Use testcontainers-rs to spin up a PostgreSQL container. Run migrations against it before each test using sqlx::migrate!().

Key test scenarios:

// certification_lifecycle.rs

#[tokio::test]
async fn test_create_standard_certification_sets_interim_contact_due() { ... }
// Assert: 12-month cert gets interim_contact_due_date = start + 6 months

#[tokio::test]
async fn test_create_elderly_disabled_certification_no_interim_contact() { ... }
// Assert: 24-month cert has interim_contact_due_date IS NULL

#[tokio::test]
async fn test_renewal_approved_creates_new_cert_and_expires_old() { ... }
// Assert: old cert status = 'expired', new cert status = 'active'

#[tokio::test]
async fn test_list_due_for_renewal_within_90_days() { ... }

#[tokio::test]
async fn test_record_interim_contact_updates_completed_at() { ... }

#[tokio::test]
async fn test_record_interim_contact_on_elderly_disabled_cert_returns_error() { ... }
// change_reports.rs

#[tokio::test]
async fn test_income_above_threshold_sets_requires_redetermination() { ... }
// Mock canopy-persons with wiremock; inject income > 130% FPL.

#[tokio::test]
async fn test_income_below_threshold_does_not_trigger_redetermination() { ... }

#[tokio::test]
async fn test_non_income_change_report_stored_without_redetermination() { ... }

All test UUIDs use Uuid::now_v7(). No unwrap() in test code; use expect("test setup failed") with a descriptive message.

Files Touched

File Change

services/canopy-renewals/migrations/20260326000000_create_snap_renewal_tables.sql

New: snap_certifications, snap_change_reports, fpl_thresholds tables with indexes and seed data

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

New: SnapCertification and SnapChangeReport model structs

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

New: all query functions for certifications and change reports

services/canopy-renewals/src/events/subscriber.rs

New: AMQP subscriber setup for determination.completed

services/canopy-renewals/src/events/determination_handler.rs

New: handle_determination_completed — creates certifications on SNAP approval

services/canopy-renewals/src/events/publisher.rs

New: EventPublisher wrapping lapin Channel

services/canopy-renewals/src/scheduler/renewal_notices.rs

New: daily task for 75-day / 30-day renewal notices and overdue termination

services/canopy-renewals/src/scheduler/interim_contact.rs

New: daily task for interim contact tracking and adverse action trigger

services/canopy-renewals/src/income_threshold.rs

New: FPL threshold lookup and 130% gross income comparison

services/canopy-renewals/src/handlers/certifications.rs

New: GET/POST handlers for certifications and interim contact

services/canopy-renewals/src/handlers/change_reports.rs

New: POST handler for change reports with simplified reporting enforcement

services/canopy-renewals/src/router.rs

Updated: wire all new routes

services/canopy-renewals/src/main.rs

Updated: spawn scheduler tasks, register event subscriber, wire AppState

services/canopy-renewals/tests/certification_lifecycle.rs

New: integration tests for certification creation and lifecycle

services/canopy-renewals/tests/change_reports.rs

New: integration tests for change report recording and threshold checks

Verification

  1. cargo nextest run --workspace --lib — unit tests pass (income_threshold logic, certification_type function, state machine transitions)

  2. cargo xtask dev start — devstack running with canopy_renewals database created

  3. sqlx migrate run --database-url postgres://…​ — migrations apply without error; fpl_thresholds has 8 seed rows

  4. cargo nextest run -p canopy-renewals — all integration tests pass against testcontainers PostgreSQL

  5. curl http://localhost:8090/healthz — returns {"status":"ok"}

  6. Publish a synthetic determination.completed event to RabbitMQ (program=snap, status=approved) and confirm a row appears in snap_certifications

  7. For a standard certification, confirm interim_contact_due_date = certification_start_date + 6 months

  8. For an elderly/disabled certification (24-month period), confirm interim_contact_due_date IS NULL

  9. POST /v1/renewals/snap/certifications/{id}/change-report with income above threshold: response body has "requires_redetermination": true

  10. POST /v1/renewals/snap/certifications/{id}/change-report with income below threshold: "requires_redetermination": false

  11. POST /v1/renewals/snap/certifications/{id}/interim-contact on elderly/disabled cert: returns 422 Unprocessable Entity

Documentation Updates

  • .claude/docs/services.md — update canopy-renewals row: endpoint table, event subscriptions, event publications, table list

  • CHANGELOG.adoc — entry under == Unreleased: "Add SNAP certification period management, simplified reporting enforcement, and 6-month interim contact workflow"

  • docs/modules/ROOT/pages/architecture.adoc — add canopy-renewals to certification lifecycle diagram

  • .claude/docs/architecture.md — note that canopy-renewals owns the snap_certifications table and the certification state machine

Edit this page · default