# Backup & export

> titen backup makes a verified online copy of the whole database; export and import move canonical records between deployments with their ids intact.

Section: Deploy · Source: https://titen.dev/docs/backup-export
Derived from: https://github.com/RamaAditya49/titen/blob/main/src/core/portability.ts

---
`titen backup` copies the **whole database**, including credentials, audit and collaboration
state. `GET /v1/export` copies **canonical records** — projects, observations, claims — for
one organization, to move or fork memory. Use the first for disaster recovery and the second
for portability.

## The verified online copy

```sh
titen backup --db /var/lib/titen/titen.db --out /var/lib/titen/backups/titen.db
```

```
backup verified: /var/lib/titen/backups/titen.db
```

The copy is made with `VACUUM INTO`, which is safe against a live WAL database, so the
service keeps serving. The command then opens the copy and runs `PRAGMA integrity_check` on
it, and exits non-zero if the result is not `ok`.

<div class="callout callout--alarm">
<strong>Never <code>cp</code> a live database</strong>

A file copy of an open SQLite database with a WAL sidecar is not a snapshot of anything. Use
`titen backup`, or the SQLite backup API, or `VACUUM INTO`.
</div>

## Let systemd own it

[deploy/backup.sh](https://github.com/RamaAditya49/titen/blob/main/deploy/backup.sh) wraps
the command with the filesystem work: it creates the backup directory mode `700`, writes the
file mode `600`, records a `.sha256` sidecar beside it, and deletes the pair together so a
pruned database never leaves an orphan checksum. If verification fails the partial file is
removed. It shells out to `titen backup` rather than `sqlite3`, so the host needs no extra
SQLite CLI.

<div class="table-wrap">

| Knob | Default |
| --- | --- |
| database | `/var/lib/titen/titen.db` (first argument) |
| destination | `/var/lib/titen/backups` (second argument) |
| `TITEN_BACKUP_RETENTION_DAYS` | `30` |
| schedule | `03:00` daily, `RandomizedDelaySec=900`, `Persistent=true` |

</div>

The shipped `backup.service` is `Type=oneshot`, runs as `titen`, and sets `Nice=19` with
`IOSchedulingClass=idle` so a nightly copy does not compete with request traffic.
`Persistent=true` means a host that was off at 03:00 takes the backup when it comes back.

## Restore

<div class="rules">

<p>Restore into a <strong>new</strong> file. Never overwrite the live database in place.</p>
<p>Run <code>PRAGMA integrity_check</code> on the restored file and confirm record counts against whatever you still have.</p>
<p>Point the service at the verified file, then start it.</p>
<p>A vector index is rebuildable and never blocks a canonical restore, but it is not restored with the database and nothing re-embeds old claims on its own. See <a href="/docs/vectors">Semantic vectors</a>.</p>

</div>

## Export is records, not a database

NDJSON, one header line then one record per line, paged by id. `type` is `projects`,
`observations` or `claims`; `limit` runs 1 to 2000 and defaults to 500; `after` takes the
previous page's cursor.

```sh
curl -H "authorization: Bearer $TITEN_API_KEY" \
  'http://127.0.0.1:8787/v1/export?type=claims&limit=2'
```

```json
{"type":"titen.export.header","format_version":1,"record_type":"claims",
 "org_id":"org_724bbe3aab2a4286b25dabec65bce85e","exported_at":"2026-07-30T10:48:06.342Z",
 "count":2,"next_cursor":"claim_f3963d7b876143f5bfc8230db2757067","complete":false,
 "import_order":["projects","observations","claims"],"depends_on":["projects","observations"]}
{"type":"claim","id":"claim_d5ce4657d6b947aa8ade42271b4176f5","subject_id":"user_docs_loop",
 "project_id":null,"observer_id":null,"actor_id":"agent_8df7b8699a0945488ceb3d4b104006d6",
 "kind":"procedural","statement":"Checkout deploys must pass smoke before release.",
 "confidence":0.9,"trust":"verified","visibility":"team","status":"disputed","version":1,
 "valid_from":"2026-07-30T10:47:30.946Z","valid_to":null,"created_at":"2026-07-30T10:47:30.946Z",
 "sources":[{"observation_id":"obs_bc4984ed9153467eb41b4753c5c96d6c","relation":"contradicts",
             "created_at":"2026-07-30T10:47:30.946Z"},
            {"observation_id":"obs_c98879c3adc44f6a9b2227b821a033bd","relation":"supports",
             "created_at":"2026-07-30T10:47:30.946Z"}]}
```

Claim lines carry their `sources`, so evidence links travel with the claim. A `disputed`
claim exports as disputed, with the contradicting source that made it so.

## Import order is part of the contract

Export pages one record type per request, so a restore arrives as several requests and the
order is yours to get right. Observations reference a project, and claim sources reference
an observation. The header publishes `import_order`.

<div class="loopstrip"><span>projects</span><i></i><span>observations</span><i></i><span>claims</span></div>

Out of order, the import refuses the whole request and names what is missing. Nothing partial
is written:

```json
{"error":{"code":"UNRESOLVED_REFERENCE","message":"Import references 1 observations record(s) that do not exist yet (for example: obs_99999999999999999999999999999999). Import types in dependency order: projects, then observations, then claims."},
 "meta":{"request_id":"req_…"}}
```

Within one request, line order does not matter: statements are emitted parent-first, so
records that arrive together satisfy each other. Re-running an import you already ran changes
nothing; `received` still counts what it read:

```json
{"data":{"format_version":1,"received":{"project":0,"observation":0,"claim":2,"claim_source":3}},
 "meta":{"request_id":"req_…","batches":1}}
```

## What a restore guarantees

<div class="table-wrap">

| Preserved | Not carried |
| --- | --- |
| Record ids, so evidence links survive the move | API keys and every other credential |
| `content_hash`, recomputed only when absent | Embeddings — an imported claim is not queued for indexing |
| Claim `status`, `version`, `valid_from` / `valid_to` | FTS rows, rebuilt from the imported text |
| Claim sources and their `relation` | Contexts, feedback, checkpoints, events, audit |
| A `record_history` row marked `import` | Webhooks, leases, handoffs, policies, releases, federation |

</div>

An id that already belongs to a **different** organization returns `409 CONFLICT` rather than
being skipped, because a silent skip looks like a successful import of data that is not
there. Requests are capped at 2000 lines and committed in atomic batches of 50
statements, inside D1's per-batch limits so the same import code path works on both runtimes.

<div class="callout">
<strong>Export is not a backup</strong>

Three record types is the portability surface, not the whole service. Losing a host means
restoring the database file, not replaying an export.
</div>

## Next

<div class="cards">

<a href="/docs/deploy-vps"><strong>Bun + SQLite (VPS)</strong><span>Where the backup timer lives, and the hardened unit around it.</span></a>
<a href="/docs/claim-lifecycle"><strong>Claim lifecycle</strong><span>What <code>status</code>, <code>version</code> and validity windows mean in an exported claim.</span></a>
<a href="/docs/errors"><strong>Errors</strong><span>UNRESOLVED_REFERENCE, CONFLICT and the rest of the code list.</span></a>

</div>