OverviewFilesCommitsIssuesDocsReleasesPeers

atproto-room commits / e9d9b1b

StateBaseV3: event-driven, self-terminating __members__ re-projection (0.2.8)
Fixes the wj62 fork ROOT (hyper-social #9, pear-git #4 lineage). Admission is gated by the CREATOR's in-apply verify against __members__ in its view; __members__ is written only by projectMembers(), which no-ops unless writable. The app drove it with a bounded 12-try (~13s) give-up boot loop — so a creator that became writable AFTER that window (re-adopt + swarm flap) never landed __members__, every member admit verified verifiedRole=null ("no __members__ in view"), and members held their own divergent lines. Fix (mirrors the proven _maybePromote pattern — retry-until-success, NOT give-up): - projectMembers() retains the roster (this._roster) and marks this._membersProjected once __members__ is appended + drained into the view. - _ensureMembersProjected(): if roster retained, not yet projected, and writable → project; early-returns forever once done (cheap when idle). - Driven EVENT-DRIVEN: every new swarm connection (a member arriving) + the promote-timer tick. So a late-writable creator still lands __members__ and admits members. Proof: test/admission-members-projection.test.mjs (3 tests, 13 asserts) — A: no __members__ ⇒ admission REJECTED (reproduces the exact wj62 log signatures); B: late projection + member re-ring ⇒ admitted + converge (+ surfaces the optimistic- writable-vs-finalized gap, pear-git #4 §3); FIX: a single projectMembers call that NO-OPS at first (creator not writable) self- recovers via the connection-hook ⇒ member admitted with NO manual re-projection. No regressions: doorbell/multi-did/non-indexer/migration-readopt/phase2-nonblocking green. Tier-1 mechanism proof; nothing deployed. Next: tier-2 replay on a read-only wj62 corestore. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
e9d9b1b730eca2b5b63b43f8217cdc5c1dc02547
by plankatron <fleeky@gmail.com> · 2026-07-20T21:55:28.000Z

Changed files (3) +55 -4

lib/state-base.js +29 -2
……@@ -202,6 +202,15 @@ export class StateBaseV3 {
202202 // hyper-fleeky-dev #99: a local identity in the roster but absent from the
203203 // indexer's membership read writable=true while its writes never finalized).
204204 this._selfIsAdmittedWriter = false
205 // __members__ projection is EVENT-DRIVEN + self-terminating (NOT a bounded give-up loop). The
206 // creator retains the roster (set by projectMembers) and re-projects on the promote-timer tick +
207 // every new swarm connection until __members__ is confirmed in its view. A creator that becomes
208 // writable AFTER boot (re-adopt / swarm flap) therefore still lands __members__ and can admit
209 // members — the wj62 fork root was the app's 12-try boot loop giving up before writability settled,
210 // so __members__ never landed → every admit verified verifiedRole=null → members held their own
211 // lines. See test/admission-members-projection.test.mjs.
212 this._roster = null
213 this._membersProjected = false
205214 }
206215
207216 // ── Lifecycle ──────────────────────────────────────────────
……@@ -253,7 +262,7 @@ export class StateBaseV3 {
253262 // Cheap when idle: _maybePromote early-returns once we're a durable indexer or
254263 // while we're not (yet) an admin. unref so it never keeps the process alive.
255264 if (!this._promoteTimer) {
256 this._promoteTimer = setInterval(() => { this._maybePromote().catch(() => {}) }, this._promoteRetryMs)
265 this._promoteTimer = setInterval(() => { this._maybePromote().catch(() => {}); this._ensureMembersProjected().catch(() => {}) }, this._promoteRetryMs)
257266 if (this._promoteTimer && typeof this._promoteTimer.unref === 'function') this._promoteTimer.unref()
258267 }
259268 }
……@@ -381,6 +390,10 @@ export class StateBaseV3 {
381390 // pear-git.js:447 ("corestore.replicate unreliable for writer cores").
382391 try { this.base.replicate(socket) } catch (err) { console.error('[state-base-v3] replicate threw:', err.message) }
383392 try { this.attachSignerPresence(socket) } catch {} // ephemeral signer-presence handshake — feeds the election, appends nothing
393 // EVENT-DRIVEN __members__ projection: a peer (a member) just connected. If we're the writable
394 // creator and haven't projected the roster yet, (re)project now so its admission verifies —
395 // instead of waiting on the slow promote-timer tick. Self-terminating + idempotent.
396 this._ensureMembersProjected().catch(() => {})
384397 })
385398 }
386399 this.swarm.join(this.base.discoveryKey)
……@@ -871,11 +884,24 @@ export class StateBaseV3 {
871884
872885 // ── Membership projection (cross-base sync) ────────────────
873886 // Daemon's out-of-apply watcher copies keet-room membership in so apply can
887 // Event-driven, self-terminating __members__ re-projection (mirrors _maybePromote). Fires on the
888 // promote-timer tick + every new swarm connection. If we retain a roster (set by projectMembers),
889 // haven't confirmed __members__ yet, and are writable, project it now; once landed it early-returns
890 // forever (cheap when done). This is the fix for the wj62 fork: it replaces the app's bounded 12-try
891 // boot loop, so a creator that becomes writable AFTER boot still lands __members__ and admits members.
892 // No hang: projectMembers's base.update() drains OUR OWN writable base (clean); a non-writable device
893 // no-ops here and retries on the next event.
894 async _ensureMembersProjected () {
895 if (this._membersProjected || !this._roster || !this._ready || !this.base || !this.base.writable) return
896 try { await this.projectMembers(this._roster) } catch {}
897 }
898
874899 // gate admission deterministically. Stored under the reserved key __members__
875900 // (a memberId-hex → role map). No-op until we're a writer (another writer
876901 // projects; peers replicate it).
877902 async projectMembers (members) {
878 if (!this._ready || !this.base || !this.base.writable) return false
903 if (members) this._roster = members // retain the roster for event-driven, self-terminating re-projection
904 if (!this._ready || !this.base || !this.base.writable) return false // not writable yet → a later event retries
879905 const map = {} // memberKey(hex) -> role
880906 const devmap = {} // keet writer/device key(hex) -> memberKey(hex) (for admission model A)
881907 if (Array.isArray(members)) {
……@@ -892,6 +918,7 @@ export class StateBaseV3 {
892918 await this.base.append(encode('@state/set-entry', { key: '__members__', value: JSON.stringify(map), ts: Date.now() }))
893919 await this.base.append(encode('@state/set-entry', { key: '__devices__', value: JSON.stringify(devmap), ts: Date.now() }))
894920 await this.base.update()
921 this._membersProjected = true // __members__ appended + drained into our view → stop re-projecting (early-returns cheaply)
895922 // P3 (#103): a re-projection is the moment a role change becomes visible. If THIS
896923 // node was just granted admin, re-check promotion immediately rather than waiting
897924 // for the next timer tick. Background + best-effort; the timer is the general
package.json +1 -1
……@@ -1,6 +1,6 @@
11{
22 "name": "atproto-room",
3 "version": "0.2.7",
3 "version": "0.2.8",
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/admission-members-projection.test.mjs +25 -1
……@@ -42,7 +42,8 @@ async function setup (testnet) {
4242 const mk = (dev, proof) => new StateBaseV3(tmp(), roomKey, {
4343 roomSeed, encryptionKey, swarmOpts: { bootstrap: testnet.bootstrap },
4444 selfKey: dev.publicKey, attest: makeAttest(dev, proof), verifyAttest: makeVerifyAttest(rootPub),
45 getMembers: async () => members
45 getMembers: async () => members,
46 promoteRetryMs: 2000 // fast self-terminating fallback tick for the fix test (default 30s)
4647 })
4748 return { A, B, members, mk }
4849}
……@@ -102,3 +103,26 @@ test('B: late __members__ projection + member re-ring ⇒ ADMITTED and converges
102103 for (let i = 0; i < 20 && !(await sbA.get('post/1')); i++) { await sbA.base.update().catch(() => {}); await sbB.base.update().catch(() => {}); await sleep(300) }
103104 t.alike(await sbA.get('post/1'), { text: 'admitted-after-late-projection' }, 'creator converged with the admitted member — ONE line, no fork')
104105})
106
107// FIX: the app calls projectMembers ONCE and it NO-OPS at first (creator not yet writable). The old
108// bounded boot loop would drop __members__ forever here → fork. The fix retains the roster and
109// self-drives the projection (promote-timer tick + connection event) once writable — so the member is
110// admitted with NO manual re-projection loop. This is the event-driven replacement for the give-up timer.
111test('FIX: single no-op-at-first projectMembers self-recovers ⇒ member admitted, NO manual re-projection', { timeout: 120000 }, async (t) => {
112 const testnet = await createTestnet(3)
113 const { members, mk, A, B } = await setup(testnet)
114 const sbA = mk(A.dev, A.proof)
115 // The app's ONE call — but BEFORE ready(), so it retains the roster yet the projection no-ops
116 // (models the creator becoming writable only later, e.g. re-adopt + swarm flap in prod).
117 const first = await sbA.projectMembers(members)
118 t.absent(first, 'the single projectMembers call NO-OPPED (creator not writable yet) — old code would fork here')
119 await sbA.ready() // now writable; the fix self-drives __members__ projection in the background
120 const sbB = mk(B.dev, B.proof); await sbB.ready()
121 t.teardown(async () => { try { await sbA.close(); await sbB.close() } catch {} ; await testnet.destroy() })
122
123 // No manual re-projection anywhere. The creator's connection-hook + promote-timer land __members__;
124 // the member's doorbell re-ring is then admitted.
125 const r = await sbB.requestWriter({ timeoutMs: 40000 })
126 t.ok(r.writable, 'member ADMITTED via self-driven re-projection (single no-op-at-first call) — the fix')
127 t.ok(await sbA.get('__members__'), '__members__ landed with NO manual re-projection loop')
128})