// Admission ⇄ __members__ projection: reproduce the wj62 fork's ROOT and prove the fix mechanism.
//
// Diagnosis (pear-git #4 / hyper-social #9 lineage): a member is admitted by the CREATOR's in-apply
// verify (_verifyAdmissionInApply), which reads __members__ from the creator's VIEW. __members__ is
// written ONLY by the creator's projectMembers(), which no-ops unless base.writable and — in the app
// (device-node) — is driven by a BOUNDED boot loop (12 tries / ~13s) with NO re-projection afterward.
// If the creator's writability / projection lands after that window (re-adopt + swarm flap in prod),
// __members__ is never in the view, every member admit verifies verifiedRole=null (state-base.js:990
// "no __members__ in view"), the member is never admitted, and it holds its own line → the fork.
//
// A = bug repro: creator UP but __members__ NOT projected ⇒ member admission REJECTED, stays read-only.
// B = fix mechanism: a LATER (event-driven) projection + the member's existing re-ring ⇒ admitted.
// This is exactly what an on-writable-transition re-projection buys — the give-up TIMER is the bug,
// the event-driven re-projection is the fix; the member side already re-rings (requestWriter).
import '../lib/bare-globals.js'
import test from 'brittle'
import crypto from 'hypercore-crypto'
import fs from 'bare-fs'
import b4a from 'b4a'
import createTestnet from '@hyperswarm/testnet'
import { StateBaseV3 } from '../lib/state-base.js'
import { makeAttest, makeVerifyAttest, attestDeviceChain, deviceKeetKeyPair, rootIdentityPublicKey } from '../lib/membership.js'
const ROOT = 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about'
const DEVA = 'legal winner thank year wave sausage worth useful legal winner thank yellow'
const DEVB = 'zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo wrong'
const tmp = () => { const d = '/tmp/hpp-adm-' + Math.floor(Math.random() * 1e9); fs.mkdirSync(d, { recursive: true }); return d }
const sleep = (ms) => new Promise(r => setTimeout(r, ms))
async function setup (testnet) {
const rootPub = await rootIdentityPublicKey(ROOT)
const da = await deviceKeetKeyPair(DEVA); const pa = await attestDeviceChain({ rootMnemonic: ROOT, devicePublicKey: da.publicKey })
const db = await deviceKeetKeyPair(DEVB); const pb = await attestDeviceChain({ rootMnemonic: ROOT, devicePublicKey: db.publicKey })
let A = { dev: da, proof: pa }, B = { dev: db, proof: pb }
if (b4a.compare(A.dev.publicKey, B.dev.publicKey) > 0) { const t = A; A = B; B = t } // creator = lowest key
const members = [
{ key: b4a.toString(A.dev.publicKey, 'hex'), role: 1 },
{ key: b4a.toString(B.dev.publicKey, 'hex'), role: 1 }
]
const roomSeed = crypto.randomBytes(32); const roomKey = crypto.randomBytes(32); const encryptionKey = crypto.randomBytes(32)
const mk = (dev, proof) => new StateBaseV3(tmp(), roomKey, {
roomSeed, encryptionKey, swarmOpts: { bootstrap: testnet.bootstrap },
selfKey: dev.publicKey, attest: makeAttest(dev, proof), verifyAttest: makeVerifyAttest(rootPub),
getMembers: async () => members,
promoteRetryMs: 2000 // fast self-terminating fallback tick for the fix test (default 30s)
})
return { A, B, members, mk }
}
// A — THE BUG: creator up, __members__ never projected ⇒ member can't be admitted (the fork precondition).
test('A: no __members__ in the creator view ⇒ member admission REJECTED, stays read-only', { timeout: 120000 }, async (t) => {
const testnet = await createTestnet(3)
const { members, mk, A, B } = await setup(testnet)
const sbA = mk(A.dev, A.proof); await sbA.ready() // creator UP + writable, but we DELIBERATELY never projectMembers
const sbB = mk(B.dev, B.proof); await sbB.ready()
t.teardown(async () => { try { await sbA.close(); await sbB.close() } catch {} ; await testnet.destroy() })
t.ok(sbA.base.writable, 'creator is writable (bootstrap indexer) — writability is NOT the miss')
t.absent(await sbA.get('__members__'), 'creator view has NO __members__ (the give-up-timer state: projection never landed)')
// Member rings the doorbell. With no __members__, the creator verifies verifiedRole=null and never
// addWriters → member stays read-only. Bounded so the test doesn't hang.
const r = await sbB.requestWriter({ timeoutMs: 8000 })
t.absent(r.writable, 'requestWriter returned writable:false — the member was NOT admitted')
t.absent(sbB.base.writable, 'member base stays READ-ONLY (would hold its own divergent line in prod)')
})
// B — THE FIX MECHANISM: a LATER projection (what an on-writable-transition re-projection fires) +
// the member's existing re-ring ⇒ admission succeeds where the bounded timer had given up.
test('B: late __members__ projection + member re-ring ⇒ ADMITTED and converges (event-driven fix)', { timeout: 120000 }, async (t) => {
const testnet = await createTestnet(3)
const { members, mk, A, B } = await setup(testnet)
const sbA = mk(A.dev, A.proof); await sbA.ready() // creator up, projection intentionally NOT done yet
const sbB = mk(B.dev, B.proof); await sbB.ready()
t.teardown(async () => { try { await sbA.close(); await sbB.close() } catch {} ; await testnet.destroy() })
// (1) reproduce the stuck state: member cannot be admitted while __members__ is absent.
const stuck = await sbB.requestWriter({ timeoutMs: 6000 })
t.absent(stuck.writable, 'pre-projection: member is stuck read-only (the bug)')
// (2) the fix: the creator projects __members__ LATER — this is exactly what a re-projection hooked
// to the writable-transition (instead of the one-shot boot loop) would do once writability settles.
await sbA.projectMembers(members)
t.ok(await sbA.get('__members__'), 'creator view now HAS __members__ (event-driven re-projection landed)')
// (3) the member's existing re-ring (as the sign-tick's ensureAdmitted re-fires) now gets admitted.
const admitted = await sbB.requestWriter({ timeoutMs: 30000 })
t.ok(admitted.writable, 'post-projection: member is ADMITTED (re-ring succeeded where the timer gave up)')
t.ok(sbB.base.writable, 'member base is now writable — one line, not two')
// (4) prove it is a genuine single line: member writes, creator converges to the same record.
// NB: admission can be OPTIMISTICALLY writable before the indexer FINALIZES it (pear-git #4 Surprise
// #3) — a durable set() in that window throws "Not writable". Drive updates + retry until the
// admission finalizes and the write sticks (this is what the app's sign-tick re-fire does in prod).
let wrote = false
for (let i = 0; i < 30 && !wrote; i++) {
try { await sbB.set('post/1', { text: 'admitted-after-late-projection' }); wrote = true } catch {
await sbA.base.update().catch(() => {}); await sbB.base.update().catch(() => {}); await sleep(300)
}
}
t.ok(wrote, 'member durably wrote once admission FINALIZED (optimistic-writable gap, pear-git #4 §3)')
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) }
t.alike(await sbA.get('post/1'), { text: 'admitted-after-late-projection' }, 'creator converged with the admitted member — ONE line, no fork')
})
// FIX: the app calls projectMembers ONCE and it NO-OPS at first (creator not yet writable). The old
// bounded boot loop would drop __members__ forever here → fork. The fix retains the roster and
// self-drives the projection (promote-timer tick + connection event) once writable — so the member is
// admitted with NO manual re-projection loop. This is the event-driven replacement for the give-up timer.
test('FIX: single no-op-at-first projectMembers self-recovers ⇒ member admitted, NO manual re-projection', { timeout: 120000 }, async (t) => {
const testnet = await createTestnet(3)
const { members, mk, A, B } = await setup(testnet)
const sbA = mk(A.dev, A.proof)
// The app's ONE call — but BEFORE ready(), so it retains the roster yet the projection no-ops
// (models the creator becoming writable only later, e.g. re-adopt + swarm flap in prod).
const first = await sbA.projectMembers(members)
t.absent(first, 'the single projectMembers call NO-OPPED (creator not writable yet) — old code would fork here')
await sbA.ready() // now writable; the fix self-drives __members__ projection in the background
const sbB = mk(B.dev, B.proof); await sbB.ready()
t.teardown(async () => { try { await sbA.close(); await sbB.close() } catch {} ; await testnet.destroy() })
// No manual re-projection anywhere. The creator's connection-hook + promote-timer land __members__;
// the member's doorbell re-ring is then admitted.
const r = await sbB.requestWriter({ timeoutMs: 40000 })
t.ok(r.writable, 'member ADMITTED via self-driven re-projection (single no-op-at-first call) — the fix')
t.ok(await sbA.get('__members__'), '__members__ landed with NO manual re-projection loop')
})