OverviewFilesCommitsIssuesDocsReleasesPeers

atproto-room commits / 9b83af9

test: admission ⇄ __members__ projection — reproduce the wj62 fork root + prove the event-driven fix
Offline proof (tests A+B, 10 asserts) of the hyper-social #9 / pear-git #4 lineage diagnosis: a member is admitted by the CREATOR's in-apply verify against __members__ in its view; __members__ is written only by projectMembers() (creator-only, no-op unless writable), driven in the app by a BOUNDED give-up boot loop with no re-projection. If the creator's projection lands after that window, __members__ is never in the view and every member admit verifies verifiedRole=null ("no __members__ in view", state-base.js:990) → member never admitted → holds its own line → fork. - A: creator UP + writable but __members__ NOT projected ⇒ member admission REJECTED, stays read-only (the fork precondition). Reproduces the exact wj62 log signatures (same base.key on both, "NOT self-promoting … PLAIN WRITER (N=1 posture)", "no __members__ in view" → verifiedRole=null). - B: a LATER projection (what an on-writable-transition re-projection fires) + the member's existing re-ring ⇒ ADMITTED (verifiedRole=1 → addWriter OK) and the two converge to ONE line. Proves the give-up TIMER is the bug and event-driven re-projection is the fix; also surfaces the optimistic-writable-vs-finalized gap (pear-git #4 Surprise #3) — durable set() retries until the indexer finalizes. Tier-1 mechanism proof; nothing touches the live daemon. Next: C (convergence), D (adopt-from-CAR reconciliation + union), then the tier-2 offline replay on a read-only copy of wj62's real corestore. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
9b83af9b3ccdb8d9794aac242fb01b5bff14fabf
by plankatron <fleeky@gmail.com> · 2026-07-20T21:21:23.000Z

Changed files (2) +105 -0

