Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ standards register names the rows that ask for them.
| 031 | Session search (SQLite FTS5) | 3 Memory | S | 010 | approved |
| 032 | Embeddings + semantic memory + hybrid retrieval | 3 Memory | L | 031 | planned |
| 033 | Project context: AGENTS.md | 3 Memory | S | 030, 022 | done |
| 034 | Export, import, restore | 3 Memory | S | 030, 031 | planned |
| 034 | Export, import, restore | 3 Memory | S | 030, 031 | done |
| 040 | Skills registry + agentskills.io format + progressive disclosure | 4 Skills | M | 020 | planned |
| 041 | Skill self-management with staged approval + scanner | 4 Skills | M | 040, 021 | planned |
| 050 | Scheduler: Oban cron agent tasks with delivery targets | 5 Automation | M | 012 | planned |
Expand Down
1 change: 1 addition & 0 deletions coverage.tsv
Original file line number Diff line number Diff line change
Expand Up @@ -14,3 +14,4 @@ slice_id percent sha date
031 76.96 866dc3f 2026-09-20
030 78.40 c657da8 2026-09-21
033 78.50 3b5e89e 2026-09-21
034 78.83 3a5785f 2026-09-21
62 changes: 62 additions & 0 deletions docs/backup.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
# Backup, restore, and moving to another machine

Slice 034. One archive holds a Trinity; a fresh install takes it back. Losing the machine is a recoverable
event, not a total one.

## What the archive holds

`mix trinity.export --out <file.tar.gz>`, or the Export button on `/settings`, writes a gzip tarball with:

| Entry | What it is | Always |
|---|---|---|
| `manifest.json` | the format (`trinity-archive/1`), this build's version, when it was made, the migrated schema versions of each database, whether private keys are inside, and one line per file with its size and SHA-256 | yes |
| `trinity.db` | the primary database: personas, sessions, messages, the search index, memories and their change log, approvals and rules | yes |
| `receipts.db` | the receipt chains and their checkpoints | yes |
| `keys/registry.json` | the key registry: public keys, algorithms, statuses (a stranger verifies receipts with this) | yes |
| `keys/receipts-<algorithm>.key` | the private signing key | only with `--keys` (or `?keys=1` on the page) |
| `skills/…`, `personas/…` | files under those directories, when present (skills arrive at slice 040) | when present |

Each database is snapshotted with `VACUUM INTO`: a consistent copy including what the write-ahead log held,
taken while Trinity runs. The manifest says `keys_included` either way, so an archive's holder knows what it
carries.

## What it does not hold

- Model caches, the desktop shell's state, the `LOCK` file, `RESTORED` markers from earlier restores.
- The private key, unless asked for. An archive without it verifies every receipt ever written (the registry
carries the public keys) and cannot write a new one as you: a fresh install without the key generates its
own on first boot and appends it to the registry as a new key id; the old chain stays verifiable under the
old key's row. Hand an archive to someone else without `--keys`.
- Anything from other machines: this is one data directory, not a sync.

## Restoring

On a fresh install, with Trinity stopped:

```
mix trinity.import trinity-2026-09-21.tar.gz
```

The task reads the manifest, checks the schema versions against this binary's migrations (an archive from a
newer Trinity refuses by name; an older one migrates on the next boot), verifies every file's digest against
the tarball's bytes, and only then writes the files. A data directory that is not empty is refused with the
paths it found; `--force` replaces them and prints each one. A directory held by a live Trinity is refused
until that process stops. A `RESTORED` file in the data directory names the archive and the time.

