Plan: Typst Document Generation Architecture
On this page
- Status
- Context
- Scope
- Dependencies
- Design
- Steps
- Step 1: Create canopy-typst shared crate
- Step 2: Create Orchard design system Typst components
- Step 3: Create SNAP notice templates
- Step 4: Create SNAP form templates
- Step 5: Configuration and manifest
- Step 6: Database migration
- Step 7: canopy-notices service implementation
- Step 8: Integration tests
- Files Touched
- Verification
- Documentation Updates
- Potential Improvements
Status
| Step | Description | Status |
|---|---|---|
1 |
Create canopy-typst shared crate (engine, context, manifest, error types) |
Done (2026-03-28) |
2 |
Create Orchard design system Typst components (orchard.typ, letterhead, footer, hearing-rights, civil-rights, accessibility) |
Done (2026-03-28) |
3 |
Create SNAP notice templates (noa-approval, noa-denial, noa-termination, abawd-warning, expedited, expungement) |
Done (2026-03-28) — (10 of 10) |
4 |
Create SNAP form templates (verification-checklist, renewal-form, change-report, work-requirements) |
Done (2026-03-28) |
5 |
Add [notices] section to jurisdiction.toml and manifest.toml registry |
Done (2026-03-28) |
6 |
Database migration: notices table with PDF-centric schema (replaces body_text/body_html) |
Done (2026-03-28) |
7 |
Implement canopy-notices service (generator, delivery adapter, event handlers, API) |
Done (2026-03-28) |
8 |
Integration tests (render to PDF, verify branding, store in S3) |
Done (2026-03-28) — (render tests; S3 integration deferred to canopy-store wiring) |
Epic: &41
Branch: feature/typst-document-generation
Labels: type::feature, priority::critical, program::cross-program, service::notices
Context
Canopy must generate 400+ unique document types (notices, forms, reports) across 5 benefit programs (SNAP, TANF, Medicaid/CHIP, CAPS, WIC) plus Spanish and large-print variants. The existing notice-generation plan uses Askama (compile-time Rust templates) producing plain text. This doesn’t work because:
-
Agencies mail physical PDFs, not plain text. Every Notice of Action (NOA) is a multi-page PDF with agency letterhead, regulatory citations, hearing rights, and civil rights statements.
-
Askama templates are compiled into the binary — jurisdiction wording changes require a Rust recompile and redeploy. With 400+ documents, this is unmaintainable.
-
No composability — each Askama template is standalone, duplicating headers, footers, hearing rights blocks, and civil rights statements across every template.
-
No PDF output — Askama produces text/HTML, not the typeset PDFs that agencies actually mail.
Typst replaces Askama. Typst is a Rust-native typesetting system that compiles .typ template files to PDF. Templates are loaded from the filesystem at runtime under rulesets/{jurisdiction}/notices/, following the ADR-003 ruleset-as-data pattern. A new shared crate canopy-typst wraps typst-as-lib for Rust integration, reusable by any service (notices, reports, federal exports).
Analysis of actual Georgia DHS documents (Form 297 — 25-page multi-program application, Form 333 — 2-page sanction notice, Form 700 — 9-page Medicaid application, and 55 others) confirmed that all documents share common composable blocks: agency letterhead, hearing rights tear-off, USDA civil rights statement, ADA/language accessibility boilerplate, form revision tracking footer, and program-specific content sections.
Scope
In scope:
-
New shared crate
canopy-typstwrappingtypst-as-libwith dedicated render thread -
Orchard design system components in Typst (colors, fonts, spacing from brand sheet)
-
Composable document components: letterhead, footer, hearing-rights, civil-rights, language-access, disability-access, signature-block, checkbox-grid, data-table, field-row
-
SNAP notice templates: approval, denial, termination, change, pending, ABAWD warnings (month 1/2/exhausted), expedited, expungement, sanction, continued-benefits
-
SNAP form templates: verification-checklist (Form 173), renewal-form (Form 508), change-report (Form 846), work-requirements (Form 859)
-
Template manifest (
manifest.toml) with version tracking per template -
PDF storage in canopy-store (S3), metadata in database
-
Notice timing logic via JDM ruleset (advance_notice_days from jurisdiction.toml)
-
jurisdiction.toml[notices]section (hearing phone, agency info, advance notice days) -
Updated canopy-notices service with Typst rendering, event subscriptions, delivery adapter, API
Out of scope:
-
TANF, Medicaid, CAPS, WIC notice templates (later phases — same architecture)
-
Form 297 (Application for Benefits) — complex multi-program form, separate plan
-
Live mail/email/SMS delivery adapters (TestDeliveryAdapter for UAT)
-
Spanish/large-print variants (architecture supports locale directories, English-only for UAT)
-
Applicant portal notice inbox (canopy-portal plan)
-
G-845 form generation for SAVE Step 3 manual review
Dependencies
This plan depends on:
-
canopy-store (complete): S3 storage for generated PDFs
-
canopy-mq (complete): event subscriptions from
canopy.events -
canopy-persons (complete): fetching recipient display data (ADR-004)
-
canopy-applications (complete): fetching application/determination data
-
canopy-signing (complete):
DeterminationSignertrait pattern (reused for render thread architecture) -
canopy-rules-client (complete): calling canopy-rules for notice timing evaluation
Design
Template file organization
rulesets/{jurisdiction}/notices/
manifest.toml # Template registry
fonts/ # Montserrat TTFs
assets/ # SVG logos
components/ # Shared Typst components
orchard.typ # Design system constants
letterhead.typ # Agency header with logo
footer.typ # Form number + revision + page count
hearing-rights.typ # Fair hearing rights (parameterized)
civil-rights.typ # USDA nondiscrimination statement
language-access.typ # Language accessibility
disability-access.typ # ADA accommodation
signature-block.typ # Official signature area
checkbox-grid.typ # Yes/no and program selection
data-table.typ # Repeating row tables
field-row.typ # Label + blank fill-in line
snap/ # SNAP templates
noa-approval.typ
noa-denial.typ
noa-termination.typ
...
tanf/ # TANF (future)
medicaid/ # Medicaid (future)
Composability pattern
Each notice template imports shared components via Typst #import:
// snap/noa-approval.typ
#import "../components/orchard.typ": *
#import "../components/letterhead.typ": letterhead
#import "../components/hearing-rights.typ": hearing-rights
#import "../components/civil-rights.typ": civil-rights-statement
#import "../components/footer.typ": page-footer
#set text(font: body-font, fill: body-color, size: 11pt)
#set page(footer: page-footer(form-number, template-version))
#letterhead(agency-name, agency-address, agency-phone, notice-date,
case-number, recipient-name, recipient-address)
#heading(level: 1)[NOTICE OF ACTION — SNAP BENEFITS APPROVED]
Your application for SNAP benefits has been approved.
#table(
columns: (auto, auto),
[*Monthly Benefit Amount:*], [$ #benefit-amount],
[*Effective Date:*], [#effective-date],
[*Certification Period:*], [#cert-start to #cert-end],
)
#hearing-rights(hearing-phone, hearing-address, 90, false, none, none)
#civil-rights-statement()
Jurisdictions customize wording by editing component files. New programs reuse all components with different notice bodies.
Orchard design system (orchard.typ)
#let primary = rgb("#1e5146")
#let secondary = rgb("#2d7060")
#let accent = rgb("#ecbf44")
#let body-color = rgb("#38424b")
#let heading-color = rgb("#031018")
#let muted = rgb("#5f766b")
#let light-bg = rgb("#f3f7f5")
#let border-color = rgb("#c8d9cf")
#let body-font = "Montserrat"
#let heading-size = 16pt
#let body-size = 11pt
#let small-size = 9pt
Template versioning (manifest.toml)
[meta]
jurisdiction = "georgia"
schema_version = 1
[templates.snap.noa-approval]
version = "2026.1"
file = "snap/noa-approval.typ"
form_number = "DHS-297-A"
effective_date = 2026-04-01
Rust data contract
// crates/canopy-typst/src/context.rs
pub struct TemplateRef {
pub jurisdiction: String,
pub program: Program,
pub template_key: String,
pub version: String,
pub form_number: Option<String>,
}
pub struct NoticeContext {
pub agency_name: String,
pub agency_address: String,
pub agency_phone: String,
pub recipient_name: String,
pub recipient_address: Address,
pub case_number: String,
pub notice_date: NaiveDate,
pub form_number: String,
pub template_version: String,
pub locale: String,
pub program_data: serde_json::Value, // per-notice-type fields
pub hearing_phone: String,
pub hearing_address: Option<String>,
pub appeal_deadline_days: i32,
pub effective_date: Option<NaiveDate>,
pub continued_benefits_available: bool,
pub continued_benefits_deadline: Option<NaiveDate>,
}
pub struct RenderedNotice {
pub pdf_bytes: Vec<u8>,
pub template_ref: TemplateRef,
pub page_count: u32,
}
Rendering engine
Follows the proven canopy-rules zen-engine pattern: dedicated OS thread with mpsc/oneshot channel protocol.
// crates/canopy-typst/src/engine.rs
pub struct TypstEngine {
render_tx: mpsc::Sender<RenderRequest>,
}
impl TypstEngine {
pub fn new(rulesets_root: PathBuf, font_paths: Vec<PathBuf>) -> Self;
pub async fn render(&self, template_ref: &TemplateRef,
context: &NoticeContext) -> Result<RenderedNotice, RenderError>;
}
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,
program TEXT,
application_id UUID,
determination_id UUID,
subject TEXT NOT NULL,
template_key TEXT NOT NULL,
template_version TEXT NOT NULL,
form_number TEXT,
locale TEXT NOT NULL DEFAULT 'en-US',
regulatory_basis TEXT NOT NULL,
effective_date DATE,
notice_date DATE NOT NULL,
advance_notice_days INTEGER,
advance_notice_adjusted BOOLEAN NOT NULL DEFAULT false,
pdf_storage_path TEXT,
pdf_size_bytes BIGINT,
page_count INTEGER,
delivery_status TEXT NOT NULL DEFAULT 'pending',
delivered_at TIMESTAMPTZ,
delivery_channel TEXT DEFAULT 'test',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
active BOOLEAN NOT NULL DEFAULT true
);
CREATE TABLE notice_appeals_rights (
id UUID PRIMARY KEY,
notice_id UUID NOT NULL REFERENCES notices(id),
hearing_request_deadline DATE NOT NULL,
continued_benefits_available BOOLEAN NOT NULL DEFAULT false,
continued_benefits_request_deadline DATE,
hearing_phone TEXT NOT NULL,
hearing_address TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
jurisdiction.toml additions
[notices]
hearing_phone = "1-877-423-4746"
hearing_address = "Office of State Administrative Hearings, 225 Peachtree Street NE, Suite 400, Atlanta, GA 30303"
appeal_deadline_days = 90
advance_notice_days = 14
agency_name = "Georgia Department of Human Services"
agency_address = "2 Peachtree Street NW, Suite 29-250, Atlanta, GA 30303"
agency_phone = "1-877-423-4746"
default_locale = "en-US"
Steps
Step 1: Create canopy-typst shared crate
Files: crates/canopy-typst/Cargo.toml, crates/canopy-typst/src/lib.rs, engine.rs, context.rs, manifest.rs, error.rs, flatten.rs
-
Create crate under
crates/canopy-typst/ -
Add
typst-as-lib,typst,serde,serde_json,chrono,uuid,tokio,tracing,thiserror,tomlas dependencies -
Implement
TypstEnginewith dedicated OS thread (followservices/canopy-rules/src/engine.rspattern) -
Implement
NoticeContext,TemplateRef,RenderedNoticestructs -
Implement
manifest.tomlparsing and template resolution -
Implement
flatten_context()to convertNoticeContextto flat Typst input dictionary -
Unit test: render a minimal
.typtemplate to PDF bytes, verify non-empty output
Step 2: Create Orchard design system Typst components
Files: rulesets/georgia/notices/components/*.typ, rulesets/georgia/notices/fonts/, rulesets/georgia/notices/assets/
-
Create
orchard.typwith all brand colors, font names, spacing constants -
Create
letterhead.typ: agency name + logo + county + date + case number + recipient address block -
Create
footer.typ: form number, template version, page X of Y -
Create
hearing-rights.typ: parameterized by program, phone, address, deadline, continued benefits availability. Include tear-off hearing request section with checkboxes. -
Create
civil-rights.typ: USDA nondiscrimination statement (federal boilerplate) -
Create
language-access.typ: "(877) 423-4746. Our services, including interpreters, are free…" -
Create
disability-access.typ: ADA reasonable modification request block -
Create
signature-block.typ: perjury declaration, date, signature line -
Bundle Montserrat TTF files (Regular, Medium, Bold) under
fonts/ -
Export leaf mark SVG under
assets/leaf-mark.svg
Step 3: Create SNAP notice templates
Files: rulesets/georgia/notices/snap/*.typ
Create each notice template importing shared components:
-
noa-approval.typ— benefit amount, effective date, cert period, household size -
noa-denial.typ— denial reason (from rules output), regulatory basis, no income amounts in body -
noa-termination.typ— 14-day advance notice enforcement, effective date, reason, continued benefits language -
noa-change.typ— change reason, new benefit amount, effective date -
noa-pending.typ— pending verification items, deadline -
abawd-warning.typ— parameterized for month 1, month 2, and exhausted variants -
expedited.typ— 7-day processing confirmation -
expungement.typ— 30-day pre-notice before EBT stale benefit removal -
sanction.typ— Form 333 equivalent with work requirement violation details -
continued-benefits.typ— benefits continue pending fair hearing
Step 4: Create SNAP form templates
Files: rulesets/georgia/notices/snap/*.typ
-
verification-checklist.typ— Form 173 equivalent: verification items table with deadlines -
renewal-form.typ— Form 508 equivalent: pre-filled renewal with household data -
change-report.typ— Form 846 equivalent: change type checkboxes, detail fields -
work-requirements.typ— Form 859 equivalent: SNAP work/ABAWD requirements acknowledgment
Step 5: Configuration and manifest
Files: rulesets/georgia/jurisdiction.toml, rulesets/georgia/notices/manifest.toml
-
Add
[notices]section tojurisdiction.tomlwith all fields from Design section -
Create
manifest.tomlwith entries for all Step 3-4 templates (version, file path, form number, effective date) -
Verify manifest parsing unit test passes
Step 6: Database migration
Files: services/canopy-notices/migrations/20260401000000_notices.sql
Create notices and notice_appeals_rights tables using schema from Design section. No body_text/body_html columns — PDF is canonical. Include all indexes.
Step 7: canopy-notices service implementation
Files: services/canopy-notices/src/generator.rs, delivery.rs, store/, api/, events.rs, main.rs, config.rs
-
config.rs: load[notices]from jurisdiction.toml -
store/: SQL queries for notices and appeals_rights (insert, get, list, update delivery status) -
generator.rs:NoticeGeneratorstruct orchestrating data fetch → render → S3 store → DB insert → delivery -
delivery.rs:NoticeDeliveryAdaptertrait +TestDeliveryAdapter(sets status='sent' immediately) -
events.rs: subscribe todetermination.completed,application.expedited_identified,abawd.*,enrollment.expungement_pending -
api/: GET list, GET by id, POST resend, GET queue, GET preview (returns PDF bytes) -
main.rs: wire TypstEngine, Store, Publisher, NoticeGenerator, event subscriptions
Step 8: Integration tests
Files: crates/canopy-typst/tests/, services/canopy-notices/tests/
-
Render SNAP approval notice to PDF with fixture data — verify non-empty, correct page count
-
Verify Montserrat font embedded in PDF
-
Verify hearing rights block present with correct phone/address/deadline
-
Verify form number and version in footer
-
Verify manifest resolves template version correctly
-
Verify PDF stored in mock S3, metadata in database
-
Verify 14-day advance notice adjustment (Georgia-specific)
Files Touched
| File | Change |
|---|---|
|
New shared crate: TypstEngine, NoticeContext, manifest parsing |
|
Add canopy-typst to workspace members, typst deps to workspace.dependencies |
|
New: manifest.toml, components/.typ, snap/.typ, fonts/, assets/ |
|
Add [notices] section |
|
Full service: generator, delivery, store, API, events, migrations |
|
Add canopy-typst, canopy-store, canopy-rules-client deps |
|
Add canopy-notices endpoint table, notice types, delivery channels |
|
Add Typst rendering to document generation section |
|
Update: replace Askama design with Typst architecture reference |
|
Add typst-document-generation plan to Infrastructure section |
Verification
-
cargo nextest run -p canopy-typst— unit tests render PDF from test template -
Open rendered SNAP approval PDF — verify Orchard branding, Montserrat font, leaf mark
-
Verify hearing rights block with Georgia 14-day language and (877) 423-4746
-
Verify USDA civil rights statement and ADA/language accessibility blocks
-
Verify form number (DHS-297-A) and template version (2026.1) in footer
-
Verify manifest.toml resolves all SNAP templates without errors
-
cargo nextest run -p canopy-notices— integration tests with fixture data -
cargo xtask validate --skip-docker— full pre-push validation passes
Documentation Updates
-
.claude/docs/services.md— add canopy-notices endpoints, notice types, delivery channels -
.claude/docs/architecture.md— add Typst rendering architecture -
.claude/CLAUDE.md— update canopy-notices from "stub" to "implemented" -
CHANGELOG.adoc— entry under== Unreleased -
docs/modules/ROOT/pages/plans/notice-generation.adoc— update to reference Typst plan
Potential Improvements
-
Font bundling: Montserrat TTF files are not yet included in
rulesets/georgia/notices/fonts/. The render engine degrades gracefully (uses fallback font), but production deployments need the actual Montserrat font files added. -
Logo/seal assets:
rulesets/georgia/notices/assets/is empty. Agency seal/logo SVG should be added and integrated intoletterhead.typ. -
Form-building components:
checkbox-grid.typ,data-table.typ,field-row.typ, andconditional-section.typ(referenced in plan scope) are not yet created. These are needed for Step 4 (SNAP forms) and for Form 297 (application for benefits). -
Template hot-reload: Currently the render engine loads fonts once at startup. A watch-based reload mechanism would speed up template development during jurisdiction onboarding.
-
PDF/A compliance: For long-term archival and federal reporting, PDFs should conform to PDF/A. Typst’s
typst-pdfcrate may support this in a future version. -
Batch rendering: For mass notice generation (e.g., annual recertification mailings), a batch render API that reuses a single TypstEngine instance across many notices would improve throughput.
Tracked follow-ups (filed 2026-04-24 after audit of plan Errata + Potential Improvements sections across the repo):
Tracked follow-ups (filed 2026-05-04 during PI sweep):