Repository navigation
ADR 034 Client Side Report PDF Generation
Accepted
The Bank Report Wizard (mini-epic "Bank Report Wizard", stories #1878/#1879) produces the single most externally-visible artifact Cornerstone generates: a PDF claim package handed to a bank or subsidy authority. It is composed of three parts:
- A cover letter — addressee, claim period, total amount, signature block.
- An overview table — one row per invoice/budget line (vendor, invoice number, date, invoice amount, allocated amount, budget source, appendix marker), followed by a legend block: numbered notes for documents that could not be attached, plus document-level legend entries explaining the inline row annotations (see the legend addendum below).
- An appendix — the original invoice PDFs, fetched from Paperless-ngx and appended page-for-page behind the generated pages.
Part 3 is the constraint that shapes everything else: the deliverable is not a rendered document, it is a generated document merged with foreign PDFs that already exist in Paperless. Any solution must both author pages and splice in pages from arbitrary third-party PDFs.
The wizard is also interactive. Step 4 offers options (attach documents on/off, include zero-allocation lines, grouping) and shows a live preview in an iframe. The product requirement is "whatever is previewed is what ships" — the bytes in the preview must be byte-identical to the bytes that are downloaded or uploaded back to Paperless. This rules out any design where preview and export are separate render passes.
Deployment context matters as much as the feature: Cornerstone is a single Docker container serving fewer than 5 users, with SQLite on a volume and no worker process, no job queue, and no headless-browser runtime (ADR-003, ADR-007). Paperless-ngx is already the system of record for documents (ADR-015), so the app is not itself a document archive — a generated report either goes straight to the user's disk or is uploaded back into Paperless.
| Option | Where it runs | Can merge foreign PDFs | Container cost | Native binaries | Preview fidelity |
|---|---|---|---|---|---|
| pdfmake + pdf-lib in the browser (chosen) | Client | Yes (pdf-lib copyPages) |
None | No (pure JS) | Exact — preview is the artifact |
| Puppeteer / headless Chromium HTML→PDF | Server | No (needs a second tool) | Very high (~300MB+ Chromium, sandbox/--no-sandbox) |
Yes (Chromium binary) | Approximate (re-render per request) |
| PDFKit (or pdfmake) on the server | Server | No (needs pdf-lib anyway) | Moderate (CPU/RSS spikes on the only process) | No | Approximate (needs a preview endpoint + cache) |
| Server-side pdfmake + pdf-lib | Server | Yes | Moderate; adds a stateful preview/temp-file lifecycle | No | Only via a cached-artifact endpoint |
| jsPDF in the browser | Client | Partial/awkward | None | No | Exact |
Client-side generation (chosen).
-
It is what makes "preview equals download" true by construction. The wizard already holds the full report payload (
GET /api/source-reports) and can fetch each linked document through the existing proxy (GET /api/paperless/documents/:id/preview). Generating in the browser produces oneBlobthat is simultaneously the iframesrc(viaURL.createObjectURL) and the bytes handed to the download anchor and to the Paperless upload. There is no second render pass to diverge, and no server-side artifact cache, temp directory, or TTL to reason about. - Zero added server surface. No new server dependency, no new runtime in the image, no PDF code path to keep patched, and no CPU/RSS spike on the single process that also serves the API and the SPA. A 40-invoice report merging 40 multi-megabyte PDFs is a meaningful memory transient; on the client it is the user's own tab, and it cannot degrade the API for anyone else.
- The client is already where the work is. Every option toggle changes the document; server-side generation would mean a network round trip per toggle plus an artifact lifecycle, for a document only ever consumed by the person who requested it.
- Fits the scale. Fewer than 5 users, one container. Server-side PDF rendering is machinery for multi-tenant/scheduled reporting; neither exists here.
- Paperless is the archive. The report does not need to be persisted server-side to be durable — the upload sink puts it back into Paperless, which is already the document system of record (ADR-015). This removes the strongest argument for server-side generation (server-owned, retrievable artifacts).
Puppeteer / headless Chromium (rejected). Adds a platform-specific Chromium binary — a direct violation of the dependency policy's no-native-binaries rule for build/render tooling (CLAUDE.md, ADR-004), and a known hazard in the ARM64-emulated dev environment. It would multiply the image size several-fold, add a sandbox/--no-sandbox security decision to the container, and become a recurring CVE-patching surface. It still cannot append foreign PDF pages, so pdf-lib (or similar) would be needed anyway. Rejected outright.
PDFKit / pdfmake on the server (rejected). Pure JS and dependency-policy-clean, but it buys nothing the client does not already provide while costing: (a) a new preview endpoint with an artifact cache or temp-file lifecycle, (b) a per-toggle round trip that ships every generated PDF over the wire twice (preview, then export), (c) render CPU and peak RSS on the single process, and (d) the loss of the byte-identity guarantee unless the preview artifact is itself retained and re-served. Since a server-side design would still need pdf-lib for the appendix, the only difference from the chosen design is where the bytes are built — and the server is the worse place at this scale.
jsPDF (rejected). Viable for simple output, but its declarative table/layout story is weaker than pdfmake's document-definition model (which the cover letter and 7-column overview lean on heavily), and it does not solve foreign-page merging either.
This is the non-obvious part of the decision. pdfmake generates; pdf-lib merges. pdfmake takes a declarative document definition (styles, tables, column widths, headers/footers) and renders it to a fresh PDF, but it has no ability to embed pages from an existing PDF. pdf-lib can load arbitrary PDFs and copyPages them into a target document, but authoring a styled multi-page table with it means hand-positioning text. The split is therefore along a real capability seam, not redundancy:
pdfmake: report data + i18n + formatters -> cover letter + overview pages (Blob)
pdf-lib: that Blob + N fetched Paperless PDFs -> final merged Blob
Generate report PDFs entirely in the browser, using pdfmake@0.3.11 for document authoring and pdf-lib@1.17.1 for merging fetched Paperless invoice PDFs into the appendix. Both are exact-pinned (no caret) per the dependency policy, together with @types/pdfmake@0.3.3. The combined subtree (27 transitive packages: fontkit, pdfkit, restructure, pako, brotli, linebreak, @noble/*, unicode-*) is pure JavaScript with no install scripts and no platform-specific binaries, and hoists to the root node_modules/ with zero workspace nesting.
No server-side PDF dependency is added, and no new API endpoint is introduced — the pipeline consumes only pre-existing endpoints (GET /api/source-reports, POST /api/source-reports/mark-claimed, POST /api/paperless/documents, GET /api/paperless/documents/:id/preview).
| Module | Role |
|---|---|
loader.ts |
Lazy import() of both packages, promise-cached; owns vfs/font wiring |
pageGeometry.ts |
Sole owner of the pt coordinate system: PAGE_WIDTH/HEIGHT, PAGE_MARGIN_*, CELL_PADDING_X, V_LINE_WIDTH, font sizes, PDF_STYLES, printableWidth/Height(), tableOffsetsTotal(cols), usableColumnWidth(cols), headerFootprint(), derived PAGE_TOP_MARGIN
|
shared.ts |
Page header/footer builders, TABLE_LAYOUT callbacks (which read their numbers from pageGeometry.ts), PDF-local colours/sizes for cell annotations |
coverLetterPdf.ts |
Pure function: ReportContent -> cover-letter Content[]
|
overviewPdf.ts |
Pure function: ReportContent -> overview-table Content[]; owns column widths, cell packing, and the *N skip notes |
merge.ts |
Orchestration: fetch documents, build, pdfmake render, pdf-lib merge |
sinks.ts |
Output: browser download / preview blob URL / upload to Paperless |
types.ts |
ReportPdfOptions, GeneratedReport, SkippedDocument
|
index.ts |
Barrel: the public surface of the pipeline (also re-exports ReportContent from ../reportContent/) |
shared.ts deliberately owns no formatters. It briefly did; they were deleted in review because a PDF-local formatter is a second implementation of client/src/lib/formatters.ts bound to a different locale — exactly the B3 defect below. All formatting reaches the builders through the injected reportFormatters.
pageGeometry.ts was extracted in #1939 because the page geometry had been duplicated across four sites (PAGE_TOP_MARGIN in shared.ts, L/R/B margins inline in merge.ts, a printable-width prose comment in overviewPdf.ts, paddings in TABLE_LAYOUT) and drifted. Note that tokens.css is explicitly not the home for these values: the pdfmake layer is its own pt coordinate system, outside the design system (Style Guide, ux-designer's domain).
The content builders are pure functions. They therefore cannot call React hooks, which has a direct consequence: anything locale- or configuration-dependent must be threaded in as a parameter, not read from a module default. Reaching for the bare formatCurrency/formatDate module exports inside a builder silently falls back to en-US + EUR, ignoring both the user's locale and the CURRENCY env config — see finding B3 in the Lessons section.
Since #1900 the builders take (reportContent: ReportContent, …) and no formatters at all: every formatted string is pre-derived into ReportContent by buildReportContent, which is the single place reportFormatters is applied (see the content-layers addendum). Since #2001 they take no TFunction either: the last three generation-time strings (overviewPdf's *N skip-note reasons, coverLetterPdf's Reference: / Subject: prefixes) moved onto ReportContentLabels. buildReportContent is now the only module in the whole pipeline that accepts a TFunction, which turns invariant 1 below from a caller convention into a compile-time property of reportPdf/* — there is no longer a channel through which the wrong t could be passed. The fix for a new generation-time string is a new ReportContentLabels field, never a new parameter.
The ~2MB cost (dominated by the base64 Roboto virtual file system) stays out of the initial bundle only because every static reference to either package is import type, which TypeScript erases. The sole value-level references in the entire client graph are the import() calls inside loader.ts. Webpack's splitChunks: { chunks: 'all' } (ADR-004) then keeps them in async chunks, and ReportWizardPage is additionally lazy()-loaded in App.tsx.
One plain import { X } from 'pdfmake/...' anywhere in the client graph silently pulls all ~2MB into the initial bundle. The guard is:
grep -rn "from 'pdfmake\|from 'pdf-lib" client/src --include=*.ts --include=*.tsx | grep -v "import type"This must return nothing. Any future consumer of these libraries must go through loader.ts.
These are library-version-specific facts that cost review rounds to discover. They are recorded here because they are institutional knowledge, not test trivia:
-
pdfmake/build/pdfmakeis a UMD/CJS bundle. Under NodeNext ESM the real exports live on the dynamic-import namespace's.default, not on the namespace. The namespace object is non-extensible, so assigning.vfsonto it throwsTypeError: Cannot add property vfs. -
pdfmake/build/vfs_fontsdefault-exports the font map directly. The.pdfMake.vfswrapper shape is pre-0.3.x and does not apply. -
pdfMake.fontsdefaults to{ Roboto: … }only. Any other font family named indefaultStylethrowsFont 'X' in style 'normal' is not defined. By contrast, an unknown style name is silently ignored (probed — no throw), so straystyle:references are dead code rather than crashes. -
widths: 'auto'applies no total-width constraint — an over-wide table simply runs off the page rather than wrapping or erroring. A4 portrait with 40pt margins gives a printable width of exactly 515.28pt. -
Declared widths are content widths, not occupied widths. pdfmake subtracts
_offsets.totalfrom the available width before distributing the declared widths:offsetsTotal = cols * (paddingLeft + paddingRight + vLineWidth) + vLineWidth. This is why every width budget must be computed againstprintableWidth() - tableOffsetsTotal(cols), never against 515.28 directly — henceusableColumnWidth(cols)inpageGeometry.ts. Budgeting against the raw page width was how a review comment came to claim the Usage column got 185.28pt when it actually got 69.28pt. -
A
'*'column never shrinks below its longest unbreakable word.columnCalculator.js:66-75: whenminW >= availableWidththe star is set tostarMaxMinand the table overflows. No static assertion on thewidthsarray can therefore prove "no horizontal overflow" — a German compound (Wärmedämmverbundsystem, ~128pt at 10pt Roboto) pushes a 69.28pt star to 128pt. The overview table's columns are consequently all fixed widths, which has its own cost:elasticWidthnever grows a fixed column to fit its own header, so every DE translation of asourceReports.table.*header key is width-constrained and DE is always the binding locale (#1937). -
dontBreakRowslives ontable, notlayout.TableProcessor.js:123readstableNode.table.dontBreakRows. Thelayoutobject is packed intonode._layout(DocMeasure.js:469) and only feeds the border/padding/fill callbacks — nothing reads_layout.dontBreakRows. Putting it inlayoutproduces a byte-identical PDF (modulo/CreationDateand/ID) while a unit test assertingTABLE_LAYOUT.dontBreakRows === truepasses and proves nothing. The@types/pdfmakedeclarations place it onCustomTableLayout, so the wrong placement also type-checks. -
_calcWidthis the verification lever.createPdf(def)mutates each entry oftable.widthsin place into an object; afterawait getBlob()each carries_calcWidth, the resolved pt width. It is a private field, so pin the version — but the claim that pdfmake's public Node API cannot expose computed widths is wrong. For text measurement,pdfkitplus the Roboto TTF out ofvfs_fontsgivesdoc.widthOfString(s)(average lowercase prose character ≈ 4.68pt at 10pt Roboto). -
getBlob()is promise-based per@types/pdfmake@0.3.3and works under jsdom.getBuffer()with a callback did not fire in plain Node CJS — probe inside Jest/jsdom, not a bare Node script. - Rendering mutates the
contentobjects in place (pdfmake annotates_minWidth,_maxWidth,positions,__height). This is the mechanism by which real layout geometry can be asserted in tests.
Any change to the report pipeline must be covered by at least one test that renders the real content builders through the real pdfmake, for every supported locale. loader.test.ts deliberately does not mock either package and is the template to copy.
This is not a stylistic preference — it is the direct lesson of the PR #1887 review (see below). That PR shipped through three review rounds with 355+ green client tests while carrying five code defects (B1-B5, four of them empirically confirmable by probing the real libraries), all invisible to the suite because the pipeline was mocked at every layer (loader, shared, coverLetterPdf, overviewPdf, paperlessApi in merge.test.ts; generateReportPdf wholesale in ReportWizardPage.test.tsx). Nothing ever laid out the real document tree with the real library.
Minimum bar for a generated-document pipeline:
-
Real-layout width bound, per locale. Render, then assert on geometry pdfmake resolved — for both
enandde:-
No horizontal overflow:
max(node.positions[].horizontalRatio) <= 1.horizontalRatiois(x - pageMargins.left) / innerWidth(DocumentContext.js:490), so a value above1means laid-out content advanced past the printable right edge. This is the only assertion that actually proves "nothing ran off the page". -
The table box fits:
tableOffsetsTotal(cols) + sum(widths[i]._calcWidth) <= printableWidth()(equality, for the current all-fixed-width shape)._calcWidthis written onto each declared width object during render.
Do not assert
table._minWidth <= printableWidth()._minWidthis the width of the widest unbreakable token, not the laid-out width, so the check both false-negatives and false-positives: a table whose columns wrap fine still overflows if a'*'column'sminWexceeds the available width (columnCalculator.js:66-75sets the star tostarMaxMinand lets the table run off the page), while correct code that relies onwordBreak: 'break-all'to split an over-long token fails the assertion despite rendering inside the margins. An earlier revision of this ADR stated the_minWidthform as rule #1; it was a mis-transcription of finding B2's narrative (where_minWidthwas the diagnostic that located the bug) into a correctness check, which it is not. -
No horizontal overflow:
-
Height bound on what a cell renders, per locale. See "Unbreakable rows are silently dropped" below. Page count must be monotonic in content size and channel-independent.
-
Unique-value mocks for anything whose return feeds
setState. A mock returning a constant can terminate a loop that never terminates in production. -
Idle-stability assertion. Settle, then assert the generation call count has not grown.
-
Export sinks disabled on preview error. For any preview+export pair, assert the export path is unavailable when the preview is in an error state.
table.dontBreakRows: true makes pdfmake measure a row as one unit. If that unit is taller than the printable height (pageHeight - topMargin - bottomMargin = 706.89pt at A4 with the current margins), pdfmake does not paginate it — PageElementWriter discards the overflow. No throw, no visible truncation, no warning. Measured on a 7-column table with a 69.28pt Usage column: 450 characters render, 500 characters render the header row only, and the text-show-op count stays flat no matter how much longer the text gets.
This defect recurred across three review rounds, each time with a fix that passed its own unit tests. The rule that finally held:
Bound the height of what a cell renders, never the length of a source field.
A per-field cap is coupled to a layout decision and detaches silently the moment the layout changes — which is exactly how one round's per-field continuation rows became the next round's unbounded inline run. The bound belongs where the cell's content stream is assembled (packUsageCellRows(segments, MAX_SAFE_USAGE_CHUNK_CHARS) in overviewPdf.ts), so a newly added segment is bounded automatically rather than requiring the cap to be extended.
Two threshold-free detection recipes — both reusable for any generated document:
- Page count must be monotonic in content size. The symptom of a dropped row is saturation (page count pins at ~2) and sometimes non-monotonicity (3 → 2 pages as content grows) while the rendered line count rises linearly.
- Page count must be channel-independent. The same text costs the same paper whichever field carries it. A discrepancy means one field's content is escaping the bound.
The assertion asymmetry that let it recur, and the fix. The per-row character budget is asserted generically (it counts characters across all runs, so a new channel would be counted), but the test inputs are hand-enumerated (usageText, areaText, attachmentsNote). A newly added string field on ReportContentRow therefore defaults to empty and every assertion passes vacuously. The durable fix is key-driven saturation — iterate Object.entries(row) and saturate every string field — not another hand-written case.
Geometry constraint that blocks a feature. USAGE_WIDTH_7COL / USAGE_WIDTH_6COL derive from usableColumnWidth(n), and MAX_SAFE_USAGE_CHUNK_CHARS was measured against the 7-column shape. The PDF's column count is therefore fixed at 6 or 7 (7 for budget-overview, 6 otherwise). Making the wizard's column-visibility toggles affect the PDF would widen the Usage column and invalidate the measured ceiling — that is a re-measurement story, not a UI change. The toggles introduced in #1959 are deliberately preview-only local state.
Related sharp edge: packUsageCellRows hangs on maxChars <= 0 (its remaining <= 0 -> flush() no-op -> continue loop never terminates), whereas splitIntoPageSafeChunks fails loudly with a RangeError. Unreachable while the budget is a constant; it matters the moment it becomes computed.
Each of these was found by probing the real libraries, and each maps to one of the rules above:
-
B1 — unbounded regeneration loop.
regeneratePdf'suseCallbackdeps includedpreviewUrl, and the callback itself setpreviewUrlfromURL.createObjectURL— which returns a unique string per call. The debounce effect depended on the callback, closing a self-retriggering cycle: 6 full report generations in 2.5s idle, growing without bound, each one re-fetching every linked Paperless document over HTTP. The suite was green solely because the test mockedcreatePreviewUrlwith a constant return: React bails out of re-render onObject.is-equal state, so the cycle terminated at 2 calls in the test and never in the browser. Generalizable rule: when a mocked function's return value flows intosetStateand that state sits in auseCallbackdep array that an effect depends on, the mock must return unique values and the test must assert idle stability. -
B2 — German overview table overflowed A4 and clipped its last column.
widths: ['auto', …]imposes no total constraint. Measured on the real layout:en_minWidth504.4pt (2% headroom against 515.28pt),de553.7pt — overflows by 38pt, leaving ~4pt for the final column. The driver was header labels (Zugeordneter BetragvsAllocated Amount), not data. Generalizable rule: layout bugs in generated documents are locale-dependent and German is consistently the worst case; assert real-layout widths for every locale. Note that long multi-word values do not widen anautocolumn (pdfmake's_minWidthis the widest single word, so it wraps) — headers are the risk._minWidthwas the diagnostic that located this bug; it is not the correctness check — see minimum-bar rule #1, and note that the table has since moved to all-fixed widths, which changes the failure mode from "column overflows" to "header cannot fit its own label" (#1937). -
B3 — amounts and dates ignored locale and
CURRENCY. Two causes: (a)t(bodyKey, { total: report.totalAmount })— the i18n config sets onlyescapeValue: falsewith noformatfunction, so i18next interpolated a bare1234.56as the headline figure of a bank-facing letter; (b) the pure builders called the module-defaultformatCurrency/formatDateexports, so adeinstance withCURRENCY=CHFrendered€1,234.56/Jan 10, 2026in the PDF while the screen beside it showedCHF 1.234,56/10.01.2026. Generalizable rule: pure export/report builders cannot useuseFormatters(), so resolved formatters must be threaded in as parameters, and interpolated money/date placeholders must be pre-formatted before reachingt(). -
B4 — marker namespace collision.
*Nwas used for two independent counters in one document (appendix numbers and skipped-document footnotes), and the footnotes were orphaned — no row referenced them and the owning invoice id was discarded. Generalizable rule: a document has one marker/footnote numbering source, and no note may be orphaned — every note must be resolvable from what the reader actually sees in the table. There are exactly two legitimate note shapes and they must not share a numbering scheme: (a) a numbered, row-owned note (*N), which the owning row must reference by that number — this is what B4 broke; and (b) a document-level legend entry keyed by a repeated inline label that any number of rows may carry, where the reader resolves the label rather than a number. Shape (b) is what the split / deposit-reduced annotations became after #1965 — see the legend addendum below. Note that "one entry per owning row" is a property of shape (a) only; do not generalize it to the legend. -
B5 — stale export after failed regeneration. The catch path nulled
previewUrlbut keptpreviewBlob, and the export buttons were gated only onisRegenerating. So "uncheck Attach documents → generation fails → download" yielded a PDF that still had the documents attached — breaking the exact invariant the feature exists to guarantee. Generalizable rule: the preview-equals-export invariant must hold on the error path too. -
M4 — the root cause. No test rendered the real builders through the real library;
loader.test.tsrendered only{ text: 'hello' }. That single gap is why B2 and B3 survived three review rounds. One integration test pipingbuildCoverLetterContent+buildOverviewContentinto a realpdfMake.createPdf(...).getBlob()for both locales closes the whole class.
The report's language is chosen per-run in the wizard's Settings step and is independent of the app's ambient locale. Exactly two locale-bound artifacts are built in ReportWizardPage.tsx and threaded into the pipeline; nothing inside reportPdf/ is locale-aware on its own:
-
reportT = i18n.getFixedT(reportLanguage, 'budget')— synchronous becauseclient/src/i18n/index.tsstatically imports both theenanddebundles into oneresourcesobject. Never calli18n.changeLanguage()to switch report language — that mutates the app UI. -
reportFormatters = createFormatters(localeTag, currency)(client/src/lib/formatters.ts).useFormatters()is a thin wrapper over the same factory, so screen and report formatting cannot diverge in implementation, only in bound locale.
Report-language rendering flows exclusively through the two injected artifacts above. Stated as invariants that must be preserved on every change:
-
reportPdf/*andreportContent/*never reach the i18n singleton or the ambient locale. No module under either directory may import thei18ninstance, calli18n.t/useTranslation(), or use rawIntl.*/toLocaleString/toLocaleDateString. Every locale-dependent value arrives as a parameter:reportTfor text,reportFormattersfor numbers, money, and dates. -
Every key passed to
reportTis bare (sourceReports.*, resolved against thebudgetnamespace fixed atgetFixedTtime), never namespace-prefixed. A singlet('common:…')resolves against the interface bundle and leaks the UI locale into the PDF. -
i18n.changeLanguage()is never called to switch report language. It mutates the app UI.getFixedTis the whole mechanism. -
The bound locale is chosen once, at the page boundary.
ReportWizardPage.tsxis the only place that mapsreportLanguagetoreportT/reportFormatters. There is no second entry point, and no module downstream re-derives the locale. -
No module under
reportPdf/accepts, imports, or references aTFunctionat all (#2001).buildReportContentis the single point at which aTFunctionenters the pipeline; every module downstream of it receives already-resolved strings onReportContent. This is the strongest of the five invariants and the only one a compiler can enforce: with noTFunctionin scope, there is no wrongtavailable to pass.
Why this is a contract and not a style preference: the preview and the export are the same bytes by construction (see the Decision above), so a locale leak does not produce a wrong preview and a right export — it produces a document whose language is internally inconsistent, and which is inconsistent with the language the user selected. The failure is silent: a leaked string is still a valid string, just in the wrong language, and it renders without error. Nothing in the type system distinguishes reportT from the interface t — both are TFunction — so wherever a TFunction is in scope at all, the only defences are this contract, review, and per-locale real-render tests. Invariant 5 exists to shrink that surface to a single module: for reportPdf/* the contract is now compiler-enforced, and only buildReportContent still depends on the caller having picked reportT.
Grep guard for invariant 1:
grep -rnE "useTranslation|/i18n/|Intl\.|toLocale" \
client/src/lib/reportPdf client/src/lib/reportContent \
--include='*.ts' | grep -v '\.test\.'Grep guard for invariant 5:
grep -rn "TFunction" client/src/lib/reportPdf --include='*.ts' | grep -v '\.test\.'Both must return nothing. Note that the invariant-1 guard cannot see an invariant-5 violation: a t: TFunction parameter matches none of useTranslation / /i18n/ / Intl. / toLocale, which is why a leak of this shape survived three separate fix rounds (#1938, #1993, #2001). Run both. (The .test. exclusion is deliberate and narrow: realRender.test.ts legitimately imports both the en and de budget.json bundles directly and builds a reportT per locale, because asserting per-locale layout requires it.)
Locale-decoupling invariant — the class is closed (#1938, #1993, #2001). Three rounds moved the last generation-time strings onto ReportContentLabels, each populated via reportT in buildReportContent alongside the other entries: the page header (#1938), the page footer (#1993), and finally the cover-letter Reference: / Subject: prefixes plus the *N skip-note reasons (#2001). With #2001 the TFunction parameter itself is gone from reportPdf/*, so the contract is no longer merely unviolated — it is unreachable from any call site. The general rule all three instantiate: artifact content resolves through reportT/reportFormatters; only edit affordances use the interface t. The process lesson is worth as much as the fix: each of the three rounds left the next instance to be found by review, because the call sites were being fixed one at a time while the channel that made them possible stayed open. When a hazard is enforced only by convention, remove the channel, not the individual call.
Does this exact string appear in the exported PDF? If yes it follows the report language wherever it captions that data (including responsive/mobile variants of the same caption — otherwise the language flips at a breakpoint). Screen-reader affordance sentences (ariaLabel, resetAriaLabel) are wholly UI language. Never splice a report-language noun into a UI-language sentence.
Worked cases, so the boundary is not re-litigated:
-
labels.usageis a PDFtableHeader→ report language everywhere it captions that data, including the responsive mobile card. Sourcing the mobile caption from the interfacetmade the language flip at a breakpoint. -
labels.attachmentsNoteis not exported (there is no attachments column in the PDF) — but its desktop header already reads fromcontent.labels, so the mobile variant must match or desktop and mobile split. - Cover-letter
senderLabel/recipientLabel/bodyLabelstay UI language becausecoverLetterPdfrenders those blocks with no label prefix at all — they are genuinely unexported. (Reference:andSubject:do print, so since #2001 they live onReportContentLabelsascoverLetterReferenceLabel/coverLetterSubjectLabel, resolved fromsourceReports.coverLetter.*viareportT. Note these coexist with the UI-languagesourceReports.editable.referenceLabel/subjectLabelthat caption the same two fields inReportContentEditor.tsx— two labels for one field is correct here, and is the discriminator working as intended: the editor's is an edit affordance, the PDF's is artifact content.) The cover-letter card is therefore not a precedent for putting editable-field labels on the interfacet.
An editable content model sits between the API response and the pdfmake builders. The layer order is load-bearing:
SourceReportResponse
--buildReportContent(report, includedIds, useCase, reportT, reportFormatters, opts)--> ReportContent (layer 1: baseline)
--applyAiContent(content, aiContent | null)--> ReportContent (layer 2: AI, optional)
--applyOverrides(content, overrides)--> ReportContent (layer 3: user edits)
--generateReportPdf(...)--> Blob
-
buildReportContentowns all text derivation, includingincludedTotaland per-status subtotals.merge.ts/overviewPdf.ts/coverLetterPdf.tsare pure layout with zero derivation. -
applyAiContentfolds AI output into the baseline, not into the overrides map. This is deliberate: overrides carry "the user edited this" semantics (the edited-dot indicator, per-field reset, and the discard-confirm guard). AI text placed inoverrideswould mark every generated field as user-edited, offer a reset that reverts to non-AI text, and be silently wiped by the step 1–4 change guard. Empty strings from the generator fall back to the baseline value rather than blanking a field. -
applyOverridesis pure and ignores unknown keys. Override keys are enumerated inreportContent/overrideKeys.ts:coverLetter.{sender,recipient,reference,subject,body,signature}androw.<invoiceId>.usageText.coverLetter.signatureis the one derived field, with a three-way precedence that must be preserved: an explicitsignatureoverride always wins; otherwise, if and only ifsenderwas overridden, the signature is re-derived from the first line of the new sender; otherwise the baseline signature stands. (The re-derivation is gated on the sender actually having changed — an unconditional recompute would overwrite a baseline signature thatbuildReportContentderived differently.) -
Translated labels live in the model — a consumer must never re-derive them.
ReportContentLabelsis built once withreportTinbuildReportContentand consumed by bothoverviewPdf.ts(the PDF) andReportContentEditor.tsx(the on-screen<th>s, mobile card captions, and column-toggle text). One translated string set per report is what makes a second consumer unable to drift. Any directt()call in a consumer ofReportContentsilently reintroduces the preview/export locale mismatch — it resolves against the interface bundle while everything around it resolves againstreportT. When adding a consumer, the first question is whichtit receives; when adding a label, it goes onReportContentLabels, not into the consumer.- Corollary: because
ReportContentLabelsisreportT-derived and not user-editable, changing a label's translation (e.g. shortening a DE column header to fit a fixed width, #1937) is safe and lands in both surfaces at once — but it must be identical in both by design, so neither surface may hold its own copy. -
Nothing downstream is re-translated independently any more (#2001). Every string the PDF prints comes from
ReportContentLabels: page header (#1938), page footer (#1993), and finally the cover-letterReference:/Subject:prefixes and the*Nskip-note reasons (#2001). Because theTFunctionparameter is gone from all threereportPdf/*builders (invariant 5), this bullet can no longer regress by a caller passing the wrongt— only by someone re-introducing a parameter. -
Value-keyed label sets must be keyed by the union, not indexed with a widened
string. The*Nskip-note reasons are the one label chosen by a runtime value rather than fixed at the call site:labels.skipReasonLabelsis aRecord<ReportSkipReason, string>, andReportSkipReasonis declared once inreportContent/types.tsand reused by every declaration that carries a skip reason (SkippedDocument.reason, theMap<string, ReportSkipReason[]>threaded frommerge.tsintobuildOverviewContent). That is what makes adding a skip reason a compile error at the single population site inbuildReportContent.ts. The shape to avoid — caught in the PR #2007 review — is a widenedMap<string, string[]>re-narrowed at the consumer withlabels.skipReasonLabels[reason as 'a' | 'b'] ?? reason: it type-checks, adds no compile error when the union grows, and the dead-looking??fallback then prints the raw identifier into a bank-facing PDF. Apply the same rule to any future value-keyed label set.
- Corollary: because
-
ReportContentRow.statusis the raw key (feeding theBadgevariant map);statusTextis the report-language label. Both fields are needed — a consumer that renders the badge's label from its ownt()is the same UI-locale leak in miniature. -
Two independent references by design:
coverLetter.reference(editable) vssourceInfo.referenceText(the overview block's read-only Reference line). Both baseline offreport.source.reference; editing the letter does not change the table.
AI generation is server-side and stateless (POST /api/source-reports/generate-content, story #1901). The server re-fetches the report from the database rather than trusting client-supplied invoice data; the client sends only selection (includedInvoiceIds, excludedLineIds), report type, sourceId, and language. Nothing is persisted — the response exists only to seed layer 2. It reuses the single LLM gateway (server/src/services/llmGateway/, renamed from budgetExtraction/ in #1917 once it stopped being budget-specific) rather than introducing a second provider path; the system prompt treats all invoice-derived text as untrusted data and forbids acting on instructions embedded in it.
Monetary-unit invariant across the AI seam. Report amounts (allocatedAmount, totalAmount, allocatedPortion) are major currency units rounded to 2 dp, never minor units/cents. Any value handed to the prompt builder must already be in that convention and must be rendered without a further scale factor. The included total shown in the letter and the total shown in the overview table are the same number, so the server's total must mirror applyLineExclusions + buildReportContent: round per invoice (Math.round((allocatedAmount − excludedPortion) * 100) / 100), then sum, then round the sum once more to shed float-accumulation dust. The per-invoice amounts handed to the generator must be the same exclusion-adjusted figures that the total is built from — sending raw allocatedAmount alongside an exclusion-adjusted total gives the model a set of parts that does not add up to the stated whole. Generalizable rule: when a monetary value crosses a module boundary, its unit belongs in the type's JSDoc, and a test must assert the rendered string, not just the numeric field.
The overview table carries two structurally different kinds of note, and conflating them is the mistake B4 above records. They are now separated:
| Kind | Marker in the cell | Cardinality | Built by | Rendered by |
|---|---|---|---|---|
| Skipped-document note |
*N, numbered, referenced by the owner row |
one per skipped document (row-owned) |
overviewPdf.ts at generation time |
overviewPdf.ts (generation-time data, not in ReportContent) |
Legend entry (content.footnotes[]) |
a repeated inline word label in the Allocated Amount cell — partial, less deposit — not a symbol or number |
at most one per flag type for the whole document | buildReportContent.ts |
overviewPdf.ts legend block; ReportContentEditor on screen |
content.footnotes[] entries are document-level legend entries, not per-row footnotes. Each entry is one deduplicated sentence per flag type across all rows in the report. buildReportContent.ts accumulates splitInvoiceIds / depositReducedInvoiceIds as Set<string> while building rows, then pushes at most one entry per flag — gated on set.size > 0, never per row:
if (splitInvoiceIds.size > 0) footnotes.push({ id: 'split', marker: splitInlineLabel, text: splitFootnote })
if (depositReducedInvoiceIds.size > 0) footnotes.push({ id: 'depositReduced', marker: depositReducedInlineLabel, text: depositReducedFootnote })
So content.footnotes.length is bounded by the number of flag types (currently 2: split, depositReduced), regardless of whether one row or forty carry the flag. Consequences that follow from this and must be preserved:
-
markeris a label, not an identifier. It is the same human-readable word (partial/less deposit, report-language) that the row cell prints inline, and it is what the reader matches on.id('split'/'depositReduced') is the stable machine key; nothing keys off the marker string. -
The row↔legend link is by repetition, not by reference. Rows carry the boolean flags
isSplit/isDepositReduced;overviewPdf.tsappends the corresponding inline label to the Allocated Amount cell. No row stores a footnote id, index, or number, and none should — that would reintroduce a second numbering namespace alongside*N(B4). -
The two note kinds share the legend block but not a numbering scheme.
overviewPdf.tsemits the*Nskip notes first, then thereportContent.footnotes[]entries. The*Ncounter must never be extended to cover legend entries. -
A flag type is added in exactly one place. New annotation ⇒ a new
Set+ a newsize > 0push inbuildReportContent.ts, a new boolean onReportContentRow, and a new inline label inoverviewPdf.ts. Emitting one legend entry per flagged row instead is the regression to guard against; assertfootnotes.length(not>= 1) on a fixture where several rows share a flag. -
Legend text is report-language and comes from
reportTinbuildReportContent, like every other string inReportContent— it prints in the exported PDF, so the export test in the report-language addendum applies.
Easier:
- The preview-equals-download invariant is structural, not enforced by convention: one
Blobfeeds the iframe, the download anchor, and the Paperless upload. - No server-side PDF surface at all — no Chromium, no render library, no artifact cache, no temp-file lifecycle, no TTL/cleanup job, and no PDF CVE-patching burden on the container image.
- No server CPU or memory cost. A large merge is a transient in the user's own tab and cannot degrade the API for the other users of the single-process container.
- Option toggles regenerate locally — no round trip per toggle, and no report bytes crossing the wire twice.
- Dependency policy stays clean: pure JS throughout, exact-pinned, no install scripts, no ARM64-emulation hazard.
- A reusable client-side document pipeline now exists (
reportPdf/) with clear seams, available to future export features.
More difficult:
-
~2MB of lazily-loaded JavaScript, dominated by the base64 Roboto vfs. It is kept out of the initial bundle by a fragile, easily-broken invariant (
import typeonly outsideloader.ts) that must be re-verified with the grep guard on every change. A single careless static import regresses initial load for every page in the app. -
Merge memory is client-side and unbounded by design. Every appendix document is fetched fully into memory and parsed by pdf-lib. The practical ceiling is the browser tab, with the server's 50MB multipart cap (
server/src/app.ts) bounding only the upload of the finished report — a merged report larger than that cannot be pushed back into Paperless even though it generated fine. Large-claim reports (dozens of multi-megabyte invoices) are the risk case; document fetching is currently serial, which is slow but keeps peak memory lower than a parallel fetch would. - Typography is Roboto-only. Any other family requires shipping and registering additional font files, adding further weight to the async chunk. Font choice is therefore effectively frozen for generated documents, independent of the app's on-screen type (Style Guide, ux-designer's domain).
- Generation quality depends on the client. Old or memory-constrained browsers, and mobile tabs in particular, may struggle with a large merge. There is no server-side fallback path.
- Two libraries to keep current rather than one, and their combined 27-package subtree must be re-verified as pure-JS on every upgrade (Dependabot handles the bumps).
- Testing costs more. Mocked unit tests are actively misleading for this pipeline; real-render tests for both locales are mandatory, and they are slower than the mocked equivalents. This is a deliberate trade: the four defects in PR #1887 that a real render would have caught were each cheaper to prevent than to find.
-
Locale/config plumbing is manual. Because the builders are pure, every locale- or config-dependent value must be explicitly threaded in; forgetting to do so fails silently with
en-US+EURdefaults rather than raising.
-
ADR-004 — Webpack
splitChunksis what makes the async chunk possible; the no-native-binaries dependency policy originates here. - ADR-015 — the document proxy the appendix fetches through, and the archive the finished report is uploaded back into.
-
ADR-021 — the i18n configuration whose lack of a
formatfunction caused finding B3. - ADR-013, ADR-017, ADR-033 — precedent for documenting client rendering/library choices.
| Date | Deviation | Resolution |
|---|---|---|
| 2026-08-03 | Finding B4's generalized rule read "every footnote is referenced from the row that owns it". That was true when the split / deposit-reduced markers were inline symbols (†, ‡) printed in the cell and paired with a note per owning row. Since issue #1965 the markers are repeated inline word labels (partial, less deposit) and content.footnotes[] holds document-level, deduplicated legend entries — at most one sentence per flag type for the whole report, gated on Set.size > 0 in buildReportContent.ts. The wiki still described per-row footnotes. |
Wiki was outdated; code is correct. B4's rule reworded to distinguish numbered row-owned notes (*N) from document-level legend entries, and to state explicitly that "one note per owning row" applies only to the former. Added the "legend is document-level and deduplicated" addendum recording the cardinality bound, the label-not-identifier property, and the row↔legend-by-repetition link. Intro bullet 2 updated to name the legend block. |
| 2026-08-04 |
Minimum-bar rule #1 was wrong. It read "Assert table._minWidth <= 515.28 for both en and de". _minWidth is the width of the widest unbreakable token, not the laid-out width, so the assertion fails on correct code that relies on wordBreak: 'break-all' and passes on incorrect code whose '*' column overflows via starMaxMin. It was a mis-transcription of finding B2's diagnostic (where _minWidth legitimately located the bug) into a correctness check. Owed since the #1876–#1879 round and tracked as issue #1914. |
Wiki was wrong; code is correct. Rule #1 replaced with two render-derived assertions: max(node.positions[].horizontalRatio) <= 1 (verified against DocumentContext.js:490) for overflow, and tableOffsetsTotal(cols) + sum(widths[i]._calcWidth) <= printableWidth() for the table box. Added an explicit "do not assert _minWidth" paragraph with the reason, and a pointer from B2's narrative clarifying diagnostic-vs-check. |
| 2026-08-04 |
Module table drifted twice. It listed shared.ts as owning "PDF-local formatters" (deleted in the #1879 review round 2, precisely because a PDF-local formatter is a second formatter implementation bound to a different locale — finding B3) and "table layout constants" (moved to pageGeometry.ts in #1939), and omitted pageGeometry.ts and index.ts entirely. Tracked as issue #1914. |
Wiki was outdated; code is correct. Rewrote the module table: added pageGeometry.ts as sole owner of the pt coordinate system and index.ts as the barrel, restated shared.ts's role without formatters or geometry constants, and added notes recording why the formatters were removed and why tokens.css is not the home for pt geometry. |
| 2026-08-04 |
The override-key list was stale. It read row.<invoiceId>.{usageText,attachmentsNote} — attachmentsNote stopped being an override key in #1959 — and omitted coverLetter.signature, added later with a three-way precedence rule (explicit override > re-derive when sender changed > baseline) that the flat sentence "recomputes coverLetter.signature from sender" misdescribed as unconditional. |
Wiki was outdated; code is correct. Corrected the key list against reportContent/overrideKeys.ts and wrote out the signature precedence explicitly, including why the re-derivation is gated on senderChanged. Source of truth: client/src/lib/reportContent/applyOverrides.ts. |
| 2026-08-04 |
Three empirically-established constraint sets were never recorded, so each was rediscovered at review cost: the dontBreakRows-on-table-not-layout placement trap (a wrong placement type-checks, produces a byte-identical PDF, and passes a config-shape unit test); the silent-drop-of-over-tall-unbreakable-rows defect with its "bound the rendered cell, not a source field" rule and two threshold-free detection recipes; and the fixed 6-or-7 column-count constraint that makes PDF column-visibility toggles a re-measurement story. Owed since #1929/#1959. |
Added "Unbreakable rows are silently dropped" as a section, added minimum-bar rule #2 (height bound) pointing at it, and added four bullets to the pdfmake 0.3.x constraint list (content-vs-occupied widths and usableColumnWidth, '*' never shrinking below its longest token plus the resulting all-fixed-width consequence, dontBreakRows placement, and _calcWidth as the verification lever). |
| 2026-08-04 |
The locale-decoupling contract was described but not stated as enforceable invariants (architect finding L5, PR #1903 review; tracked as issue #1914). The addendum recorded the two injected artifacts and the two properties to preserve, but not the injection-only contract as numbered invariants, no grep guard, and no record of the outstanding merge.ts footer violation (buildPageFooter(t('sourceReports.table.pageLabel')) — interface t, so an English report gets a Seite 2 / 5 footer under a German UI). |
Added an "injection is the only channel" subsection with four numbered invariants, the reason the failure mode is silent (nothing in the type system distinguishes reportT from the interface t), a verified grep guard, and the known open violation with its fix shape. Promoted the export-test discriminator to its own subsection with the three worked cases. The merge.ts footer violation is a code defect and is NOT fixed by this pass — flagged for a follow-up issue. |
| 2026-08-04 |
merge.ts page-footer had a fragile caller contract. generateReportPdf accepts a TFunction t parameter, and the footer called buildPageFooter(t('sourceReports.table.pageLabel')). The only production caller (ReportWizardPage.tsx) always passed reportT, so no mislocalised footer was ever observable; but t and reportT live one character apart in the same component, so the contract required every future caller to pass the right one — with no type-system enforcement and no grep guard catching a t()-on-injected-param call. The 2026-08-04 locale-decoupling row above documented this as a known open violation; it was re-characterised as fragility rather than a live defect in the PR #2000 review. |
Hardened in PR #2000 (issue #1993): added pageLabel to ReportContentLabels, populated it via reportT in buildReportContent.ts, and replaced the parameter-dependent t() call with reportContent.labels.pageLabel in merge.ts. The footer now follows the same injection-only path as every other ReportContentLabels field, making the locale contract impossible to violate from the call site. Added regression assertion in merge.test.ts and updated all seven test files that construct ReportContentLabels literals. |
| 2026-08-04 |
LLM gateway path references went stale when #1917 renamed server/src/services/budgetExtraction/ to server/src/services/llmGateway/ (the directory stopped being budget-specific once report-content generation started using it). Live references existed on this page (1), API-Contract.md (2), and Security-Audit.md (2). |
Updated the references on this page, API-Contract.md, and Security-Audit.md in the same PR as the rename, noting the old name once for searchability. Historical Deviation Log rows keep the then-current path deliberately. (Security-Audit.md is security-engineer-owned; the path-only update was approved by that agent as part of this PR.) |
| 2026-08-04 |
The content-model addendum understated the labels rule (post-#1909). It said labels live in the model and "when adding a consumer, check which t it receives", but did not state that a direct t() call in a ReportContent consumer *silently reintroduces the preview/export locale mismatch, nor name the two consumers, nor record that ReportContentRow.status/statusText both exist for the same reason. |
Expanded the labels bullet to name both consumers (overviewPdf.ts, ReportContentEditor.tsx), state the direct-t() failure explicitly, record the not-user-editable corollary that makes translation changes safe (#1937), and list the sites still translating independently — including the merge.ts footer exception. Added the status/statusText bullet. |
| 2026-08-05 |
The locale-decoupling contract was documented at its pre-#2001 strength in five places, all falsified by PR #2007 (issue #2001) removing the TFunction parameter from reportPdf/*. Line 82 quoted the builder signature as (reportContent, …, t: TFunction) and described a "residual t" that "is always reportT" — instructing a future contributor to add one back. The "why this is a contract" paragraph ended "nothing in the type system distinguishes reportT from the interface t … so the only defences are this contract, review, and per-locale real-render tests", which had become false for reportPdf/*. The invariant list stopped at 4 and its single grep guard (useTranslation|/i18n/|Intl.|toLocale) structurally cannot match a t: TFunction parameter — which is why this leak shape survived three fix rounds. The content-model addendum still listed overviewPdf.ts's *N skip reasons and coverLetterPdf.ts lines 46/54 as "still re-translated independently", a now-empty list. The export-test worked case said Reference:/Subject: print "from separate sourceReports.coverLetter.* keys" without noting they had moved onto ReportContentLabels. |
Wiki was outdated; code is correct. Rewrote line 82 to state that buildReportContent is the pipeline's only TFunction entry point and that a new generation-time string means a new ReportContentLabels field, never a new parameter. Added invariant 5 (no module under reportPdf/ accepts, imports, or references a TFunction) with its own grep guard, plus an explicit note that the invariant-1 guard cannot see an invariant-5 violation and both must be run. Re-scoped the "nothing in the type system distinguishes" sentence to "wherever a TFunction is in scope at all", naming buildReportContent as that single remaining surface. Generalised the #1993 footer paragraph into "the class is closed (#1938, #1993, #2001)" with the process lesson: when a hazard is convention-enforced, remove the channel, not the individual call. Replaced the "still re-translated independently" bullet with the closed-class statement, and added a value-keyed label set rule recording the Record<ReportSkipReason, string> design and the widen-then-as-narrow anti-pattern caught in the PR #2007 review. Corrected the export-test worked case to name coverLetterReferenceLabel / coverLetterSubjectLabel and to record that their coexistence with the UI-language sourceReports.editable.* labels is the discriminator working as intended, not a duplicate. |