Skip to content

Changelog

Notable changes to argala. Dates are the day the work landed; versions begin at 0.1.0, and until 1.0.0 a minor bump may change the wire.

  • A read-only seat the relay enforces. Signatures proved who wrote a record and said nothing about whether they were allowed to, so inside a shared graph every member was effectively an owner — able to rotate the key out from under everybody or overwrite anyone’s work, with only the UI not offering the button. Directory.capabilitiesOf is where a host app’s roles arrive, and the relay refuses a push, a blob upload, a blob delete (write), or a new key epoch (rotate) that a member may not perform. The relay enforces these permissions because there is no other path between two devices: a rule enforced only by a client is a convention; a rule enforced by the relay is a rule. A directory that does not implement it grants everything, so an existing deployment is unchanged. Personal and pairwise graphs are never asked about. A refused push is HELD rather than dropped and never aborts the sync, so a read-only device keeps pulling. The scope is deliberately narrow: it does not stop a member reading (they hold the key), does not give per-record ownership, and is not a defence against a relay, which decides the answer it enforces.

  • A device is not handed a key just because the relay listed it. Every wrap of a graph key to another device — creating a graph, filling a key gap, and rotating — now passes one approval gate, which fingerprints the key material it is handed rather than a cached roster. A device is approved with no prompt only on proof of possession. It must have written a record that this device opened under the graph key and verified under the signing key published in the roster. Its new selfSig must also bind that signing key to the wrapping key published beside it. Everything else raises device.pending and waits for a human to compare a twelve-character pairing code (myPairingCode, pendingDevices, approveDevice, rejectDevice, forgetDevice, trustedDevices). Pins are local and never synced. The gate never throws and never blocks a rotation: refused devices are named in the new excluding field on a key put, which the relay validates and honours in the completeness check. The default is linking: 'observe' for one release — it reports and hands the key over anyway, because an older relay drops excluding and would then refuse the rotation as incomplete, and breaking revocation is worse than the hole it closes. status.linking and a once-per-boot linking.unenforced error make that impossible to forget. This is the ghost-device limitation SECURITY.md used to list as undefended. New on the wire: selfSig on PUT /devices/:id and on the roster, sigJwk and selfSig on keygaps, and excluding on POST /graphs/:gid/keys (echoed as excluded on the graph.rotate event). New from @argala/client: formatPairingCode, and the Pin and PendingDevice types.

  • Rollback and omission detection. The relay keeps an append-only log of every write it accepted — seq, recordId, rev, and nothing it could not already read — behind GET /graphs/:gid/log. Devices check it against what they were actually served (an omitted or rolled-back record contradicts the relay’s own account of a push it accepted) and fold it into a hash chain that depends on the log alone, so every device that read the same range computes the same head regardless of when it joined. Heads are published as ordinary signed, sealed records of reserved kind skm.checkpoint, quantised to a stride so two devices have the same point to compare, which catches a relay serving one private history per member. Alarms latch; status.graphs[g].log reports off | unverified | ok | alarm. On by default (verifyLog). This closes the limitation SECURITY.md led with. What is left, and now documented there instead: a relay that shows every member a wholly separate world is still invisible in-band, and a filtered subscription cannot verify.

  • GraphStore.getMeta / setMeta. These device-local strings are neither records nor secrets; they store verification state. They are implemented by all five stores and the worker RPC.

  • Signed records. Every device carries an ECDSA P-256 signing key alongside its wrapping key, and signs each record it writes. Receivers verify against the graph’s roster, check the claimed author against the signing device’s member, and reject anything a revoked device claims to have written after its revocation. by is no longer a claim.

  • Partial sync. Opt-in kind and shard metadata on pushed records, and filtered pulls with a cursor per subscription. Off by default: the metadata is readable by the relay forever, and that is a decision an app makes deliberately or not at all.

  • Per-device key custody. Keys are wrapped to devices, not accounts. Linking a device is a ceremony (register a public key, be granted the keyring) instead of copying a private key between machines, and one device can be revoked without touching the others.

  • Stores: OPFS-SQLite for browsers, IndexedDB as the universal fallback, native SQLite for Node (@argala/client/node), and one conformance suite that every implementation passes.

  • Relay hardening: quotas, per-member rate limits, device/member binding checks, append-only schema migrations, a pluggable BlobStore (SQLite or filesystem), and a typed event stream for metrics and audit.

  • Engine: watch() for reactive queries, flush() for “everything is really saved”, typed reads through the kind registry, retries with jittered backoff, and relay rejections surfaced rather than silently dropped.

  • Clock repair: a device whose clock is wrong adopts the relay’s, once, instead of writing records nothing will ever accept.

  • pushDebounceMs: how long a write waits for its neighbours before the queue is sent. The default remains 250 ms.

  • Offboarding a member no longer destroys their records. The roster a device verifies against was computed from CURRENT membership, so it answered a different question depending on when it was asked. The moment somebody left a group, every record they had ever written became unattributable: a device that had already synced kept them, a device that synced afterwards rejected them as unknown-device and advanced its cursor past them for good. The result was two honest devices, one honest relay, permanently different stores, no alarm anywhere, and somebody’s missing work. The relay now records who has pushed to a graph (migration 6) and serves that union as the verification roster; wrapping and rotation still see only current members, so a departed member is not handed the next key.

  • A record is verified as it was sealed, not as this build rewrites it. GraphRecordShape is a non-strict zod object, so a member a later build adds is stripped on the way in — and verification re-canonicalised the stripped record, hashing different bytes from the ones the writer signed. The day anyone added a field to a record, every record from every upgraded writer would have failed bad-signature on every device that had not upgraded: fleet-wide, one-way, and looking exactly like forgery rather than like a version skew. Nothing is loosened — the signature still covers the whole record, and only the parsed, schema-checked, stripped record is ever stored. PROTOCOL.md now states the rule normatively, because a second implementation getting this wrong is the same partition.

  • A device linked after a log prune no longer accuses a colleague. The guard that recognises an operator reclaiming disk read “cursor > 0 AND the log starts later than the cursor”, which is exactly the case a FIRST fold cannot satisfy — it has no cursor. So a device joining after a prune folded from genesis over a history beginning in the middle, held a head at every boundary that no peer could reproduce, and read the difference as a member asserting a history nobody shares: a latching alarm, naming a colleague by device and by member, raised by somebody running a DELETE. The chain is now anchored (status.graphs[g].log.from), the guard covers both cases with one comparison, and an anchored device keeps checking forward while publishing nothing and corroborating nobody. Found by an adversarial review of the code, not by a test — the suite was green because the only pruning test covered the case that already worked.

  • Pushes no longer scan the graph. Quotas were enforced with COUNT(*) and SUM(size) on every write, so a graph of 100,000 records pushed about five times slower than an empty one. Usage is counted incrementally now, and npm run argala:bench fails if a scan comes back.

  • A push larger than 1 MB stalled the queue forever. The route inherited Fastify’s default body limit, and the resulting 413 is not retryable.

  • Two devices creating or rotating a graph at once kept different keys. The relay answers the second one 200, not 409; establishing an epoch is now confirmed by reading back what landed.

  • Equal revisions diverged permanently. A tie is resolved toward the relay’s copy, which is the one everybody else has.

  • A wrap a device could not open locked it out silently. It is discarded and re-granted.

  • A failed roster fetch dropped records as if their authors were unknown.

  • An edit could be stamped below the revision it replaced, whenever the record being edited came from a device whose clock ran ahead. The relay dropped the push as a last-write-wins loser, the local store kept the edit, and the pull cursor was already past the relay’s copy — so the edit lived on one device and nowhere else, permanently, with no error anywhere. A write now reads what it replaces and clears its revision. Found by npm run argala:soak.

  • A device needed two syncs to read records written after a rotation. The retry compared a keyring to itself, because refreshing it mutates the same object.

  • A rotation took up to a minute to reach the other devices. They found out by meeting a record they could not open, or when the periodic epoch check ran — and until then kept sealing under the key the rotation was performed to take away. Push and pull now report the graph’s epoch (epoch on both answers, optional on the wire), and a device that hears a higher one fetches keys before its next write.

  • 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. The first run left four mutations alive, and each was a real gap in the suite.
  • npm run argala:soak drives hundreds of randomized convergence scenarios across rotations, deletions, revision ties, five devices and a device linked halfway through. Failing seeds are promoted into the commit suite.
  • The recovery bundle is sealed under a passphrase, and carries graph keys only — never the device keypair. Two machines signing as one device would make revocation meaningless.
  • Personal-graph record IDs are derived tags, so the relay cannot join what you pin to what exists.
  • Device public keys are set-once; rebinding a device ID is refused.

The substrate lifted out of xdash: crypto boundary, wire protocol, device engine, and the blind relay, with membership and authentication turned into seams (Directory, authenticate), without requiring the platform to own its users.