Plan: Typst Document Generation Architecture

On this page

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:

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

  2. Askama templates are compiled into the binary — jurisdiction wording changes require a Rust recompile and redeploy. With 400+ documents, this is unmaintainable.

  3. No composability — each Askama template is standalone, duplicating headers, footers, hearing rights blocks, and civil rights statements across every template.

  4. 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-typst wrapping typst-as-lib with 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): DeterminationSigner trait 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

  1. Create crate under crates/canopy-typst/

  2. Add typst-as-lib, typst, serde, serde_json, chrono, uuid, tokio, tracing, thiserror, toml as dependencies

  3. Implement TypstEngine with dedicated OS thread (follow services/canopy-rules/src/engine.rs pattern)

  4. Implement NoticeContext, TemplateRef, RenderedNotice structs

  5. Implement manifest.toml parsing and template resolution

  6. Implement flatten_context() to convert NoticeContext to flat Typst input dictionary

  7. Unit test: render a minimal .typ template 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/

  1. Create orchard.typ with all brand colors, font names, spacing constants

  2. Create letterhead.typ: agency name + logo + county + date + case number + recipient address block

  3. Create footer.typ: form number, template version, page X of Y

  4. Create hearing-rights.typ: parameterized by program, phone, address, deadline, continued benefits availability. Include tear-off hearing request section with checkboxes.

  5. Create civil-rights.typ: USDA nondiscrimination statement (federal boilerplate)

  6. Create language-access.typ: "(877) 423-4746. Our services, including interpreters, are free…​"

  7. Create disability-access.typ: ADA reasonable modification request block

  8. Create signature-block.typ: perjury declaration, date, signature line

  9. Bundle Montserrat TTF files (Regular, Medium, Bold) under fonts/

  10. 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:

  1. noa-approval.typ — benefit amount, effective date, cert period, household size

  2. noa-denial.typ — denial reason (from rules output), regulatory basis, no income amounts in body

  3. noa-termination.typ — 14-day advance notice enforcement, effective date, reason, continued benefits language

  4. noa-change.typ — change reason, new benefit amount, effective date

  5. noa-pending.typ — pending verification items, deadline

  6. abawd-warning.typ — parameterized for month 1, month 2, and exhausted variants

  7. expedited.typ — 7-day processing confirmation

  8. expungement.typ — 30-day pre-notice before EBT stale benefit removal

  9. sanction.typ — Form 333 equivalent with work requirement violation details

  10. continued-benefits.typ — benefits continue pending fair hearing

Step 4: Create SNAP form templates

Files: rulesets/georgia/notices/snap/*.typ

  1. verification-checklist.typ — Form 173 equivalent: verification items table with deadlines

  2. renewal-form.typ — Form 508 equivalent: pre-filled renewal with household data

  3. change-report.typ — Form 846 equivalent: change type checkboxes, detail fields

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

  1. Add [notices] section to jurisdiction.toml with all fields from Design section

  2. Create manifest.toml with entries for all Step 3-4 templates (version, file path, form number, effective date)

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

  1. config.rs: load [notices] from jurisdiction.toml

  2. store/: SQL queries for notices and appeals_rights (insert, get, list, update delivery status)

  3. generator.rs: NoticeGenerator struct orchestrating data fetch → render → S3 store → DB insert → delivery

  4. delivery.rs: NoticeDeliveryAdapter trait + TestDeliveryAdapter (sets status='sent' immediately)

  5. events.rs: subscribe to determination.completed, application.expedited_identified, abawd.*, enrollment.expungement_pending

  6. api/: GET list, GET by id, POST resend, GET queue, GET preview (returns PDF bytes)

  7. main.rs: wire TypstEngine, Store, Publisher, NoticeGenerator, event subscriptions

Step 8: Integration tests

Files: crates/canopy-typst/tests/, services/canopy-notices/tests/

  1. Render SNAP approval notice to PDF with fixture data — verify non-empty, correct page count

  2. Verify Montserrat font embedded in PDF

  3. Verify hearing rights block present with correct phone/address/deadline

  4. Verify form number and version in footer

  5. Verify manifest resolves template version correctly

  6. Verify PDF stored in mock S3, metadata in database

  7. Verify 14-day advance notice adjustment (Georgia-specific)

Files Touched

File Change

crates/canopy-typst/

New shared crate: TypstEngine, NoticeContext, manifest parsing

Cargo.toml

Add canopy-typst to workspace members, typst deps to workspace.dependencies

rulesets/georgia/notices/

New: manifest.toml, components/.typ, snap/.typ, fonts/, assets/

rulesets/georgia/jurisdiction.toml

Add [notices] section

services/canopy-notices/

Full service: generator, delivery, store, API, events, migrations

services/canopy-notices/Cargo.toml

Add canopy-typst, canopy-store, canopy-rules-client deps

.claude/docs/services.md

Add canopy-notices endpoint table, notice types, delivery channels

.claude/docs/architecture.md

Add Typst rendering to document generation section

docs/modules/ROOT/pages/plans/notice-generation.adoc

Update: replace Askama design with Typst architecture reference

docs/modules/ROOT/nav.adoc

Add typst-document-generation plan to Infrastructure section

Verification

  1. cargo nextest run -p canopy-typst — unit tests render PDF from test template

  2. Open rendered SNAP approval PDF — verify Orchard branding, Montserrat font, leaf mark

  3. Verify hearing rights block with Georgia 14-day language and (877) 423-4746

  4. Verify USDA civil rights statement and ADA/language accessibility blocks

  5. Verify form number (DHS-297-A) and template version (2026.1) in footer

  6. Verify manifest.toml resolves all SNAP templates without errors

  7. cargo nextest run -p canopy-notices — integration tests with fixture data

  8. 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 into letterhead.typ.

  • Form-building components: checkbox-grid.typ, data-table.typ, field-row.typ, and conditional-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-pdf crate 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):

  • #321 — Batch rendering API reusing a single TypstEngine (from Potential Improvements)

  • #335 — Watch-based hot-reload for Typst templates (from Potential Improvements)

  • #337 — PDF/A conformance for archival notice PDFs (from Potential Improvements)

Tracked follow-ups (filed 2026-05-04 during PI sweep):

  • #403 — Bundle Montserrat TTF in rulesets/georgia/notices/fonts/

  • #404 — Agency seal / logo SVG assets for letterhead.typ

  • #405 — Form-building Typst components (checkbox-grid, data-table, field-row, conditional-section)

Edit this page · default