// VALIDATION: the keet-faithful model for a member GRANTED admin at runtime, and the
// autobase constraint that shapes it (#103 open-question 1 + the in-place-promotion
// investigation).
//
// KEY FINDING: autobase 7.28.1 can only elevate a writer to indexer while it is FRESH
// (base.local.length === 0). A writer that already appended data cannot be promoted in
// place — the manifest fork keeps rolling back (apply-state _rollbackViews; see
// memory/project_statebase-inplace-indexer-promotion). keet has the same constraint: it
// mints indexers only from FRESH devices/joins (addDevice → addWriter(deviceKey,
// {indexer:isIndexing})); its change-member-role op sets the admin CAPABILITY only, never
// addWriter. So "admin" (a permission) and "indexer" (an autobase co-signer) are decoupled.
//
// Scenario: A (genesis admin) + B (admin) form the indexer set; C opens as a plain MEMBER
// (role 3), WRITES game data (→ data-bearing plain writer), then is GRANTED admin (role 1)
// and the roster is re-projected. CORRECT behavior:
// - C is NOT an indexer while role 3 (non-admins never self-promote), AND
// - after the grant, C gains the admin CAPABILITY (role 1 in __members__) but — because
// its writer already has data — does NOT become an indexer (and _maybePromote does NOT
// futilely churn the set), AND
// - C stays a healthy admitted WRITER whose writes still persist + replicate via the
// existing indexer quorum (durability is provided by the fresh/anchor indexers).
// (Separately, the P3 re-fire — timer + projectMembers hook — DOES promote an admin whose
// role becomes visible while its writer is still fresh; that is exercised by
// probe-indexer-quorum.mjs and the fresh-open path, not here.)
import { StateBaseV3 } from '../lib/state-base-v3.js'
import createTestnet from 'hyperdht/testnet'
import crypto from 'hypercore-crypto'
import b4a from 'b4a'
import fs from 'bare-fs'
import path from 'bare-path'
import os from 'bare-os'
const ROOT = path.join(os.tmpdir(), 'sbv3-promote-midrun-' + Bare.pid)
try { fs.rmSync(ROOT, { recursive: true, force: true }) } catch {}
const hex = (b) => b4a.toString(b, 'hex')
// Three keypairs; lex-lowest = genesis creator (A). A and B start admin (role 1);
// C starts as a plain member (role 3) and is promoted mid-run.
const kps = [crypto.keyPair(), crypto.keyPair(), crypto.keyPair()].sort((x, y) => hex(x.publicKey) < hex(y.publicKey) ? -1 : 1)
const [kpA, kpB, kpC] = kps
// Live, MUTABLE roster — mutating an entry's role simulates a runtime grant-admin;
// getMembers returns this same reference so every peer's projection sees the change.
const members = [
{ key: hex(kpA.publicKey), role: 1 }, // genesis creator (admin)
{ key: hex(kpB.publicKey), role: 1 }, // admin
{ key: hex(kpC.publicKey), role: 3 } // plain member — promoted later
]
let pass = 0; let fail = 0
const ok = (c, m) => { if (c) { pass++; console.log(' ok ' + m) } else { fail++; console.log(' FAIL ' + m) } }
const sleep = (ms) => new Promise(r => setTimeout(r, ms))
const idxCount = (sb) => { try { return sb.base.linearizer.indexers.length } catch { return '?' } }
let A, B, C
const live = () => [A, B, C].filter(x => x && x.base && !x._closing)
let hb = 0
const churnSettle = async (predicate, maxMs = 60000) => {
const deadline = Date.now() + maxMs
while (Date.now() < deadline) {
for (const x of live()) { if (x.base.writable) { try { await x.set('__hb__:' + hex(x._selfKey).slice(0, 6), hb++) } catch {} } }
for (const x of live()) await x.base.update().catch(() => {})
await sleep(150)
if (await predicate()) return true
}
return await predicate()
}
let stage = 'init'
const watchdog = setTimeout(() => { console.log(' [WATCHDOG] stuck at stage=' + stage + ' — exiting to flush'); Bare.exit(3) }, 180000)
async function main () {
stage = 'testnet'
const testnet = await createTestnet(3)
const bootstrap = testnet.bootstrap
const roomSeed = crypto.randomBytes(32)
// promoteRetryMs small so the P3 re-fire timer is exercised quickly + deterministically.
const opt = (kp) => ({ roomSeed, encryptionKey: roomSeed, network: true, swarmOpts: { bootstrap }, selfKeyPair: kp, selfKey: kp.publicKey, getMembers: async () => members, promoteRetryMs: 3000 })
stage = 'A.ready'; A = new StateBaseV3(path.join(ROOT, 'A'), crypto.randomBytes(32), opt(kpA))
await A.ready(); await A.projectMembers(members)
ok(A.writable, '1. A (lex-lowest admin) = genesis creator + writable')
stage = 'B.ready'; B = new StateBaseV3(path.join(ROOT, 'B'), A._roomKey, opt(kpB)); await B.ready()
stage = 'C.ready'; C = new StateBaseV3(path.join(ROOT, 'C'), A._roomKey, opt(kpC)); await C.ready()
await churnSettle(async () => (B.base.length || 0) > 0 && (C.base.length || 0) > 0, 45000)
// Grow the initial admin indexer set to {A,B} (C is only a member so far).
stage = 'growB'; await B.requestWriter({ timeoutMs: 40000, startIndexing: true }); await churnSettle(async () => B._isLocalIndexer() === true, 40000)
ok(B._isLocalIndexer() === true, '2. B (admin) self-promoted to indexer')
// C writes as a plain member so it becomes an admitted (indexer:false) writer.
// NOTE: base.writable lags admission — an indexer admits our optimistic block into
// the system writer set before base.writable's localWriter activation, so the
// authoritative "admitted" signal is effectiveWritable() (#99), not base.writable.
stage = 'C.write'; await C.set('c:hello', { ok: true })
const cAdmitted = await churnSettle(async () => (await C.effectiveWritable()) === true, 40000)
ok(cAdmitted, '3. C admitted as a plain writer (member)')
ok(C._isLocalIndexer() !== true, '3b. C is NOT an indexer while role 3 (non-admin never self-promotes)')
// ── The P3 event: C is granted admin at runtime; re-project the roster ──
stage = 'promoteC'
members[2].role = 1 // grant-admin
// Re-project from an existing writer (A) so the updated __members__ replicates to C.
await A.projectMembers(members)
await churnSettle(async () => (await C.getProjectedRole(hex(kpC.publicKey))) === 1, 30000)
ok((await C.getProjectedRole(hex(kpC.publicKey))) === 1, '3c. C sees its new admin role in the projected __members__')
// CORRECT behavior: C already has data → NOT promotable to indexer in place. Give the
// re-fire timer several ticks and prove it does NOT futilely churn the indexer set.
stage = 'C.postpromote'
await churnSettle(async () => false, 14000)
console.log(' [diag] indexer counts A=%s B=%s C=%s', idxCount(A), idxCount(B), idxCount(C))
ok(C._isLocalIndexer() !== true, '4. data-bearing C does NOT become an indexer (autobase length-0 constraint) — correct, not a bug')
ok(idxCount(A) === 2 && idxCount(B) === 2, '4b. indexer set stays at 2 — no futile promotion churn')
ok((await C.effectiveWritable()) === true, '4c. C keeps admin capability + remains a healthy admitted WRITER')
// Durability preserved: a NEW write from C persists + replicates via the existing quorum.
stage = 'C.write2'
const k2 = 'c:after-grant:' + Date.now()
await C.set(k2, { ok: true })
const gotOnA = await churnSettle(async () => { try { const v = await A.get(k2); return v && v.ok === true } catch { return false } }, 40000)
ok(gotOnA, '5. C\'s writes still persist + replicate to A (durability via the existing indexers)')
clearTimeout(watchdog)
console.log('\n RESULT: ' + pass + ' passed, ' + fail + ' failed')
Bare.exit(fail === 0 ? 0 : 1)
}
main().catch((e) => { console.log(' FATAL ' + (e && e.stack || e)); Bare.exit(2) })