Then start Trinity. The receipt chains verify under the restored registry (`mix trinity.receipts.verify --scope
<scope>`, or the Verify button on a session's receipts page), which is the sharpest check that the copy is
faithful. The search index is inside the database on SQLite; on Postgres it is a generated column.

`--data-dir <dir>` points either task at a directory other than the configured one.

## Postgres

The archive is the SQLite data directory's. A deployment on Postgres (`TRINITY_DB=postgres`) keeps its rows in
that database and backs it up with Postgres's own tools (`pg_dump`); the export there carries the key registry
and the directories, not the databases, and says so in the manifest's file list.

## Size

On the machine this was written on (2026-09-21), 18 sessions and 8,029 messages made a 713 KB archive (3.1 MB
of database before compression). Vectors (slice 032) and any model cache are not measured yet; the slice that
adds them reads this line.
51 changes: 51 additions & 0 deletions lib/mix/tasks/trinity.export.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
# SPDX-FileCopyrightText: Sudo Apt Holdings LLC
# SPDX-License-Identifier: Apache-2.0
defmodule Mix.Tasks.Trinity.Export do
@shortdoc "Writes the data directory to one archive (databases, registry, skills; private keys only with --keys)"

@moduledoc """
Slice 034 (`docs/backup.md`):

mix trinity.export --out trinity-2026-09-21.tar.gz
mix trinity.export --out with-keys.tar.gz --keys

The databases are snapshotted with `VACUUM INTO`, so a running Trinity is fine. Without
`--keys` the archive carries the key registry (public) and not the private key files, so it
can be handed to someone without the ability to sign as you; the manifest records which.
"""
use Boundary, classify_to: Trinity
use Mix.Task

@impl Mix.Task
def run(argv) do
{opts, _, _} =
OptionParser.parse(argv, strict: [out: :string, keys: :boolean, data_dir: :string])

opts = Map.new(opts)

unless Map.has_key?(opts, :out) do
Mix.shell().error(
"usage: mix trinity.export --out <file.tar.gz> [--keys] [--data-dir <dir>]"
)

exit({:shutdown, 2})
end

Mix.Task.run("app.config")

layout =
if d = opts[:data_dir],
do: Trinity.Archive.Layout.of(d),
else: Trinity.Archive.Layout.current()

case Trinity.Archive.export(layout, opts.out, keys: opts[:keys] == true) do
{:ok, %{manifest: m, bytes: bytes}} ->
Mix.shell().info(
"exported #{length(m.files)} files, #{bytes} bytes, keys #{if m.keys_included, do: "included", else: "not included"}: #{opts.out}"
)

{:error, reason} ->
Mix.raise("export failed: #{inspect(reason)}")
end
end
end
75 changes: 75 additions & 0 deletions lib/mix/tasks/trinity.import.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
# SPDX-FileCopyrightText: Sudo Apt Holdings LLC
# SPDX-License-Identifier: Apache-2.0
defmodule Mix.Tasks.Trinity.Import do
@shortdoc "Restores an archive into an empty data directory (or replaces one with --force)"

@moduledoc """
Slice 034 (`docs/backup.md`):

mix trinity.import trinity-2026-09-21.tar.gz
mix trinity.import trinity-2026-09-21.tar.gz --force
mix trinity.import trinity-2026-09-21.tar.gz --data-dir /path/to/dir

Stop Trinity first: the task refuses while a live process holds the data directory's lock.
Every digest in the manifest is verified and the schema versions are checked against this
binary before anything is written; a non-empty directory is refused unless `--force`, and
then the task prints what it replaced. Exit 2 on usage, 1 on refusal.
"""
use Boundary, classify_to: Trinity
use Mix.Task

@impl Mix.Task
def run(argv) do
{opts, args, _} = OptionParser.parse(argv, strict: [force: :boolean, data_dir: :string])

case args do
[archive] ->
Mix.Task.run("app.config")

layout =
if d = opts[:data_dir],
do: Trinity.Archive.Layout.of(d),
else: Trinity.Archive.Layout.current()

case Trinity.DataDir.Lock.live_holder(layout.data_dir) do
{:ok, %{pid: pid, mode: mode}} ->
Mix.shell().error(
"refused: a #{mode} Trinity (OS pid #{pid}) holds #{layout.data_dir}; stop it first"
)

exit({:shutdown, 1})

:none ->
import_into(archive, layout, opts[:force] == true)
end

_ ->
Mix.shell().error("usage: mix trinity.import <file.tar.gz> [--force] [--data-dir <dir>]")
exit({:shutdown, 2})
end
end

defp import_into(archive, layout, force?) do
File.mkdir_p!(layout.data_dir)

case Trinity.Archive.import(archive, layout, force: force?) do
{:ok, %{manifest: m, written: written, replaced: replaced}} ->
Enum.each(replaced, &Mix.shell().info("replaced #{&1}"))

Mix.shell().info(
"restored #{length(written)} files from #{archive} (created #{m.created_at}, keys #{if m.keys_included, do: "included", else: "not included"}) into #{layout.data_dir}"
)

{:error, {:not_empty, present}} ->
Mix.shell().error(
"refused: #{layout.data_dir} is not empty (#{Enum.join(present, ", ")}); --force replaces them"
)

exit({:shutdown, 1})

{:error, reason} ->
Mix.shell().error("refused: #{inspect(reason)}")
exit({:shutdown, 1})
end
end
end
3 changes: 3 additions & 0 deletions lib/trinity.ex
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,9 @@ defmodule Trinity do
Personas,
Context,
Context.AgentsMd,
Archive,
Archive.Layout,
Archive.Manifest,
LLM,
Memory,
Memory.Tokens,
Expand Down
Loading
Loading