ADR-029: General Signed-Document Renderer

On this page

Status

Accepted (2026-06-06)

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

  1. canopy-typst gains a general render path. RenderEngine::render_document(relative_path, &serde_json::Value) → RenderedDocument renders any template file under the notices root from free-form JSON inputs (injected as Typst inputs.*), 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.

  2. 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-listed template_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.

  3. Documents are signed over their canonical data. When sign=true, the service canonicalizes the inputs (RFC 8785 JCS, serde_json_canonicalizer since #1281) and produces an ES256 detached JWS (canopy-signing, key id canopy-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 the X-Canopy-Signature header. For audit citations the embedded ADR-014 previous_hash/event_hash + a verify-chain attestation remain the event-level tamper-evidence; the JWS is the document-level attestation.

  4. 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. The canopy document render CLI 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_KEY from SOPS, .keys/notices-private.pem dev 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.

Edit this page · default