# argala — sealed graphs

**Local-first storage, sync, and key custody for apps whose server is not
allowed to read the data.**

Every device holds the real database. The server is a **relay** that orders
and carries ciphertext it can never decrypt — no plaintext, no keys, no parsing
of record contents. Two members on two continents converge; the machine in
the middle learns sizes and timing and nothing else.

```bash
npm i @argala/core @argala/client   # devices
npm i @argala/core @argala/relay    # your server
```

## Why this exists

There are good sync engines already, and most of them sync plaintext through
a server the vendor can read. That is a reasonable trade: server-side search,
notifications and AI features all need it.

argala is for cases where that trade is unacceptable because "we cannot read
it" is the promise your product makes. The sync loop is the easy half of that.
The hard half is key distribution: who holds a key, how a second device gets
one without anyone sending a private key between machines, what happens when a
laptop is stolen, and what happens when you remove someone. Teams
abandon end-to-end encryption at that wall, not at the merge algorithm.

The key ceremonies are therefore the product, and the sync engine is their
foundation.

## What you get

```
@argala/core      crypto boundary + wire protocol   isomorphic, zod, WebCrypto only
@argala/client    the device engine + local stores  browsers, native shells, Node
@argala/relay     the blind relay                   a Fastify plugin you mount
```

- **Three graph shapes.** `u` (personal), `g:<id>` (a shared group — teams,
  workspaces, projects), `d:<id>` (a pairwise channel with its own key).
- **Per-device keys.** Keys are wrapped to _devices_, not accounts. Adding a
  device is a ceremony: register a public key and receive the keyring, rather
  than copy a private key between machines. You can revoke one lost device
  without affecting the others.
- **Signed records.** Every device signs what it writes (ECDSA P-256, inside
  the ciphertext, so the relay never learns an author). Receivers verify
  against the graph's roster and check the claimed author against the signing
  device. `by` is not a string somebody typed.
- **Epoch rotation.** Removing someone bumps the epoch and re-wraps the key for
  everyone who remains. The relay refuses any rotation that would strand a
  device rather than silently splitting the graph in half.
- **Invites that carry keys.** The invite string is `<doorCode>.<keySecret>`.
  Only the door code reaches your server; the keyring rides along sealed
  with a key derived via PBKDF2 from the half that never reaches it.
- **Partial sync, opt in.** Declare `kind` or an app-chosen `shard` as
  metadata and pull filtered subsets, with a cursor per subscription. Off by
  default, because everything you declare is readable by the relay forever.
- **Offline by default.** Reads and writes never wait on a network. LWW per
  record, monotonic revisions, cursor-paged pull, a queue that survives a
  restart.
- **Blobs with a retention policy**: `retain`, `transport` (forgotten once
  every member has a copy), `ephemeral` (TTL) — over SQLite, the filesystem,
  or your own store.
- **A read-only seat that is actually read-only.** Your roles arrive through
  the same seam as your users, and the relay refuses a push, a blob or a
  rotation the member is not permitted to make. The relay enforces the rule
  because there is no other path between two devices: a rule enforced only by
  your client is a convention; a rule enforced by the relay is a rule.
- **A device is approved before it is trusted.** A relay can add a row to the
  device roster, and every honest device would helpfully wrap the graph key to
  it. So every wrap now passes a gate: a device is approved silently only when
  it has proven it already holds the key, and otherwise a person compares a
  twelve-character pairing code. The gate also covers rotation because a
  planted device can wait for it.
- **A relay you can catch lying.** Every write it accepts goes into an
  append-only log. Devices check that log against what they were actually
  served, so an omitted or rolled-back record contradicts the relay's own
  account. They also fold the log into a chain they sign and exchange, which
  catches a relay telling two members different stories. Alarms latch, because
  a relay caught omitting one record is not cleared by behaving afterwards.
- **Unlinkable personal IDs.** A record in your personal graph that refers to
  a shared one is named by an HMAC tag, so the relay cannot join the two and
  infer what you track. Record IDs are metadata; this is how you stop
  metadata from being an index.

## Quickstart

Mount the relay in the app you already have — your sessions, your users:

```ts
import Fastify from 'fastify';
import { openDb, registerRelay, SqliteDirectory } from '@argala/relay';

const db = openDb('data/relay.db');
const app = Fastify();

await registerRelay(app, {
  db,
  // argala does not want to own your users. Answer two questions about them.
  directory: new SqliteDirectory(db),
  // Your session check. The token must name the member AND the device.
  authenticate: async (req) => {
    const session = await mySessions.verify(req);
    return session ? { memberId: session.userId, deviceId: session.deviceId } : null;
  },
  // Optional: wire to whatever realtime you already run. Pokes carry nothing.
  onPoke: (evt) => myHub.toMembers(evt.memberIds, { type: 'argala.poke' }),
  onEvent: (evt) => metrics.count(evt.type),
});
```

