// Admitting a second machine must not steal the CREATOR role — the loser-club production fork.
//
// StateBaseV3's creator election is `lowest-keyed member == self` (_amCreator), and the creator opens the
// roomSeed-derived gsKeyPair. So admitting a device whose keet key sorts BELOW the incumbent's silently
// flips the role, and BOTH halves are wrong:
//
// - the joiner elects itself creator, opens gsKeyPair EMPTY-and-writable in its fresh store, and the
// boot-write block (fresh-account bootstrap) authors a rival genesis
// - the incumbent, no longer lowest, opens keyPair=null and ABANDONS the genesis writer it was using
//
// Neither errors. Observed live admitting stortron to loser-club: the joiner came up amCreator=true with
// its own 1-record head while the incumbent logged "stays a PLAIN WRITER". The membership crypto was
// perfect — the ROLE broke. The offline suites missed it entirely because they tested the admission layer
// (which works) and never booted two StateBases with a lower-keyed joiner.
//
// Fix: a room's creator is EXPLICIT, not emergent. The joiner sets `joiner` (forceNonCreator +
// converge-only boot) and the incumbent sets `forceCreator`. Both flags already existed for the resync
// path; neither was reachable from a config entry, so a joiner could not be expressed.
//
// Test 1 is the CONTROL — it reproduces the production fork so the fix is measured against a real
// failure, not an assumption. Test 2 is the fix over the same topology.
//
// Run: bare test/coowner-join-role.test.js
import '../lib/bare-globals.js'
import test from 'brittle'
import fs from 'bare-fs'
import b4a from 'b4a'
import createTestnet from '@hyperswarm/testnet'
import { bootMultiDevice, provisionDevice } from '../lib/device-node.js'
import { deviceKeetKeyPair } from 'atproto-room/lib/membership.js'
const ROOT = 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon art'
const DEV_A = 'zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo vote'
const DEV_B = 'legal winner thank year wave sausage worth useful legal winner thank year wave sausage worth useful legal will'
const tmp = () => { const d = '/tmp/hpp-joinrole-' + Math.floor(Math.random() * 1e9); fs.mkdirSync(d, { recursive: true }); return d }
const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
async function bundle (devMnem, handle) {
const dk = await deviceKeetKeyPair(devMnem)
const b = await provisionDevice({ rootMnemonic: ROOT, devicePublicKey: dk.publicKey, handle, pdsUrl: 'https://pds.local' })
return { entry: { deviceMnemonic: devMnem, did: b.did, signingKey: b.signingKey, deviceProof: b.deviceProof, rootIdentityKey: b.rootIdentityKey, handle }, keetPub: b4a.toString(dk.publicKey, 'hex'), did: b.did }
}
// The INCUMBENT must be the HIGHER key so the joiner's key sorts below it — that is the whole hazard.
// If the joiner sorted above, the election would leave the roles alone and there would be nothing to fix.
async function roles () {
const a = await bundle(DEV_A, 'club.local')
const b = await bundle(DEV_B, 'club.local')
const joinerIsA = a.keetPub < b.keetPub
return { INC: joinerIsA ? b : a, JOIN: joinerIsA ? a : b, did: a.did }
}
const post = (rk, text) => ({ action: 'create', collection: 'app.bsky.feed.post', rkey: rk, record: { $type: 'app.bsky.feed.post', text, createdAt: '2026-07-27T00:00:00.000Z' } })
// The incumbent legitimately holds a THIRD record: bootSwarmDevice authors app.bsky.feed.post/self.hello
// for a brand-new account so a fresh DID is frontable before its first real post. So assert on the KEY
// SET, never on a count — and the joiner is expected to CONVERGE self.hello, not to lack it.
const keys = async (n) => (await n.device.state()).records.map((r) => r.collection + '/' + r.rkey).sort()
const waitFor = async (fn, ms = 60000) => { const end = Date.now() + ms; while (Date.now() < end) { if (await fn()) return true; await sleep(500) } return false }
test('CONTROL: a lower-keyed joiner STEALS the creator role and forks (the loser-club failure)', { timeout: 300000 }, async (t) => {
const testnet = await createTestnet(3)
const swarmOpts = { bootstrap: testnet.bootstrap }
const { INC, JOIN, did } = await roles()
t.ok(JOIN.keetPub < INC.keetPub, 'precondition: the joiner\'s key sorts BELOW the incumbent\'s')
// The incumbent bootstrapped the room alone (roster absent === self-only) and authored real content.
const sInc = tmp()
const nInc = await bootMultiDevice({ storage: sInc, dids: [INC.entry], swarmOpts, signIntervalMs: 1000 })
t.teardown(async () => { try { await nInc.close() } catch {} ; await testnet.destroy() })
const inc = nInc.byDid.get(did)
await inc.device.applyWrite(post('3aaa', 'incumbent one'))
await inc.device.applyWrite(post('3bbb', 'incumbent two'))
await waitFor(async () => (await inc.device.state()).recordCount >= 3)
const incKeys = await keys(inc)
t.ok(incKeys.includes('app.bsky.feed.post/3aaa') && incKeys.includes('app.bsky.feed.post/3bbb'), 'the incumbent holds its records')
t.is((await inc.device.membersDiag()).amCreator, true, 'and is the creator')
// Now admit the joiner the WRONG way: roster names both, no explicit roles.
const members = [{ key: INC.keetPub, role: 1 }, { key: JOIN.keetPub, role: 1 }]
const sJoin = tmp()
const nJoin = await bootMultiDevice({ storage: sJoin, dids: [{ ...JOIN.entry, members }], swarmOpts, signIntervalMs: 1000 })
t.teardown(async () => { try { await nJoin.close() } catch {} })
const join = nJoin.byDid.get(did)
await sleep(6000)
const jd = await join.device.membersDiag()
t.is(jd.amCreator, true, 'THE BUG: the joiner elected ITSELF creator (it is the lowest key)')
t.is(jd.forcedRole, null, 'with no explicit role pinning it either way')
const js = await join.device.state()
t.absent(js.records.some((r) => r.rkey === '3aaa'), 'and it did NOT converge the incumbent\'s records — a rival line')
})
test('FIX: an explicit joiner converges the incumbent\'s records and never becomes creator', { timeout: 300000 }, async (t) => {
const testnet = await createTestnet(3)
const swarmOpts = { bootstrap: testnet.bootstrap }
const { INC, JOIN, did } = await roles()
t.ok(JOIN.keetPub < INC.keetPub, 'precondition: the joiner still sorts BELOW the incumbent')
const members = [{ key: INC.keetPub, role: 1 }, { key: JOIN.keetPub, role: 1 }]
// Incumbent: pinned creator, so the election cannot demote it when the roster grows.
const sInc = tmp()
const nInc = await bootMultiDevice({ storage: sInc, dids: [{ ...INC.entry, members, forceCreator: true }], swarmOpts, signIntervalMs: 1000 })
t.teardown(async () => { try { await nInc.close() } catch {} ; await testnet.destroy() })
const inc = nInc.byDid.get(did)
await inc.device.applyWrite(post('3aaa', 'incumbent one'))
await inc.device.applyWrite(post('3bbb', 'incumbent two'))
await waitFor(async () => (await inc.device.state()).recordCount >= 2)
const incBefore = await inc.device.state()
const incKeys = await keys(inc)
t.ok(incKeys.includes('app.bsky.feed.post/3aaa') && incKeys.includes('app.bsky.feed.post/3bbb'), 'incumbent holds its records')
t.is((await inc.device.membersDiag()).amCreator, true, 'and remains the creator with a 2-member roster')
// forceCreator on an EXISTING store: its docstring warns against re-assuming an ABANDONED genesis
// (that reindexes the view and crashes on write). Pinning a CONTINUOUSLY-creator node is a different
// case — it opens the same gsKeyPair either way — but that is an assumption, so assert it.
t.is(incBefore.writable, true, 'and is still writable on its EXISTING store under forceCreator')
// Joiner: explicit joiner posture.
const sJoin = tmp()
const nJoin = await bootMultiDevice({ storage: sJoin, dids: [{ ...JOIN.entry, members, joiner: true }], swarmOpts, signIntervalMs: 1000 })
t.teardown(async () => { try { await nJoin.close() } catch {} })
const join = nJoin.byDid.get(did)
const jdiag = await join.device.membersDiag()
t.is(jdiag.amCreator, false, 'the joiner is NOT the creator despite the lower key')
t.is(jdiag.forcedRole, 'forced-non-creator', 'because it booted with the explicit joiner posture')
t.is((await inc.device.membersDiag()).forcedRole, 'forced-creator', 'and the incumbent is pinned')
const converged = await waitFor(async () => {
const s = await join.device.state()
return s.records.some((r) => r.rkey === '3aaa') && s.records.some((r) => r.rkey === '3bbb')
}, 90000)
t.ok(converged, 'the joiner CONVERGED the incumbent\'s records instead of authoring a rival line')
// The real "no rival line" check: the joiner's record set is EXACTLY the incumbent's. A count would
// not catch a joiner that authored its own self.hello while missing one of the incumbent's.
t.alike(await keys(join), incKeys, 'the joiner\'s record set is EXACTLY the incumbent\'s — nothing self-authored')
// The incumbent must be unharmed: same records, still creator, still writable, head not regressed.
const incAfter = await inc.device.state()
t.alike(await keys(inc), incKeys, 'incumbent still holds exactly its records')
t.is((await inc.device.membersDiag()).amCreator, true, 'still the creator after the joiner arrived')
t.is(incAfter.writable, true, 'and still writable')
if (incBefore.head && incAfter.head) t.ok(incAfter.head.rev >= incBefore.head.rev, 'its head advanced or held — never regressed')
// And the room still works: a write on the INCUMBENT reaches the joiner.
await inc.device.applyWrite(post('3ccc', 'after the join'))
const live = await waitFor(async () => (await join.device.state()).records.some((r) => r.rkey === '3ccc'), 90000)
t.ok(live, 'a post-join write on the incumbent replicates to the joiner — the room is co-owned and live')
})
// ── EVIDENCE BEATS ELECTION: an incumbent is not demoted even WITHOUT forceCreator ────────────────
// The two tests above pin the ceremony's behaviour (incumbent pinned, joiner explicit). This one pins the
// SUBSTRATE: _holdsGenesis now takes precedence over the key-order election, so the node that actually
// bootstrapped a room keeps its genesis writer however the roster grows. That removes the demotion half
// of the hazard for every room, including ones nobody remembered to pin — which is the case that bit
// loser-club. The promotion half still needs the explicit `joiner` signal, because a node with an empty
// store cannot tell "new room" from "joining one" (keet requires the same explicit distinction).
// ⚠ SKIPPED — this test asserts the `holdsGenesis` substrate change, which was REVERTED
// (hyper-social#16 P2: "do not ship as-is", patch kept at scratchpad/holdsGenesis-REGRESSES-do-not-ship.patch).
// Without that change phase 2 demotes the incumbent — the log says so directly:
// [state-base-v3] NOT self-promoting: non-designated device stays a PLAIN WRITER
// and the test then HANGS in the phase-2 re-boot rather than failing: it never emits a result, outlives
// its own 300000ms timeout, and has to be killed. Because it is imported by test/all.js, that took the
// WHOLE hyper-proto-peer suite with it — `npm test` could not complete on this tree, so no change to this
// repo could be verified against it.
//
// Left intact, not deleted: un-skip it the moment the substrate change lands (that is what it is for).
// Tests 1 and 2 above cover the shipped behaviour (incumbent pinned via forceCreator, joiner explicit)
// and both pass in ~17s and ~13s.
test.skip('the incumbent keeps the genesis WITHOUT forceCreator — evidence beats the election', { timeout: 300000 }, async (t) => {
const testnet = await createTestnet(3)
const swarmOpts = { bootstrap: testnet.bootstrap }
const { INC, JOIN, did } = await roles()
t.ok(JOIN.keetPub < INC.keetPub, 'precondition: the joiner sorts BELOW the incumbent (the election would flip)')
const members = [{ key: INC.keetPub, role: 1 }, { key: JOIN.keetPub, role: 1 }]
// Phase 1: the incumbent bootstraps the room ALONE, so it genuinely holds the genesis.
const sInc = tmp()
let nInc = await bootMultiDevice({ storage: sInc, dids: [INC.entry], swarmOpts, signIntervalMs: 1000 })
let incClosed = false
t.teardown(async () => { if (!incClosed) { try { await nInc.close() } catch {} } ; await testnet.destroy() })
await nInc.byDid.get(did).device.applyWrite(post('3aaa', 'bootstrapped by the incumbent'))
await waitFor(async () => (await nInc.byDid.get(did).device.state()).recordCount >= 2)
const incKeys = await keys(nInc.byDid.get(did))
t.is((await nInc.byDid.get(did).device.membersDiag()).amCreator, true, 'it holds the genesis as sole member')
// Phase 2: restart it with the roster GROWN to include the lower-keyed joiner, and NO forceCreator.
// Before this change the election would demote it here.
await nInc.close(); incClosed = true
nInc = await bootMultiDevice({ storage: sInc, dids: [{ ...INC.entry, members }], swarmOpts, signIntervalMs: 1000 })
incClosed = false
const inc = nInc.byDid.get(did)
const d = await inc.device.membersDiag()
t.is(d.forcedRole, null, 'no role is pinned — this is the unpinned path')
t.is(d.amCreator, true, 'and it is STILL the creator: it holds the genesis, so key order does not matter')
t.is((await inc.device.state()).writable, true, 'and still writable')
t.alike(await keys(inc), incKeys, 'with its records intact')
// And a joiner alongside it still converges rather than forking.
const sJoin = tmp()
const nJoin = await bootMultiDevice({ storage: sJoin, dids: [{ ...JOIN.entry, members, joiner: true }], swarmOpts, signIntervalMs: 1000 })
t.teardown(async () => { try { await nJoin.close() } catch {} })
const join = nJoin.byDid.get(did)
t.is((await join.device.membersDiag()).amCreator, false, 'the joiner does not claim the genesis')
const conv = await waitFor(async () => (await keys(join)).includes('app.bsky.feed.post/3aaa'), 90000)
t.ok(conv, 'and converges the incumbent\'s line')
})