OverviewFilesCommitsIssuesDocsReleasesPeers

keet-daemon / lib / state-base-v3.js

775 lines · javascript ref:
/**
 * state-base-v3.js — Per-room game-state store, v3 (HyperDB view).
 *
 * Clean cutover from the v2 Hyperbee StateBase (state-base.js, kept as reference).
 * Storage layer is keet-faithful: the Autobase view is a HyperDB instance, ops
 * are hyperdispatch-encoded and routed through a generated Router to handlers
 * that write into HyperDB collections inside a per-apply transaction. See
 * spec/state-base-design.md + spec/state-base-v3.md.
 *
 * Bootstrap/keys/encryption are UNCHANGED from v2 (room-state.md v2): base.key is
 * derived deterministically from the room's members-only encryptionKey, opened
 * encrypted; non-members can neither derive nor read it.
 *
 * v3.0a (this file): single-writer (deterministic creator) round-trip + lifecycle
 *   (fd-lock-safe open via the router cache, corruption quarantine, encryption).
 * v3.0b (next): multi-writer admission (members projection + optimistic _admit +
 *   addWriter), ported from v2 §3.2 to the hyperdispatch op model.
 */

import Corestore from 'corestore'
import Autobase from 'autobase'
import Hypercore from 'hypercore'
import Verifier from 'hypercore/lib/verifier' // encodeManifest/decodeManifest for the keet-writer admission proof (A)
import Hyperswarm from 'hyperswarm'
import HyperDB from 'hyperdb'
import crypto from 'hypercore-crypto'
import fs from 'bare-fs'
import path from 'bare-path'
import process from 'bare-process'

import hyperdbDefinition from '../spec/state-base/contract/hyperdb/index.js'
import dispatch from '../spec/state-base/contract/hyperdispatch/index.js'
import { timed } from './timed.js'

const { Router, encode } = dispatch

const STATE_VERSION = 3
const GS_NAMESPACE = Buffer.from('hyper-tps/state/v' + STATE_VERSION, 'utf-8')
const ENTRIES = '@state/entries'
// Reserved op key: a designated admin anchor claims the SOLE indexer slot
// (move-indexer). Carried as the `key` of an optimistic admit op; never inserted.
const MOVE_INDEXER = '__move_indexer__'
// Reserved op key: an admin GROWS the indexer set by adding ITSELF as an indexer
// (keet members/index.js op D `start-indexing`) — NO demote of anyone. This is the
// quorum-resilience path: with >=3 admin-indexers, autobase majority quorum
// ((n>>>1)+1, consensus.js:15) finalizes even when the genesis/creator is offline.
// Proven by test/probe-indexer-quorum.mjs (autobase 7.28.1 holds >2 indexers fine).
const START_INDEXING = '__start_indexing__'

function toBuf (k) {
  if (Buffer.isBuffer(k)) return k
  const s = String(k)
  return /^[0-9a-fA-F]{64}$/.test(s) ? Buffer.from(s, 'hex') : Buffer.from(s)
}
// Normalize an identity key to lowercase hex (members keys arrive 64-hex from
// router._stateBaseProviders, which z32-decodes memberIds to hex).
function normKeyHex (k) {
  if (!k) return null
  if (Buffer.isBuffer(k)) return k.toString('hex')
  const s = String(k)
  return /^[0-9a-fA-F]{64}$/.test(s) ? s.toLowerCase() : s
}
function deriveGsKeyPair (roomSeed) { return crypto.keyPair(crypto.hash([GS_NAMESPACE, toBuf(roomSeed)])) }
function deriveBaseKey (gsPublicKey) { return Hypercore.key({ version: 1, signers: [{ publicKey: gsPublicKey }] }) }

// Router → instance handlers (need instance state for admission). ctx carries
// { tx, base, node, sb }. LWW-by-ts; D1b admission on optimistic writes.
function makeRouter () {
  const r = new Router()
  r.add('@state/set-entry', (rec, ctx) => ctx.sb._applySet(rec, ctx))
  r.add('@state/del-entry', (rec, ctx) => ctx.sb._applyDel(rec, ctx))
  return r
}

