Skip to content

Architecture

How the pieces fit, why each boundary is where it is, and what happens on the wire when somebody types a character.

This document is for people changing argala, evaluating it, or writing a second implementation of the protocol. If you only want to use it, start with GUIDE.md. If you want the exact wire format, read PROTOCOL.md; this file explains the shape, that file is normative.

Contents

  1. The one-sentence version
  2. The three packages
  3. Identities, graphs and storage keys
  4. The crypto boundary
  5. Anatomy of a record
  6. The sync loop
  7. Key ceremonies
  8. Epochs and rotation
  9. Storage layout
  10. The relay’s request pipeline
  11. The append-only log
  12. Blobs and retention
  13. Failure and recovery
  14. Trust boundaries
  15. Extension seams
  16. Deployment shapes
  17. How it is tested

Every device holds the real database in plaintext. The server holds an ordered log of opaque (nonce, ciphertext) pairs and wrapped keys it cannot open. The interesting engineering lies in getting the right key onto the right device without ever handing that key to the server.

Device BRelayDevice Asealed opssealed opssealed opssealed ops

app code

ArgalaEngine

local store
plaintext

Fastify plugin

SQLite
ciphertext only

app code

ArgalaEngine

local store
plaintext

Green is plaintext. Grey is ciphertext. There is no third colour: the relay never sees a key, a plaintext record, or an author’s name.


@argala/core
crypto boundary, wire protocol, key schemas
no I/O, isomorphic

@argala/client
device engine + local stores
browsers, native shells, Node

@argala/relay
the blind relay
a Fastify plugin you mount

The dependency arrow only ever points at core, and that is enforced: the test suite scans the relay’s own source for any import that could decrypt, and fails the build if one appears. @argala/relay does not depend on @argala/client and never will — the two ends share a protocol, not code paths.

package holds never holds
@argala/core AES/ECDH/ECDSA/HKDF wrappers, canonical JSON, zod schemas, LIMITS, AAD and storage-key naming any network or disk call
@argala/client keyrings, the sync loop, LWW, local stores, blobs, invites, recovery anything the app should decide (schemas, sessions)
@argala/relay ordering, quotas, rate limits, the device roster, wrapped-key custody a decrypt routine, a graph key, a record’s contents

ArgalaEngine

Transport

GraphStore

KeyStore

KindRegistry

HttpTransport

MemoryGraphStore

IdbGraphStore

OpfsGraphStore
SQLite-WASM in a worker

SqlGraphStore

NodeSqliteGraphStore

MemoryKeyStore

IdbKeyStore

FileKeyStore
server-side agents

Four seams, four interfaces. The ArgalaEngine implementation is the only place that knows how they fit together, so mistakes in that file are expensive.


argala does not own your users. It asks the host app two questions (Directory) and takes one answer per request (authenticate).

names one

ArgalaIdentity

+memberId: string

+deviceId: string

Directory

+membersOfGroup(groupId)

+isGroupMember(groupId, memberId)

+isActiveMember(memberId)

RosterDevice

+deviceId: string

+memberId: string

+pubJwk: string

+sigJwk: string

+revokedAt: number|null

A member is a person or account, as defined by your system. A device is one installation of an app. Keys belong to devices, never to members — that is what makes revocation meaningful.

d:<id> — pairwise

sam addresses d:alex
alex addresses d:sam
storage key: d:alex:sam (sorted)

g:<id> — shared

engine.openGraph('g:acme')
storage key: g:acme
membership from Directory

u — personal

engine.openGraph('u')
storage key: u:<memberId>

The pairwise case deserves careful attention. Each side addresses the graph by the other party’s ID, but both must resolve to one row in the relay and one AAD value in the cryptographic layer, so the storage key sorts the pair. Get this wrong and the two sides seal data into slots the other cannot read, with no error anywhere.

storageKeyFor('d:alex', 'sam')

sort(['sam','alex'])

storageKeyFor('d:sam', 'alex')

sort(['alex','sam'])

d:alex:sam

The same function computes it on both ends — it lives in @argala/core precisely so it cannot drift.


Everything cryptographic lives in argala-core/src/crypto.ts, and every primitive is WebCrypto. Nothing here is hand-rolled; the layout around it is.

per personal id

HKDF then HMAC-SHA256
truncated to 132 bits

per passphrase

PBKDF2-SHA256, 310k iterations
invites and recovery bundles

per device

ECDH P-256 + HKDF-SHA256
wrap a graph key to a device pubkey

ECDSA P-256
sign the record, inside the ciphertext

per record / per blob

AES-256-GCM
fresh 96-bit nonce
AAD = storageKey/recordId

