> ## Documentation Index
> Fetch the complete documentation index at: https://docs.vinmake.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Composition and the render seam

> One render path, so the bytes an approver signs are the bytes they saw. And the server-side projection that makes those bytes the only ones that can post.

# 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

```mermaid theme={null}
flowchart LR
  UI["Typewriter builder<br/>the human types"] -->|"{doc_type, payload}"| SVC
  AGENT["Typewriter agent<br/>proposes"] -.->|"opens in the builder"| UI
  SVC["typewriter_service<br/>every privileged effect"] -->|"render"| RND
  RND["renderer<br/>one render_document_html"] -->|"HTML preview"| SVC
  RND -->|"PDF bytes"| SVC
  SVC -->|"sha256(exact bytes)"| DB["typewriter_documents<br/>pdf_hash · pdf_path · provenance"]
  DB -->|"approve, head-only, maker≠checker"| APP["approval trail"]
  APP -->|"submit"| Q["intake_queue"]
```

| Part                                    | What it owns                                                                                                |
| --------------------------------------- | ----------------------------------------------------------------------------------------------------------- |
| `renderer/`                             | the render service. `{doc_type, payload}` in, HTML or PDF out. Internal only                                |
| `devcenter/typewriter_{api,service}.py` | every privileged effect: render, hash, object storage, database insert, the approval RPC, the Clerk handoff |
| `agents/typewriter/`                    | the graph, which **proposes** and never writes                                                              |

## 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.

<Warning>
  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.
</Warning>

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.

<Note>
  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.
</Note>

## 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:

| Rule                                                              | Migration | Why it is in the lock                                                                                                                                                                                                                                        |
| ----------------------------------------------------------------- | --------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| **only the head of a version group** may be approved or submitted | `0029`    | `status` says whether a *version* was approved, never whether it is still *the document*. Without the check both versions reached APPROVED and each got its own intake row with no link, which the duplicate-group guard cannot connect - so both could post |
| **maker is not checker**: a document's author cannot approve it   | `0031`    | so a second caller cannot skip the rule by taking a different code path                                                                                                                                                                                      |

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](/greatbook/capabilities/idempotency-and-durability), 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

| Concern                                                     | Code                                                                         |
| ----------------------------------------------------------- | ---------------------------------------------------------------------------- |
| the one render path, and the three properties               | `renderer/render_router.py`, `renderer/render_core.py`, `renderer/README.md` |
| the projection, and every privileged effect                 | `langgraph_chat/devcenter/typewriter_service.py`                             |
| the API surface                                             | `langgraph_chat/devcenter/typewriter_api.py`                                 |
| head-only approval, maker-is-not-checker, the version group | migrations `0026`, `0029`, `0031`                                            |
| the propose-only graph                                      | `langgraph_chat/agents/typewriter/`                                          |

<Note>
  `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.
</Note>

## The tests that would fail if it broke

| Suite                                     | What it proves                                                                                                                   |
| ----------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------- |
| `backend/tests/test_typewriter_schema.py` | the database-level guarantees, against a migrated Postgres                                                                       |
| `devcenter/tests/test_typewriter_api.py`  | the projection, the approval rules, the version-group link                                                                       |
| the renderer suite                        | byte identity - the same payload renders to the same bytes twice - plus a gate that renders all 146 types, and `test_no_cors.py` |

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.

## Related

* [The 146-document registry](/greatbook/capabilities/document-registry) - the catalog and schema this shares
* [The Typewriter](/greatbook/agents/typewriter) - the propose-only graph
* [Idempotency and durability](/greatbook/capabilities/idempotency-and-durability) - the duplicate-group guard this feeds
* [Compose the document](/greatbook/guides/compose-a-document) - the same seam, from a user's chair
