# 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.

## [Unreleased]

### Added

- **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.

### Fixed

- **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.

### Testing

- **`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.

### Security

- 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.

## [0.1.0] — extracted

The substrate lifted out of [xdash](https://github.com/rishabhshuklax/xdash-thinkingmachinelabsdev):
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.