Why the signature is inside the ciphertext

Section titled “Why the signature is inside the ciphertext”

If the signature rode outside, the relay would learn which device wrote each record — the social graph, in the clear, forever. So a record is wrapped in an envelope, the envelope is what gets sealed, and the relay sees one opaque blob.

record
{id, kind, data, at, by}

canonicalJson

skm-record-v1 · storageKey · canonical JSON
newline-separated, as bytes

ECDSA sign

envelope {v, r, d, s}

AES-256-GCM
AAD = storageKey/recordId

op {recordId, rev, e, n, c}

canonicalJson sorts keys recursively and drops undefined exactly as JSON.stringify does, so two devices that built the same object in a different order produce the same bytes to sign. Without it, a signature would verify or not depending on property insertion order — a bug that appears months later, on one platform.

Every seal is bound to <storageKey>/<recordId> (or <storageKey>/blob/<id>). A relay that shuffles rows, or replays one record’s ciphertext into another record’s slot, produces a decryption failure, never a wrong answer.

devicemalicious relayciphertext of r1, served in the slot of r2open(key, ct, aad="g:acme/r2")GCM tag mismatchemit record.rejected {reason: shape}, skip, keep syncing

Note the last line: a bad record is skipped, not fatal. One corrupt row must never wedge a sync.

A record in your personal graph that refers to a shared record must never be named after it. pin_<docId> sitting beside g:acme/<docId> lets the relay join the two and read off what every member tracks, without decrypting anything.

personal graph key

HKDF info='skm-pid-v1'

HMAC-SHA256

'pin'

'doc_manifesto'

p_ + 132 bits
deterministic, unlinkable

Deterministic, so upsert-by-id still merges across your own devices. Derived from epoch 1 of the personal graph, so it survives rotation, a newly linked device, and a recovery import. Two members pinning the same record produce unrelated ids. engine.personalId(kind, target) is the only correct way to name such a record.


wrapsseals

GraphRecord

+id: string

+kind: string

+data: object

+at: number "revision AND timestamp"

+by: string «member»

+deleted: true "tombstone, optional"

RecordEnvelope

+v: 1

+r: GraphRecord

+d: deviceId "optional"

+s: signature "optional"

GraphOpIn

+recordId: string

+rev: number

+e: epoch "optional"

+n: nonce

+c: ciphertext

+k: kind "optional, LEAKS"

+sh: shard "optional, LEAKS"

GraphOpOut

+seq: number

at is both the record’s timestamp and its revision. That dual role is deliberate — one number, monotonic per device, is all last-write-wins needs — and it carries two rules that are easy to get wrong:

  1. Monotonic per device. Two writes inside one millisecond must not stamp the same number, or the relay drops the second and the device keeps content nobody else has.
  2. Above what it replaces. Editing a record another device wrote from a clock a few milliseconds ahead must stamp above that revision, not just above this device’s own high-water mark. Otherwise the relay drops the edit as a last-write-wins loser, the local store keeps it, and the pull cursor has already passed the relay’s copy — the edit lives on one device forever, with no error anywhere.

npm run argala:soak exposed the second rule; code inspection did not.

Tombstones are records. The relay never deletes a row; a deletion is a record with deleted: true and an empty data. Anything else would make “deleted on device A, edited on device B” unresolvable.


One graph at a time, coalesced so eight concurrent callers produce one run.

yesnonoyesyesnonoyesokrejectdeferyesnoyesnoyesno

syncGraph

epoch stale
or no key?

refreshKeys

pushPending

key now?

sync.done

behind an
epoch?

hold writes
emit keys.behind

pullAll

seal a batch
bounded by count AND bytes

POST push

queue
empty?

for each subscription

GET pull?after=cursor

note the relay's epoch

for each op

open + verify

newer than local?

emit record.rejected

stop, keep cursor

stage for upsertMany

next op

advance cursor

more?

epoch ahead
of keyring?

refreshKeys

grantPending

Four details in that diagram are essential:

A push is bounded by bytes as well as by op count. Two hundred records at the maximum ciphertext size total roughly 140 MB, and the resulting 413 is not retryable — a queue that stops draining and stays stopped.

A device that is behind an epoch holds its writes. Sealing under the previous key would be readable by whoever the rotation excluded. Unsent is recoverable; sent under the wrong key is not.

Meeting an op sealed under a missing epoch stops the pull before that op, without advancing the cursor, fetches keys, and tries once more in the same sync. Skipping it would lose the record forever.

“Cannot verify” is not “does not verify.” If the roster cannot be fetched — offline, rate-limited — the batch is deferred, not dropped. Dropping would silently lose data that is sitting right there.