test/admission-members-projection.test.mjs +104 -0
……@@ -0,0 +1,104 @@
1// Admission ⇄ __members__ projection: reproduce the wj62 fork's ROOT and prove the fix mechanism.
2//
3// Diagnosis (pear-git #4 / hyper-social #9 lineage): a member is admitted by the CREATOR's in-apply
4// verify (_verifyAdmissionInApply), which reads __members__ from the creator's VIEW. __members__ is
5// written ONLY by the creator's projectMembers(), which no-ops unless base.writable and — in the app
6// (device-node) — is driven by a BOUNDED boot loop (12 tries / ~13s) with NO re-projection afterward.
7// If the creator's writability / projection lands after that window (re-adopt + swarm flap in prod),
8// __members__ is never in the view, every member admit verifies verifiedRole=null (state-base.js:990
9// "no __members__ in view"), the member is never admitted, and it holds its own line → the fork.
10//
11// A = bug repro: creator UP but __members__ NOT projected ⇒ member admission REJECTED, stays read-only.
12// B = fix mechanism: a LATER (event-driven) projection + the member's existing re-ring ⇒ admitted.
13// This is exactly what an on-writable-transition re-projection buys — the give-up TIMER is the bug,
14// the event-driven re-projection is the fix; the member side already re-rings (requestWriter).
15
16import '../lib/bare-globals.js'
17import test from 'brittle'
18import crypto from 'hypercore-crypto'
19import fs from 'bare-fs'
20import b4a from 'b4a'
21import createTestnet from '@hyperswarm/testnet'
22import { StateBaseV3 } from '../lib/state-base.js'
23import { makeAttest, makeVerifyAttest, attestDeviceChain, deviceKeetKeyPair, rootIdentityPublicKey } from '../lib/membership.js'
24
25const ROOT = 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about'
26const DEVA = 'legal winner thank year wave sausage worth useful legal winner thank yellow'
27const DEVB = 'zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo wrong'
28const tmp = () => { const d = '/tmp/hpp-adm-' + Math.floor(Math.random() * 1e9); fs.mkdirSync(d, { recursive: true }); return d }
29const sleep = (ms) => new Promise(r => setTimeout(r, ms))
30
31async function setup (testnet) {
32 const rootPub = await rootIdentityPublicKey(ROOT)
33 const da = await deviceKeetKeyPair(DEVA); const pa = await attestDeviceChain({ rootMnemonic: ROOT, devicePublicKey: da.publicKey })
34 const db = await deviceKeetKeyPair(DEVB); const pb = await attestDeviceChain({ rootMnemonic: ROOT, devicePublicKey: db.publicKey })
35 let A = { dev: da, proof: pa }, B = { dev: db, proof: pb }
36 if (b4a.compare(A.dev.publicKey, B.dev.publicKey) > 0) { const t = A; A = B; B = t } // creator = lowest key
37 const members = [
38 { key: b4a.toString(A.dev.publicKey, 'hex'), role: 1 },
39 { key: b4a.toString(B.dev.publicKey, 'hex'), role: 1 }
40 ]
41 const roomSeed = crypto.randomBytes(32); const roomKey = crypto.randomBytes(32); const encryptionKey = crypto.randomBytes(32)
42 const mk = (dev, proof) => new StateBaseV3(tmp(), roomKey, {
43 roomSeed, encryptionKey, swarmOpts: { bootstrap: testnet.bootstrap },
44 selfKey: dev.publicKey, attest: makeAttest(dev, proof), verifyAttest: makeVerifyAttest(rootPub),
45 getMembers: async () => members
46 })
47 return { A, B, members, mk }
48}
49
50// A — THE BUG: creator up, __members__ never projected ⇒ member can't be admitted (the fork precondition).
51test('A: no __members__ in the creator view ⇒ member admission REJECTED, stays read-only', { timeout: 120000 }, async (t) => {
52 const testnet = await createTestnet(3)
53 const { members, mk, A, B } = await setup(testnet)
54 const sbA = mk(A.dev, A.proof); await sbA.ready() // creator UP + writable, but we DELIBERATELY never projectMembers
55 const sbB = mk(B.dev, B.proof); await sbB.ready()
56 t.teardown(async () => { try { await sbA.close(); await sbB.close() } catch {} ; await testnet.destroy() })
57
58 t.ok(sbA.base.writable, 'creator is writable (bootstrap indexer) — writability is NOT the miss')
59 t.absent(await sbA.get('__members__'), 'creator view has NO __members__ (the give-up-timer state: projection never landed)')
60
61 // Member rings the doorbell. With no __members__, the creator verifies verifiedRole=null and never
62 // addWriters → member stays read-only. Bounded so the test doesn't hang.
63 const r = await sbB.requestWriter({ timeoutMs: 8000 })
64 t.absent(r.writable, 'requestWriter returned writable:false — the member was NOT admitted')
65 t.absent(sbB.base.writable, 'member base stays READ-ONLY (would hold its own divergent line in prod)')
66})
67
68// B — THE FIX MECHANISM: a LATER projection (what an on-writable-transition re-projection fires) +
69// the member's existing re-ring ⇒ admission succeeds where the bounded timer had given up.
70test('B: late __members__ projection + member re-ring ⇒ ADMITTED and converges (event-driven fix)', { timeout: 120000 }, async (t) => {
71 const testnet = await createTestnet(3)
72 const { members, mk, A, B } = await setup(testnet)
73 const sbA = mk(A.dev, A.proof); await sbA.ready() // creator up, projection intentionally NOT done yet
74 const sbB = mk(B.dev, B.proof); await sbB.ready()
75 t.teardown(async () => { try { await sbA.close(); await sbB.close() } catch {} ; await testnet.destroy() })
76
77 // (1) reproduce the stuck state: member cannot be admitted while __members__ is absent.
78 const stuck = await sbB.requestWriter({ timeoutMs: 6000 })
79 t.absent(stuck.writable, 'pre-projection: member is stuck read-only (the bug)')
80
81 // (2) the fix: the creator projects __members__ LATER — this is exactly what a re-projection hooked
82 // to the writable-transition (instead of the one-shot boot loop) would do once writability settles.
83 await sbA.projectMembers(members)
84 t.ok(await sbA.get('__members__'), 'creator view now HAS __members__ (event-driven re-projection landed)')
85
86 // (3) the member's existing re-ring (as the sign-tick's ensureAdmitted re-fires) now gets admitted.
87 const admitted = await sbB.requestWriter({ timeoutMs: 30000 })
88 t.ok(admitted.writable, 'post-projection: member is ADMITTED (re-ring succeeded where the timer gave up)')
89 t.ok(sbB.base.writable, 'member base is now writable — one line, not two')
90
91 // (4) prove it is a genuine single line: member writes, creator converges to the same record.
92 // NB: admission can be OPTIMISTICALLY writable before the indexer FINALIZES it (pear-git #4 Surprise
93 // #3) — a durable set() in that window throws "Not writable". Drive updates + retry until the
94 // admission finalizes and the write sticks (this is what the app's sign-tick re-fire does in prod).
95 let wrote = false
96 for (let i = 0; i < 30 && !wrote; i++) {
97 try { await sbB.set('post/1', { text: 'admitted-after-late-projection' }); wrote = true } catch {
98 await sbA.base.update().catch(() => {}); await sbB.base.update().catch(() => {}); await sleep(300)
99 }
100 }
101 t.ok(wrote, 'member durably wrote once admission FINALIZED (optimistic-writable gap, pear-git #4 §3)')
102 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) }
103 t.alike(await sbA.get('post/1'), { text: 'admitted-after-late-projection' }, 'creator converged with the admitted member — ONE line, no fork')
104})
test/all.js +1 -0
……@@ -10,6 +10,7 @@ import './signer.test.js'
1010import './signer-conformance.test.js'
1111import './membership.test.js'
1212import './doorbell-durable-write.test.js'
13import './admission-members-projection.test.mjs'
1314import './phase2-nonblocking.test.js'
1415import './multi-did.test.js'
1516import './non-indexer-default.test.js'