faktorei

Engineering

The same invoice, the same bytes

Render the same invoice twice with an ordinary PDF toolchain and you get two different files. Nothing is wrong with either. That is the problem.

An invoice is kept for years — eight to ten across most of Europe, and the number moves: Germany shortened its invoice retention from ten years to eight with effect from 1 January 2025, while France and Belgium still require ten. During that time someone may need to establish that the PDF in the archive is the document that was issued. If re-rendering the source XML produces a different file every time, that question has no cheap answer. You are reduced to comparing rendered pages by eye, which is not evidence.

Byte-determinism turns it into a one-line check. Re-render, compare hashes, done.

What a PDF toolchain puts in your way

Quite a lot, and all of it invisible in the rendered page.

Apache FOP stamps the wall-clock time into two places — the document information dictionary as /CreationDate, and the XMP metadata packet as xmp:CreateDate and dc:date. FOP offers FOUserAgent.setCreationDate, which pins those. It does not pin xmp:MetadataDate, which keeps taking the wall clock, and it has nothing to say about the trailer /ID, which is random by design.

So a naive "pin the date" fix gets you most of the way and leaves two moving values. Both are invisible in a viewer and both change the hash on every run, which is the worst combination: the file looks stable and is not.

Pin to the document, not to the clock

The first decision is what the timestamp should be. The usual reproducible-build answer is a fixed epoch or a build-time variable, but an invoice has a better one already inside it: BT-2, the issue date. Pinning to the invoice's own issue date makes the output a function of the input and nothing else — no environment variable, no build argument, no hidden state. The same XML renders to the same bytes on any machine on any day.

The two values FOP will not pin are handled afterwards. Both are fixed-width, so they can be rewritten in the saved bytes without re-serialising the document — which matters, because re-serialising a PDF/A file risks invalidating the conformance you just produced. A length-preserving post-pass is the conservative option: it cannot move an offset, so it cannot break the cross-reference table.

The timezone matters too. A date pinned in local time still moves with the host, so the process runs in UTC — otherwise the same input renders differently in Frankfurt and Brisbane, which is precisely the class of bug this is meant to remove.

What it holds against

A determinism claim is only as good as the axes you have actually varied. These are the ones tested against the published container (ghcr.io/faktorei/render, 2025.11 line), hashing the finished PDF each time:

VariedResult
The same request, twicebyte-identical
UBL source vs. its CII twinbyte-identical
linux/amd64 vs. linux/arm64*byte-identical
Networked host vs. --network nonebyte-identical
Writable root vs. --read-only rootfsbyte-identical

* The arm64 run was under QEMU emulation on an amd64 host, not on native arm64 silicon. Emulation executes the real aarch64 instruction stream, so the JVM JIT-compiles the aarch64 path — but it is not the same as a Graviton box, and we say so rather than let the table imply otherwise. Native arm64 confirmation is outstanding.

The architecture row is the one worth dwelling on. Rendering runs through an XSLT processor and a text layout engine, both doing floating-point work, on two instruction sets with different register widths and different default rounding behaviour. There is no law that says those produce the same glyph positions. That they do — to the byte, in the compressed content stream — is the property that lets you deploy on Graviton and x86 in the same fleet and still treat the output as one thing.

The air-gap row exists because "no telemetry" is easy to claim and cheap to check. Run the container with no network interface at all: it renders, and the output matches the networked run exactly. Nothing about the presence of a network changes the document, because nothing about the document consults the network.

What determinism is not

It is not a guarantee that two different versions of the renderer agree. They will not, and should not — a fixed layout bug changes bytes, and that is the fix working. Determinism means the output is a pure function of the input for a given version, which is why the version and the image digest are part of any claim worth making about a rendered archive.

It is also not free to keep. Determinism is the kind of property that decays silently: a dependency bump reintroduces a timestamp, and nothing fails except a promise nobody is testing. It only survives as a gate that re-renders and compares on every change.

Why bother

Because the alternative is that "this is the invoice we issued" rests on trusting the archive rather than checking it. With deterministic rendering, an auditor's question becomes a hash comparison against the source XML you already keep — and the PDF stops being a separate artefact you have to preserve and start being one you can regenerate.

The stylesheets and corpus are Apache-2.0: github.com/faktorei/stylesheets. Date pinning and the metadata post-pass are in the render container; the benchmark page covers the reproducibility of the published throughput figure.