OverviewFilesCommitsIssuesDocsReleasesPeers

hypercore-kb commits / 0302fff

skills: autobase setLocal writer rotation — the two blockers and the one real use
A keet dev pointed at autobase's undocumented setLocal(key,{keyPair}) as a way to give each peer its own writer keypair. Characterized on 7.28.1; recording what it actually does before anyone reaches for it. autobase: two new sections. Never rotate an INDEXER — it throws a bare empty-message AssertionError today (_clearLocalIndexer asserts on the localWriter that _rotateLocalWriter just nulled), and even fixed it would drop the sole indexer to a plain writer and freeze indexedLength with no way back. For a PLAIN writer it works, but setLocal never passes the keyPair to store.get, so the rotated core is silently read-only until you pre-create it with that keypair; and LocalState.moveTo carries the boot record over, so rotation is not a clean slate. atproto-room: the corollary for roles — "creator authors genesis then rotates off gsKeyPair" does not work, so the forceCreator/joiner pins remain the only thing keeping exactly one holder. Convention, not cryptography. hyper-proto-peer: mark the resync side-Corestore load-bearing so it does not get "simplified" into a rotation later. pear-git#35 has the probes; #36 tracks the gsKeyPair hazard, #37 the missing rotation. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
0302fff895ae93133768f690815c4cfd946152a7
by plankatron <fleeky@gmail.com> · 2026-08-13T03:24:58.000Z

Changed files (3) +221 -1

