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.
There are two different operations here and they are not interchangeable. 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
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. A backup that cannot pass its own check
must not be left behind looking usable.
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. Nothing else.
Let systemd own it
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 — Bun is already there.
| 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 |
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
Restore into a new file. Never overwrite the live database in place.
Run PRAGMA integrity_check on the restored file and confirm record counts against whatever you still have.
Point the service at the verified file, then start it.
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 Semantic vectors.
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.
curl -H "authorization: Bearer $TITEN_API_KEY" \
'http://127.0.0.1:8787/v1/export?type=claims&limit=2'
{"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 instead of being
inferred at the destination. A disputed claim exports as disputed, with the contradicting
source that made it so — a portability format that silently resolved conflicts would destroy
the only information that makes a disagreement actionable.
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. It is not optional: observations reference a project, and claim
sources reference an observation. The header publishes import_order for exactly this
reason.
Out of order, the import refuses the whole request and names what is missing. Nothing partial is written:
{"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:
{"data":{"format_version":1,"received":{"project":0,"observation":0,"claim":2,"claim_source":3}},
"meta":{"request_id":"req_…","batches":1}}
What a restore actually guarantees
| 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 |
An id that already belongs to a different organization returns 409 CONFLICT rather than
being skipped, because a silent skip looks exactly like a successful import of data that is
not there. Requests are capped at 2000 lines and committed in atomic batches of 50 statements,
which stays inside D1’s per-batch limits so the same import code path works on both runtimes.
Three record types is the portability surface, not the whole service. Losing a host means restoring the database file, not replaying an export — an export brings back what an agent believed and why, and nothing about who was allowed to ask.