Appearance
ADR-0048: Curation runs only where nothing is served, renders pages in the curator's browser, and leaves no orphaned file
Status: Accepted Date: 2026-09-11
Decisions
D-110 Curation is mounted only on a loopback bind, and the bind address is the whole of its access control
The API mounts its Curation and upload routes only when the address it binds is a loopback literal (127.0.0.0/8 or ::1). On any other bind they are absent — not guarded, absent: a curation path answers 404 because there is no route to probe. The value that decides whether the routes exist is the value handed to the socket, so the curation surface cannot be obtained without binding where the network cannot reach it. The curator's pages are likewise absent from the build that ships to Handlers, and present only in a separate curation build.
The stored copy of a document reaches the curator through this process, streamed from a route keyed on the document, and never as a URL. A presigned URL works from any machine holding the string for as long as it lives, which is the credential this ruling declines to invent.
D-111 The page image is rendered in the curator's browser with pdf.js, and never stored
Verifying extracted text against the rendered page (D-56) happens in the curator's browser, which already has a renderer and a canvas. No page-rendering dependency enters the API and no page image is kept: the image exists to be looked at by a person, once. The server stores the verification record and nothing else.
D-112 A refused admission reuses the upload it already made, for the same picked file and no other, and no object delete is mounted
The admission surface uploads the file and then admits the document against the path the upload returned. When admission is refused, pressing the button again admits against the object already stored instead of storing the bytes a second time. The held upload is matched on the picked file's identity, so any new pick uploads afresh and a document can never be admitted against another document's bytes. DELETE /v1/file stays unmounted.
Context
Seven owner rulings were made during the curation chart on 2026-09-11 while the spec was frozen, each marked on its own ticket as "an ADR is owed" and recorded nowhere a reader of docs/adr/ would find them. The grill of the same day ruled them written as themed ADRs, safety-first. This is the first, because it is the only one with security consequences.
D-110 answers a question the no-accounts ruling left open. D-60 removed every account, and the curation write paths were built and left unmounted because nothing said who may reach them. Every available answer to "who may curate" is a credential — a token, a password, a shared secret — and a credential in an account-free product is an account by another name, one with nobody to rotate it. The bind address is not a claim a caller makes; it is a fact about where the process can be reached. A curator runs the tool on their own machine, against their own database, and nothing else can.
It is a bind address rather than NODE_ENV, a flag or a header on purpose. A boolean can be passed by anyone, and an environment variable is a statement of intent, where the bind address is a statement of exposure. localhost is refused because a name is whatever the resolver was told.
D-111 was ticket 26's ruling. The alternative was rendering on the server, which puts a PDF rasteriser into the service whose job is to be the Corpus of record, and stores images nobody reads twice. The tool proved its reason on the first document put through it: the parent repository's own Thai PDF rendered with correct glyphs beside an extraction visibly broken by spurious intra-word spaces — D-56's premise reproduced rather than assumed.
D-112 came from reading the admission page ticket 36 built. It uploaded, then admitted; every refused admission left an SDS original in storage that no Provenance row referenced, and the corrected resubmission uploaded it again. That is the only legitimate job an object delete has. Everything else a stored copy can be — the source of a Section, a Source Span, a published snapshot — reaches it through its Sds, and nothing deletes an Sds (D-45). Remove the cause and the delete has no job left.
Decision
Curation exists only on a loopback bind and only in the curation build. Pages render in the curator's browser and are not kept. An admission refusal holds the upload for the same picked file, and no delete is mounted.
Rejected options
- A curator token or password — rejected. It is an account in all but name, contradicting D-60, and a secret that can leak and that nobody is assigned to rotate.
- Gating on
NODE_ENVor a flag — rejected. Either is a claim about intent. A deployment that sets the wrong value exposes the write paths while every test stays green. - Presigned URLs for the stored copy — rejected. A bearer capability that works off the machine reintroduces, unrevocably, the credential D-110 declines to create. The cost accepted instead is that the bytes pass through this process.
- Rendering pages on the server, or storing page images — rejected. A rasteriser in the Corpus origin and a store of images read once, to save a browser work it already does.
- A delete-orphans route — rejected. A cleanup surface nobody asked for, cleaning up after a cause that can be removed.
- Leaving orphans — rejected. They are The Plant's documents, accumulating unrecorded.
- Reusing the held upload while a file is picked — rejected. Matching on "a file is present" rather than on the picked file binds document B to document A's bytes the moment the curator picks again after a refusal — ticket 35's hazard, reintroduced by the fix for orphans.
This ruling may not be re-decided
If a change contradicts this ADR: stop and raise it. Do not implement over it.
Specifically: do not replace the bind check with a boolean, an environment variable or a header; do not mount file/ or any object delete; do not hand the curator a URL to a stored object; do not store page images; and do not widen the held-upload match beyond the picked file's identity.
Consequences
What becomes easy. There is no credential to leak, rotate or revoke, and no auth scheme to audit. The deployed instance serves reads and nothing else, provably: over a real socket from the shipping binary, an unset HOST answers 404 on a curation route and 127.0.0.1 reaches the handler. Inside a container, loopback is the container's own, so HOST=127.0.0.1 under compose makes the published port unreachable rather than exposing Curation — the mechanism fails closed.
What this costs. There is no remote curation. A curator needs the whole stack — Postgres, object storage, the API and the curation build — on the machine they curate from. Collaboration between two curators goes through a published snapshot, not a shared live instance. The curation build carries pdf.js, which the build that ships to Handlers does not.
What remains, named. A genuinely abandoned upload — a file stored, its admission refused, and the curator walking away — is still an orphan. It is a named cost rather than a hidden one, and the only one left.
Coverage
| Upstream | Landed in | Evidence | Note |
|---|---|---|---|
| R-107 | D-110 | the stored copy is streamed through the API rather than read by presigned URL; the closed content-type table is kept as the upload policy | the sibling's presigned read is the half rejected, as a bearer capability |