# Export

> Status: built. `codna export`, `codna import`, and `codna ingest` all exist.

Export is not a backup format. It is the answer to *"what happens if we stop using CoDNA?"* — and
the answer has to be a directory of markdown files that are useful on their own.

## The tree

A document at address `wiki:engineering/onboarding/laptop-setup` exports to
`wiki/engineering/onboarding/laptop-setup.md`:

```
codna-export-2026-09-24/
├── wiki/
│   ├── engineering/
│   │   ├── onboarding.md
│   │   ├── onboarding.files/
│   │   │   └── network-diagram.png   ← the page's images and PDFs, beside it
│   │   └── onboarding/
│   │       ├── laptop-setup.md
│   │       └── vpn-access.md
│   └── ops/
│       └── deploy.md
├── raw/
│   └── 2026/09/
│       ├── it-handbook.md
│       └── it-handbook.pdf        ← the original, alongside its conversion
├── output/
│   └── 2026-q3-summary.md
└── codna-manifest.json
```

Because links are stored as relative `.md` paths ([linking.md](https://codna.me/docs/linking.md)), the tree works as-is:
open it in Obsidian, push it to a git repository, browse it on GitHub. Every link resolves. That is
the entire reason the link format was chosen over `[[wikilinks]]` or `codna://` URIs.

Originals from `raw` are exported next to their converted markdown, so the evidence survives too.

A page's images and PDFs ([files.md](https://codna.me/docs/files.md)) are written into a folder beside it named after its
slug, `onboarding.files/`, which is where the page's own links to them point:
`![Network diagram](onboarding.files/network-diagram.png)` shows the picture in Obsidian or on
GitHub as it does in CoDNA. Every file the page holds is written, whether or not its current version
shows it, since an older version may.

## Frontmatter

Columns are canonical; frontmatter is how they serialize. Export writes them back out:

```markdown
---
id: 018f3c2a-7d41-7c9e-b3a5-2f1e8c4d6a90
title: Laptop Setup
space: wiki
path: engineering/onboarding
slug: laptop-setup
tags: [onboarding, it]
status: active
version: 7
created: 2026-09-22T10:14:00Z
updated: 2026-09-24T08:02:00Z
author: tornike@example.com
---

See the [onboarding guide](../onboarding.md) before starting.
```

### Reserved keys

CoDNA owns these. On import they populate columns; on export they are rewritten from columns:

`id` · `title` · `space` · `path` · `slug` · `tags` · `status` · `version` · `created` · `updated` ·
`author` · `source`

Every other key is preserved verbatim in `frontmatter_extra` and written back unchanged. Someone
else's `reviewed_by:` or `confluence_id:` survives a round trip through CoDNA untouched — a
knowledge base that quietly drops metadata it does not recognize is not one people will trust with
their existing files.

`id` is what makes re-import an update rather than a duplicate.

## What is not exported

| Excluded | Why |
|---|---|
| `acl_read` / grants | Access control is a property of the deployment, not of the file. A file tree that carried ACLs would imply the tree enforces them, and it cannot — anyone with the directory has everything in it. |
| Other people's documents | An export as a person contains exactly what that person can read: their slice, never the whole. Only `codna export --all`, on the host, writes every document. |
| Tombstoned documents | Unless `--include-deleted` |
| Revision history | Unless `--with-history`; see below |

The first row is the one to be deliberate about. It is tempting to write permissions into
frontmatter for round-tripping, and it would mislead: the export is a flat directory, and a
directory has no opinion about who reads it.

## History

Full snapshots per revision ([data-model.md](https://codna.me/docs/data-model.md#revisions)) make history export
straightforward:

```
--with-history →  _codna/history/<document-id>/
                    0001-create.md
                    0002-content.md
                    0005-move.md
```

Each file is a complete document with its frontmatter, plus `change_kind`, `change_summary`, and the
author. Diffs are not exported — they are derivable from the snapshots, and `git diff` on the
exported tree is better than anything CoDNA would pre-render.

## Manifest

```json
{
  "codna_version": "0.1.0",
  "exported_at": "2026-09-24T09:00:00Z",
  "exported_by": "tornike@example.com",
  "scope": { "spaces": ["wiki", "raw"], "complete": false,
             "include_deleted": false, "with_history": false },
  "counts": { "documents": 412, "revisions": 0, "originals": 87, "files": 140 },
  "documents": [
    { "id": "018f3c2a-…", "address": "wiki:engineering/onboarding",
      "file": "wiki/engineering/onboarding.md", "version": 7,
      "files": ["wiki/engineering/onboarding.files/network-diagram.png"] }
  ]
}
```

The manifest states the scope explicitly. An export that silently omitted documents the actor could
not read, without saying so, would be mistaken for a complete backup — and the moment that matters
is the moment nobody can check. `scope.complete` is `true` only for `codna export --all` of every
space, whose `exported_by` reads `management, with codna export --all`; a person's export names the
person and is never complete.

## Import

Import is export in reverse, and the same code path serves migrating in from Obsidian or a docs
repository:

1. Walk the tree; the first path segment names the space.
2. Parse frontmatter. Reserved keys populate columns; the rest goes to `frontmatter_extra`.
3. `id` present and known → update as a new revision. `id` absent or unknown → create.
4. Extract links after every document exists, so cross-references resolve rather than landing as
   `pending`.
5. Missing frontmatter is fine: `title` comes from the first `# heading` or the filename, `path` and
   `slug` from the file's location.
6. A page's `<slug>.files/` folder is read back into it. A file the page does not hold is added under
   its name; one it holds with the same bytes is left alone, so re-importing an export adds nothing;
   one it holds with other bytes is reported and skipped, because files do not change. A name has to
   be one CoDNA would give the file (lowercase words joined by dashes, with the extension of what the
   bytes are), or the page's link to it would not reach it, and is reported otherwise.

Step 4 matters for large imports. Resolving links as each file lands would leave every forward
reference `pending` until something else happened to touch it.

## Commands

| Command | Does |
|---|---|
| `codna export --as <user>` | Everything that person can read, as a directory |
| `codna export --as <user> --space wiki` | One space |
| `codna export --as <user> --with-history --include-deleted` | All of that person's slice, with history and tombstones |
| `codna export --all` | Every document, whoever can read it: a backup or a move. Host only |
| `codna export --all --with-history --include-deleted` | Every document and every revision, for migration or archival |
| `codna import --as <user> <dir>` | Read a tree back in, matching on id then address |
| `codna import --as <user> --dry-run <dir>` | Report what would be created and updated |
| `codna import --as <user> --space wiki <dir>` | Import a vault whose paths do not name a space |
| `codna ingest --as <user> <dir>` | Bulk-load unstructured files into `raw` |
| `GET /api/me/export?with_history=&include_deleted=&with_originals=&spaces=` | What the signed-in person can read, as a streamed `.tar.gz` of the same tree |

The HTTP form is a person's: it lets someone take what they can read without a shell on the host.
It runs as the caller, so its manifest is never complete, and a management token is refused it
like every other content route. No API call exports everything
([adr/0013](https://codna.me/docs/adr/0013-no-administrators.md)). Import over HTTP is deliberately absent;
`codna import` covers restore, and a write of that size belongs on a shell.

`import` and `ingest` are different operations and it is worth being clear which
you want. `import` reads a *structured* tree — the first path segment names a
space, frontmatter carries ids — and preserves addresses, so it round-trips an
export and migrates an Obsidian vault. `ingest` takes a pile of PDFs, Word
files and HTML with no structure to preserve, converts each one, and files the
results in `raw` by date.

### Raw on import

`raw` is immutable, which makes it the one interesting case.

- A raw document that is **already present** is left exactly as it is. Re-importing
  an export must not rewrite the record of what was received, so it is reported
  as `unchanged` rather than as an error.
- A raw document that is **absent** is restored. This is the case that matters
  when rebuilding a deployment from nothing but the directory: without it every
  wiki page cites evidence that no longer exists.

Restoring raw is one of exactly two code paths allowed to write there; the
other is ingestion. No `ingestions` row is created, because the original bytes
are not in the tree and recording an ingestion that never happened would be
worse than recording none.

One of `--as` and `--all` is required on export, and which one is recorded in the manifest. `--all`
is the one read in CoDNA that no permission bounds. It exists only as a command on the host, because
whoever can run it there already holds the database; no API call, and no token, can make it. Each
run is recorded in the management audit as `export.all`, with how much it wrote. An
`--as` export prints a line saying it is one person's slice, because an export that quietly
contained one person's slice would be mistaken for a backup at exactly the wrong moment.

Originals from `raw` are written beside their conversions, except when the original *is* markdown —
pasted text, or an uploaded `.md`. Copying those would write two near-identical files, and `import`
would then read the copy back as a second document.
