Titentiten.devꦠꦶꦠꦺꦤ꧀
v0.1.1 · npmGitHub
DocsDeployBackup & export

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.

VACUUM INTOintegrity_checkNDJSONformat_version 1

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.

Never cp a live database

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.

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.

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. 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.

projectsobservationsclaims

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 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 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.

Export is not a backup

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

Next

↑↓ navigate↵ open