export class StateBaseV3 {
  constructor (storagePath, roomKey, opts = {}) {
    this._roomKey = Buffer.isBuffer(roomKey) ? roomKey : Buffer.from(String(roomKey), 'hex')
    if (this._roomKey.length !== 32) throw new Error('roomKey must be 32 bytes (or 64-char hex)')
    this._roomKeyHex = this._roomKey.toString('hex')

    // Distinct v3 path — v2 'state-bases/' is left untouched (clean cutover).
    this._storagePath = path.join(storagePath, 'state-bases-v3', this._roomKeyHex)
    this._network = opts.network !== false
    this._swarmOpts = opts.swarmOpts || null
    // Designated always-on anchor: on open, claim the SOLE indexer slot via
    // move-indexer so the room finalizes even when the creator is offline.
    this._claimIndexer = !!opts.claimIndexer

    // P3 (#103): admins self-promote to indexers not just at open but whenever their
    // keet role changes at runtime (member→admin), driven by a low-frequency retry
    // timer. _promoteRetryMs = how often to re-check; _promoting guards overlapping
    // runs (timer + projectMembers can race). Q7 (#103): _maxIndexers bounds the
    // ACTUAL indexer set so a large admin room doesn't push the majority-quorum
    // threshold so high that finalization gets fragile — admins past the cap stay
    // plain writers (still admitted, still write; they just don't index).
    this._promoteRetryMs = Number(opts.promoteRetryMs) || Number(process.env.KEET_PROMOTE_RETRY_MS) || 30000
    this._maxIndexers = Number(opts.maxIndexers) || Number(process.env.KEET_MAX_INDEXERS) || 5
    this._promoting = false
    this._promoteTimer = null
    this._promoteFutileLogged = false

    // Membership context. selfKey (this daemon's keet identity pubkey) drives the
    // deterministic creator election; it comes from router._stateBaseProviders as
    // `selfKey` (a 32-byte pubkey), NOT a keypair. selfKeyPair is optional (kept
    // for v3.0b signing). CRITICAL: without a real selfKey the election would
    // default amCreator=true on every daemon → multiple genesis → split-brain.
    this._selfKeyPair = opts.selfKeyPair || null
    this._selfKey = opts.selfKey
      ? toBuf(opts.selfKey)
      : (this._selfKeyPair ? toBuf(this._selfKeyPair.publicKey) : null)
    this._getMembers = typeof opts.getMembers === 'function' ? opts.getMembers : null
    // Injectable crypto for membership-gated admission (keet attestation primitives).
    // attest: sign localKey‖base.key as our keet identity; verifyAttest: IdentityKey.verify.
    this._attest = typeof opts.attest === 'function' ? opts.attest : null
    this._verifyAttest = typeof opts.verifyAttest === 'function' ? opts.verifyAttest : null
    // Admission model (A): prove control of THIS daemon's keet room writer key, which
    // keet maps (via @keet/devices) to our active member. roomWriterKeyPair signs the
    // proof; roomWriterManifest binds the signer to the writer/device key
    // (deviceKey = Hypercore.key(manifest)). Both come from room.base.local in the
    // router. The verifier re-derives the device key and looks it up in __devices__
    // (deviceKey→memberKey) → __members__ (memberKey→role). See hyper-fleeky-dev #99.
    this._roomWriterKeyPair = opts.roomWriterKeyPair || this._selfKeyPair || null
    this._roomWriterManifest = opts.roomWriterManifest || null

    // Deterministic base identity from the room's members-only secret (v2 design).
    this._roomSeed = opts.roomSeed ? toBuf(opts.roomSeed) : null
    this._gsKeyPair = this._roomSeed ? deriveGsKeyPair(this._roomSeed) : null
    this._detBaseKey = this._gsKeyPair ? deriveBaseKey(this._gsKeyPair.publicKey) : null
    this._encryptionKey = opts.encryptionKey ? toBuf(opts.encryptionKey) : null

    this._router = makeRouter()
    this.store = null
    this.base = null
    this.swarm = null
    this._ready = false
    this._closing = false
    this._watchers = new Set()
    // Writer-hex keys we've already admitted via addWriter at apply time. A remote
    // member attaches its `admit` proof to EVERY optimistic write until it observes
    // itself become writable, so without this guard apply re-runs addWriter/ackWriter
    // (and logs) once per op — hundreds of redundant calls per peer. addWriter is
    // idempotent, so de-duping the CALL leaves the writer set identical.
    this._admitted = new Set()
    // Cached "self is an ADMITTED, durable writer of this room": our local writer
    // key (base.local.key) is in the autobase linearized system writer set
    // (base.system). This is the signal `writable` reports — it answers "will my
    // durable writes persist?" correctly: true once an indexer has actually
    // admitted us (at/before base.writable's localWriter activation, so it still
    // covers the post-admission lag), and FALSE for a peer that is merely an
    // eligible room member in __members__ but was never admitted by an indexer
    // (the false-green that keying off __members__ produced — see
    // hyper-fleeky-dev #99: a local identity in the roster but absent from the
    // indexer's membership read writable=true while its writes never finalized).
    this._selfIsAdmittedWriter = false
  }

  // ── Lifecycle ──────────────────────────────────────────────
  async ready () {
    if (this._ready) return
    if (this._closing) throw new Error('StateBaseV3 is closing')
    fs.mkdirSync(this._storagePath, { recursive: true })
    this.store = new Corestore(this._storagePath)
    await this.store.ready()
    try {
      await this._open()
    } catch (err) {
      if (!this._isCorruptionError(err)) throw err
      // Diagnostic mode: report corruption WITHOUT quarantining (non-destructive
      // probe of live storage). Used by tools/probe-room.mjs.
      if (process.env.KEET_PROBE_NO_QUARANTINE) {
        console.error('[state-base-v3] room=%s CORRUPT (%s) — probe mode, NOT quarantining',
          this._roomKeyHex.slice(0, 12), err.message)
        throw err
      }
      console.error('[state-base-v3] room=%s CORRUPT (%s) — quarantining + restarting',
        this._roomKeyHex.slice(0, 12), err.message)
      await this._quarantineAndReset()
    }
    this._ready = true
    // Admins/mods AUTO-promote to indexers on open (keet model: admins ARE the
    // indexers — members/index.js addWriter(indexer:isIndexing)). Best-effort +
    // background so it never blocks boot; needs an online indexer to land and
    // retries on the next open. Non-admins are untouched (stay plain writers),
    // so a room's indexer set = its online admins (quorum tolerates the creator
    // dropping once ≥3 admins index).
    this._maybePromote().catch(() => {})
    // P3 (#103): the open-time call above only sees our role AS OF open. An admin
    // GRANTED at runtime (member→admin while the daemon stays up) would otherwise
    // need a full reopen to self-promote — the ashatron footgun. Re-fire on a
    // low-frequency timer so a newly-promoted admin becomes an indexer on its own.
    // Cheap when idle: _maybePromote early-returns once we're a durable indexer or
    // while we're not (yet) an admin. unref so it never keeps the process alive.
    if (!this._promoteTimer) {
      this._promoteTimer = setInterval(() => { this._maybePromote().catch(() => {}) }, this._promoteRetryMs)
      if (this._promoteTimer && typeof this._promoteTimer.unref === 'function') this._promoteTimer.unref()
    }
  }

