Docs Decisions
0015 · Images and files belong to a page
Status: Accepted · 2026-10-15
Context
Pages could not show a picture. A screenshot of an error, a diagram of a deploy, a scanned form: each had to live somewhere else, behind a link that a reader with access to the page might not be able to open, and that an export left behind. Images were put off during the live-editor work on 2026-10-10, because doing them properly needs three things CoDNA did not have: files that belong to something whose access is already decided, a route that checks that access on every request, and a place in the export beside the page.
A 2026-10-15 product evaluation picked them as the next bet. Four decisions were made by the user before building, and are recorded here as given:
- Page-owned files. Each file belongs to one document. Whoever can read the document can open its files, and sharing the page shares them. There is no media library.
- Stored and written plainly. Files go through the storage driver that keeps ingested originals, and a page refers to one with an ordinary relative markdown link. The page stays plain markdown and is never re-serialised (0006); export writes the files next to the page.
- Agents can read images. Through MCP an agent fetches a page's image and gets it back as image content, so the model sees the screenshot. Uploads by agents come later.
- Limits. PNG, JPEG, GIF, WebP and PDF, up to 20 MB each. SVG is refused. A file never changes after upload. Files go to the trash and come back with their page.
What was left to decide is how a link names a file, what history keeps, and how a file is served.
Decision
Where a page's files are
A page at wiki:engineering/onboarding keeps its files in a folder beside it named after its slug,
onboarding.files/. In an export that is a real directory:
wiki/engineering/onboarding.md
wiki/engineering/onboarding.files/diagram.png
wiki/engineering/onboarding.files/vendor-contract.pdf
and the page refers to its files exactly as a file on disk would:

