Aletheia

29. Aletheia Sync

Status: implemented, 2026-10-26 (T-102, T-103, T-104, T-108; E2EE T-117). This page describes the code that exists.

Aletheia Sync is a free, self-hosted, dependency-free way to move memory between devices. It is an append-only operation log with per-operation results, tombstones, device pairing, scoped tokens and an optional end-to-end encryption mode (§29.6). Team mode builds on the same tokens and account: see 31-team.md.

29.1 Protocol

All JSON over HTTP, authenticated with Authorization: Bearer <token> and X-Device-Id. The hub is a single Bun process with a SQLite file (sync.db). Binds to loopback by default; a non-loopback bind needs --behind-tls and a warning, because TLS must be handled by a reverse proxy or tunnel.

RouteMethodWhoWhat
/v1/sync/joinPOSTdeviceexchange a one-time pairing code for a device token
/v1/sync/pushPOSTsync/adminappend ops, one result per op
/v1/sync/pullGETany valid tokenops with seq > since, cursor-resumable
/v1/sync/snapshotGETany valid tokenself-contained heads for re-bootstrap
/v1/sync/statusGETany valid tokenepoch, head seq, device count
/v1/sync/devicesGETadmindevice registry
/v1/sync/revokePOSTadminrevoke a device and its tokens
/v1/sync/tokensGET/POSTadminlist or mint scoped tokens
/v1/sync/compactPOSTadminrun compaction
/v1/sync/purgePOSTadminpurge one entity everywhere
/v1/sync/auditGETadminappend-only audit log
/v1/sync/e2eeGETany valid tokenthe account's wrapped E2EE keys (for a newly joined device)

Push returns one result per op: applied, duplicate, or rejected with a reason. One bad op never rejects the rest of the batch.

There is also a folder transport: aletheia sync export <dir> writes signed op files and aletheia sync import <dir> reads them, for Git, Syncthing or Dropbox.

29.2 Data model

An operation:

{ device_id, seq, hlc, entity_type, entity_id, op, payload, supersedes, content_hash }

Server tables: sync_op (the append-only log), sync_head (the winning state per entity, self-contained with payload), sync_device, sync_token, pair_code, sync_audit, sync_usage.

Redaction runs on the device before any op leaves the machine. The same redact.mjs patterns used at capture run again here.

29.3 Conflict rules

Total order is (hlc, device_id). Higher hlc wins; equal hlc is broken by device_id, so two devices that write offline converge to the same winner.

  1. Facts are append-only: a put with a new entity_id coexists with older rows.
  2. Edits (put on an existing entity_id) and dismissals (dismiss/restore) are last-writer-wins.
  3. Deletes are tombstones, also last-writer-wins; the tombstone replaces the content.
  4. Supersession links merge by union: the supersedes set is the union across every op that named it.

These rules are tested in test/sync-core.test.mjs.

29.4 Retention, compaction, purge and snapshot

Retention default: 30 days. aletheia sync compact drops the bodies of ops older than the window that every non-revoked device has acked past and that a newer put or delete superseded. Head op bodies are kept, so snapshot recovery always works. Tombstone and dismiss/restore heads keep their content bodies.

aletheia sync purge <entity_id> removes content everywhere and leaves only the tombstone.

An offline device older than the window must re-bootstrap from a snapshot: pull returns snapshot_required when the cursor is behind the retained window, and /v1/sync/snapshot returns the self-contained heads.

29.5 Security model

29.6 End-to-end encryption (optional, T-117)

Status: implemented, 2026-10-26. Optional per account, off by default; unencrypted accounts behave exactly as before. Tested by test/sync-e2ee.test.mjs.

Key hierarchy

What is encrypted and what is not

Encrypted on the device, before the op enters the outbox: the whole payload of a put (titles, text, project names, tags — everything inside the payload object). The ciphertext is an envelope { "@e2ee": 1, v: 1, kid, nonce, ct } (AES-256-GCM, random 96-bit nonce per message, never reused). content_hash is computed over the op with the ciphertext, so the server's dedupe and integrity checks run on ciphertext.

Not encrypted, and visible to the server: device_id, seq, hlc, entity_type, entity_id, the op kind (put/dismiss/restore/delete), supersedes, the ciphertext size, the server-side timestamp, the per-op result code, device names, token ids and the audit rows (which already never carry payloads).

Associated data: AES-GCM authenticates the ciphertext against the canonical op minus payload and content_hash — so ciphertext cannot be moved to another device, seq, entity, kind or hlc without failing the GCM tag.

Enabling, new devices, rotation, recovery

What a compromised server can and cannot do

Can see: the metadata list above, ciphertext, sizes, timing. Cannot see: payload plaintext, without the passphrase or a recovery key file. Cannot move ciphertext between entities (AAD fails). Can still drop, reorder or refuse ops — there is no hash chain across the log — and can DoS the account; the client keeps its outbox and full local replica either way.

What is not covered

29.7 Threat list and what is NOT covered

ThreatCovered
One bad op rejects a batchyes: per-op results
Replay or duplicate pushyes: (device_id, seq) idempotency
Secret in the log, audit or wireyes: redaction before send, audits hold no payloads
Lost ack mid-pushyes: outbox survives, re-push is idempotent
Revoked device still writesyes: 401 on the next call
Eavesdropping on remote trafficNOT covered here: TLS is delegated to a reverse proxy or tunnel
Hub reading plaintext payloadscovered when E2EE is on (§29.6); plaintext accounts keep the old behaviour
Compromised hub tampering with historyNOT covered here: there is no hash chain across the log
A device with an old cursorcovered: snapshot_required and snapshot re-bootstrap

Do not claim "end-to-end encrypted" for plaintext accounts. E2EE covers what §29.6 says and nothing more.

29.8 Failure modes

FailureBehaviour
Hub down or 5xxops stay in the outbox; push/pull are retried
Crash between apply and cursorsame transaction; both or neither
Reordered pullsonly a higher hlc wins; order does not change the replica
Poison oprejected alone, the rest of the batch drains
Disk fullpush returns an error; the client keeps the outbox

29.9 Running two machines

On the hub machine:

bun bin/aletheia-sync.mjs --port 37791 --db ~/.aletheia/sync.db
aletheia sync pair --db ~/.aletheia/sync.db

On the device:

aletheia sync join http://<hub-host>:37791 <code> --name laptop
aletheia sync run

For a remote hub, the hub must be reachable over TLS. A Cloudflare Tunnel example (no vendor lock — any reverse proxy or Tailscale works):

cloudflared tunnel --url http://127.0.0.1:37791
aletheia sync join https://<assigned-host>.trycloudflare.com <code>

The tunnel only forwards TLS to the loopback hub. Aletheia itself does not depend on the tunnel provider.

29.10 Comparison with claude-mem's sync

claude-mem's hub appends to a per-user log and refuses the whole batch when any op is refused ("any refusal rolls back all of it", store.ts). Users got stuck for days on a stale or conflicting op: issue 4086 and issue 3964. Aletheia answers per op, so one bad op never blocks the rest.

claude-mem couples every push to its closed "projection" catching up (projected_seq), which caused 33-hour 500/429 outages (issue 4191) and a stuck projected_seq (issue 4198). Aletheia's hub is its own store, so there is no second system to lag.

claude-mem stores op bodies in clear text on the hub; the schema file says retention, rotation and compaction are out of scope. Aletheia runs redaction before send, adds tombstones with compaction and purge, keeps payloads off the audit table, and offers the optional E2EE mode in §29.6 for accounts that enable it.

29.11 Sources