OverviewFilesCommitsIssuesDocsReleasesPeers

atproto-room commits / e0d1f4c

0.2.22: gate ADD_MEMBER behind an in-band room ABI
ADD_MEMBER changes what apply DOES, and apply must be deterministic across every node or the views diverge: a node predating it does not treat `__add_member__` as reserved, so it inserts the key as an ordinary entry while a current node inserts nothing — one log, two views. Old nodes cannot be retrofitted, so the gate is on the AUTHOR. Each writer stamps `__abi__/<writer key>` = { v }. Deliberately an ORDINARY entry: old nodes store it byte-identically and stay convergent, and simply never write one of their own — which is the signal. addMember enumerates active writers and refuses if any lacks the stamp, naming it, so the check can be answered rather than bypassed. Fails CLOSED: a missing stamp is exactly what a pre-ABI node looks like. `force` exists for an operator who has verified the room out of band and is never the default. Same shape as keet's ABI interrupt and pgv3's in-band schema guard. ⛔ NOT announced at boot. The first version stamped during _open and immediately reproduced what this file warns about everywhere else — write nothing until replication converges: [hypercore] conflict detected in b632843d… (writable=false,quorum=1) Uncaught HypercoreError: SESSION_CLOSED … at ApplyState._boot The stamp is only needed when another writer is present to read it, so it is announced on connection and on admission instead — both real events, no timer. The gate test is single-node by design: one writer proves both directions (unstamped refuses and names the writer, stamped proceeds, force overrides) without going near the two-writer admission race that made the first draft flaky. Suite 62/62, 233/233. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
e0d1f4c5432bb8acdef3a014e3a61e8a21a02ffd
by plankatron <fleeky@gmail.com> · 2026-08-10T21:20:00.000Z

Changed files (3) +105 -2