device Brelaydevice Aboth edit r1 in the same millisecond, rev = 500converged on A's contentpush r1 rev 500 (A's content)no existing row → storepush r1 rev 500 (B's content)existing rev 500 >= 500 → drop silentlyaccepted 0pullop r1 rev 500 (A's content)local.at == record.at AND content differs→ take the relay's copy

The relay keeps the first of two equal revisions. The device’s comparison is therefore local.at <= record.at on a difference, not <: a strict < would leave B holding its own version forever, with no error and no way back.

A device that just pushed a record will receive it again on the next pull. Decrypting and verifying its own writes is unnecessary, so the engine remembers (graphId, recordId) → rev and skips an op that matches exactly.

The rule: mark echoes only when EVERY op in the batch landed. rejected lists refusals, not the ops that quietly lost a last-write-wins comparison — and a record that lost is one where the relay’s copy is somebody else’s. Marking that as our own echo would skip the one record we most need to read.


relaydevicealt[somebody else got there first][fresh]GET /graphs/g:acme/devicesroster (pubJwk per active device)generate a 256-bit graph keywrap it to every active device's pubJwkPOST /graphs/g:acme/keys {epoch: 1, wraps}200 — it sees "epoch 1" and treats it as a grant200 — epoch 1 createdGET /graphs/g:acme/keyswhatever actually landedadopt what the relay holds, never what we sent

That read-back is not merely defensive programming; it fixes a real race. A concurrent create is answered 200, not 409 — the relay sees a known epoch and treats the request as a grant, ignoring the duplicate wrap. A device that trusted that 200 would keep a key nobody else has: every record it writes unreadable, every record it reads undecipherable, and no error anywhere.

No private key ever moves between machines.

a device holding the keysrelaynew deviceset-once. Rebinding an id is refused (409).possession-pinned, human-approved, or blockedpubkeys come from the roster, never fromthe asking device — a forged request cannot redirect a keygenerate ECDH + ECDSA keypairsselfSig = sign(fingerprint) under its own signing keyPUT /devices/<id> {pubJwk, sigJwk, selfSig}GET /graphs/g:acme/keyslatestEpoch 3, wraps: [] — nothing for me yetstatus = waiting, showing its pairing codeGET /graphs/g:acme/keygaps[{deviceId: new, pubJwk, sigJwk, selfSig, epochs: [1,2,3]}]fingerprint the triple → is this device approved here?wrap each epoch to the PUBLISHED pubkeyPOST /graphs/g:acme/keys {epoch, wraps, excluding}GET /graphs/g:acme/keysthree wrapsunwrap with its own private key → status ok

grantPending runs automatically during every sync performed by a device that holds the keys (autoGrant, on by default). This makes linking feel automatic instead of like a ceremony someone must remember to perform.

A relay cannot mint a graph key, but it can add a row to the roster: a device ID it invented, published with a wrapping key whose private half it holds. Every honest device then sees a gap and helpfully fills it. Nothing appears broken, but the relay gains access to the graph key.

Gating grantPending alone would not help. A planted device does not need to be granted — it can wait for rotate(), which is what you run when a laptop is stolen. So the gate sits on the one function every wrap goes through:

wrapsFor() — the only place a key is wrapped to another deviceapproved, fingerprintmatchesapproved, fingerprintchangedrejected / conflictednoneyesno'approve''observe' (default)

createGraphKey · grantPending · rotate

fingerprint the key material
we were HANDED, not a cached roster

pin for this device?

wrap

pin: conflicted
device.conflict
blocked

blocked

proof of possession?

pin: approved via 'possession'

device.pending
blocked

linking mode

named in excluding
on the key put

wrapped anyway,
linking.unenforced once per boot

It fingerprints what it is handed. Splitting “the bytes a human approved” from “the bytes we encrypt to” is exactly how a relay would serve an honest key to the approval screen and a substituted one to the wrap.

It never throws. A device nobody approved must not be able to stop a rotation, or the gate becomes a way to disable the only remedy argala has for a compromised device. Blocked devices are named in excluding, and the relay accepts the rotation as complete.

Why proof of possession makes the gate usable

Section titled “Why proof of possession makes the gate usable”

a record device X wrote

we opened it
under the graph key

its signature verifies under
the sigJwk the roster publishes for X

X held the graph key.
The relay cannot fake this — the
signature is inside ciphertext
it cannot produce.

X's selfSig over its own fingerprint

verifies under the same sigJwk

the published WRAPPING key belongs
to the same holder as the signing key

pin X, via 'possession', no prompt