The signed [contract](onboarding.files/vendor-contract.pdf) is attached.
A slug and a folder name never contain a dot, so <slug>.files can never be a page or a folder,
and never collides with onboarding/, where the pages under a folder page live.
A link maps to a file by resolving it the way a link to a page resolves. The href is read from
the page's folder, as linking.md resolves .md links. If what it names is
<space>/<path>/<slug>.files/<name>, it is the file <name> of the page at <space>:<path>/<slug>.
The page there now is looked up first; failing that, the page that most recently moved away from
that address, through document_path_history. That is the same rule the link graph and
Service.Move's locator follow, so a link to a moved page's file still reaches it.
GET /api/files/<space>/<path>/<slug>.files/<name> serves it: the URL is the export path, so the
browser's job is to resolve the href and put /api/files/ in front.
A file's name is chosen when it is uploaded and never changes. It is the uploaded name slugified as
a page's would be (Screenshot 2026-10-15 at 18.30.png becomes
screenshot-2026-10-15-at-18-30.png), with the extension of what the bytes actually are. A name a
page already uses for other bytes gets -2, -3. The same bytes uploaded to the same page again
are the same file: pasting one screenshot twice does not store it twice.
Moving and renaming
Service.Move already rewrites the moved page's own relative links in the move's revision
(linking.md). It now treats a relative href to a
page's files the same way, image or link:
- A link to the page's own files follows it. A slug change turns
onboarding.files/x.pngintosetup.files/x.png; a move to another folder with the same slug changes nothing, because the files folder moves with the page and is still its neighbour. - A link to another page's files is rebased to reach the same file from the new folder, and pointed at where that page is now if it has moved.
Links to a page's files from other pages are not rewritten, for the reason links to pages are not (0006): it would put a revision on every page that mentions the file, by whoever moved it. They keep working through the path history while nothing else takes the old address. File links are not edges in the link graph in this version, so a moved file raises no decision in anyone's inbox.
What history keeps
Revisions are full snapshots and kept forever, and reverting to one should still show its images. So a file is kept for as long as its page exists. Removing an image from the page removes the reference, not the file.
The one exception is a file nothing ever named: pasted into the editor, then the edit abandoned. A
file that no revision of its page has ever referred to is collected by the maintenance job thirty
days after its upload. Thirty days leaves a local draft time to come back. Once any revision has
named the file, it stays until the page itself is purged. Maintenance records when a file was
first seen named (referenced_at), so each file is looked for in the history until it is found,
and then never again.
The trash
A file is reached only through its page, and nobody can read a page in the trash, so its files are unreachable while it is there, and come back with it on restore. When the page is purged after its retention window, its rows go with it and maintenance deletes the stored bytes.
Storing
Files go through internal/storage, under the same root as ingested originals: files/YYYY/MM/<uuid>.<ext>
beside raw/. Hosting archives that whole root (<name>-storage-*.tgz), so files are in every
backup with no change there. The bytes are named by a uuid, never by the uploaded name. The
attachments row carries the page, the name, the sniffed type, the size and a SHA-256.
Serving safely
A page is written by people and agents, and a file is whatever someone uploaded, so the route assumes the bytes are hostile.
- The type comes from the bytes. At upload the first 512 bytes are sniffed
(
http.DetectContentType, the WHATWG algorithm) and only the five types above pass. The name the client gave and the type it claimed are not consulted. SVG is refused, with a message saying to export it as PNG: it is a document that can carry script, and no header makes serving it inline safe enough. X-Content-Type-Options: nosniff, so a browser never second-guesses the stored type.Content-Security-Policy: default-src 'none'; style-src 'unsafe-inline'; sandbox, as GitHub serves raw user content. An image opened on its own still displays; nothing in it can run.- PDFs download (
Content-Disposition: attachment). Browsers' PDF viewers do not load under a sandboxing policy, and an inline PDF on the app's own origin is not worth the exposure. Images areinline. Cache-Control: private, no-cachewith the SHA-256 as the ETag: a browser keeps the file but asks again each time, so a revoked share takes effect on the next view, and the answer to that question is a 304 with no body.Cross-Origin-Resource-Policy: same-origin, so no other site can embed one.- A file the reader cannot reach — no such page, a page they cannot read, no such file — is the same 404. A file must not confirm a page exists.
- The route is in the
contentclass: people only, never management.
Search
The index gets the words around a file and never its bytes: an image's alt text, and the page's file names split into words. Excerpts still leave alt text out; it is a caption, not prose.
Agents
get_file takes a page and a file — its name, or the href as the page writes it — and returns an
image as MCP image content. An image larger than 1568 pixels on its long side, or than 3.75 MB, is
scaled down to fit before it is sent: a model sees no more than that, and clients refuse larger
payloads. A PDF is described, not sent. get_document lists the page's files in its header.
Export and import
Export writes every file of every exported page into its <slug>.files/, whether or not the
current version names it, since an older one may. codna import reads a page's <slug>.files/
back in beside it: a file missing from the page is added under its name, one already there with
the same bytes is left alone, and one there with other bytes is reported and skipped, because files
do not change.
Consequences
- An exported page opens in Obsidian or on GitHub with its images showing, because the link is a relative path to a file that is really there.
- A page and its files have one permission. There is nothing to keep in step, and nothing to leak through a file whose page was unshared.
- Embedding another page's image shows it only to people who can read that page. Sharing the page that embeds it does not share it. Put an image in the page that shows it.
- Storage only grows while pages live. A page edited for years keeps every image any version showed. That is the price of history that can be restored; the purge of deleted pages is the only reclaim apart from abandoned uploads.
- A file cannot be replaced. A corrected diagram is a new upload with a new name, and the page's history shows the change.
- A new page has no files until it is saved, since files belong to a page that exists. The editor says so when an image is pasted into an unsaved page.
document_linksdoes not track file links. A broken file link shows as a missing image, not as an inbox decision. Tracking them is possible later without changing anything written.
Alternatives rejected
A media library, files owned by the instance or a folder and linked from anywhere. Decided against by the user: a second permission model, and a file shared by accident through a page that happened to link it.
codna://file/<uuid> or absolute /api/files/<uuid> links. Never stale. Rejected for the
reason 0006 rejected the same for pages: the markdown stops working anywhere but a running
instance, and an export is not self-contained.
Files in a folder named like the page (onboarding/diagram.png). That folder already holds the
pages beneath a folder page, and a file there would look like a page that failed to import.
Content-addressed names (diagram-3f9a1c.png). Deduplicates and never collides, but the names
are noise in the markdown and in the export, and same-bytes deduplication within a page gets most
of the benefit.
Re-encoding every image on upload. Would strip metadata and normalise formats, but it changes the bytes someone handed over, which raw's rule — keep the original — argues against everywhere else. Sniffing and the serving headers are what make the original safe to serve.