  // A DESIGNATED always-on anchor (opts.claimIndexer) automatically claims the
  // sole indexer slot on open, so the room finalizes (quorum 1) even when the
  // creator is offline. NOT every admin — autobase can't hold many indexers, so
  // only the one designated anchor takes the slot; everyone else stays a plain
  // writer (admitted on first write). Best-effort + background; needs the current
  // indexer online once to sign the handoff, then it's durable in the oplog.
  async _maybePromote () {
    if (this._promoting) return // guard the timer/projectMembers race — one attempt at a time
    this._promoting = true
    try {
      if (!this.base) return
      if (this.base.writable && this._isLocalIndexer() === true) return // already an indexer
      // keet model (members/index.js:137 `isIndexing = canAdmin`): EVERY admin is an
      // indexer, so the set grows to = the online admins and majority quorum tolerates
      // any single node (incl. the genesis creator) dropping. Verify we're an admin
      // (role 1); non-admins stay plain writers. Best-effort + background; needs an
      // online indexer to sign the addWriter (autobase invariant) — retries next open.
      if (this._getMembers && this._selfKey) {
        let members; try { members = await this._getMembers() } catch { members = null }
        const selfHex = this._selfKey.toString('hex')
        const me = Array.isArray(members) ? members.find(m => normKeyHex(m.key || m.devicePubkey || m.identity || m.memberId || m.id) === selfHex) : null
        if (!me || Number(me.role) !== 1) return // non-admin → stays a plain writer
      }
      // Q7 (#103): bound the ACTUAL indexer set. Every admin is ELIGIBLE (keet model),
      // but once the live set has _maxIndexers members we stop growing it — a room with
      // many admins would otherwise push the majority threshold ((n>>>1)+1) so high that
      // a couple of offline indexers stall finalization. Admins past the cap stay plain
      // writers (admitted on their writes; they just don't index). We're not already an
      // indexer here (early-returned above), so a full set means: leave it alone.
      let idxLen = 0; try { idxLen = this.base.linearizer.indexers.length } catch { idxLen = 0 }
      if (idxLen >= this._maxIndexers) {
        console.log('[state-base-v3] room=%s admin NOT self-promoting: indexer set full (%d/%d) — staying a plain writer', this._roomKeyHex.slice(0, 12), idxLen, this._maxIndexers)
        return
      }
      // AUTOBASE CONSTRAINT (7.28.1): a writer can only be elevated to indexer while it is
      // FRESH — base.local.length === 0. Reliable promotion is a two-phase protocol anchored
      // at length 0 (addWriter parks the key in pendingIndexers, the writer self-acks — gated
      // on local.length===0, apply-state.js:356 — and the manifest fork fires anchored to that
      // writer's own first confirmed node). A writer that has ALREADY appended data
      // (_seenLength>0) takes system._updateIndexer's immediate branch, binding the fork to an
      // un-anchored tip node authored by another indexer; the reorganising linearizer keeps
      // rolling it back (_rollbackViews) → indexerUpdate is set but the indexer set never
      // advances. So a data-bearing writer CANNOT be promoted in place — retrying is futile and
      // just spams the log. keet has the same constraint: it mints indexers only from FRESH
      // devices/joins (addDevice: addWriter(deviceKey,{indexer:isIndexing})); change-member-role
      // sets the admin CAPABILITY only, never addWriter. So we promote ONLY while fresh; an
      // admin that already wrote game data keeps its admin capability but stays a plain writer
      // (durability is provided by the fresh/anchor indexers, not by every admin). See
      // hyper-fleeky-dev #103 + memory/project_statebase-inplace-indexer-promotion-*.
      let localLen = 0; try { localLen = this.base.local ? this.base.local.length : 0 } catch { localLen = 0 }
      if (localLen > 0) {
        if (!this._promoteFutileLogged) {
          this._promoteFutileLogged = true
          console.log('[state-base-v3] room=%s admin NOT self-promoting: writer already has data (local.length=%d) — autobase only elevates a FRESH (length 0) writer to indexer; keeping admin as a plain writer', this._roomKeyHex.slice(0, 12), localLen)
        }
        return
      }
      // Every admin GROWS the set via start-indexing (no demote → quorum). A DESIGNATED
      // anchor (opts.claimIndexer) still does the SOLE-slot move-indexer for back-compat.
      const mode = this._claimIndexer ? { claimIndexer: true } : { startIndexing: true }
      console.log('[state-base-v3] room=%s admin self-promoting to indexer (%s)', this._roomKeyHex.slice(0, 12), this._claimIndexer ? 'move-indexer anchor' : 'start-indexing grow')
      const r = await this.requestWriter({ timeoutMs: 25000, ...mode })
      console.log('[state-base-v3] room=%s promote result writable=%s indexer=%s', this._roomKeyHex.slice(0, 12), r.writable, r.indexer)
    } catch (e) { console.log('[state-base-v3] promote failed: %s', e.message) } finally { this._promoting = false }
  }

  async _open () {
    if (!this._detBaseKey) throw new Error('StateBaseV3 requires roomSeed (deterministic key)')
    const amCreator = (!this._getMembers || !this._selfKey) ? true : await this._amCreator()
    console.log('[state-base-v3] room=%s %s base=%s', this._roomKeyHex.slice(0, 12),
      amCreator ? 'creator' : 'member', this._detBaseKey.toString('hex').slice(0, 12))
    await this._openBase(this._detBaseKey, amCreator ? this._gsKeyPair : null)
  }