Without this, every already-linked device, every invited member and every recovered laptop would raise a prompt on upgrade day — and a product that asks a hundred questions on Monday gets its questions dismissed for the rest of its life. The pin is written where the evidence appears, inside record verification, and not where a key gap appears: an already-linked fleet has no gaps, so nothing would ever trigger it.

possession, or a humansaid yesa human said nopublished keys changedforgetDevice()forgetDevice()forgetDevice()

unknown

approved

rejected

conflicted

approved is the only state in which a key is wrapped, and it is not terminal: the fingerprint is re-checked on every wrap, so an approved device whose published keys change lands in conflicted on the next sync rather than keeping the grant it already had.

Pins live in the local KeyStore under pin:<deviceId> and are never synced. A pin is the one record in this system that must not be something the relay — or another member — can write; sync it and the question becomes “who approved the approval”, which is the same question one level up.

forgetDevice() is the anti-kill-switch. A conflicted pin blocks a device forever, so a relay that rewrote one roster row could otherwise brick an honest laptop with no way back. There has to be a path from any state to “ask me again”.

The human-facing half is a pairing code: twelve Crockford base32 characters over a PBKDF2 stretch of the fingerprint. It is one-sided — computed from the joining device’s own keys on one screen, and from what the relay published on the other — because a symmetric safety-number construction would let a relay controlling both screens run a birthday attack across two candidate sets at once.

Invites: a keyring that rides beside the door code

Section titled “Invites: a keyring that rides beside the door code”

invite string

split on '.'

doorCode
goes to your server

keySecret
never leaves the two humans

PBKDF2 310k

open the sealed keyring

your invite table:
who is this for?

The server learns the door code and can answer “is this invite valid” — it never sees the half that opens the keyring. Losing the whole string to a network attacker is a real compromise; losing your server’s database is not.

import on a NEW device

every graph keyring on this device

payload {keyrings}

AES-GCM under PBKDF2(passphrase, salt)

base64 bundle

new device keypair
+ known graph keys

The bundle carries graph keys only — never a device keypair. A restored machine is a new device that happens to already know the graph keys. This keeps two machines from signing as one and preserves meaningful revocation. Importing first wipes the local record store: a store partially populated by a previous identity is worse than an empty one.


An epoch is a generation of a graph’s key. Rotation mints the next one and wraps it to everybody who remains.

graph exists, nobody hasmade a keycreateGraphKeyrotate (a device wasrevoked)rotategrant — a new device iswrapped ingrant

NoKey

Epoch1

Epoch2

Epoch3

relay reports epoch 0

old epochs are KEPT.
Records sealed under epoch 1
must still open.

Old epochs are never thrown away. A record written last year is still sealed under the key from last year, so a keyring is a map of epoch → key, and a device that lacks an old epoch appears in keygaps until somebody fills it.

A rotation that would strand somebody is refused

Section titled “A rotation that would strand somebody is refused”
relaydevicerefused, with the IDs named —not accepted with a warningPOST keys {epoch: 4, wraps for 3 of the 4 devices}eligible = active devices of current membersmissing = eligible - covered - excluding400 incomplete-rotation {missing: ['sam-phone']}

The alternative is a graph silently split in half, which is the failure mode nobody notices until a week of messages is unreadable on one laptop.

excluding is the one way out, and it is a decision rather than a gap: the caller names the devices it is stranding on purpose — the ones the approval gate blocked. The relay refuses an exclusion naming a device that is not eligible, or that the same request also wraps to (400 invalid-exclusion), and reports the exclusions on the graph.rotate event so a host app can alarm on a rotation that left somebody behind. Without this field, the completeness rule and the approval gate deadlock each other and rotation stops being possible at all.

A rotation reaches the other devices on their next sync

Section titled “A rotation reaches the other devices on their next sync”
device Brelaydevice AB has not re-opened the graph.It only syncs.revoke sam-phonePOST keys {epoch: 2, wraps to A and B}POST push (still under epoch 1){accepted, cursor, now, epoch: 2}GET pull{ops, cursor, more, epoch: 2}noteEpoch(2) — ahead of my keyringGET keysthe epoch-2 wrapnext write is sealed under epoch 2

Before the relay reported the epoch, B learned about it only after receiving a record it could not open — or when the periodic check ran, up to a minute later — and kept sealing under exactly the key the rotation was performed to take away. A guarantee that takes effect once somebody notices is not much of one.

Anyone holding a graph’s key can rotate it, and the relay cannot check that a wrap decrypts because the relay itself cannot decrypt. A malicious member can therefore hand another device a wrap it cannot open. argala makes that failure recoverable rather than terminal:

okfails

wrap arrives

unwrap

