Docs How it works
Images and files
Status: built. The decision is adr/0015.
A page can hold images and PDFs. Each one belongs to that page and to nothing else: whoever can read the page can open its files, whoever can edit the page can add to it, and sharing the page shares them. There is no library of files apart from the pages they are in.
Where a page's files are
A page keeps its files in a folder beside it named after its slug. The page at
wiki:engineering/onboarding keeps them in onboarding.files/, and refers to them with ordinary
relative links, written as they would be for a file on disk:

The signed [contract](onboarding.files/vendor-contract.pdf) is attached.
The page stays plain markdown (adr/0006). In an export the folder is a real one, so the images show when the tree is opened in Obsidian or browsed on GitHub:
wiki/engineering/onboarding.md
wiki/engineering/onboarding.files/diagram.png
wiki/engineering/onboarding.files/vendor-contract.pdf
wiki/engineering/onboarding/ ← the pages under it, if it is a folder page
A slug never contains a dot, so onboarding.files is never the name of a page or of a folder.
Another page can show the same image with a relative path to it, such as
. It shows only to people who can read
onboarding: the file has its own page's permission, wherever it is shown. Put an image in the page
that shows it.
An image written as a web address, , or as a data: URL shows
too. It is not a page's file: it is fetched from where it lives each time the page is read, so its
host learns that someone opened the page, from which IP address and when. The web UI's
Content-Security-Policy allows images from any https: address for this, and refuses plain http:
ones (SECURITY.md in the repository explains the policy).
Adding an image while writing
In the editor, an image or a PDF is added by pasting it, by dropping it on the text, or with
Image in the slash menu (/image), which asks for files. Each is uploaded to the page, and the
markdown that shows it is written where it was put, on a line of its own:
- an image as
, captioned with its file's name in words (deploy-pipeline.pngis "deploy pipeline"), or with no caption when the name is one a device made up (image.png,Screenshot 2026-…,IMG_4031.jpeg); - a PDF as a link,
[Vendor contract](onboarding.files/vendor-contract.pdf).
While a file uploads, a placeholder holds its place and nothing is written into the text, so saving in the middle saves no half-made link. A paste that carries text as well as a picture is text: a spreadsheet's cells arrive with a picture of them, and the cells are what was meant.
A line that is only images is drawn as the images, as reading draws them. Its caption — the alt text, which says what the picture shows to someone who cannot see it, and which search finds — shows over the picture on hover. Choosing it selects the caption in the markdown, to be written over; a picture with none says Add a description. Clicking the picture puts the cursor on its line, and the markdown shows above it.
A page that is being written for the first time has no files until it is saved, since a file belongs to a page that exists. Pasting an image into it says so.
In reading, a page's images are shown from where they are served, and load as they come into view. An image whose path names no page's file — markdown pasted in from somewhere else, naming a file that never came with it — shows its caption in a dashed box instead of a broken picture.
Diagrams
A diagram is not a file. It is written as text, in a fenced code block marked mermaid, and drawn
as a picture wherever the page is read, as GitHub draws it
(adr/0019):
```mermaid
flowchart LR
Browser --> Core
Core --> Postgres[(Postgres)]
```
The page keeps the text, so a diagram is read, changed and reviewed like the rest of the page: by a person or an agent, with each change in the page's history and the block as written in an export. Mermaid draws flowcharts, sequence, class, state and entity-relationship diagrams, Gantt and pie charts, and more.
- Reading. A diagram is drawn in CoDNA's colours, light or dark as the page is, and again when
the theme changes. One wider than the text is shrunk to fit it, so the page shows it whole and
never scrolls sideways. Click it, or its expand button, to open it over the page as large as the
window allows: zoom with the buttons,
+-and0, or a pinch, and drag it to move around. One that Mermaid cannot read shows its text, with a line under it saying what was wrong; the rest of the page reads as ever. - Writing. In the editor a diagram is drawn until the cursor goes into it; a click on it puts the cursor there, and its expand button opens it full size. With the cursor in it, its text shows, with the drawing under it, drawn again a moment after each change. While the text does not parse, the last drawing stays, with what is wrong under it.
- Elsewhere. An excerpt, a search result or a list row says
[Diagram]where a diagram is, never its code. The words inside a diagram are not searched, as code is not, so say what matters in the text around it too. - Safety. Mermaid draws with its strict security level and no HTML labels: no click handlers and
no markup in labels, and an
%%{init}%%line in a diagram cannot change that. A diagram is a picture, not a menu: a box aclickline links is drawn, but leads nowhere. What Mermaid draws is sanitised again before it reaches the page, and its stylesheet keeps only rules about the diagram itself. Mermaid is loaded only by a page that has a diagram.
What a page holds
| Types | PNG, JPEG, GIF and WebP images; PDFs |
| Size | 20 MB each |
| SVG | Refused: it can carry script. Export it as PNG. |
| Changes | None. A file never changes after it is uploaded; a corrected diagram is a new file. |
The type is decided by the bytes, not by the file's name or what the browser said it was. A file
called cat.png that is really a web page is refused as one.
A file is named after what was uploaded, slugified as a page's title is, with the extension of what
the bytes are: Screenshot 2026-10-15 at 18.30.png becomes screenshot-2026-10-15-at-18-30.png. A
name the page already uses gets -2, -3. The same bytes uploaded to the same page again are the
file already there.
Over HTTP
POST /api/documents/{id}/filesadds one file, sent as the multipart fieldfile. It needs edit access to the page.GET /api/documents/{id}/fileslists the page's files. It needs read access.GET /api/files/<space>/<path>/<slug>.files/<name>is the file itself, at its path in an export. It needs read access to its page.
An upload answers with what to write in the page:
{
"name": "diagram.png",
"href": "onboarding.files/diagram.png",
"path": "wiki/engineering/onboarding.files/diagram.png",
"url": "/api/files/wiki/engineering/onboarding.files/diagram.png",
"mime_type": "image/png", "size": 48213, "image": true,
"created_at": "2026-10-15T18:30:12Z"
}
All three routes are in the content class (permissions.md):
people only, never a management token. A file that someone cannot reach is a 404 whether the page
is missing, is not theirs to read, or does not hold that file, so a file never confirms that a page
exists.
A file is served with the type found at upload and headers that keep a hostile one harmless:
X-Content-Type-Options: nosniff: the browser takes the stored type and does not guess another.Content-Security-Policy: default-src 'none'; style-src 'unsafe-inline'; sandbox: an image opened on its own still shows, and nothing in it runs.Content-Disposition:inlinefor images,attachmentfor PDFs. Browsers' PDF viewers do not run under a sandbox, so a PDF downloads.Cache-Control: private, no-cacheand anETag: the browser keeps the file but asks again each time, so taking a share away holds from the next view. The answer is a 304 when nothing changed.Cross-Origin-Resource-Policy: same-origin: no other site can embed it.
When a page moves
Moving a page rewrites its own relative links in the move's revision (linking.md), and links to files are among them:
- Its own files follow it. A new slug turns
onboarding.files/x.pngintosetup.files/x.png. A move to another folder under the same slug changes nothing, since the files move with the page. - A link to another page's file is rebased to reach it from the new folder, and pointed at where that page is now if it has moved too.
Other pages that show a moved page's file are not edited. Their links keep working through the address the page left, until another page takes that address. File links are not part of the link graph, so a moved file raises no decision in anyone's inbox.
History, the trash and retention
Every revision is kept, and restoring an old one should still show its images, so a file is kept for as long as its page exists. Taking an image out of a page takes out the link, not the file.
- A page in the trash takes its files with it: nobody can read a trashed page, so nobody can open its files, and restoring the page brings them back.
- When a trashed page is purged after its retention window, its files' bytes are deleted.
- A file that no revision of its page has ever named is deleted thirty days after it was uploaded. This is the image pasted into an edit that was then abandoned. Once any revision names a file, it stays until the page is purged.
The bytes are in the storage driver's root (CODNA_STORAGE_PATH), under files/YYYY/MM/<uuid>.<ext>
beside the raw/ originals, so whatever backs that directory up has both. The name a file has in
its page is in the attachments table, never in the path.
Search
A page is found by its images' alt text and by its files' names, split into words: a page holding
vendor-contract-2026.pdf is found by "vendor contract". A file's bytes are never read for search.
Alt text is left out of excerpts, so a page that opens with a screenshot does not have its preview
open with the screenshot's caption.
Export and import
An export writes each page's files into its <slug>.files/ folder, beside the page, and lists them
in the manifest (export.md). A person's export holds the files of the pages they can
read, and nothing else. codna import reads the folder back into the page: a file the page does not
hold is added, one it holds with the same bytes is left alone, and one it holds with other bytes is
reported and skipped.
An import from another tool (/import: Notion, Confluence, a folder of markdown) brings the images
its pages show as their files in the same way, each written with the version of the page that names
it (ingestion.md).
Agents
An agent reads a page's images through MCP with get_file (reference):
it names the page and the image, by its link as the page writes it (onboarding.files/error.png) or
by its name, and gets the image back as image content, so the model sees the screenshot. An image
larger than 1568 pixels on a side, or than 3.75 MB, is scaled to fit first: a model sees no more
than that, and clients refuse larger payloads. A WebP that would need scaling is refused, since
CoDNA does not decode WebP. A PDF is described, not sent. get_document lists a page's files in its
header.
The agent reaches a file through its page, so it sees what its person can read and nothing more.
Agents cannot add files yet. A diagram needs none: an agent writes it as a mermaid block
(above), and the MCP server's instructions say so.