ADR-0074: PDF Export of Released Documents — Server-Side via Apache FOP over the Canonical Projection¶
- Status: accepted (Nicolas 2026-07-15: approved MVP gap #1 together with the gap-analysis A3 amendment — the uncontrolled-copy distribution-control layer folds into this export)
- Date: 2026-07-15
- Deciders: Nicolas Burri (scope pair), design by Claude.
- Relates to: ADR-0057/0058 (tree as record, markdown as the canonical projection — the render source), ADR-0062 (SBOM/license posture — the constraint that decided the library), ADR-0045 (attachment scheme / rendering policy), ADR-0072 (draw.io xmlsvg diagrams), ADR-0029 (audit).
Context¶
MVP gap #1 (docs/ai/mvp-definition.md): auditors and externals ask for controlled copies, and nothing renders a released version out of the system. Gap analysis A3 adds the ISO 13485 §4.2.4 distribution-control layer: every export must self-identify as an uncontrolled copy with its print/export date. Numbered controlled-copy registers are explicitly out of scope.
The core question is WHERE the PDF is produced:
- (a) Server-side rendering — deterministic, auditable (the system emits the artifact and can record WHO exported WHAT WHEN), no browser dependency, one gated endpoint. Cost: no JavaScript, so client-rendered constructs (Mermaid) cannot render; a JVM rendering stack must be chosen.
- (b) Client print view (print CSS + browser print-to-PDF) — trivially renders everything the reading view renders (incl. Mermaid), but the artifact is browser-produced (not server-attested, no server-side EXPORTED audit event at the moment of artifact creation), header/footer control across browsers is weak (the §4.2.4 footer band is exactly the part browsers control worst), and it creates a second, permanently diverging render surface.
The market expectation — and the audit expectation — is a downloadable PDF from the system. Option (a) is chosen; the print-CSS path is deliberately NOT built (no double surface).
The library question (the real investigation)¶
The obvious HTML→PDF JVM libraries fail our non-negotiable license gate (ADR-0062: the backend
runtime classpath is permissive-only, enforced by scripts/check-backend-licenses.py on every
build): openhtmltopdf and Flying Saucer are LGPL-2.1 — copyleft, not on the allow-list, and
not the kind of deviation the exceptions file exists for (it holds unavoidable Jakarta EE API
jars, not an avoidable rendering engine).
Apache FOP (XSL-FO → PDF) is Apache-2.0 with an all-ASF dependency tree (xmlgraphics-commons,
Batik, commons-io/logging, fontbox — all Apache-2.0; even the xml-apis family resolves with
Apache-2.0 POM metadata, so the license gate passes with zero new exceptions — verified against the
real SBOM before implementation). Batik gives FOP native SVG rendering — which our content actually
needs (draw.io diagrams are SVG images, ADR-0072). FOP is the ASF's reference XSL-FO formatter,
maintained for two decades; "no headless browser, deterministic layout" is its exact design point.
FOP consumes XSL-FO, not HTML — so we need a document-AST→FO serializer. That is small and closed-vocabulary here, because of the second decision:
The render-source question¶
The authoritative record is the ProseMirror tree (ADR-0057 §1); every version also stores a derived, deterministic, canonical markdown projection (ADR-0057 §2), and legacy v1 versions store markdown text directly. Rendering the PDF from the stored markdown (projection for v2, text for v1) via commonmark-java (BSD-2-Clause) + its GFM-tables extension gives:
- ONE render path for both content formats (v1 and v2) — no format dispatch in the PDF layer;
- a parsed AST for free (headings, tables, lists, quotes, code, images, links) — the FO serializer walks a small, closed CommonMark+GFM vocabulary instead of the full tree schema;
- degradation semantics that are ALREADY documented per feature (
fidelity/projection-notes.md, the ADR-0057 known-limitations duty): alignment, column widths, text colour, highlight, underline and block-in-cell structure degrade in the projection, and the PDF inherits exactly those documented degradations — no new fidelity contract to define or drift from.
The alternative (walking the tree JSON directly) would preserve alignment/colwidth/colour but requires a second full-vocabulary serializer that must chase every future schema node. The MVP scope line is explicit: "not a print designer". The projection is the human-readable record copy by design (ADR-0057 §2) — the PDF is that record copy, typeset.
Decision¶
- Server-side PDF generation in the
documentmodule: stored markdown (v2 projection / v1 text) → commonmark-java AST → XSL-FO → Apache FOP → PDF. No client print path. - Endpoint:
GET /api/documents/versions/{id}/export/pdf— authenticated, RLS-scoped through the standardrunAspath,application/pdfwith a content-disposition filename derived from the human document id + version. - Exportable states: every post-working state — RELEASED (including versions whose effective standing is superseded or future-effective) and REVOKED. DRAFT, IN_REVIEW and CANCELLED return 409: a draft PDF invites exactly the uncontrolled circulation §4.2.4 exists to prevent, and pre-release content has no controlled identity to stamp. Superseded and revoked versions DO export (auditors ask for historical copies) — the identity header stamps the state and, for a superseded/future-effective released version, its effective standing, so an outdated copy self-identifies twice (standing + uncontrolled banner). Disposed (tombstoned) versions have no content to render and 409 likewise.
- Identity header (first page, above the body): human document id, title, version number, state, effective date, scope name — the §4.2.4 identity block.
- Distribution-control footer on EVERY page (fo:static-content): "UNCONTROLLED COPY —
exported \<date> by \<user display name>" plus
page N / total. The footer band IS the §4.2.4 control. A diagonal watermark was considered and dropped: FOP rotates text only in 90°-steps (reference-orientation), a true diagonal needs SVG-underlay hacks that hurt readability and add fragility for zero additional control value. - Images (
attachment:scheme, IMAGE content parts incl. draw.io xmlsvg SVGs) are resolved server-side from the frozen manifest and inlined as data URIs info:external-graphic— the PDF is self-contained, and Batik renders the SVGs. - Honest degradations (documented, not hidden):
- Mermaid code blocks cannot render without JS: they render as a labeled source block ("Mermaid diagram — source") in monospace. Draw.io diagrams are unaffected (SVG images).
- Math renders as the literal
$latex$/$$latex$$source (no KaTeX server-side). - Projection-level degradations carry through (alignment, colwidth, colour/highlight/underline,
block-in-cell flatten) — see
fidelity/projection-notes.md. doc:links render as their link text (a PDF cannot resolve an internal scheme); external http(s) links stay live PDF links;attachment:non-image links render as their text.- Audited: every export records an
EXPORTEDaudit event (actor, document entity, version id in the changes payload) — who pulled copies is part of the distribution-control story. - License/SBOM: fop-core and its ASF tree enter the runtime classpath (Apache-2.0);
commonmark-java is BSD-2-Clause (allow-listed). Verified against the gate BEFORE implementation:
checkBackendLicensespasses with zero new exceptions (thexml-apisfamily resolves with Apache-2.0 POM metadata, so the anticipated W3C deviation never materialized). PDFBox is used test-only (PDF text extraction in the export test; pinned to the same fontbox line FOP pulls) and never enters the L1 runtime SBOM. THIRD_PARTY_NOTICES.md gains the FOP/Batik/commonmark section (attribution duty rides the standing ADR-0062 pre-distribution notices work).
Consequences¶
- The controlled artifact is server-attested: state gate, RLS, audit and the §4.2.4 stamp are all enforced at one endpoint; no browser variance.
- PDF fidelity is coupled to the projection's documented canon — a deliberate, already-governed contract. Features with no markdown expression need no PDF work until they earn a projection expression (per-feature duty, ADR-0057).
- Mermaid-heavy documents export with source blocks, not diagrams. If that proves unacceptable in practice, the escalation path is pre-rendering Mermaid SVGs at SAVE time (client-side, where JS exists, stored as content parts like draw.io) — a content-model change, its own ADR.
- FOP + Batik add ~15 MB of jars to the backend image. Accepted for a JVM-only, license-clean, SVG-capable formatter.
Clarification (2026-07-19): the distribution-control footer wording¶
Nicolas decided the §4.2.4 footer band wording (previously "UNCONTROLLED COPY — exported \<date> by \<user>"). It now reads:
Uncontrolled copy — issued via LQMS to \<user display name> on \<date>; validity must be verified against the live system.
Rationale: the copy stays uncontrolled on purpose. Calling an exported PDF a "controlled copy"
would imply a recall / update-distribution obligation (numbered registers, forced re-issue on change)
that LQMS deliberately does NOT provide — numbered controlled-copy registers are explicitly out of
scope (see Context). The honest form is the audited issue event (the EXPORTED audit records who
pulled which version, when) plus an explanatory uncontrolled stamp that tells the reader exactly
what the artifact is and what to do (verify against the live system). This changes only the §5 band's
WORDING — the band is still on every page, still carries page N / total, and the §4.2.4 identity
elements are intact. The single-document export and every pack/merged context share the one render
path, so they share the wording.
Addendum (2026-07-19): PDF navigation (in-doc, cross-file, merged) — contexts of the one renderer¶
Auditor-driven request (Nicolas): clickable navigation inside a PDF, between the audit pack's PDFs, and a single merged pack PDF. Built as CONTEXTS of the ADR-0074 renderer — not new render surfaces.
- In-document navigation. Every heading gets a stable GitHub-style slug
id; afo:bookmark-treeoutline is emitted from the heading hierarchy (the PDF bookmark panel), nested by heading level. In-text#fragmentlinks resolve to the matching heading viafo:basic-link internal-destination; an unmatched fragment degrades to inert text (never a dead link). This — plus the bookmark tree — is the ONLY change to the single-document output; layout/footer are otherwise unchanged. doc:link resolution is context-dependent (aDocLinkResolverinjected per render, ADR-0051):- single-document export — a
doc:link renders as inert text +(HUMAN-ID)(the improved honest form of §7's "renders as its link text"); - audit-pack (zip) — a target IN the pack becomes
fo:basic-link external-destinationto the relative sibling filename<humanId>_v<n>.pdf; a target NOT in the pack degrades to text + id. Relative cross-file (GoToR) links are viewer-dependent and require the files to stay in one folder — noted in the packmanifest.txt. - merged — the same
doc:target becomes aninternal-destinationto that document's chapter. - Merged pack PDF (
GET /api/scopes/{scopeId}/audit-pack?format=merged): one PDF — cover (scope, generated-at/by, document count) + a clickable ToC (one entry per document) + each document as a chapter (its own bookmark subtree, its §4.2.4 identity header, continuous page numbering). Same permission gate + one scope-levelEXPORTEDevent shapedformat: merged; the zip and the merged PDF are separate artifacts (zip OR merged), never double-bundled. - Reusable for ADR-0083 dossier 3a. The merged rendering is expressed as "given an ordered list of
versions + a front-matter spec → one PDF with ToC/bookmarks/internal links"
(
PdfExportService.renderMergedVersions→PdfRenderer.renderMerged), so the dossier's reviewer's copy (D-4) will call the SAME service seam with its skeleton sections — no audit-pack-specific coupling.
Clarification (2026-07-20): the derived-traceability annex on risk-defining exports¶
Decided by Nicolas ("b sounds like a good compromise") after his export review: a risk analysis PDF showed the risk/control CONTENT but not the controls' implementation/verification — which are EDGES (trace links, ADR-0079's deliberate cut), not document content, and therefore honestly absent from the frozen record. Three options were weighed: inline derived columns in the controls table (rejected — blends approved content with live derivation in one table), keep exports pure and rely on the traceability-workspace exports (rejected — reintroduces the cross-artifact hop), or:
- A clearly-bounded derived ANNEX after the document content in the PDF export of any version
whose content defines
risk/risk-controlitems: heading "Derived traceability — as at export (not part of the controlled content)", one row per control — mitigates → risks, implemented by (requirement item OR document), verified by (document · version), current gap flags. Derived live fromRiskTraceabilityServiceat export time, RLS-bounded exactly like the on-screen view. - The boundary is the point: the export is already a stamped point-in-time artifact (identity header, per-page uncontrolled-copy band), so a derivation is consistent with its nature — but it must be VISUALLY separate and labeled, so an auditor can never mistake link-derived rows for reviewed content. The annex never enters the content hash discussion: same version, different export moment, possibly different annex — by design and by label.
- Applies to all export shapes (single, zip pack entries, merged chapters). Documents defining no risk items get NO annex (no empty scaffolding).
- Permission-conditional (coordinator addendum, same day): the annex derives from the same
data the ADR-0080-gated traceability views show, so it is included only when the exporting
caller holds
VIEW_TRACEABILITYin the scope — otherwise the PDF renders without it (no placeholder). The annex must never become a backdoor around the per-view gating. - Risk rows join the annex (Nicolas 2026-07-20, "yes, please extend the annex"): the
control-centric annex was silent on a RISK-defining document whose controls live in ANOTHER
document (the split-file shape his walkthrough exposed). The annex now also carries one row
per
riskitem defined in the exported document — Key | Mitigated by | Gaps — where "Mitigated by" names the control keys (with the defining document's human id when the control is defined elsewhere) and "Gaps" carries the derived risk-level flags with the ADR-0079 accepted-without-reduction semantics intact (an acceptable initial risk with no control and no residual is COMPLETE, no flag). Both sections appear when a document defines both item types, in content order (risks, then controls); the trigger, label, boundary and permission rule are unchanged.