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 |
3 |
Typst notice templates (14 SNAP templates + 11 Orchard components in |
Done (2026-04-02) — (evolved from Askama to Typst PDF generation via |
4 |
10-day advance notice enforcement and effective date adjustment |
Done (2026-04-02) — ( |
5 |
Notice delivery queue and test delivery adapter |
Done (2026-04-02) — ( |
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):
-
noticesandnotice_appeals_rightstables -
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
-
TestDeliveryAdapterthat 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 |
|---|---|---|
|
SNAP determination approved |
ApprovalNotice |
|
SNAP determination denied |
DenialNotice |
|
SNAP pended for verification |
PendingNotice |
|
Termination or reduction pending |
TerminationNotice with 10-day advance enforcement |
|
Household qualifies for expedited SNAP |
ExpeditedNotice |
|
ABAWD month 1 of 3-month window |
AbawdNotice (month 1 warning) |
|
ABAWD month 2 of 3-month window |
AbawdNotice (month 2 warning) |
|
ABAWD exhausted 3 months |
TerminationNotice (ABAWD basis) + AbawdNotice |
|
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 |
|---|---|
|
List notices for household (paginated, newest first) |
|
Get notice with full body and appeals rights |
|
Re-queue notice for delivery |
|
Admin: pending delivery queue |
|
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.eventstopic 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— fromjurisdiction.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 |
|---|---|
|
New: notices, notice_appeals_rights tables |
|
New: base template with header/footer |
|
New: SNAP approval notice template |
|
New: SNAP denial notice template |
|
New: 10-day advance termination notice |
|
New: ABAWD time limit warning (parametrized for months 1 and 2) |
|
New: Expedited service identification notice |
|
New: EBT stale benefit expungement pre-notice |
|
New: NoticeGenerator with per-type methods |
|
New: NoticeDeliveryAdapter trait, TestDeliveryAdapter |
|
Replace empty Router::new() with full route set |
|
Enable migrations; wire event subscriber; wire delivery adapter |
Verification
-
cargo nextest run -p canopy-notices— all tests pass -
Approval event → approval notice in DB within 1 second
-
Termination with 5-day effective date → date adjusted to +11,
advance_notice_adjusted = true -
GET /v1/notices/{id}→ notice body contains regulatory basis, hearing rights -
Denial notice body does NOT contain household income amount (privacy check)
-
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