  async _openBase (bootstrapKey, keyPair) {
    const baseOpts = {
      // The Autobase view IS a HyperDB. autoUpdate is load-bearing: HyperDB reads
      // a snapshot frozen at construction, advanced only by core 'append'.
      open: (store) => HyperDB.bee(store.get('view'), hyperdbDefinition, { autoUpdate: true }),
      apply: this._apply.bind(this),
      close: async (view) => { if (view) await view.close() },
      valueEncoding: 'binary', // ops are hyperdispatch-encoded buffers
      optimistic: true,
      ackInterval: 1000,
      ackThreshold: 4
    }
    if (keyPair) baseOpts.keyPair = keyPair
    if (this._encryptionKey) { baseOpts.encryptionKey = this._encryptionKey; baseOpts.encrypted = true }

    this.base = new Autobase(this.store, bootstrapKey, baseOpts)
    await this.base.ready()

    if (this._network) {
      if (!this.swarm) {
        this.swarm = new Hyperswarm(this._swarmOpts || undefined)
        this.swarm.on('connection', (socket) => {
          // MUST replicate via base.replicate (not store.replicate): it ALSO
          // wires the autobase wakeupProtocol onto the stream, which announces
          // optimistic/pending writer cores. With bare store.replicate the
          // indexer never discovers a not-yet-admitted member's optimistic
          // onboarding block → admission never runs → member stays non-writable
          // (the testy1/AI-Testing-Town "can't write" deadlock). cf. pear-git-v3
          // pear-git.js:447 ("corestore.replicate unreliable for writer cores").
          try { this.base.replicate(socket) } catch (err) { console.error('[state-base-v3] replicate threw:', err.message) }
        })
      }
      this.swarm.join(this.base.discoveryKey)
    }
    console.log('[state-base-v3] room=%s base.key=%s writable=%s', this._roomKeyHex.slice(0, 12),
      this.base.key.toString('hex').slice(0, 12), this.base.writable)
  }

  // keet's _autobaseApply commit shape: a per-batch transaction; handlers write
  // into it; flush on success / close on error (never insert on the db directly).
  async _apply (nodes, view, base) {
    if (process.env.SBV3_DIAG) {
      const who = base.writable ? 'WRITABLE' : 'member'
      const froms = nodes.map(n => (n.from && n.from.key ? n.from.key.toString('hex').slice(0, 8) : 'sys')).join(',')
      console.log('[SBV3_DIAG] _apply self=%s(%s) nodes=%d from=[%s]',
        (this._selfKey ? this._selfKey.toString('hex').slice(0, 8) : '?'), who, nodes.length, froms)
    }
    const tx = view.transaction()
    try {
      for (const node of nodes) {
        const op = node.value
        if (op == null || !Buffer.isBuffer(op)) continue // null/system nodes
        await this._router.dispatch(op, { tx, base, node, sb: this })
      }
      await tx.flush()
    } catch (err) {
      try { await tx.close() } catch {}
      throw err
    }
    for (const w of this._watchers) { try { w() } catch {} }
  }

  // ── Public API (generic KV — unchanged surface vs v2) ──────
  async set (key, value) {
    if (!this._ready) throw new Error('StateBaseV3 not ready')
    // DOORBELL (keet block-until-admitted). NEVER fuse durable CONTENT into a pre-admission
    // OPTIMISTIC block: autobase keeps an optimistic block ONLY if the SAME apply admits+acks its
    // writer, else it is rolled back every drain and discarded (apply-state.js:1110-1141). Fusing
    // content+admit therefore reports success while the write silently VANISHES when no indexer is
    // online to ack it — and the author can never read it back (test/silent-loss.repro.mjs). Every
    // durable autobase app (autopass/keet/hyper-rooms/hypernote) instead admits FIRST, writes AFTER.
    // So: if we are not yet an admitted writer, drive a CONTENT-FREE admission and block until we
    // are genuinely in the system writer set; only then append content non-optimistically. If
    // admission cannot be reached, _ensureWritable THROWS (never a silent loss). cf. skills/autobase.
    await this._ensureWritable()
    const rec = { key: String(key), value: this._enc(value), ts: Date.now() }
    await this.base.append(encode('@state/set-entry', rec))
    await this.base.update()
  }

  async del (key) {
    if (!this._ready) throw new Error('StateBaseV3 not ready')
    await this._ensureWritable()
    await this.base.append(encode('@state/del-entry', { key: String(key), ts: Date.now() }))
    await this.base.update()
  }

  // Block until we are a genuinely ADMITTED writer (our local writer key is in the linearized
  // system writer set), then return. No-op if already writable. Otherwise ring the CONTENT-FREE
  // admission doorbell (requestWriter, which re-rings until admitted) and wait; if no indexer
  // admits us within the timeout, THROW rather than let a caller write content that autobase would
  // silently roll back. This is the guard that makes set()/del() durable (block-until-admitted).
  async _ensureWritable ({ timeoutMs = 20000 } = {}) {
    if (this.base && this.base.writable) return
    await this.requestWriter({ timeoutMs })
    if (!this.base || !this.base.writable) {
      throw new Error('StateBaseV3: not admitted as a writer (no indexer online within ' + timeoutMs + 'ms) — refusing to write content that would be silently lost')
    }
  }

  // Trigger our OWN admission without writing any game data: append a keyless
  // optimistic block carrying just the admission proof. When an online indexer
  // applies it, _applySet addWriters us (INDEXER if our keet role is admin/mod,
  // role 1/2). So an always-on `--pin-room` anchor that is an admin becomes a
  // durable indexer and can then admit other members even when the original
  // room creator is offline (fixes the "creator went away → room stuck" class).
  // No-op if already writable. Requires an online indexer to land the first
  // promotion (bootstrap); thereafter the writer status is durable in the oplog.
  // claimIndexer: a DESIGNATED admin anchor takes the sole indexer slot (move-
  // indexer). Without it, just become a plain writer. Goal reached when writable
  // (and, if claiming, when we are the indexer).
  async requestWriter ({ timeoutMs = 15000, claimIndexer = false, startIndexing = false } = {}) {
    if (!this._ready) throw new Error('StateBaseV3 not ready')
    const wantIndexer = claimIndexer || startIndexing
    const done = () => this.base.writable && (!wantIndexer || this._isLocalIndexer() === true)
    if (done()) return { writable: true, already: true, indexer: this._isLocalIndexer() }
    const admit = await this._buildAdmissionProof()
    if (!admit) throw new Error('StateBaseV3 cannot request writer: no membership/identity to build an admission proof')
    // key MUST be a string (encoder always writes it; undefined crashes compact-
    // encoding). MOVE_INDEXER = indexer swap (deliberate hand-off); START_INDEXING =
    // grow the indexer set (self-promote, quorum path); '' = plain-writer admission.
    // None insert a game entry.
    const key = claimIndexer ? MOVE_INDEXER : (startIndexing ? START_INDEXING : '')
    await this.base.append(encode('@state/set-entry', { key, admit, ts: Date.now() }), { optimistic: true })
    await this.base.update()
    const deadline = Date.now() + timeoutMs
    while (!done() && Date.now() < deadline) {
      await new Promise(r => setTimeout(r, 250))
      // RE-RING the doorbell while still un-admitted: a single optimistic block can be rolled back
      // before an intermittent / just-connected indexer acks it (silent-loss RECOVER mode), so keep
      // a live optimistic block present to re-anchor the tip until admission lands. Once writable,
      // stop — further appends go through the normal (non-optimistic) writer path.
      if (!this.base.writable) {
        try { await this.base.append(encode('@state/set-entry', { key, admit, ts: Date.now() }), { optimistic: true }) } catch {}
      }
      await this.base.update().catch(() => {})
    }
    return { writable: this.base.writable, indexer: this._isLocalIndexer() }
  }

