ADR-029: General Signed-Document Renderer
On this page
Amends
ADRs are immutable once accepted, so this ADR amends ADR-010 rather than editing it. ADR-010 established canopy-typst (wrapping typst-as-lib) as the renderer for "notices, forms, and reports." This ADR widens that from a notice-only implementation to a general signed-document renderer and settles the data-tenancy question that gated centralization.
Context
ADR-010 already names notices, forms, and reports as canopy-typst’s remit, but the implementation was coupled to the Notice-of-Action model: RenderEngine::render(program, template_key, &NoticeContext) resolves a .typ via a program-keyed manifest.toml and flattens a typed, NOA-shaped NoticeContext (recipient, case number, appeal-deadline, …). canopy-notices owned the only render engine + the fonts + the object store.
#503 (the worker-portal unified audit surface) needs a non-NOA PDF: a hearing-citable "Cite for hearing" citation of a single audit event — its provenance, its ADR-014 hash chain, and a chain-verification attestation. This is not a notice; shoehorning it into NoticeContext + the NOA manifest would be dishonest and brittle.
That raised the broader question: should one service render all the project’s PDFs? The only architectural objection was ADR-004 — FTI (IRS Pub 1075), IEVS, and SSA data are legally isolated to authorized program services. A single renderer that pulled such data would breach that isolation.
Determination: no PDF ever carries FTI. FTI is an input to eligibility determination (income/identity verification), never an output printed on a document. A notice prints the derived result ("eligible for $X", "denied for reason Y"); a report prints aggregate statistics; an audit citation prints audit metadata (event type, actor id, SHA-256 hashes). None reproduces raw FTI. So ADR-004 imposes no practical constraint on centralizing PDF rendering — it survives only as a design guardrail (do not pass legally-isolated raw data into the renderer), not a blocker.
Decision
-
canopy-typst gains a general render path.
RenderEngine::render_document(relative_path, &serde_json::Value) → RenderedDocumentrenders any template file under the notices root from free-form JSON inputs (injected as Typstinputs.*), bypassing the NOA manifest +NoticeContext. It shares the dedicated render thread with the NOA path and re-validates the path against../absolute traversal as defense-in-depth. -
canopy-notices is the project’s general signed-document renderer. A new service-gated
POST /v1/documents/render(canopy-contracts-notices::RenderDocumentRequest) renders an allow-listedtemplate_key(mapped service-side to a.typ— never a free filesystem path) from JSON inputs. canopy-notices is a pure renderer: it does not know about audit events (or any caller’s domain) — it renders a template from inputs and optionally signs. -
Documents are signed over their canonical data. When
sign=true, the service canonicalizes the inputs (RFC 8785 JCS,serde_json_canonicalizersince #1281) and produces an ES256 detached JWS (canopy-signing, key idcanopy-notices-current) — the same pattern used for determinations (ADR-002). The JWS is embedded in the rendered PDF (so the printed document is self-attesting) and returned in theX-Canopy-Signatureheader. For audit citations the embedded ADR-014previous_hash/event_hash+ a verify-chain attestation remain the event-level tamper-evidence; the JWS is the document-level attestation. -
Orchestration stays with the data owner. The audit data owner (canopy-security) is not pulled into rendering. The BFF (canopy-web) orchestrates: it fetches the audit event + verify-chain from canopy-security, builds the citation inputs, calls
POST /v1/documents/render, and streams the signed PDF as a download. Thecanopy document renderCLI subcommand satisfies ADR-007 parity.
Consequences
-
canopy-notices' name now lags its role (it renders general documents, not only notices). A service rename is a separable refactor, deliberately out of scope here.
-
Future non-NOA documents (operational reports, forms) render through the same generic path + allow-list — no new render engine per consumer.
-
The signing key is provisioned exactly like the program services' determination keys (
CANOPY_NOTICES__SIGNING_KEYfrom SOPS,.keys/notices-private.pemdev fallback). -
If a document type ever did need legally-isolated data, ADR-004 would require it to render within that data’s authorization boundary (the data pushed to a renderer under the same authorization, not fetched cross-tenancy) — but no such document exists today.