adopt the epoch

DELETE keys/:epoch
emit keys.unopenable

reappears in keygaps

an honest holder re-grants

Keeping an unopenable wrap would be worse than discarding it: the device would sit one epoch behind forever, holding every write, with the relay insisting it had been served. Cryptographic write control — signed rotations against a policy — is the real answer and is not built. See SECURITY.md.


graph_keygraph_keygraph_keygraph_keygraph_keydevice_id

graph_meta

text

graph_key

PK

int

latest_epoch

int

rotation_required

int

records

usage, maintained incrementally

int

bytes

int

blob_bytes

graph_records

text

graph_key

PK

text

record_id

PK

int

seq

ordering

int

rev

LWW

int

epoch

which key sealed it

text

nonce

text

ciphertext

text

kind

NULL unless the app opted in

text

shard

NULL unless the app opted in

int

size

int

at

graph_keys

text

graph_key

PK

int

epoch

PK

text

device_id

PK

text

wrapped

sealed TO a device pubkey

int

created_at

graph_blobs

text

graph_key

PK

text

blob_id

PK

text

nonce

int

size

text

policy

int

expires_at

graph_seq

text

graph_key

PK

int

next_seq

graph_log

text

graph_key

PK

int

seq

PK

text

record_id

append-only; see below

int

rev

devices

text

device_id

PK

text

member_id

text

pub_jwk

ECDH

text

sig_jwk

ECDSA

text

label

int

created_at

int

revoked_at

Nothing in that schema can hold plaintext, and a test asserts it — by column name and by content, across every table.

Two design notes:

Usage is counted incrementally. records, bytes and blob_bytes on graph_meta exist because enforcing quotas with COUNT(*) and SUM(size) on every push made a graph of 100,000 records push about five times slower than an empty one. npm run argala:bench fails if a scan comes back.

graph_seq is a counter, never MAX(seq) + 1. A number reused after a delete would make a puller skip a record.

Migrations are append-only. MIGRATIONS runs under PRAGMA user_version; a migration that has shipped is never edited. The relay refuses to run against a database written by a newer build rather than guessing.

KeyStore — secrets

device:<id> → ECDH keypair

sign:<id> → ECDSA keypair

keyring:<graphId> → {epochs, current}

GraphStore — records, plaintext

records: graphId + id → GraphRecord

queue: ids waiting to push

cursors: per SUBSCRIPTION, not per graph

They are deliberately separate. Wiping records on a recovery import must not take the keys with it, and a native shell wants the keys in the OS keychain while records stay in a database file.

Cursors are keyed by subscription because a device pulling g:acme unfiltered and g:acme filtered to one kind is at two different places in the same sequence. Sharing one cursor would make each subscription skip what the other consumed.

yesno

bestStore

OPFS + SharedArrayBuffer
+ worker available?

OpfsGraphStore
SQLite-WASM, SAH-pool VFS, in a worker

IdbGraphStore
indexed by graph and kind

Node / Electron main

NodeSqliteGraphStore
node:sqlite on a real file

tests, server agents

MemoryGraphStore

OPFS is one tab at a time. bestStore falls back to IndexedDB in a second tab, so two tabs of the same app hold two different local stores. They converge through the relay, but neither sees the other’s unsynced writes. Not a confidentiality problem; very much a correctness surprise, and it is in SECURITY.md for that reason.


overoknullidentitynoyesnorevokedyesnot a memberunknown memberokfailok

HTTP request

rate limiter
per member, per bucket

429

authenticate

401

isSafeId
member AND device

403 malformed-identity

device registered
to THIS member?

403 device-not-registered

403 device-revoked

resolve the graph

403

404

zod body schema

400 invalid

transact: BEGIN IMMEDIATE

route logic

poke(graph) — carries no content

response

isSafeId is not cosmetic. A member ID containing a colon would let two different pairs collide on one d:a:b storage key — an isolation break, not a typo — so IDs are validated before anything builds a key from them.

Every inbound frame is schema-validated before any logic touches it. At the relay that means the HTTP body; on the device it means the decrypted record, because the relay cannot validate what it cannot read and any key-holding member can craft anything. Validation lives at the last boundary that can enforce it, not the first one that is convenient.

blobs

POST /graphs/:gid/blobs/:id

GET /graphs/:gid/blobs/:id

POST /graphs/:gid/blobs/:id/ack

DELETE /graphs/:gid/blobs/:id

keys

GET /graphs/:gid/keys

GET /graphs/:gid/keygaps

POST /graphs/:gid/keys

DELETE /graphs/:gid/keys/:epoch

GET /graphs/:gid/devices

records

POST /graphs/:gid/push

