Skip to main content

Composition and the render seam

What it guarantees

What you approved is what you saw. Not as a policy. Structurally: the preview and the PDF come from the same function, the save path hashes the exact bytes that function returned, and the approval trail attests to that hash. And separately: an undeclared field cannot survive into the posting path, because the payload is projected onto the document type’s declared properties before anything is rendered, hashed or stored.

The three parts

One render path

Both endpoints go through a single render_document_html. The chain that rests on it:
  1. the reviewer sees the preview,
  2. the save path hashes the PDF bytes the service returned,
  3. the approval trail attests to that hash.
So “what I approved is what I saw” holds only while preview and PDF are the same bytes for the same input.
Give them separate code paths and nothing fails visibly. The preview renders, the PDF renders, both look right, and the guarantee just stops being true. This is why it is stated as one of the render service’s three named properties rather than left as an implementation detail.
The other two properties of that service:
  • No browser can reach it. No host port, no public route, no CORS middleware, and a router-level token dependency so an endpoint added later cannot forget it. An unset token is a 503 rather than an open door: a render oracle for financial payloads that anything on the network can drive is worse than an outage.
  • A 500 body carries a reference, not the exception. A render exception is raised from code holding the payload, so echoing it would put field names and values into a response.
The renderer’s base image is pinned to python:3.12-slim-bookworm rather than the floating tag, because Debian dropped the PDF engine in the next release and the day that tag rolled, the build died having passed everywhere before. The PDF engine is this service’s reason to exist, so the base image is part of its contract.

The projection

This is the control that makes the signed PDF authoritative, and it is subtle enough to be worth the full account. The payload is projected onto the document type’s declared properties, server-side, before anything is rendered, hashed or stored. Both the preview path and the save path call it.

The attack it closes

The renderer draws only schema-declared fields. So an undeclared key is invisible on the PDF an approver signs. But it survives verbatim into the intake row’s field map, where the Bookkeeper’s key-precedence probe prefers amount over total_amount, and kind over the document type’s default. So a document rendering as 100 could post 1,000,000,000 to a different account, under a real approver’s name. That was executed, not theorised.

Why it is not “more validation”

It is a projection, and it is an integrity control rather than a quality one. It needs only the property-name list, so there is no second validator to keep in step with the first. The client-side schema validation stays a quality gate, because a logged-in user can call the proxy directly and skip it. Dropping undeclared keys rather than repairing them is also deliberate. A model that invented supplier_address for a schema declaring counterparty_address produced a field, not a correction, and silently renaming it would be a guess about money.

What the client may decide, exhaustively

doc_type, payload, and supersedes_id. Everything else on the row is set server-side from the verified session and the server’s own computation: the organisation, the creator and their identity, the status, the drafting agent, the source, the PDF path, the PDF hash, the document group and the version. That is the integrity boundary.

What is deliberately not validated server-side

The payload’s conformance to its JSON Schema: types, formats, required fields. Re-implementing the 146-schema validator in Python would be a second validator to keep in step with the first, and disagreement between two validators is worse than one. What is checked is everything cheap and structural: the document type exists in the schema the renderer renders from, the payload is an object, the request is within the size cap, every key is a declared property, and the renderer’s own refusal of a type it cannot map. A payload that is structurally fine but semantically wrong produces a document a human then declines to approve, which is the gate that was always going to catch it.

Approval rules, enforced in the database

Two rules, both inside the approval RPC’s own for update lock rather than only at the service layer: The service-layer copies of both still exist, for the message. The trail says why (blocked_reason), so the UI shows a rule rather than a button that returns 403.

The other half of the version problem

v1 approved and submitted, then revised to v2, leaves v2 legitimately approvable and submittable - a different PDF hash, therefore a different idempotency key, and nothing linking its intake row to v1’s. So the submit path asks whether any version of the group already has a live intake row, and files the new one flagged, with duplicate_of pointing at the incumbent. That is what makes the pair visible to the duplicate-group guard, which is the control that actually protects the ledger. The submit response carries the link back, read from the stored row rather than from the pre-check that decided it, so the trail can never assert a link the queue does not hold.

Where it is enforced

renderer/pdf_forms.py is not the live engine. It is the unported original the live render_core.py was extracted from, kept in the tree for a future reconciliation, not importable, referenced by nothing, and excluded from the built image. Two copies of a layout registry with one exercised is precisely the drift the seam exists to prevent.

The tests that would fail if it broke

The renderer’s CI job runs inside its own pinned base image and installs the PDF engine there, so the PDF suites are exercised rather than skipped, and a base image that can no longer install the engine fails the build instead of the deploy.

What goes wrong without it

Without the one-render-path rule, an approver signs a preview and the archived PDF is a different document, with nothing to indicate it. Without the projection, a document that renders as one number posts another, under a real approver’s name, and the audit trail records the approval correctly - because the approval was correct. The approver saw what the renderer drew.