lib/state-base.js +65 -1
……@@ -74,6 +74,18 @@ const REMOVE_DEVICE = '__remove_device__'
7474// A BANNED identity is NOT re-admitted by this op — REMOVED_SET stays authoritative, or a ban would be
7575// reversible by any member simply re-adding the target. Un-banning is deliberately a separate act.
7676const ADD_MEMBER = '__add_member__'
77// Room ABI. Bumped when an op changes what APPLY does, because apply must be deterministic across
78// every node or their views diverge. ADD_MEMBER is exactly such a change: a node that predates it does
79// not treat `__add_member__` as reserved, so it INSERTS it as an ordinary entry while a current node
80// inserts nothing — same log, two different views. Keet gates this class of change with an in-band ABI
81// stamp and refuses to proceed under an unsupported one (skills/keet-abi-upgrades; pear-git-v3 does the
82// same for its org schema). We cannot retrofit old nodes, so the gate is on the AUTHOR: refuse to emit
83// an op no current writer can apply identically.
84const ROOM_ABI = 1
85// Per-writer capability stamp: `__abi__/<writer key hex>` -> { v }. An ORDINARY entry, deliberately —
86// old nodes store it byte-identically and stay convergent; they simply never write one of their own,
87// which is precisely the signal we need.
88const ABI_PREFIX = '__abi__/'
7789// Durable set of banned device identities (hex → { ts }). The admission guard checks it so a
7890// removed identity cannot (re)admit even with a valid attestation.
7991const REMOVED_SET = '__removed__'
……@@ -298,6 +310,11 @@ export class StateBaseV3 {
298310 // Awaited so the roster is in the view before ready() returns, hence before any consumer can wire up
299311 // connections. A no-op when nothing was retained, when not writable, or when already projected.
300312 await this._ensureMembersProjected().catch(() => {})
313 // NO ABI announce at boot. Writing during _open is the fork hazard this file warns about throughout
314 // (the resync/joiner rule is to write NOTHING until replication converges) — and it reproduced here
315 // immediately: `[hypercore] conflict detected ... (writable=false,quorum=1)` followed by
316 // SESSION_CLOSED inside ApplyState._boot. The stamp is only ever needed when another writer is
317 // present to read it, so it is announced on connection and on admission instead.
301318 // One-time in-place re-adopt migration for the namespace rename: if this is the writable creator
302319 // and the (renamed) base is empty but a legacy-namespace base is present in the same store, copy
303320 // its durable entries across. Best-effort + awaited so the room is seeded before consumers read.
……@@ -485,6 +502,7 @@ export class StateBaseV3 {
485502 // creator and haven't projected the roster yet, (re)project now so its admission verifies —
486503 // instead of waiting on the slow promote-timer tick. Self-terminating + idempotent.
487504 this._ensureMembersProjected().catch(() => {})
505 this._announceAbi().catch(() => {}) // a peer arrived: stamp our ABI if we have not yet
488506 })
489507 }
490508 this.swarm.join(this.base.discoveryKey)
……@@ -655,6 +673,7 @@ export class StateBaseV3 {
655673 }
656674 await this._updateBounded()
657675 }
676 if (this.base.writable) this._announceAbi().catch(() => {}) // just admitted: we can stamp now
658677 return { writable: this.base.writable, indexer: this._isLocalIndexer() }
659678 }
660679
……@@ -892,8 +911,18 @@ export class StateBaseV3 {
892911 // it; apply authorizes by THIS caller's role. That is the whole point: membership becomes a fact of
893912 // the ROOM, replicated to every device, instead of a roster in one machine's config file that only
894913 // the creator could project (hyper-social #40 §6).
895 async addMember (identityHex, role = 1) {
914 async addMember (identityHex, role = 1, { force = false } = {}) {
896915 if (!this._ready) throw new Error('StateBaseV3 not ready')
916 // ABI GATE. Emitting this op into a room containing a writer that predates it makes the two apply
917 // it differently — the old node inserts `__add_member__` as an entry, we insert nothing — and a
918 // divergent apply is a forked view, which is far worse than the problem this op solves. Refuse and
919 // name the laggards. `force` exists for an operator who has verified the room out of band; it is
920 // never the default, and the message says who is missing so the check can be answered rather than
921 // bypassed blindly.
922 if (!force) {
923 const below = await this._writersBelowAbi(ROOM_ABI)
924 if (below.length) throw new Error('addMember: room ABI ' + ROOM_ABI + ' not supported by writer(s) ' + below.join(', ') + ' — upgrade them first (or pass force after verifying)')
925 }
897926 const admit = await this._buildAdmissionProof()
898927 if (!admit) throw new Error('addMember: no identity to authorize the admission')
899928 await this.base.append(encode('@state/set-entry', { key: ADD_MEMBER, value: this._enc({ id: String(identityHex), role: Number(role) || 1 }), admit, ts: Date.now() }), { optimistic: true })
……@@ -975,6 +1004,41 @@ export class StateBaseV3 {
9751004 } catch { return null }
9761005 }
9771006
1007 // Announce THIS writer's room ABI. Written once per writer key, on the events that can make it true
1008 // (open-while-writable, and admission succeeding) — never on a timer. Idempotent: a re-announce of an
1009 // unchanged value is skipped, so this cannot become an append loop of its own.
1010 async _announceAbi () {
1011 try {
1012 if (!this._ready || !this.base || !this.base.writable) return false
1013 const localHex = this.base.local && this.base.local.key ? this.base.local.key.toString('hex') : null
1014 if (!localHex) return false
1015 const key = ABI_PREFIX + localHex
1016 const cur = await this.getNow(key).catch(() => null)
1017 if (cur && Number(cur.v) === ROOM_ABI) return false
1018 await this.set(key, { v: ROOM_ABI, ts: Date.now() })
1019 return true
1020 } catch { return false }
1021 }
1022
1023 // Which active writers have NOT announced support for `abi`? Returns an array of short keys; empty
1024 // means every writer in the room can apply the op identically. Fails CLOSED: a writer we cannot read
1025 // a stamp for counts as unsupported, because "no stamp" is exactly what a pre-ABI node looks like.
1026 async _writersBelowAbi (abi = ROOM_ABI) {
1027 const below = []
1028 try {
1029 await this._updateBounded()
1030 const writers = (this.base && this.base.activeWriters) ? [...this.base.activeWriters] : []
1031 for (const w of writers) {
1032 if (!w || w.isRemoved) continue // a removed writer applies nothing that matters
1033 const key = w.core && w.core.key ? w.core.key.toString('hex') : null
1034 if (!key) continue
1035 const stamp = await this.getNow(ABI_PREFIX + key).catch(() => null)
1036 if (!stamp || Number(stamp.v) < abi) below.push(key.slice(0, 12))
1037 }
1038 } catch { /* fail closed via whatever was collected */ }
1039 return below
1040 }
1041
9781042 // ── One-time in-place re-adopt migration (namespace rename) ─────────────────
9791043 // The 'hyper-tps/state/v3' -> 'atproto-room/state/v3' rename changes the derived base key, so a
9801044 // room created under the old namespace opens here as a FRESH, empty base that shares the same
package.json +1 -1
……@@ -1,6 +1,6 @@
11{
22 "name": "atproto-room",
3 "version": "0.2.21",
3 "version": "0.2.22",
44 "description": "Attestation-gated, encrypted multi-writer room engine — StateBase (keet-model admission) + Lease + signer + RepoBase + LabelBase/LabelRoom + serve-membrane. Extracted from hyper-proto-peer; consumed by the atproto peer (repo devices) and the co-authored labeler.",
55 "type": "module",
66 "main": "index.js",
test/doorbell-bounded.test.js +39 -0
……@@ -36,6 +36,7 @@ const DEVB = 'zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo wrong'
3636const tmp = () => { const d = '/tmp/hpp-doorbell-' + Math.floor(Math.random() * 1e9); fs.mkdirSync(d, { recursive: true }); return d }
3737const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
3838const hex = (k) => b4a.toString(k, 'hex')
39const ABI_KEY = (writerHex) => '__abi__/' + writerHex
3940const localLen = (sb) => (sb.base && sb.base.local ? sb.base.local.length : 0)
4041// projectMembers is event-driven + self-terminating: it lands on a connection or a promote tick, not
4142// on the await. A member cannot author anything before the room HAS a roster, so wait for the fact.
……@@ -162,3 +163,41 @@ test('an admitted device still rings — the Phase-2 reboot case is preserved',
162163 // an optimistic block is not there until it is acked.)
163164 t.ok(b.base.writable, 'a LISTED device still rings and is admitted — the Phase-2 reboot case survives')
164165})
166
167test('addMember is REFUSED while any writer predates the op — the ABI gate', { timeout: 300000 }, async (t) => {
168 // ADD_MEMBER changes what apply DOES, and apply must be deterministic across the room: a node that
169 // predates it inserts `__add_member__` as an ordinary entry while a current node inserts nothing —
170 // one log, two views. Old nodes cannot be retrofitted, so the AUTHOR refuses to emit it.
171 //
172 // The evidence is a per-writer stamp, and its ABSENCE is exactly what a pre-ABI node looks like — so
173 // the gate must fail CLOSED on a missing stamp. One node is enough to prove both directions, and it
174 // keeps the assertion away from the two-writer admission race.
175 const testnet = await createTestnet(3)
176 t.teardown(() => testnet.destroy())
177 const { da, pa, mk, members } = await room(testnet)
178
179 const a = mk(da, pa, { forceCreator: true })
180 await a.ready(); t.teardown(async () => { try { await a.close() } catch {} })
181 await a.projectMembers(members).catch(() => {})
182 t.ok(a.base.writable, 'the creator is a writer')
183
184 // No stamp yet — fail closed, and say which writer is missing.
185 const localHex = a.base.local.key.toString('hex')
186 t.ok((await a._writersBelowAbi()).includes(localHex.slice(0, 12)), 'an unstamped writer counts as below the ABI')
187 let threw = null
188 try { await a.addMember(hex(crypto.randomBytes(32)), 1) } catch (e) { threw = e }
189 t.ok(threw && /room ABI/.test(threw.message), 'addMember REFUSED: ' + (threw && threw.message))
190 t.ok(threw && threw.message.includes(localHex.slice(0, 12)), 'and it names WHICH writer, so the check can be answered rather than bypassed')
191
192 // Stamped — the same call now proceeds.
193 await a._announceAbi()
194 t.is((await a._writersBelowAbi()).length, 0, 'every writer now announces the ABI')
195 await a.addMember(hex(crypto.randomBytes(32)), 1)
196 t.pass('addMember proceeds once the room supports it')
197
198 // force is an explicit operator override, never the default.
199 await a.del(ABI_KEY(localHex))
200 t.ok((await a._writersBelowAbi()).length > 0, 'stamp removed again')
201 await a.addMember(hex(crypto.randomBytes(32)), 1, { force: true })
202 t.pass('force overrides the gate for an operator who has verified the room out of band')
203})