Plan: Notice Generation (SNAP First)

On this page

Status

Step Description Status

1

Database schema: notices, notice_appeals_rights tables

Done (2026-04-02) — (tables in canopy-notices migrations)

2

Event subscriptions from canopy.events

Done (2026-04-02) — (publisher wired; subscriber deferred to deployment-profiles-event-wiring)

3

Typst notice templates (14 SNAP templates + 11 Orchard components in rulesets/georgia/notices/)

Done (2026-04-02) — (evolved from Askama to Typst PDF generation via canopy-typst crate)

4

10-day advance notice enforcement and effective date adjustment

Done (2026-04-02) — (generator.rs enforces advance notice period)

5

Notice delivery queue and test delivery adapter

Done (2026-04-02) — (delivery.rs with TestDeliveryAdapter for UAT)

6

API endpoints and integration tests

Done (2026-04-02) — (6 routes: generate, list, get, get_pdf, resend, delivery_queue)

Epic: &41
Branch: feature/notice-generation

Context

Federal regulations require written notices at specific points in the eligibility lifecycle. The content, timing, and delivery of notices are federally mandated — failure creates fair hearing rights and payment error exposure.

Key requirements: - 7 CFR 273.13(a): Written notice of approval, denial, or pended status required within 30 days of application - 7 CFR 273.13(b): 10-day advance notice required before adverse actions (termination, reduction) take effect - 7 CFR 273.2(i)(3): Written notice when household identified for expedited service - 7 USC §2016(h)(9): Written notice 30 days before EBT benefit expungement - All notices must state: action taken, reason with regulatory citation, right to fair hearing, right to continued benefits (where applicable), contact information

canopy-notices subscribes to events from the event bus and generates notice records. For UAT: notices are stored in the database and viewable by workers. Physical delivery (mail, email) is implemented via a test adapter that records delivery attempts.

Scope

In scope (SNAP UAT):

  • notices and notice_appeals_rights tables

  • Event subscriptions: determination.completed, determination.adverse_action_pending, application.expedited_identified, abawd.warning_month_1, abawd.warning_month_2, abawd.time_limit_reached, enrollment.expungement_pending

  • Askama templates for: SNAP approval, SNAP denial (with regulatory basis), 10-day advance notice of termination, ABAWD month-1 warning, ABAWD month-2 warning, ABAWD exhausted, expedited service notice, EBT expungement pre-notice

  • 10-day advance notice enforcement: auto-adjust effective_date if < 10 days from notice generation

  • TestDeliveryAdapter that stores delivery attempts and returns success (for UAT)

  • API: list notices, get notice, resend notice, delivery queue admin

Out of scope:

  • TANF, Medicaid, CAPS, WIC notices (later phases)

  • Physical mail integration (printer/mail vendor API)

  • Email delivery (SMTP configuration)

  • SMS delivery

  • Fluent i18n translation (Spanish) — architecture supports it, English only for UAT

  • Applicant portal notice inbox (canopy-portal plan)

Design

Database schema

CREATE TABLE notices (
    id UUID PRIMARY KEY,
    household_id UUID NOT NULL,
    recipient_person_id UUID NOT NULL,
    notice_type TEXT NOT NULL,           -- NoticeType enum value
    program TEXT,                        -- Program enum value; null for cross-program notices
    application_id UUID,
    determination_id UUID,
    subject TEXT NOT NULL,
    body_text TEXT NOT NULL,             -- plain text body (required)
    body_html TEXT,                      -- HTML body for email/portal (optional)
    locale TEXT NOT NULL DEFAULT 'en-US',
    regulatory_basis TEXT NOT NULL,      -- e.g., '7 CFR 273.13(a)'
    effective_date DATE,                 -- date adverse action takes effect; null for non-adverse
    notice_date DATE NOT NULL,           -- date notice generated
    advance_notice_days INTEGER,         -- days between notice_date and effective_date
    advance_notice_adjusted BOOLEAN NOT NULL DEFAULT false,
    -- true if effective_date was pushed forward to comply with 10-day rule
    delivery_status TEXT NOT NULL DEFAULT 'pending',
    -- 'pending', 'queued', 'sent', 'delivered', 'failed', 'suppressed'
    delivered_at TIMESTAMPTZ,
    delivery_channel TEXT DEFAULT 'test',
    -- 'mail', 'email', 'portal', 'test'
    created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
    active BOOLEAN NOT NULL DEFAULT true
);

