Skip to content
Registry StackDocsDevelopment (unreleased)

How Registry Render works

View as Markdown

Registry Render promises that fixed inputs produce identical PDF bytes, on one machine and across machines.

A bundle. Its manifest.yaml declares each document type with a version, a Typst entry file, a JSON Schema for the document’s data, and the label locales it uses. Label tables hold the words a template prints in each language, and every table carries the same keys. Fonts ship in the bundle. The configuration reference lists every key.

A template reads one input: an envelope holding the request’s data, its decoded image assets, the label tables, the locale when one is sent, the issuance time, and the document’s id and version. Nothing else reaches it: no host fonts, no environment, no clock, no network.

Render canonicalizes the envelope with RFC 8785 (JSON Canonicalization Scheme) and hands the template that exact string. The dataDigest a caller stores is the sha256 of those bytes, so the hash and the template’s view of the request are the same thing.

issuedAt is the document’s issuance claim and its only clock: datetime.today() in a template and the PDF creation date both come from it. Render normalizes it to UTC, so 10:32Z and 13:32+03:00 render identically and the caller’s offset is not kept. A template that prints a local date shifts the instant itself with datetime.today(offset: ...).

Each render starts a fresh Typst world, so no state or cached layout passes between requests. The world offers three roots: the bundle, the request’s assets under a virtual assets/ path, and the vendored packages. A path outside them is refused even when request data supplies it, and error messages name virtual paths, never the host’s. Fonts load in a fixed order, the binary’s baseline set and then the bundle’s sorted by path, because order decides fallback.

The PDF names a fixed creator with no version, so an upgrade that changes no layout changes no bytes. Below Typst, the workspace lockfile pins the compression libraries, because a patch release there can re-encode a stream and move every byte after it. The same inputs give the same bytes on Linux and macOS.

registry-render serve serves one package. registry-render package copies validated source into a new directory and writes a sorted SHA256SUMS file covering every file, plus an optional REVISION; the package digest is the sha256 of that checksum file. The package writer refuses an existing directory, so a digest never gains different bytes.

At startup serve verifies the checksums and, when configured, package.expectedDigest. Each render runs in a worker process that verifies the package again, and the parent requires the worker’s digest to equal the startup digest, so a file changed after startup is refused by name. A worker that passes its timeout is killed, and on Linux its address space is capped.

The service keeps no database and no rendered document. Its one file is the audit log: a request entry before each render starts and a response entry, with both hashes, before the PDF leaves. If either write fails, the call fails and no document is returned.

The caller does. Render returns the PDF and forgets it; the record the document was made from should keep pdfDigest for the file and dataDigest for the exact input. The audit log holds hashes, versions, and a fingerprint of the caller’s key, never data values or image bytes. What a template prints is a disclosure decision that belongs to its author: once on paper, it has left every access profile behind.

Re-render it. The same package, document type, locale, data, assets, and issuedAt reproduce the stored pdfDigest byte for byte, which is what makes a replay after a failure safe and lets an auditor match paper to the log. GET /health names the live package digest as bundleHash, and every audit entry carries the bundle hash and renderer version.

When a hash moved, one of three things changed: an input, the package, or the renderer. A Typst or compression upgrade can change the bytes, so check a new binary before you switch to it.

registry-render check prints a hash for a source directory too, but for unpackaged source it covers manifest.yaml only. Compare packages, not source directories.