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

1. **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.
2. **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](https://codna.me/docs/adr/0006-inline-markdown-links.md)); export writes
   the files next to the page.
3. **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.
4. **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:

```markdown
![The deploy pipeline](onboarding.files/diagram.png)
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](https://codna.me/docs/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](https://codna.me/docs/linking.md#the-moved-documents-own-links)). 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.png` into
  `setup.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](https://codna.me/docs/adr/0006-inline-markdown-links.md)): 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
  are `inline`.
- `Cache-Control: private, no-cache` with 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 `content` class: 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_links` does 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.