  // Is THIS daemon's local writer an indexer? autobase tracks it directly.
  _isLocalIndexer () {
    try { return this.base.isIndexer === true } catch { return null }
  }

  async get (key) {
    if (!this._ready) return null
    await this.base.update()
    const rec = await this.base.view.get(ENTRIES, { key: String(key) })
    return rec ? this._dec(rec.value) : null
  }

  async list (prefix = '') {
    if (!this._ready) return {}
    await this.base.update()
    const out = {}
    const stream = this.base.view.find(ENTRIES, { gte: { key: prefix }, lt: { key: prefix + '~' } })
    for await (const rec of stream) out[rec.key] = this._dec(rec.value)
    return out
  }

  // value is an arbitrary JSON-able Variant from the game; stored as a JSON string.
  _enc (v) { try { return JSON.stringify(v) } catch { return String(v) } }
  _dec (s) { if (typeof s !== 'string') return s; try { return JSON.parse(s) } catch { return s } }

  // ── Apply-side op handlers (called by the router) ──────────
  async _applySet (rec, ctx) {
    // D1b admission: an optimistic write from a not-yet-admitted member carries
    // an `admit` proof. Verify it deterministically (pure crypto + projected
    // __members__, NO external lookup) and ackWriter so the block commits.
    // Unverified optimistic blocks are NOT acked → autobase never applies them.
    if (rec.admit && ctx.node && ctx.node.from && ctx.node.from.key) {
      const fromKeyHex = ctx.node.from.key.toString('hex')
      // Already admitted this writer — addWriter is idempotent, so skip the
      // re-verify + re-addWriter + re-ack (and the log line) that the peer's
      // every-op `admit` proof would otherwise trigger. Once they're a permanent
      // writer their blocks commit via normal acking; no per-block ackWriter.
      const fromKey = ctx.node.from.key
      const fromHex = fromKeyHex.slice(0, 12)
      if (rec.key === MOVE_INDEXER) {
        // MOVE-INDEXER (keet members/index.js:258-275): a DESIGNATED admin anchor
        // takes the SOLE indexer slot via an atomic swap — promote the anchor +
        // demote the genesis creator (whose writer key is the deterministic
        // base.key). Count stays 1, so the always-on anchor finalizes (quorum 1)
        // even with the creator offline. We do NOT grow the indexer set: autobase
        // can't reliably exceed ~2 indexers (the 3rd-indexer manifest hang, proven).
        // Re-runnable (idempotent addWriter); not gated by _admitted (it's a
        // promotion, not a first admission). Only admins (role 1) may claim.
        const role = await this._verifyAdmissionInApply(fromKey, rec.admit, ctx.tx)
        if (role !== 1) { console.log('[state-base-v3] MOVE-INDEXER DENIED from=%s role=%s', fromHex, String(role)); return }
        try {
          await ctx.base.addWriter(fromKey, { indexer: true })
          this._admitted.add(fromKeyHex)
          if (this._detBaseKey && Buffer.compare(fromKey, this._detBaseKey) !== 0) {
            await ctx.base.addWriter(this._detBaseKey, { indexer: false }) // demote genesis creator
          }
          // NO preferFastForward: a fast-forwarding indexer refuses to ack/sign (autobase
          // index.js:952), which deadlocks the indexer-set migration so signedLength stalls on
          // ALL peers (audit 2026-07-02). keet's indexer ops don't call it. Removing it lets the
          // new indexer ack and quorum reform. Proven on START_INDEXING (probe-indexer-quorum 8/8
          // + probe-rotation-phantom-check 3/3 inline); MOVE-INDEXER (anchor path) same rationale.
          console.log('[state-base-v3] MOVE-INDEXER: anchor=%s is now SOLE indexer (genesis demoted)', fromHex)
        } catch (e) { console.log('[state-base-v3] move-indexer FAIL from=%s: %s', fromHex, e.message) }
        try { await ctx.base.ackWriter(fromKey) } catch (e) { console.log('[state-base-v3] ackWriter FAIL: %s', e.message) }
      } else if (rec.key === START_INDEXING) {
        // START-INDEXING (keet members/index.js op D): an admin GROWS the indexer set
        // by adding ITSELF as an indexer — NO demote of anyone. With >=3 admin-indexers,
        // autobase majority quorum ((n>>>1)+1) finalizes even when the genesis/creator
        // is offline. Only admins (role 1) may promote; idempotent addWriter (not gated
        // by _admitted — it's a promotion, and combines first-admission + indexer grow).
        const role = await this._verifyAdmissionInApply(fromKey, rec.admit, ctx.tx)
        if (role !== 1) { console.log('[state-base-v3] START-INDEXING DENIED from=%s role=%s', fromHex, String(role)); return }
        try {
          await ctx.base.addWriter(fromKey, { indexer: true })
          this._admitted.add(fromKeyHex)
          // NO preferFastForward — see MOVE-INDEXER note above (FF-deadlock, autobase index.js:952).
          console.log('[state-base-v3] START-INDEXING: %s is now an indexer (grow, no demote)', fromHex)
        } catch (e) { console.log('[state-base-v3] start-indexing FAIL from=%s: %s', fromHex, e.message) }
        try { await ctx.base.ackWriter(fromKey) } catch (e) { console.log('[state-base-v3] ackWriter FAIL: %s', e.message) }
      } else if (!this._admitted.has(fromKeyHex)) {
        const role = await this._verifyAdmissionInApply(fromKey, rec.admit, ctx.tx)
        console.log('[state-base-v3] ADMIT attempt from=%s verifiedRole=%s', fromHex, String(role))
        if (role == null) return
        // Admit as a PLAIN WRITER (indexer:false). We deliberately never grow the
        // indexer set; the sole indexer is the genesis creator OR a move-indexer
        // anchor. Everyone else writes and the indexer signs.
        try { await ctx.base.addWriter(fromKey, { indexer: false }); this._admitted.add(fromKeyHex); console.log('[state-base-v3] addWriter OK from=%s indexer=false', fromHex) } catch (e) { console.log('[state-base-v3] addWriter FAIL from=%s: %s', fromHex, e.message) }
        try { await ctx.base.ackWriter(fromKey) } catch (e) { console.log('[state-base-v3] ackWriter FAIL: %s', e.message) }
      }
    }
    // Empty/null key = a pure admission request; MOVE_INDEXER / START_INDEXING =
    // promotion requests. All ran their effect above; none write a game entry.
    if (!rec.key || rec.key === MOVE_INDEXER || rec.key === START_INDEXING) return
    const existing = await ctx.tx.get(ENTRIES, { key: rec.key })
    if (existing && typeof existing.ts === 'number' && existing.ts > (rec.ts || 0)) return
    await ctx.tx.insert(ENTRIES, { key: rec.key, value: rec.value, ts: rec.ts || 0 })
  }