.claude/skills/atproto-room/SKILL.md +167 -0
……@@ -0,0 +1,167 @@
1---
2name: atproto-room
3description: The room engine under hyper-proto-peer and favela — StateBaseV3 (attestation-gated encrypted multi-writer autobase whose view IS the DID's repo), the roomKey/roomSeed/encryptionKey derivation that makes a DID a room, proof-gated in-apply admission, the presence-based single-signer election that replaced the lease, and the four sign() gates that exist because each one was an outage. Read this BEFORE reasoning about pairing, joining, admission, who signs, or why a chain froze.
4category: project
5priority: high
6location: hyper-fleeky/atproto-room (consumed by hyper-proto-peer, favela-web, favela-android)
7updated: 2026-08-09
8---
9
10# atproto-room
11
12The shared room engine. `hyper-proto-peer` composes it into a daemon; `favela-android` bundles that
13same daemon in-process. Everything below is grounded in source read on 2026-08-09 — line refs are exact.
14
15## ⛔ THE ONE THING TO GET RIGHT: a DID *is* a room, by derivation
16
17`hyper-proto-peer/lib/device-node.js:125-127`:
18
19```js
20const roomKey = crypto.hash([NS('room-key'), b4a.from(did)]) // PUBLIC — anyone with the DID
21const roomSeed = crypto.hash([NS('room-seed'), b4a.from(sharedKey)]) // SECRET — signing-key holders
22const encryptionKey = crypto.hash([NS('enc'), b4a.from(sharedKey)]) // SECRET — signing-key holders
23```
24
25`roomSeed` → `deriveGsKeyPair` → `deriveBaseKey` (`state-base.js:95-96, 190-191`), and the base is opened
26**encrypted** with `encryptionKey` (`:432`). A non-holder can neither derive the base key nor read a byte.
27**Membership secrecy is encryption, not an ACL.**
28
29So **joining is a derivation, not a handshake**. `bootSwarmDevice` requires exactly four inputs
30(`device-node.js:116`) — `did`, `signingKey`, `deviceProof`, `rootIdentityKey` — plus a local
31`deviceMnemonic` the device generates and never transmits. Hold those, derive the room, open it.
32
33⛔ **Common wrong turns** (all made, all cost time):
34- "The invite/bundle must carry the DID." The DID is an *input to a derivation*, not payload. What gates
35 entry is the **signing key**.
36- "Some node must mint/authorize the join." No. See admission below — it is proof-gated **in apply**,
37 not owner-mediated (`state-base.js:5-8`).
38- "The phone should get a delegation instead." Delegation is for something holding **no** key (a browser).
39 A device holding the signing key derives the room and joins directly. Different products.
40
41## Admission — cryptographic, in-apply, not owner-mediated
42
43Writing needs admission; admission needs no owner:
44
451. `requestWriter()` (`:593-630`) appends a **content-free** optimistic doorbell carrying an admission
46 proof, then re-rings every 250ms until `writable` (a single optimistic block can be rolled back
47 before an intermittent indexer acks it).
482. `_buildAdmissionProof()` (`:1067`) signs `localKey ‖ base.key` — either keet-writer-control
49 (`{manifest, sig}`) or a keet attestation (`{identity, proof}`).
503. `_verifyAdmissionInApply()` (`:1094`) re-derives the device key, verifies against the DID's **root
51 identity** (`membership.js:39-50` → `IdentityKey.verify` with `expectedIdentity`/`expectedDevice`),
52 resolves device→member→role via `__devices__`/`__members__`, rejects anything in `__removed__`,
53 enforces the minimum-epoch `__receipt__`. Verified → `addWriter`.
54
55The only online requirement is **an indexer** to ack. `_ensureWritable` **throws** rather than losing a
56write silently.
57
58⚠ `__members__` must be projected before a doorbell arrives or the admit verifies with `role=null` and the
59member never re-rings. `ready()` awaits `_ensureMembersProjected()` for exactly this (`:269-286`) — the
60wj62 fork root.
61
62## Roles: creator ≠ indexer ≠ signer
63
64- **Creator** = lowest-keyed eligible member (`_amCreator:1190-1202`). Emergent from a *mutable* roster,
65 so adding a device can silently flip it — the loser-club fork. Hence the explicit pins: `joiner: true`
66 (⇒ `forceNonCreator` + converge-only boot) and `forceCreator` on the incumbent
67 (`device-node.js:97-112, 178`). Exactly one node holds `gsKeyPair` at a time.
68- **Indexer** = autobase checkpointing only. A joiner is admitted a **plain writer**; two indexers is the
69 N=2 quorum trap. Only a designated anchor grows the set, and autobase only promotes a **fresh
70 (length-0)** writer (`:352-374`).
71- **Signer** needs the `#atproto` key, **not** indexer status.
72
73⛔ **An indexer's writer key can never be ROTATED** — so "creator authors genesis, then rotates off
74`gsKeyPair` so nobody can collide with a live writer" does NOT work. autobase's `setLocal` asserts on an
75indexer, and even fixed it would drop the sole indexer to a plain writer and freeze `indexedLength` with
76no way back. The `forceCreator`/`joiner` pins above remain the ONLY thing keeping exactly one holder of
77`gsKeyPair` — convention, not cryptography. See [[autobase]] and
78`memory/reference_autobase-setlocal-writer-rotation`.
79
80## Who signs: presence, not the lease
81
82`signer-presence.js` **replaced** `lease.js`. The lease rewrote a wall-clock `ts` into an append-only log
83every ~8s — ~20MB/day, unreclaimable. The election is now the **lowest-keyed connected co-owner**,
84computed in RAM, appending nothing, with a 15s `graceMs` blip tolerance.
85
86**Currency-aware** (`currentSigner:90`): a co-owner whose announced head is behind the network head is
87excluded — otherwise it wins the role while the forward-only gate blocks it and the chain **freezes**.
88
89⚠ **Diagnostic trap:** `SwarmDevice.state()` still returns `holdsLease`/`leaseHolder`/`leaseEpoch`, and
90`swarm-device.js:739-741` says they are legacy and **`holdsLease` is false on healthy signing nodes**.
91Read `holdsSigner`. Likewise use `sb._isCreator`, never `sb._amCreator` (a method — `!!` is always true).
92
93## `sign()` — four gates, each one an incident (`swarm-device.js:343`)
94
951. **Lazy `restore()` first**, so the currency-aware election knows if *we* are caught up.
962. **Election gate** — `holdsSigner(undefined, net.rev)`; behind ⇒ return, let the caught-up peer sign.
973. **Anti-wipe, two arms** (`:371-404`). A repo with a head but an empty record set is never "everything
98 was deleted": arm 1 catches record *files* present with pointers not yet backfilled (the wj62 wipe);
99 arm 2 catches *inline* records in a legacy repo flipped onto the pointer substrate (e2jev), where both
100 the pointer set and the file set read empty. Either would emit a delete-every-record commit.
1014. **Forward-only gate** (`:405-470`). Adopt the highest-rev `__commit__` frame as head (kills rev
102 regression), replay every op up to it, refuse to sign if our view is missing any key (kills content
103 regression). Three states, only the first may be signed forward:
104 - explicit **tombstone** → genuinely deleted → sign the delete forward
105 - live pointer, bytes unresolved → **behind** → skip
106 - nothing at all → **behind** → skip (misreading this as a delete is how a lagging co-owner published
107 deletes for live records)
108 `__tombstone_epoch__` carves out legacy hard-deletes so they don't wedge the chain (delete-tip wedge).
109
110`_nextRev()` is `nextRev(head.rev)` — deterministic prev+1µs, not wall-clock — so two co-owners continuing
111the same chain emit **byte-identical** commits (materialize is RFC6979-deterministic, `signer.js:1-5`).
112Because revs stopped being wall-clock, `serializeEntry` records a **real** `time` once at the signer;
113deriving it from the rev would freeze near the first commit and relays read `time` as liveness.
114
115## Records + firehose layout
116
117`repo-base.js`: key = `collection/rkey`. Reserved = single-segment keys and any collection starting `__`,
118which is what keeps `__commit__/<rev>`, `__members__`, `__lease__`, `__prefs__`, `__device__/*` out of the
119signed repo. Deletes write an explicit **tombstone** — absence is ambiguous in a replicated log, and
120absence-as-deleted makes a lagging device publish deletes for live records.
121
122With an `OverlayRepoStore`, `PointerSubstrate` puts a `{$oid,size}` **pointer in the autobase** (so the
123record *set* converges) and the **bytes in deduped overlay files**. The firehose lives in the **same**
124StateBase under `__commit__/<rev>` — no second Hypercore, continuous across signer hand-offs.
125
126## Non-blocking discipline (this is load-bearing, not style)
127
128`applyWrite` and `state()` count **raw pointer keys** and never call `repo.listRecords()` — under
129`PointerSubstrate` that resolves bytes via `ensureLocal`, an overlay fetch-by-oid that blocks when the
130node lacks the bytes. That was a 20s `apply-write` timeout and an 8s+ hang on the liveness probe
131(`swarm-device.js:662-671, 717-734`). Snapshot reads (`listNow`/`getNow`) everywhere hot;
132`_updateBounded` (`state-base.js:646`) bounds every `base.update()` await.
133
134## Serving (`serve-membrane.js`)
135
136Each device projects the shared `__commit__` log into its own FirehoseSequencer + repo core and
137**push-announces** `atproto/did-announce/v1` on connect with `{did, handle, repoKey, firehoseKey,
138lengths}` — no round trip, no directionality. `__commit__` is rev-ordered and deterministic, so every
139device projects the **same** firehose: the keyless bridge can front any device and reconnect to another.
140The repo core advances first and the firehose only if that succeeded (`:256-274`) — they are read as a
141pair, so on failure neither advances and the previously-served head stays consistent instead of 500ing.
142`swarm.join(discoveryTopic(did))` — the DID is itself a swarm topic.
143
144⚠ **Two RPC surfaces exist** — `serve-membrane.js` (what the bridge talks to) and hpp's
145`serve-device.js`. `:325-328` records the symptom of patching only one: `getBlob` worked while
146`listBlobs` answered UNKNOWN_METHOD with the handler demonstrably deployed. Keep them in step.
147
148## Reading map
149
150- `state-base.js` — `ready`/`_open`/`_openBase`, `requestWriter`, `_buildAdmissionProof`,
151 `_verifyAdmissionInApply`, `_amCreator`, `_maybePromote`, `_updateBounded`
152- `membership.js` — `makeAttest`/`makeVerifyAttest`, epoch receipts
153- `signer.js` — `materialize`, `buildCommitWrite` (+ the covering proof that asserts invertibility)
154- `signer-presence.js` — the election; `lease.js` is superseded, kept for continuity
155- `repo-base.js` — key convention, reserved namespace, tombstones
156- `serve-membrane.js` — projection pair, announce-push, the bridge-facing RPC set
157- `hyper-proto-peer/lib/swarm-device.js` — the device object: `sign`, `applyWrite`, `delegate`, `state`
158- `hyper-proto-peer/lib/device-node.js:96-215` — derivation + boot posture flags
159
160## Related
161
162- [[hyper-proto-peer]] — the daemon that composes this
163- [[autobase]] — admission doorbell, N=2 quorum trap, fresh-writer promotion
164- [[atproto-store]] — `PointerSubstrate`, `OverlayRepoStore`, `FirehoseSequencer`
165- [[atproto-did-peer]] / [[atproto-bridge]] — the keyless consumers
166- memory: [[reference_statebase-indexing-vs-did-signing]], [[project_wj62-write-path-saga-2026-07-24]],
167 [[reference_autobase-quorum-2f1]], [[project_retention-clobber-wj62-2026-07-31]]
.claude/skills/autobase/SKILL.md +41 -0
……@@ -208,6 +208,47 @@ migration so `signedLength` freezes on ALL peers. Keet's indexer ops never call
208208it. Our fix removed it and quorum reformed (proven: probe-indexer-quorum 8/8).
209209Grow the set with a plain `addWriter(key,{indexer:true})` + `ackWriter`, nothing else.
210210
211### ⛔ NEVER rotate an INDEXER's writer key (and `setLocal` can't anyway)
212
213`setLocal(key, { keyPair })` (`index.js:489`, undocumented) rotates the local writer onto a
214new keypair. **Do not point it at an indexer.** Two independent reasons, and the second
215survives any upstream fix:
216
2171. **It throws today.** `_rotateLocalWriter` nulls `localWriter` (`:440`) *before*
218 `_makeLinearizerFromViewState()` → `_updateLinearizer` → `_clearLocalIndexer()`, which
219 asserts `localWriter !== null` (`:1716`). You get a bare `AssertionError` with an EMPTY
220 message and a half-rotated base (new local key, `writable=false`).
2212. **Even fixed, it strands the room.** The rotated key is a *plain writer*, so the indexer
222 set shrinks by one. At the default N=1 posture (creator = sole indexer) that leaves
223 **zero** indexers: `indexedLength` freezes, and minting a replacement needs an online
224 indexer to sign the `addWriter` — the need-an-indexer-to-make-an-indexer deadlock.
225
226(Reason 2 is derived from the verified quorum + promotion rules above, not probed — the
227assert blocks the experiment on 7.28.1.)
228
229Compromise recovery for an indexer is therefore **remove and replace with a fresh writer**,
230never rotation.
231
232### Rotating a PLAIN writer: pre-create the core or it is silently read-only
233
234For a plain writer, rotation works — with a catch. `setLocal` uses the keypair only to
235*derive* key + manifest, then opens `store.get({ key, manifest, … })` and **never passes the
236keyPair to the store**. No signer resolves ⇒ `base.local.writable=false` ⇒ `localWriter=null`
237⇒ `Not writable` forever, *even after an indexer re-admits the new key*. Pre-create the core
238in the SAME corestore first:
239
240```js
241const fresh = crypto.keyPair()
242const pre = store.get({ keyPair: fresh }); await pre.ready() // store now holds the signer
243await base.setLocal(null, { keyPair: fresh }) // now resolves writable
244// STILL de-admitted — an indexer must addWriter(base.local.key) before appends land
245```
246
247`base.key` and the view survive; the old writer stays in the writer set and its content stays
248in the view. But `LocalState.moveTo` copies the linearizer updates + `autobase/boot` record
249onto the new writer, so **rotation is NOT a clean slate** — it does not substitute for the
250fresh-CORESTORE recovery below. Verified: `memory/reference_autobase-setlocal-writer-rotation`.
251
211252### Recovering a POISONED local writer needs a fresh CORESTORE, not a fresh namespace
212253
213254If a peer's local writer gets into a state it can't reconcile (e.g. its authored
.claude/skills/hyper-proto-peer/SKILL.md +13 -1
……@@ -1,6 +1,6 @@
11---
22name: hyper-proto-peer
3description: Self-custody atproto peer daemon — holds the DID signing key, authors + signs its own repo/firehose over Hyperswarm, fronted keylessly by atproto-bridge. Deployed path is the MULTI-DID atproto-base daemon (StateBaseV3 + lease + shared signing key), not the legacy single-tenant Peer.
3description: Self-custody atproto peer daemon — holds the DID signing key, authors + signs its own repo/firehose over Hyperswarm, fronted keylessly by atproto-bridge. Deployed path is the MULTI-DID atproto-base daemon (StateBaseV3 + presence-elected signer + shared signing key), not the legacy single-tenant Peer.
44category: project
55priority: high
66location: hyper-fleeky/hyper-proto-peer
……@@ -129,6 +129,16 @@ joiner$ hyper-proto-peer coowner-join <did> --bundle <file> → writes its di
129129is primaryKey-derived, so `#r<gen>` re-derives the identical key. Only a fresh
130130**CORESTORE** sheds it.
131131
132⛔ **The resync side-Corestore is LOAD-BEARING — do not "simplify" it into autobase's
133`setLocal(key,{keyPair})` writer rotation.** It looks like the obvious replacement (rotate in
134place, drop the side store and its `_sideStore.replicate` ordering). Characterized on 7.28.1
135and it fails three ways: `LocalState.moveTo` copies the linearizer updates + boot record onto
136the new writer (not a clean slate, stale view cores stay); it throws a bare empty-message
137`AssertionError` when the writer is an **indexer**, which a poisoned creator always is; and it
138de-admits anyway, so an indexer still has to `addWriter` the new key. Negative result recorded
139so it does not get re-attempted — `memory/reference_autobase-setlocal-writer-rotation`,
140pear-git#35.
141
132142⚠ `index.js` dispatches on `sub === '<name>'` and **falls through to the daemon** when
133143nothing matches — so running a subcommand that is not in the *installed* tree tries to
134144open the corestore the daemon holds: `File descriptor could not be locked`. That is the
……@@ -231,6 +241,8 @@ superseded by `index.js` for production.
231241
232242## Related
233243
244- [[atproto-room]] — ⭐ the room engine this composes: DID→room derivation, proof-gated admission,
245 the presence election, and the four `sign()` gates. Read it before reasoning about pairing/joining.
234246- [[atproto-did-peer]] — the consumer side; must speak `hyper-proto-peer/rpc/v1`
235247- [[atproto-bridge]] — the keyless membrane that fronts this
236248- [[favela-web]] — shares this daemon's `atproto-repo/` overlay on the same box