GET /graphs/:gid/pull

GET /graphs/:gid/log

devices

PUT /devices/:id

GET /devices

POST /devices/:id/revoke

The relay exposes fifteen routes. None parses a record’s contents. Four handle key material, but none can open it. The log route returns the relay’s own account of what it accepted, allowing devices to hold the relay accountable.


The relay cannot read a record, alter one, or forge one. It could always do two other things: decline to hand a record over, and hand over the version that was replaced. A device asking for a graph it has never seen had no way to know what it was not given.

Why the current-record table is insufficient

Section titled “Why the current-record table is insufficient”
what actually happenedgraph_records — a tablethe row moves;the past stops existing

r7 · seq 90 · rev 900

r7 · seq 12 · rev 400

r7 · seq 51 · rev 700

r7 · seq 90 · rev 900

graph_records is keyed by record ID, so an update moves the row to a new sequence number and the versions it replaced cease to exist in that table. A device that joined last week and one that joined today therefore pull different sequences of rows — the newcomer sees r7 once, at revision 900, and can never reconstruct what the other one saw. No chain computed over what a device pulled could ever agree between them, and without agreement there is nothing to compare.

graph_log does not move. One row per accepted write, holding only what the relay was already handed, forever.

graph_log

text

graph_key

PK

int

seq

PK

text

record_id

int

rev

The log adds roughly sixty bytes per write, compared with the hundreds of bytes the ciphertext already requires. Refused writes are not logged — a last-write-wins loser is not a write — so sequence numbers have gaps, which matters later.

another devicerelaydeviceloop[every entry with seq ≤ C]no → omitted / rolled-back.Needs no peer and no key.no → diverged.The relay is not serving one graph to everybody.GET pull?after=Cops (the relay's story)GET log?after=Lentries (the relay's OWN account of what it took)fold into the chaindo I hold this record at this rev or newer?push a signed checkpoint {seq, head}pullD's checkpoint, sealed and signedmy head at that boundary — the same?

The first check catches a relay that contradicts itself. The second catches a perfectly self-consistent relay that maintains a private world for each member. Such a relay passes the first check flawlessly but fails the second because the chain head depends on the log alone and two devices reading the same range must compute the same value.

Why the chain agrees, and why boundaries are quantised

Section titled “Why the chain agrees, and why boundaries are quantised”
h₀ = SHA-256("skm-log-v1\ngenesis\n" + storageKey)
hₖ = SHA-256("skm-log-v1\n" + hₖ₋₁ + "\n" + seq + "\n" + recordId + "\n" + rev)

Genesis is bound to the storage key, so one graph’s log cannot pass as another’s. Everything else is the log and nothing but the log — not what the device stored, not what it decided, not when it joined.

Devices cross the log at different moments and with different page sizes, so their heads are rarely at the same position. Checkpoints are quantised to a fixed stride so they can be compared:

device B folds 1..40, then 41..90device A folds 1..70 in one pagecompare

head at 64

head at 64

equal?

The head “at boundary 64” is defined as the chain after folding every entry with seq ≤ 64 — which is well defined whether or not an entry sits exactly on 64. That matters because gaps are normal: a push allocates its whole seq range up front and a refused op spends one without logging. A device that only recorded a head when an entry landed exactly on a boundary would, on a busy graph, have nothing in common with anybody.

A detector that fires on an honest relay will eventually be switched off, so every innocent explanation is ruled out before an alarm is raised.

looks like an omission actually
a record missing from a filtered subscription the device asked to see less — check reports off
a record this device rejected it was refused on purpose; refusals are remembered
entries past the pull cursor not served yet — a pull stopped by a missing epoch, or somebody writing mid-sync
the log starts later than the device’s cursor an operator reclaimed disk — reports unverified
the log starts later than the very beginning a device linked after a prune — anchors, and never compares heads with anybody
no log route at all an older relay — reports unverified, not an accusation
nothing checked yetthe log agrees with whatwas servedonly filtered subscriptionshererelay unreachable, orpruned past uscontradiction, or a peer'shead differslatched — behavingafterwards proves nothing

unverified

ok

off

alarm

alarm latches deliberately. A relay caught omitting one record is not cleared by behaving for the next thousand, and a warning that clears itself is a warning nobody sees.

The anchor, and why the first fold is the dangerous one

Section titled “The anchor, and why the first fold is the dangerous one”

The row about a pruned log is two rows because it is two situations, and only one of them is obvious.

A device that has already folded past the pruned span notices immediately: the log now starts later than its own cursor, so there is a span it verified and can no longer re-derive. It says unverified and stops.