On the device:

```ts
import { ArgalaEngine, HttpTransport, bestStore, IdbKeyStore, defineKinds } from '@argala/client';
import { z } from 'zod';

const engine = await ArgalaEngine.open({
  memberId: me.id,
  deviceId: localStorage.getItem('argala-device') ?? ArgalaEngine.newDeviceId(),
  transport: new HttpTransport({ baseUrl: '/api/argala', credentials: 'same-origin' }),
  store: await bestStore({ name: me.id }), // OPFS-SQLite, or IndexedDB
  keyStore: new IdbKeyStore(),
  // A decrypted record is untrusted input. Say what your kinds look like.
  kinds: defineKinds({
    note: z.object({ body: z.string().max(10_000) }),
    task: z.object({ title: z.string(), done: z.boolean() }),
  }),
});

await engine.openGraph('u', { create: true }); // my own graph
await engine.openGraph('g:acme'); // a shared one
engine.startAutoSync();

await engine.put('g:acme', { kind: 'note', data: { body: 'hello' } });
const notes = await engine.listKind('g:acme', 'note'); // typed, local, instant

// Re-runs when the answer changes, not on every keystroke in the graph.
engine.watch('g:acme', (e) => e.listKind('g:acme', 'task'), render);
```

Run the entire lifecycle from a console: link a device by comparing a pairing
code, refuse one the relay invented for itself, share a graph, reject a forged
author, rotate out a stolen phone, and print what the relay actually holds:

```bash
npm ci
npm run argala:verify                                   # typecheck, lint, test, build, smoke
npm run build && npm run argala:example
ARGALA_DEV_AUTH=1 npm -w @argala/relay run dev         # a relay on :8787

npm run argala:mutate                                   # break each invariant; does a test notice?
npm run argala:soak                                     # hundreds of randomized convergence runs
npm run argala:bench && npm run argala:bench:client            # assert the shape of the cost
```

The dev server refuses to start without `ARGALA_DEV_AUTH=1`, because it
authenticates nobody.

## What the tests actually prove

The project uses strict TypeScript, type-aware linting, and CI on Node 22 and 24. The suite proves:

- the relay's own database contains **no plaintext and no usable key
  material** — asserted against every table, and against the relay's source,
  which is scanned mechanically for any import that could decrypt
- a second device is linked **without any private key moving between
  machines**, end to end through the real relay
- a device the relay invented for itself **is never handed a graph key** —
  not by a grant, and not by the rotation that answers a stolen laptop —
  while a device that has proven it already holds the key is approved with no
  prompt at all
- a member holding the key **cannot forge another member's authorship**
- a revoked device reads nothing sealed after its revocation, and what it
  wrote before stays verifiable
- ciphertext bound to one record will not open under another, and a flipped
  byte is rejected rather than wedging the sync
- a rotation that would strand a device is refused; a wrap a device cannot
  open is discarded and re-granted rather than locking it out forever
- a write landing mid-sync is not left in the queue
- a push too large for the wire is split rather than stalling forever
- a device with a permanently wrong clock still syncs
- a member id the relay would build an ambiguous storage key from is refused
- two devices creating or rotating a graph at the same moment end up on the
  same key, rather than each keeping one the other cannot read
- devices doing randomized work in randomized order converge — three of them
  and five, across rotations, deletions, revision ties and a device linked
  halfway through, with tombstones compared as well as visible records
- the relay answers 4xx to every hostile input a fuzz suite can think of, and
  is still working afterwards
- a device that has not been granted the current key holds its writes rather
  than sealing them under the superseded one
- a relay that withholds a record, serves the version it replaced, or tells
  two members different histories is caught — and an honest one is not
  accused, through a filtered subscription, a refused record, a half-finished
  pull, a write landing mid-sync, or a pruned log
- a personal record's derived id survives a rotation, a newly linked device,
  and a recovery import — so a preference never quietly becomes two
- every store implementation — memory, IndexedDB, SQLite — passes one
  conformance suite, and an engine survives a restart with its identity, its
  keyrings and its unflushed queue
- the documentation is answerable to the source: the API reference names only
  methods that exist, the limits tables carry the real numbers, and every link
  resolves

Several of these tests were written by breaking the fix first and watching
them go red. `npm run argala:mutate` does that on purpose and by machine: it holds a
catalogue of 94 mutations to the lines carrying an invariant — accept a bad
signature, drop the AAD's record id, let a rotation strand a device, resolve
a revision tie the other way — applies each one, and fails the build if the
suite does not go red. All 94 are killed today, and CI keeps it that way. A
green suite is not the claim; a suite that notices is.