  async _applyDel (rec, ctx) {
    const existing = await ctx.tx.get(ENTRIES, { key: rec.key })
    if (existing && typeof existing.ts === 'number' && existing.ts > (rec.ts || 0)) return
    await ctx.tx.delete(ENTRIES, { key: rec.key })
  }

  // ── Membership projection (cross-base sync) ────────────────
  // Daemon's out-of-apply watcher copies keet-room membership in so apply can
  // gate admission deterministically. Stored under the reserved key __members__
  // (a memberId-hex → role map). No-op until we're a writer (another writer
  // projects; peers replicate it).
  async projectMembers (members) {
    if (!this._ready || !this.base || !this.base.writable) return false
    const map = {}      // memberKey(hex) -> role
    const devmap = {}   // keet writer/device key(hex) -> memberKey(hex)   (for admission model A)
    if (Array.isArray(members)) {
      for (const m of members) {
        const id = normKeyHex(m.key || m.devicePubkey || m.identity || m.memberId || m.id)
        if (!id) continue
        map[id] = Number(m.role) || 3
        const devs = Array.isArray(m.devices) ? m.devices : []
        for (const d of devs) { const dh = normKeyHex(d); if (dh) devmap[dh] = id }
      }
    } else if (members && typeof members === 'object') {
      for (const id of Object.keys(members)) { const h = normKeyHex(id); if (h) map[h] = Number(members[id]) || 3 }
    }
    const tag = 'projectMembers ' + (this.base.key ? this.base.key.toString('hex').slice(0, 12) : '?')
    await timed(tag + ' append __members__', this.base.append(encode('@state/set-entry', { key: '__members__', value: JSON.stringify(map), ts: Date.now() })))
    await timed(tag + ' append __devices__', this.base.append(encode('@state/set-entry', { key: '__devices__', value: JSON.stringify(devmap), ts: Date.now() })))
    await timed(tag + ' update', this.base.update())
    // P3 (#103): a re-projection is the moment a role change becomes visible. If THIS
    // node was just granted admin, re-check promotion immediately rather than waiting
    // for the next timer tick. Background + best-effort; the timer is the general
    // fallback for role changes replicated in from another writer's projection.
    this._maybePromote().catch(() => {})
    return true
  }

  // SYNCHRONOUS snapshot of the autobase's processing state, for diagnosing a base
  // whose open or append never returns. Every other diagnostic awaits the base
  // (update(), list(), the router's cached open promise), so when the autobase
  // _advance loop is stuck they hang with it. This reads fields only, never awaits.
  // Fields are autobase 7.28.1 internals (index.js: _caughtup, fastForwardTo,
  // _draining, _advancing, _interrupting, _appending, _appended; activeWriters of
  // writers with .core + .length) — each read is guarded, so a renamed field shows
  // as null instead of throwing.
  internals () {
    const hex8 = (k) => (k ? Buffer.from(k).toString('hex').slice(0, 12) : null)
    const get = (fn) => { try { const v = fn(); return v === undefined ? null : v } catch (e) { return 'ERR ' + e.message } }
    const b = this.base
    const out = {
      ready: this._ready,
      closing: this._closing,
      hasBase: !!b,
      baseKey: get(() => hex8(b.key)),
      // THIS machine's own writer core. Every peer replicates every writer, so a key
      // being on disk does not say whose it is; this does. Matches an entry in writers[].
      localKey: get(() => hex8(b.local.key)),
      opened: get(() => b.opened),
      writable: get(() => b.writable),
      caughtup: get(() => b._caughtup),
      fastForwardTo: get(() => (b.fastForwardTo === null ? null : { length: b.fastForwardTo.length != null ? b.fastForwardTo.length : null })),
      localRecovering: get(() => b.local.recovering),
      localLength: get(() => b.local.length),
      // true from _advance()'s start to its end: stuck-true = the processing loop is
      // hung. (Not _advancing: autobase keeps the LAST promise there, so it is
      // non-null on an idle, healthy base too.)
      draining: get(() => b._draining),
      interrupting: get(() => b._interrupting),
      paused: get(() => b.paused),
      appendingPending: get(() => (b._appending ? b._appending.length : 0)),
      appended: get(() => b._appended),
      systemPresent: get(() => b.system != null),
      indexers: get(() => b.linearizer.indexers.map(w => hex8(w.core.key))),
      swarmConnections: get(() => (this.swarm ? this.swarm.connections.size : null))
    }
    out.writers = get(() => [...b.activeWriters].map(w => ({
      key: hex8(w.core.key),
      applied: w.length,
      coreLength: get(() => w.core.length),
      contiguous: get(() => w.core.contiguousLength),
      corePeers: get(() => w.core.peers.length),
      isIndexer: get(() => w.isActiveIndexer)
    })))
    return out
  }