A device folding for the first time has no cursor to compare against. The original guard was written as “cursor > 0 AND the log starts later than the cursor”, which allowed that device through. It then folded from genesis over a history that begins in the middle, arrived at a head at every boundary that no peer could reproduce, and compareCheckpoints read the difference as a member asserting a history nobody else shares. The result was a latching alarm that named a colleague by device and member after an operator ran a DELETE.

The fix is an anchor. LogState.origin records where the chain starts, and the guard became “the log starts more than one past what we have folded”, which covers both cases with one comparison. A device with a non-zero anchor:

  • still folds forward, because everything after the anchor is the relay’s own account of what it accepted and is still worth checking — one DELETE must not switch the detector off for every device linked afterwards, which would be a better outcome for a hostile relay than being caught;
  • never compares its head with a peer’s, and corroborates nobody;
  • never publishes a checkpoint, because an older build has no idea the head is anchored elsewhere and would compare the number as though it were the ordinary one — accusing the device that published it;
  • never reports ok, because there is a span it cannot see.

Putting the anchor on the wire would let two devices with the same anchor corroborate each other again. That is a real improvement and it is not free: it needs a field that older builds can safely ignore, which is the same design question the checkpoint’s optional fields already answer.

A relay that shows every member a wholly separate world, their peers’ checkpoints included, is invisible in-band — the members would have to compare a head out of band. Detection is also useless on a device that never syncs, and it is off for partial sync. So the guarantee is confidentiality and authenticity, with freshness now evidenced rather than guaranteed; the difference is deliberate and is spelled out in SECURITY.md.

One honest cost: the relay now retains the shape of a record’s edit history where it used to overwrite it. It never learns more about the contents — it watched each write happen either way — but it remembers.


Large content does not belong in a record — the record limit is 512 KB, and LWW on a 20 MB payload is a poor fit. Blobs are sealed bytes stored beside sealed records and addressed by a random ID that a record can reference.

policy retain (default)policy transportpolicy ephemeral (ttl)explicit DELETEevery member ackedttl passed

retain

transport

ephemeral

deleted

forgotten

The sweeper runs inside the relay by default (sweepIntervalMs, 60s), because retention policy that only runs when somebody remembers to schedule it is not retention policy. A multi-process deployment wants exactly one sweeper, not one per process — set sweepIntervalMs: 0 and call sweepBlobs() from a single job.

Retention is intent, not enforcement. transport and ephemeral are executed by a relay you may not control; a relay that keeps a copy is invisible to you. This is stated plainly in SECURITY.md and should be stated plainly to your users.


Everything here is designed around one assumption: the network will fail, and the app should continue to work.

debounced, backgroundoffline5xx / 429 / network4xxoknext sync

app writes

local store, synchronously

queue the id

app continues

sync

stays queued; status.lastError set

jittered backoff, bounded attempts

not retryable: surface it

dequeue

failure what happens
relay unreachable writes queue; reads are local and unaffected; status.lastError is set
relay returns 429 or 5xx retried with jittered backoff, bounded attempts
relay returns 4xx not retried — surfaced as status.rejected and a record.rejected event
device clock is wrong the relay’s now is adopted once, and the monotonic mark is reset with it
a record fails the app’s schema skipped with record.rejected {reason: 'schema'}; the rest of the batch lands
the roster cannot be fetched the batch is deferred, cursor kept — never dropped
a wrap cannot be opened discarded, reported, re-granted
the process dies mid-flush the queue is in the store; the next boot drains it
two tabs, OPFS taken the second falls back to IndexedDB — see the note above
relaydevice (clock 3 days fast)BOTH halves matter. Correcting the skewwithout resetting the high-water mark leavesevery later record rejected forever.push rev = now + 3 daysrejected {reason: 'rev-too-far-ahead'}, now = <relay clock>clockSkewMs = myClock - relayNowlastAt = min(lastAt, corrected now)push rev = correctedaccepted

Authorization here is deliberately narrow, and the narrowness is the honest part rather than a gap. A member who holds a graph key can read it; that is arithmetic. What they cannot do is reach another device except through the relay, so the relay is the only place a permission can be enforced rather than merely asserted.

groupId is null?no capabilitiesOf?answers null?answers a listyesno

a member's device

push · blob · rotate

relay

allowed — u and d: have
nobody to have a role against

does it include
the capability?

403 not-permitted

stored — now reachable
by every other device

the client HOLDS the queue
and keeps pulling

Three of those arrows represent defaults, and reversing any of them would be a bug. A directory with no capabilitiesOf grants everything, because a permission model that locks out every existing user on upgrade day is not a feature. null means unmanaged rather than restricted, so a host app can govern one group and ignore the rest. And personal and pairwise graphs are never asked about, because a check there could only lock somebody out of their own data.