`npm run argala:soak` drives hundreds more randomized convergence scenarios than
the commit suite can afford. It found a real one: an edit to a record written
by a device whose clock ran a minute ahead was stamped BELOW the revision it
replaced, so the relay dropped the push, the local store kept the edit, and
the cursor had already moved past the relay's copy — the edit existed on one
device and nowhere else, permanently, with no error anywhere. A seed that
fails there becomes a fixed seed in the suite.

## What it costs

These figures were measured on this machine with `npm run argala:bench` and
`npm run argala:bench:client` at 20,000 records. Both scripts assert a bound, so a
change in complexity fails the build rather than surprising somebody later.

|                              | per record | what it is                                          |
| ---------------------------- | ---------- | --------------------------------------------------- |
| write (queue locally)        | ~19µs      | a store write; the app never waits for more         |
| write (push)                 | ~560µs     | canonical JSON + one ECDSA signature + one AES seal |
| receive (pull)               | ~510µs     | one AES open + one ECDSA verification               |
| relay push, 200 records      | ~3ms       | flat from an empty graph to 100,000                 |
| relay pull page, 500 records | ~5ms       | flat                                                |

The per-record numbers are dominated by ECDSA P-256 — about 310µs to sign and
300µs to verify in Node's WebCrypto, and typically faster in a browser. That
is the price of knowing who wrote a record. An app that genuinely does not
need authorship can set `sign: false` and roughly halve both, at the cost of
`by` becoming a claim again. The overhead is negligible in interactive use; a
bulk import of a hundred thousand records takes a minute, not a moment.

## Status

**v0.1 — real, extracted from a system in daily use, and narrow on purpose.**

What is not here, in the order it matters:

1. **A relay that shows everybody a different world.** Rollback and omission
   are now detected — the relay is checked against its own append-only log,
   and devices compare signed chain heads — but a relay that keeps one
   private world per member, peers' checkpoints included, is still invisible
   in-band. That needs out-of-band comparison. See [ROADMAP.md](https://docs.thinkingmachinelabs.dev/argala/project/roadmap.md).
2. **Verification is off for partial sync.** A device that asked to see a
   subset cannot tell that from being shown one.
3. **Key transparency.** A device is no longer handed a key just because the
   relay listed it: every wrap passes an approval gate that auto-approves only
   on proof of possession and otherwise waits for a human to compare a pairing
   code. What is still missing is mathematics — an auditable log of device
   registrations, so a relay showing two members two different rosters is
   detectable without anybody comparing anything by hand.
4. **Authorization beyond read-only.** There is a real read-only seat now, and
   admin-only removal, enforced by the relay rather than by your UI. What is
   missing is anything finer: a member allowed to write can still overwrite or
   delete another member's records, and cannot be restricted by kind. Deciding
   either would mean the relay reading a record, which it cannot do and must
   not learn to — so those checks have to move onto the devices, where they
   become non-recognition after the fact rather than prevention.
5. **Full-text search.** Substring scan today; FTS5 is a small change against
   the SQL store and a bigger one against IndexedDB.

See [SECURITY.md](https://docs.thinkingmachinelabs.dev/argala/internals/security.md) for what argala protects, from whom, and where
it does not.

## Documents

- [docs/GUIDE.md](https://docs.thinkingmachinelabs.dev/argala/guide.md) — **start here to build on argala.** Install,
  concepts, the relay, the device, key custody, sharing, revocation, recovery,
  blobs, partial sync, operations, recipes, troubleshooting, API reference.
- [ARCHITECTURE.md](https://docs.thinkingmachinelabs.dev/argala/internals/architecture.md) — how it works and why each boundary is
  where it is, with diagrams of the sync loop, the key ceremonies, the storage
  layout and the trust boundaries.
- [PROTOCOL.md](https://docs.thinkingmachinelabs.dev/argala/internals/protocol.md) — the wire, spelled out well enough to write
  another implementation against.
- [SECURITY.md](https://docs.thinkingmachinelabs.dev/argala/internals/security.md) — invariants, threat model, and the honest
  limits. **Read before writing code here.**
- [ROADMAP.md](https://docs.thinkingmachinelabs.dev/argala/project/roadmap.md) — what is missing and what it would take.
- [CONTRIBUTING.md](https://github.com/rishabhshuklax/bapk/blob/main/packages/CONTRIBUTING.md) — how to work on it.

The guide and the architecture document are checked against the source by
`argala-client/test/docs.test.ts`: the API reference must name methods that
exist, the exports lists must name real exports, the limits tables must carry
the real numbers, and every link must resolve. Prose does not fail a build on
its own, so it is made to.

MIT.