  async getProjectedRole (id) {
    if (!this._ready || !this.base) return null
    await this.base.update()
    const mrec = await this.base.view.get(ENTRIES, { key: '__members__' })
    if (!mrec || !mrec.value) return null
    let members; try { members = JSON.parse(mrec.value) } catch { return null }
    const h = normKeyHex(id)
    const role = members[h] != null ? members[h] : members[id]
    return role != null ? Number(role) : null
  }

  // ── Admission proof (op-embedded) ──────────────────────────
  // Proof attests (over localKey‖base.key, so it can't be replayed onto another
  // base) that `identity` vouches for this writer's local key. keet attestation
  // (preferred) or raw ed25519 (fallback).
  async _buildAdmissionProof () {
    if (!this.base || !this.base.local || !this.base.local.key || !this.base.key) return null
    const localKey = this.base.local.key
    const signData = Buffer.concat([localKey, this.base.key])
    // (A) keet-writer-control: sign signData with our keet ROOM writer keypair and
    // carry that writer's manifest. The verifier derives deviceKey=Hypercore.key(manifest),
    // checks sig against manifest.signers[0].publicKey, maps device→member→role.
    if (this._roomWriterKeyPair && this._roomWriterKeyPair.secretKey && this._roomWriterManifest) {
      const sig = crypto.sign(signData, toBuf(this._roomWriterKeyPair.secretKey))
      const manifestEnc = Verifier.encodeManifest(this._roomWriterManifest)
      return JSON.stringify({ manifest: Buffer.from(manifestEnc).toString('hex'), sig: sig.toString('hex') })
    }
    // legacy keet-attestation / raw-ed25519 fallback (older callers + synthetic tests)
    if (this._attest && this._selfKey) {
      const proof = await this._attest(signData)
      if (!proof) return null
      const pbuf = Buffer.isBuffer(proof) ? proof : Buffer.from(proof)
      return JSON.stringify({ identity: this._selfKey.toString('hex'), proof: pbuf.toString('hex') })
    }
    if (this._selfKeyPair && this._selfKeyPair.secretKey) {
      const sig = crypto.sign(signData, toBuf(this._selfKeyPair.secretKey))
      return JSON.stringify({ identity: toBuf(this._selfKeyPair.publicKey).toString('hex'), proof: sig.toString('hex') })
    }
    return null
  }

  // DETERMINISTIC in-apply verification (pure crypto + projected __members__).
  async _verifyAdmissionInApply (localKey, admitStr, tx) {
    try {
      const admit = typeof admitStr === 'string' ? JSON.parse(admitStr) : admitStr
      if (!admit || !this.base || !this.base.key) return null
      const signData = Buffer.concat([localKey, this.base.key])
      // (A) keet-writer-control proof: { manifest, sig }. Re-derive the keet writer/
      // device key from the manifest, verify the sig against the manifest's signer,
      // then resolve device→member (__devices__) → role (__members__).
      if (admit.manifest && admit.sig) {
        let manifest, device, signerPub
        try {
          manifest = Verifier.decodeManifest(Buffer.from(admit.manifest, 'hex'))
          device = Hypercore.key(manifest)
          signerPub = manifest.signers && manifest.signers[0] && manifest.signers[0].publicKey
        } catch (e) { console.log('[state-base-v3] verify: bad manifest %s', e.message); return null }
        if (!signerPub) { console.log('[state-base-v3] verify: manifest has no signer'); return null }
        if (!crypto.verify(signData, Buffer.from(admit.sig, 'hex'), signerPub)) {
          console.log('[state-base-v3] verify: writer sig INVALID for device=%s', device.toString('hex').slice(0, 12)); return null
        }
        const drec = await tx.get(ENTRIES, { key: '__devices__' })
        const devmap = drec && drec.value ? JSON.parse(drec.value) : {}
        const deviceHex = device.toString('hex')
        const memberKey = devmap[deviceHex] != null ? devmap[deviceHex] : devmap[normKeyHex(deviceHex)]
        if (!memberKey) { console.log('[state-base-v3] verify: device=%s NOT in __devices__', deviceHex.slice(0, 12)); return null }
        const mrec = await tx.get(ENTRIES, { key: '__members__' })
        const members = mrec && mrec.value ? JSON.parse(mrec.value) : {}
        const role = members[memberKey] != null ? members[memberKey] : members[normKeyHex(memberKey)]
        if (role == null) console.log('[state-base-v3] verify: member=%s (device %s) NOT in __members__', String(memberKey).slice(0, 12), deviceHex.slice(0, 12))
        return role != null ? Number(role) : null
      }
      // legacy keet-attestation / raw-ed25519 proof: { identity, proof }
      if (!admit.identity || !admit.proof) return null
      const identity = Buffer.from(admit.identity, 'hex')
      const proof = Buffer.from(admit.proof, 'hex')
      let ok = false
      if (this._verifyAttest) ok = await this._verifyAttest(proof, signData, identity)
      else { if (proof.length !== 64) { console.log('[state-base-v3] verify: bad proof len'); return null }; ok = crypto.verify(signData, proof, identity) }
      if (!ok) { console.log('[state-base-v3] verify: proof INVALID for identity=%s (verifyAttest=%s)', admit.identity.slice(0, 12), !!this._verifyAttest); return null }
      const mrec = await tx.get(ENTRIES, { key: '__members__' })
      if (!mrec || !mrec.value) { console.log('[state-base-v3] verify: no __members__ in view'); return null }
      const members = JSON.parse(mrec.value)
      const role = members[admit.identity] != null ? members[admit.identity]
        : (members[normKeyHex(admit.identity)] != null ? members[normKeyHex(admit.identity)] : null)
      if (role == null) console.log('[state-base-v3] verify: identity=%s NOT in __members__ (keys=%s)', admit.identity.slice(0, 12), Object.keys(members).map(k => k.slice(0, 8)).join(','))
      return role != null ? Number(role) : null
    } catch (e) { console.log('[state-base-v3] verify: threw %s', e.message); return null }
  }