403 must not throw on the client. Handling it as a transport failure abandons the sync run before the pull, so a device permitted to read and not to write would gradually stop receiving anybody else’s records — a permission feature behaving like a disconnection. The queue is held, the refusal is reported as policy.readonly, and the pull continues.

What cannot be enforced here, and why. Per-record ownership needs to know whose record it is; restricting by kind needs to know what kind it is. Both would mean the relay reading a record, which is the one thing this system is built not to do. Those checks can only be made on a device that holds the key, which makes them non-recognition after the fact — worth having, and a different guarantee.


untrusted: the networkuntrusted: the relaysemi-trusted: the host app's servertrusted: the devicesealed opsnames member + deviceanswers membership

app code

ArgalaEngine

plaintext store

your sessions
authenticate()

your users
Directory

ciphertext + wrapped keys

TLS is the host app's job

What the relay learns anyway, stated plainly: who syncs what and when, record ids and sizes, the pairwise graph list, and — only if an app opts in — kinds and shards. What it does not learn: record contents, authorship (signatures live inside the ciphertext), and what you read (read positions live in the personal graph, encrypted).

The honest limits — rollback and omission by a malicious relay, a hurried human at the approval gate, a hostile member’s denial of service, the browser delivery caveat — are in SECURITY.md, which is the document to read before changing anything here.


Every one of these is an interface because the alternative was guessing what your deployment looks like.

client

Transport
HTTP, or your own

GraphStore
five implementations

KeyStore
keychain, file, IDB

KindRegistry
your schemas

now()
your clock

relay

Directory
your users

authenticate
your sessions

BlobStore
SQLite, filesystem, S3

RateLimiter
memory, Redis

onPoke
your realtime

onEvent
your metrics

Two of them deserve a warning:

  • MemoryRateLimiter is per process. Correct for one relay, wrong for four behind a load balancer. Back RateLimiter with something shared before you scale out.
  • FileKeyStore is plaintext on disk. Right for a server-side agent whose disk is already the trust boundary, wrong for a laptop — there the OS keychain is the answer, and KeyStore is an interface so that it can be.

same originonPoke

browser app

your Fastify server

registerRelay(app, ...)

your existing routes

SQLite / your DB

your websocket hub

This is the common case and the deployment argala is designed for. You keep your sessions, users, and realtime infrastructure; argala adds prefixed routes and a set of tables.

app instance

load balancer

app instance

relay 1

relay 2

shared database

shared rate limiter

one sweeper job

Two things change when you scale out: the rate limiter must be shared, and exactly one process should sweep blobs (sweepIntervalMs: 0 everywhere else).

a bot / worker

ArgalaEngine
MemoryGraphStore or NodeSqliteGraphStore
FileKeyStore

relay

A worker that needs to read a graph is just another device: register it, grant it, revoke it like any other. It holds real keys, so its disk is a real trust boundary.


The bugs in a system like this are the ones where everything still appears to work, so the suite is built to be adversarial rather than exhaustive.

unit — core: crypto, canonical JSON, schemas

npm run argala:verify
typecheck, lint, format, test, build

integration — real relay, real crypto, injected HTTP

adversarial — a relay that omits, rewinds, and tells two stories

fuzz — hostile input at every relay route

convergence — seeded random scenarios, 3–5 devices

smoke — the BUILT packages, over a real socket

CI

npm run argala:mutate
every invariant broken on purpose

npm run argala:soak
hundreds of randomized scenarios

npm run argala:bench
asserts a bound, not a number

Three of those are unusual enough to explain:

npm run argala:smoke drives the built packages over a real socket. Tests run against src through a vitest alias; the published packages resolve each other through exports, which point at dist. The smoke test is what closes that gap — if you change an exports map or add a file, it is the one that notices.

npm run argala:mutate breaks each guarded line in turn — accept a bad signature, drop the record id from the AAD, let a rotation strand a device, resolve a revision tie the other way — and fails if no test goes red. A test that has never been red is not evidence of anything. Its first run left four mutations alive, and each one was a real gap in the suite.

npm run argala:soak runs hundreds of randomized convergence scenarios on a virtual clock the seed advances, so a failing seed reproduces exactly. It found the revision-floor bug and the rotation-propagation delay described above. Failing seeds are promoted into the commit suite, so a bug found once is a bug tested forever. Every scenario runs with linking: 'approve' and models explicit human approval rather than switching the gate off, so each run also asserts the gate stays silent afterwards, which is the property that decides whether anybody leaves it turned on.