CREATE INDEX notices_household_idx ON notices (household_id);
CREATE INDEX notices_delivery_status_idx ON notices (delivery_status) WHERE delivery_status = 'pending';

CREATE TABLE notice_appeals_rights (
    id UUID PRIMARY KEY,
    notice_id UUID NOT NULL REFERENCES notices(id),
    hearing_request_deadline DATE NOT NULL,  -- notice_date + 90 days for SNAP
    continued_benefits_available BOOLEAN NOT NULL DEFAULT false,
    continued_benefits_request_deadline DATE,  -- must request BEFORE effective_date
    hearing_phone TEXT NOT NULL,  -- loaded from jurisdiction.toml [notices] hearing_phone
    hearing_address TEXT,
    created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

Event subscriptions

canopy-notices subscribes to the following events from canopy.events:

Event Trigger Notice Generated

determination.completed (status=approved)

SNAP determination approved

ApprovalNotice

determination.completed (status=denied)

SNAP determination denied

DenialNotice

determination.completed (status=PendingVerification)

SNAP pended for verification

PendingNotice

determination.adverse_action_pending

Termination or reduction pending

TerminationNotice with 10-day advance enforcement

application.expedited_identified

Household qualifies for expedited SNAP

ExpeditedNotice

abawd.warning_month_1

ABAWD month 1 of 3-month window

AbawdNotice (month 1 warning)

abawd.warning_month_2

ABAWD month 2 of 3-month window

AbawdNotice (month 2 warning)

abawd.time_limit_reached

ABAWD exhausted 3 months

TerminationNotice (ABAWD basis) + AbawdNotice

enrollment.expungement_pending

EBT benefits expiring in 30 days

ExpungementNotice

Per ADR-004 conventions, no events contain personal data, income amounts, or SSNs. When canopy-notices receives an event with only IDs, it calls canopy-persons and canopy-applications to fetch the display information needed for the notice body.

10-day advance notice enforcement

When generating a TerminationNotice: 1. Calculate effective_date - notice_date in calendar days 2. If result < 10: - Adjust effective_date = notice_date + 11 (11 to ensure the full 10 days, regardless of weekends) - Set advance_notice_adjusted = true - Log: WARN advance_notice_adjusted effective_date={} notice_date={} — this is an alertable condition 3. Generate notice_appeals_rights with continued_benefits_request_deadline = effective_date - 1

If advance_notice_adjusted = true, canopy-enrollment must check the revised effective_date before terminating benefits. Publish event notice.advance_notice_adjusted → {notice_id, original_effective_date, adjusted_effective_date, household_id}.

Askama templates

Template files in services/canopy-notices/templates/snap/:

All templates extend base.txt which provides header (agency name, address, date) and footer (hearing rights boilerplate).

snap_approval.txt:

NOTICE OF ACTION — FOOD STAMP BENEFITS APPROVED

Date: {{ notice_date }}
Case Number: {{ household_id }}
Head of Household: {{ recipient_name }}

YOUR APPLICATION FOR FOOD STAMP BENEFITS HAS BEEN APPROVED.

Benefit Amount: ${{ benefit_amount }} per month
Effective Date: {{ effective_date }}
Certification Period: {{ cert_start_date }} to {{ cert_end_date }}

... [hearing rights per 7 CFR 273.13] ...

snap_denial.txt:

NOTICE OF ACTION — FOOD STAMP BENEFITS DENIED

...
REASON FOR DENIAL:

Your household's income exceeds the gross income limit for your household size.

Regulatory basis: {{ regulatory_basis }}
Gross monthly income: [not included — never include income amounts in notices stored in shared DB]
Income limit for household of {{ household_size }}: See attached tables

... [hearing rights with continued benefits not available since denied] ...

Note: Include regulatory citation but NOT the household’s specific income amount in the notice body stored in the notices table. Income amounts are PII/program data — they belong only in the program service database. The notice can reference the comparison without including the amount: "Your household’s income exceeds the limit for your household size."

snap_termination.txt: Must include effective_date, reason, and explicit statement that benefits continue pending hearing if request filed before effective_date.

Notice delivery adapter

pub trait NoticeDeliveryAdapter: Send + Sync {
    async fn deliver(&self, notice: &Notice) -> Result<DeliveryResult>;
}

pub struct TestDeliveryAdapter; // Records delivery attempt, returns success
pub struct MailDeliveryAdapter { /* future */ }
pub struct EmailDeliveryAdapter { /* future */ }

For UAT: TestDeliveryAdapter is configured, sets delivery_status = 'sent' immediately.

API endpoints

Method + Path Description

GET /v1/notices?household_id={id}

List notices for household (paginated, newest first)

GET /v1/notices/{id}

Get notice with full body and appeals rights

POST /v1/notices/{id}/resend

Re-queue notice for delivery

GET /v1/notices/queue

Admin: pending delivery queue

GET /v1/notices/{id}/preview

Preview rendered notice (for worker review before send)

All endpoints require canopy-worker role minimum. /v1/notices/queue requires canopy-snap-supervisor or higher.

CLI Commands (ADR-007)

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

  • canopy notice list --household-id <id> — list notices for a household (paginated, newest first)

  • canopy notice get <id> — get notice with full body and appeals rights

  • canopy notice resend <id> — re-queue notice for delivery

  • canopy notice queue — list pending delivery queue

  • canopy notice preview <id> — preview rendered notice

Steps

Step 1: Database migrations

Files: services/canopy-notices/migrations/20260327200000_notices.sql (new), services/canopy-notices/src/main.rs (update)

Create notices and notice_appeals_rights tables using the SQL from the Design section above. Include the indexes defined in Design:

CREATE INDEX notices_household_idx ON notices (household_id);
CREATE INDEX notices_delivery_status_idx ON notices (delivery_status) WHERE delivery_status = 'pending';
CREATE INDEX notices_recipient_idx ON notices (recipient_person_id);
CREATE INDEX notices_determination_idx ON notices (determination_id) WHERE determination_id IS NOT NULL;
CREATE INDEX notice_appeals_rights_notice_idx ON notice_appeals_rights (notice_id);

Update services/canopy-notices/src/main.rs to uncomment the migration runner: boot.db.run_migrations(&sqlx::migrate!()).await?;.

Error handling: migration failure must halt service startup. Notice generation depends on these tables; running without them would silently drop regulatory notices.

Step 2: Event subscriber setup

Files: services/canopy-notices/src/events.rs (update), services/canopy-notices/src/main.rs (update)

Update services/canopy-notices/src/events.rs to implement event handlers for all trigger events:

// SPDX-License-Identifier: AGPL-3.0-or-later

use canopy_mq::subscriber::{EventSubscriber, EventHandler};
use canopy_mq::envelope::Envelope;
use uuid::Uuid;

/// Event payloads received from canopy.events topic exchange.
/// Per ADR-004: no personal data, income amounts, or SSNs in events.
#[derive(Debug, Deserialize)]
pub struct DeterminationCompletedEvent {
    pub determination_id: Uuid,
    pub household_id: Uuid,
    pub program: String,
    pub status: String,  // "approved", "denied", "PendingVerification"
}

#[derive(Debug, Deserialize)]
pub struct AdverseActionPendingEvent {
    pub household_id: Uuid,
    pub determination_id: Uuid,
    pub effective_date: NaiveDate,
    pub reason: String,
}

#[derive(Debug, Deserialize)]
pub struct AbawdWarningEvent {
    pub person_id: Uuid,
    pub household_id: Uuid,
    pub months_used: i32,
}

pub async fn handle_determination_completed(envelope: Envelope<DeterminationCompletedEvent>, generator: &NoticeGenerator) -> Result<()> {
    match envelope.payload.status.as_str() {
        "approved" => generator.generate_snap_approval(envelope.payload.determination_id, envelope.payload.household_id).await?,
        "denied" => generator.generate_snap_denial(envelope.payload.determination_id, envelope.payload.household_id, vec![]).await?,
        "PendingVerification" => generator.generate_snap_pending(envelope.payload.determination_id, envelope.payload.household_id).await?,
        _ => tracing::warn!(status = %envelope.payload.status, "Unknown determination status; no notice generated"),
    };
    Ok(())
}
// ... additional handlers for each event type

Update services/canopy-notices/src/main.rs to wire the RabbitMQ subscriber using canopy_mq::subscriber::EventSubscriber:

  • Queue name: canopy-notices.events

  • Bind to canopy.events topic exchange with routing keys: determination.completed, determination.adverse_action_pending, application.expedited_identified, abawd.warning_month_1, abawd.warning_month_2, abawd.time_limit_reached, enrollment.expungement_pending

  • Spawn the subscriber as a background Tokio task alongside the HTTP server

  • On handler error: log at ERROR level with the event routing key and payload IDs, then NACK with requeue (allows retry)

Step 3: Askama templates

Files: services/canopy-notices/templates/base.txt (new), services/canopy-notices/templates/snap/snap_approval.txt (new), services/canopy-notices/templates/snap/snap_denial.txt (new), services/canopy-notices/templates/snap/snap_termination.txt (new), services/canopy-notices/templates/snap/snap_abawd_warning.txt (new), services/canopy-notices/templates/snap/snap_expedited.txt (new), services/canopy-notices/templates/snap/snap_expungement.txt (new), services/canopy-notices/src/templates.rs (new)

Create services/canopy-notices/templates/base.txt as the base Askama template with agency header (name, address, phone from jurisdiction.toml [agency]) and footer (hearing rights boilerplate, contact info).

Create each SNAP template using Askama {% extends "base.txt" %} syntax. Template variables follow these conventions:

  • notice_date: NaiveDate — formatted as "Month DD, YYYY"

  • household_id: Uuid — displayed as case number

  • recipient_name: String — head of household name (fetched from canopy-persons)

  • regulatory_basis: String — CFR citation for the action

  • hearing_phone: String — from jurisdiction.toml [notices] hearing_phone

For snap_termination.txt: include effective_date, explicit statement that benefits continue if hearing requested before effective date, and the continued_benefits_request_deadline (= effective_date - 1 day).

Create services/canopy-notices/src/templates.rs with Askama template structs:

// SPDX-License-Identifier: AGPL-3.0-or-later

use askama::Template;
use chrono::NaiveDate;
use uuid::Uuid;

#[derive(Template)]
#[template(path = "snap/snap_approval.txt")]
pub struct SnapApprovalTemplate {
    pub notice_date: NaiveDate,
    pub household_id: Uuid,
    pub recipient_name: String,
    pub benefit_amount: String,  // formatted currency
    pub effective_date: NaiveDate,
    pub cert_start_date: NaiveDate,
    pub cert_end_date: NaiveDate,
    pub regulatory_basis: String,
    pub hearing_phone: String,
}

#[derive(Template)]
#[template(path = "snap/snap_denial.txt")]
pub struct SnapDenialTemplate {
    pub notice_date: NaiveDate,
    pub household_id: Uuid,
    pub recipient_name: String,
    pub denial_reason: String,
    pub household_size: i32,
    pub regulatory_basis: String,
    pub hearing_phone: String,
}

#[derive(Template)]
#[template(path = "snap/snap_termination.txt")]
pub struct SnapTerminationTemplate {
    pub notice_date: NaiveDate,
    pub household_id: Uuid,
    pub recipient_name: String,
    pub effective_date: NaiveDate,
    pub reason: String,
    pub regulatory_basis: String,
    pub continued_benefits_deadline: NaiveDate,
    pub hearing_phone: String,
}
// ... additional template structs for abawd_warning, expedited, expungement

Privacy note: the snap_denial.txt template must NOT include the household’s specific income amount in the body text. Reference the comparison generically: "Your household’s income exceeds the limit for your household size."

Update services/canopy-notices/src/main.rs to add mod templates;.

Step 4: Notice generation service

Files: services/canopy-notices/src/generator.rs (new)

pub struct NoticeGenerator {
    persons_client: PersonsClient,
    delivery: Box<dyn NoticeDeliveryAdapter>,
    store: NoticeStore,
}

impl NoticeGenerator {
    pub async fn generate_snap_approval(&self, determination_id: Uuid, household_id: Uuid) -> Result<Uuid>;
    pub async fn generate_snap_denial(&self, determination_id: Uuid, household_id: Uuid, denial_codes: Vec<String>) -> Result<Uuid>;
    pub async fn generate_snap_termination(&self, household_id: Uuid, effective_date: NaiveDate, reason: String) -> Result<Uuid>;
    // ... one method per notice type
}

10-day enforcement is called within generate_snap_termination before writing to DB.

Step 5: API routes

Files: services/canopy-notices/src/api/mod.rs (update), services/canopy-notices/src/store/mod.rs (new), services/canopy-notices/src/store/models.rs (new), services/canopy-notices/src/delivery.rs (new)

Create services/canopy-notices/src/store/models.rs with sqlx model structs:

// SPDX-License-Identifier: AGPL-3.0-or-later

#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
pub struct Notice { /* all columns from notices table */ }

#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
pub struct NoticeAppealsRights { /* all columns from notice_appeals_rights table */ }

Create services/canopy-notices/src/store/mod.rs with query functions:

  • list_notices_by_household(pool, household_id, page: PageRequest) → Vec<Notice> — paginated, newest first

  • get_notice(pool, id) → Option<Notice>

  • get_notice_with_appeals_rights(pool, id) → Option<(Notice, Option<NoticeAppealsRights>)>

  • list_pending_delivery(pool, page: PageRequest) → Vec<Notice>delivery_status = 'pending'

  • update_delivery_status(pool, id, status, delivered_at) → Notice

Create services/canopy-notices/src/delivery.rs with the NoticeDeliveryAdapter trait and TestDeliveryAdapter:

// SPDX-License-Identifier: AGPL-3.0-or-later

use crate::store::models::Notice;
use anyhow::Result;

pub struct DeliveryResult {
    pub delivered: bool,
    pub channel: String,
    pub error: Option<String>,
}

pub trait NoticeDeliveryAdapter: Send + Sync {
    async fn deliver(&self, notice: &Notice) -> Result<DeliveryResult>;
}

pub struct TestDeliveryAdapter;

impl NoticeDeliveryAdapter for TestDeliveryAdapter {
    async fn deliver(&self, _notice: &Notice) -> Result<DeliveryResult> {
        Ok(DeliveryResult { delivered: true, channel: "test".into(), error: None })
    }
}

Update services/canopy-notices/src/api/mod.rs with full route set:

pub fn routes() -> Router<AppState> {
    Router::new()
        .route("/v1/notices", get(list_notices))           // Query: household_id
        .route("/v1/notices/:id", get(get_notice))
        .route("/v1/notices/:id/resend", post(resend_notice))
        .route("/v1/notices/:id/preview", get(preview_notice))
        .route("/v1/notices/queue", get(delivery_queue))   // canopy-snap-supervisor
}

Auth: all endpoints require canopy-worker role minimum. /v1/notices/queue requires canopy-snap-supervisor or higher.

Error handling: - 404 if notice not found - 409 if resend_notice called on a notice with delivery_status = 'suppressed' - preview_notice re-renders the Askama template for the notice type and returns plain text

Step 6: Integration tests

Files: services/canopy-notices/tests/notice_generation_test.rs (new)

Use testcontainers-rs with PostgreSQL and RabbitMQ containers. Use canopy_test_lib for test harness setup.

// SPDX-License-Identifier: AGPL-3.0-or-later

use canopy_test_lib::{setup_test_db, setup_test_mq};

#[tokio::test]
async fn test_determination_approved_creates_approval_notice() {
    // Publish determination.completed event with status="approved"
    // Assert: notice record in DB with notice_type="ApprovalNotice", program="snap"
    // Assert: notice body contains regulatory_basis "7 CFR 273.13(a)"
    // Assert: notice_appeals_rights record created with hearing_request_deadline = notice_date + 90
}

#[tokio::test]
async fn test_termination_10_day_enforcement() {
    // Publish determination.adverse_action_pending with effective_date = today + 5
    // Assert: effective_date adjusted to today + 11
    // Assert: advance_notice_adjusted = true
    // Assert: notice.advance_notice_adjusted event published
    // Assert: continued_benefits_request_deadline = adjusted_effective_date - 1
}

#[tokio::test]
async fn test_termination_sufficient_notice() {
    // Publish determination.adverse_action_pending with effective_date = today + 15
    // Assert: effective_date NOT adjusted (15 >= 10)
    // Assert: advance_notice_adjusted = false
}

#[tokio::test]
async fn test_denial_notice_no_income_in_body() {
    // Publish determination.completed with status="denied"
    // Assert: notice body does NOT contain dollar amounts or income figures
    // Assert: body references "exceeds the limit" generically
}

#[tokio::test]
async fn test_list_notices_api() {
    // Insert 3 notices for household_id
    // GET /v1/notices?household_id={id}
    // Assert: 3 notices returned, newest first
}

#[tokio::test]
async fn test_delivery_adapter_called() {
    // Generate a notice with TestDeliveryAdapter
    // Assert: delivery_status = 'sent', delivered_at is set
}

#[tokio::test]
async fn test_abawd_warning_month_1() {
    // Publish abawd.warning_month_1 event
    // Assert: AbawdNotice created with month_1 warning content
}

Each test must run migrations via sqlx::migrate!() on the test container. Verify template rendering produces valid output (no Askama rendering errors, no empty fields).

Files Touched

File Change

services/canopy-notices/migrations/YYYYMMDD_notices.sql

New: notices, notice_appeals_rights tables

services/canopy-notices/templates/base.txt

New: base template with header/footer

services/canopy-notices/templates/snap/snap_approval.txt

New: SNAP approval notice template

services/canopy-notices/templates/snap/snap_denial.txt

New: SNAP denial notice template

services/canopy-notices/templates/snap/snap_termination.txt

New: 10-day advance termination notice

services/canopy-notices/templates/snap/snap_abawd_warning.txt

New: ABAWD time limit warning (parametrized for months 1 and 2)

services/canopy-notices/templates/snap/snap_expedited.txt

New: Expedited service identification notice

services/canopy-notices/templates/snap/snap_expungement.txt

New: EBT stale benefit expungement pre-notice

services/canopy-notices/src/generator.rs

New: NoticeGenerator with per-type methods

services/canopy-notices/src/delivery.rs

New: NoticeDeliveryAdapter trait, TestDeliveryAdapter

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

Replace empty Router::new() with full route set

services/canopy-notices/src/main.rs

Enable migrations; wire event subscriber; wire delivery adapter

Verification

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

  2. Approval event → approval notice in DB within 1 second

  3. Termination with 5-day effective date → date adjusted to +11, advance_notice_adjusted = true

  4. GET /v1/notices/{id} → notice body contains regulatory basis, hearing rights

  5. Denial notice body does NOT contain household income amount (privacy check)

  6. cargo clippy --all-targets — -D warnings — zero warnings

Documentation Updates

  • .claude/docs/services.md — add notices tables, events subscribed, endpoints

  • CHANGELOG.adoc — entry under == Unreleased

Edit this page · default