  watch (fn) { this._watchers.add(fn); return () => this._watchers.delete(fn) }
  // `writable` answers "will my durable writes persist?" — true once autobase has
  // activated our local writer (base.writable), OR an indexer has already admitted
  // our local writer into the linearized system writer set (cached
  // _selfIsAdmittedWriter). The admitted-writer signal is true at/before
  // base.writable's localWriter activation, so it still covers the post-admission
  // lag WITHOUT the false-positive of keying off __members__: room membership is a
  // SUPERSET of admitted writers, so an eligible member the indexer never admitted
  // (e.g. an identity absent from the indexer's own membership) must NOT read
  // writable. Sync getter uses the cache; callers wanting a fresh answer await
  // effectiveWritable(). See hyper-fleeky-dev #99.
  get writable () { return this.base?.writable || false }

  // Fresh "will my writes persist?" — refreshes the _selfIsAdmittedWriter cache.
  async effectiveWritable () {
    if (this.base && this.base.writable) return true
    return await this._refreshSelfWriter()
  }

  // Re-derive whether our LOCAL WRITER KEY (base.local.key) is in the autobase
  // linearized system writer set (base.system) — i.e. an indexer has durably
  // admitted us, so our writes finalize. This is the authoritative "admitted"
  // signal — the same one autobase's own fast-forward consults
  // (lib/fast-forward.js: system.get(base.local.key)). It is NOT mere room
  // membership: __members__ gates admission (_verifyAdmissionInApply), but being
  // in it does not mean an indexer actually admitted us (#99). base.system
  // (=_applyState.system) is null transiently on reboot/close → keep the cache.
  async _refreshSelfWriter () {
    try {
      if (!this._ready || !this.base || !this.base.local || !this.base.local.key) return this._selfIsAdmittedWriter
      await this.base.update()
      const sys = this.base.system
      if (!sys) return this._selfIsAdmittedWriter
      this._selfIsAdmittedWriter = await sys.has(this.base.local.key)
      return this._selfIsAdmittedWriter
    } catch { return this._selfIsAdmittedWriter }
  }
  get stateBaseKey () { return this.base?.key || null }
  get hasBootstrap () { return !!this._detBaseKey }

  // ── creator election (v2 logic) ────────────────────────────
  async _amCreator () {
    if (!this._getMembers || !this._selfKey) return false
    let members
    try { members = await this._getMembers() } catch { return false }
    if (!Array.isArray(members) || members.length === 0) return false
    const norm = (k) => (Buffer.isBuffer(k) ? k.toString('hex') : String(k)).toLowerCase()
    const eligible = members
      .map(m => ({ key: norm(m.key || m.devicePubkey || m.identity || m.memberId), role: Number(m.role) }))
      .filter(m => m.key && (m.role === 1 || m.role === 2))
      .sort((a, b) => (a.key < b.key ? -1 : a.key > b.key ? 1 : 0))
    if (eligible.length === 0) return false
    return eligible[0].key === this._selfKey.toString('hex')
  }

  // ── corruption recovery (same posture as v2 1.0.2) ─────────
  _isCorruptionError (err) {
    const m = (err && err.message ? err.message : String(err)).toUpperCase()
    return m.includes('DECODING_ERROR') || m.includes('UNKNOWN WIRE TYPE') || m.includes('CORRUPT') ||
      m.includes('INVALID DEVICE FILE') || m.includes('CHECKSUM') || m.includes('INVALID OPLOG') || m.includes('INVALID HEADER')
  }

  async _quarantineAndReset () {
    if (this.swarm) { try { await this.swarm.destroy() } catch {} ; this.swarm = null }
    if (this.base) { try { await this.base.close() } catch {} ; this.base = null }
    if (this.store) { try { await this.store.close() } catch {} ; this.store = null }
    const quarantine = this._storagePath + '.corrupt-' + Date.now()
    fs.renameSync(this._storagePath, quarantine)
    console.error('[state-base-v3] room=%s quarantined -> %s', this._roomKeyHex.slice(0, 12), quarantine)
    fs.mkdirSync(this._storagePath, { recursive: true })
    this.store = new Corestore(this._storagePath)
    await this.store.ready()
    await this._open()
  }

  async close () {
    if (this._closing) return
    this._closing = true
    if (this._promoteTimer) { clearInterval(this._promoteTimer); this._promoteTimer = null }
    if (this.swarm) { await this.swarm.destroy(); this.swarm = null }
    if (this.base) { await this.base.close(); this.base = null }
    if (this.store) { await this.store.close(); this.store = null }
    this._watchers.clear()
    this._ready = false
    this._closing = false
  }
}