OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-proto-peer / lib / device-node.js

924 lines · javascript ref:
// atproto-base device boot — the ROOT provisions devices; a DEVICE boots from its bundle, never the
// root mnemonic (atproto-base-REVOCATION.md §4). provisionDevice (root-side) creates the swarm
// identity + attests a device; bootSwarmDevice (device-side) brings the device up from the received
// { did, signingKey, deviceProof, rootIdentityKey } + its own deviceMnemonic.

import crypto from 'hypercore-crypto'
import b4a from 'b4a'
import fs from 'bare-fs'
import process from 'bare-process' // for the OTA respawn exit — Bare has no global process

// Set by the daemon entrypoint, which owns close() and therefore the exit. Kept module-level rather than
// threaded through bootMultiDevice -> bootSwarmDevice because the UDS surface is created deep inside the
// per-device boot and the only thing it needs is "tell whoever owns the process".
let otaHandler = null
export function setOtaHandler (fn) { otaHandler = typeof fn === 'function' ? fn : null }
import Hyperswarm from 'hyperswarm'
import ProtomuxWakeup from 'protomux-wakeup'
import Protomux from 'protomux'
import { CID } from 'multiformats/cid'
import { join } from 'bare-path'
import { createSwarmIdentity } from 'atproto-identity'
import { StateBaseV3 } from 'atproto-room/lib/state-base.js'
import { SwarmDevice } from './swarm-device.js'
import { nextRetry } from './reconcile-retry.js'
import { serveDevice } from './serve-device.js'
import { serveDeviceUds } from './serve-device-uds.js'
import { LabelRoom } from 'atproto-room/lib/label-room.js'
import { serveMembrane } from 'atproto-room/lib/serve-membrane.js'
import { BlobStore } from 'atproto-store/lib/blob-store.js'
import { OverlayBlobStore } from 'atproto-store/lib/overlay-blob-store.js'
import { BlobPointerStore } from 'atproto-store/lib/blob-pointer-store.js'
import { DualBlobStore } from './dual-blob-store.js'
import { OverlayRepoStore } from 'atproto-store/lib/overlay-repo-store.js'
import { HyperOverlay } from 'hyper-overlay'
import { openCorestore } from 'atproto-store/lib/corestore.js'
// Every corestore is wrapped so a hypercore conflict is RECORDED, not just console.logged by upstream.
// See lib/conflict-watch.js — nothing in this stack listened for the `conflict` event before this.
import { watchStore } from './conflict-watch.js'
import { AccountRegistry } from 'atproto-store/lib/account-registry.js'
import { loadOrCreateSwarmKeyPair } from './swarm-keypair.js'
import { normalizeMembers } from './config.js'
import { makeSessions } from './sessions.js'
import { readCar, loadCommit, listRecords } from 'atproto-repo/lib/repo.js'

// Read an existing repo CAR (e.g. wj62's, exported via the bridge) into the { records, head } shape
// bootSwarmDevice's `adopt` option consumes — so a config `adoptCar` path continue-migrates a live
// account into the swarm.
export async function readAdoptRepo (carBytes) {
  const car = await readCar(carBytes)
  const lc = loadCommit(car)
  const sc = lc.value
  const records = listRecords(car).map((r) => { const i = r.key.indexOf('/'); return { collection: r.key.slice(0, i), rkey: r.key.slice(i + 1), record: r.value } })
  return { records, head: { rev: sc.rev, commitCid: lc.cid, dataCid: sc.data, signedCommit: sc } }
}
import { makeAttest, makeVerifyAttest, attestDeviceChain, deviceKeetKeyPair, rootIdentityPublicKey } from 'atproto-room/lib/membership.js'

const NS = (s) => b4a.from('atproto-base/v1/' + s)

// provisionDevice — ROOT-side: create the swarm identity + attest ONE device, producing the bundle
// the device boots from. The root holds the mnemonic (recovery + signing keys); the device receives
// { did, signingKey, deviceProof, rootIdentityKey } — NEVER the root, so it can't derive the recovery
// key or self-recover. signingKey is the sensitive part and should be delivered encrypted (the
// broadcast-encryption slice); this just assembles the bundle. Also returns the signed genesis op
// (for PLC registration) + recoveryKey stays with the root (NOT in the bundle).
export async function provisionDevice ({ rootMnemonic, devicePublicKey, handle, pdsUrl } = {}) {
  if (!rootMnemonic || !devicePublicKey) throw new Error('provisionDevice: rootMnemonic + devicePublicKey required')
  const devPub = typeof devicePublicKey === 'string' ? b4a.from(devicePublicKey, 'hex') : devicePublicKey
  const id = createSwarmIdentity(rootMnemonic, { handle, pdsUrl })
  const deviceProof = await attestDeviceChain({ rootMnemonic, devicePublicKey: devPub })
  const rootPub = await rootIdentityPublicKey(rootMnemonic)
  return {
    did: id.did,
    signingKey: b4a.toString(id.signingKey.privateKey, 'hex'),
    deviceProof: b4a.toString(b4a.from(deviceProof), 'hex'),
    rootIdentityKey: b4a.toString(rootPub, 'hex'),
    handle,
    pdsUrl,
    signedOp: id.signedOp // for PLC registration (root-side; NOT needed to boot a device)
  }
}

// provisionAdoptedDevice — ROOT-side, ADOPT-INTO-SWARM: provision a device for an EXISTING DID with
// a GIVEN #atproto signing key (e.g. wj62's raw adopted key from its vault) + a SEPARATE keet
// membership root that governs device attestation. The atproto identity (did + signing key + its PLC
// registration) is UNCHANGED — no PLC op; the keet root is new and only decides which devices may
// sign. Returns the { did, signingKey, deviceProof, rootIdentityKey } bundle bootSwarmDevice consumes.
export async function provisionAdoptedDevice ({ did, signingKey, keetRootMnemonic, devicePublicKey } = {}) {
  if (!did || !signingKey || !keetRootMnemonic || !devicePublicKey) throw new Error('provisionAdoptedDevice: did, signingKey, keetRootMnemonic, devicePublicKey required')
  const devPub = typeof devicePublicKey === 'string' ? b4a.from(devicePublicKey, 'hex') : devicePublicKey
  const deviceProof = await attestDeviceChain({ rootMnemonic: keetRootMnemonic, devicePublicKey: devPub })
  const rootPub = await rootIdentityPublicKey(keetRootMnemonic)
  return {
    did,
    signingKey: typeof signingKey === 'string' ? signingKey : b4a.toString(signingKey, 'hex'),
    deviceProof: b4a.toString(b4a.from(deviceProof), 'hex'),
    rootIdentityKey: b4a.toString(rootPub, 'hex')
  }
}

// `joiner` — this device is JOINING an existing room, not bootstrapping one. It must never claim the
// deterministic genesis writer and must write NOTHING at boot; it converges by replication and the
// forward-only sign() picks up from the tip.
//
// This exists because the creator election is `lowest-keyed member == self` (state-base.js _amCreator),
// so admitting a device whose key sorts BELOW the existing creator's silently flips the role: the joiner
// elects itself creator, opens the roomSeed-derived gsKeyPair EMPTY-and-writable in its fresh store and
// authors a rival genesis, while the incumbent — no longer lowest — opens keyPair=null and ABANDONS the
// genesis writer it had been using. Both halves are wrong and neither errors. Observed in production
// admitting a second machine to loser-club: the joiner came up amCreator=true with its own 1-record head
// while the incumbent logged "stays a PLAIN WRITER". Membership is cryptographic and worked perfectly;
// the ROLE is what broke.
//
// So a room's creator is an explicit property, not an emergent consequence of key ordering: the joiner
// sets `joiner` and the incumbent sets `forceCreator`. Same shape as the resync path, which already had
// to solve exactly this for a creator rejoining from a fresh store.
export async function bootSwarmDevice ({ storage, deviceMnemonic, did, signingKey, deviceProof, rootIdentityKey, handle, pdsUrl, swarm = true, swarmOpts = null, signIntervalMs = 2000, members = null, control = null, writers = null, adopt = null, resetFirehose = false, appPasswordHash = null, jwtSecret = null, shared = null, records = null, federationHeartbeatMs = 0, primary = false, storeBackend = null, resyncStore = null, forceCreator = false, joiner = false, projectRepoCore = true, onRelease = null, labeler = false } = {}) {
  if (typeof storage !== 'string') throw new Error('bootSwarmDevice: storage path required')
  if (!deviceMnemonic) throw new Error("bootSwarmDevice: deviceMnemonic (this device's own identity) required")
  if (!did || !signingKey || !deviceProof || !rootIdentityKey) throw new Error('bootSwarmDevice: provisioned bundle (did, signingKey, deviceProof, rootIdentityKey) required — run provisionDevice on the root')

  // RECEIVED material — the shared signing key, the root identity pubkey, and this device's
  // attestation. NONE of it is the root mnemonic (so this device cannot derive the recovery key).
  const sharedKey = typeof signingKey === 'string' ? b4a.from(signingKey, 'hex') : signingKey
  const rootPub = typeof rootIdentityKey === 'string' ? b4a.from(rootIdentityKey, 'hex') : rootIdentityKey
  const proof = typeof deviceProof === 'string' ? b4a.from(deviceProof, 'hex') : deviceProof

  // members-only room secrets — derivable only by devices that hold the DID + shared signing key
  const roomKey = crypto.hash([NS('room-key'), b4a.from(did)])
  const roomSeed = crypto.hash([NS('room-seed'), b4a.from(sharedKey)])
  const encryptionKey = crypto.hash([NS('enc'), b4a.from(sharedKey)])

  // this device's OWN keet identity + the root→device attestation it was provisioned with
  const deviceKp = await deviceKeetKeyPair(deviceMnemonic)
  const self = b4a.toString(deviceKp.publicKey, 'hex')
  // NORMALISE AT THE BOUNDARY, do not trust the caller to have done it. `members` reaches here in three
  // shapes — [{key,role}], an array of hex strings, or a "hex,hex" comma-string — and projectMembers
  // understands only the first (state-base.js reads `m.key`, so a plain string yields undefined and is
  // skipped, producing an EMPTY map). loadConfig normalises, so every node booting through it is fine;
  // a caller that parses config.json itself is not. favela-android did exactly that, and its roster
  // silently evaporated: the room refused to project, logging "empty roster would wipe 3 existing
  // member(s)" every 30s while its config plainly listed three. Third instance of this trap in this
  // codebase (see coowner.js) — hence fixing it where the value is CONSUMED, not in each reader.
  // Idempotent for already-normalised input, which is what every fleet node passes today.
  const roster = normalizeMembers(members) || [{ key: self, role: 1 }]

  // ── PRE-CUTOVER DID: keep the StateBase WHERE IT ALREADY IS ──────────────────────────────────────
  // `primary` already means "this DID predates the multi-DID cutover on this box, keep its paths
  // stable" — it preserves the flat control.sock, swarm-keypair and membrane. It did NOT cover the
  // StateBase, which relocated from its own corestore at <storage>/statebase into a namespace of the
  // shared store. That relocation is not cosmetic: StateBaseV3 derives its local writer from the
  // corestore's primaryKey (own store) vs the shared store's primaryKey + roomKey namespace, so moving
  // it gives the DID a DIFFERENT writer on an EMPTY base. The creator's genesis writer is deterministic,
  // so it re-opens empty-and-writable and the boot-write block authors a fresh genesis — i.e. the DID's
  // history is left behind in the old directory.
  //
  // wj62 survived that relocation only because it had a CO-OWNER to backfill from. A single-homed DID has
  // no such peer, which is the entire reason ashatron could not move onto the unified path. Keeping the
  // StateBase in place removes the need for a peer: the writer identity and history are untouched, and
  // only the boot path changes. The daemon replicates this store explicitly (see _statebaseStore on the
  // returned node), exactly as it already does for a resync side store. Nothing is moved, copied or
  // deleted — the fix is to STOP relocating.
  const legacyStatebasePath = join(storage, 'statebase')
  const keepLocalStatebase = !!shared && !!primary && !resyncStore && fs.existsSync(legacyStatebasePath)
  if (keepLocalStatebase) console.log(JSON.stringify({ msg: 'pre-cutover DID: keeping its StateBase in place (own corestore) instead of re-homing to the shared store', did, path: legacyStatebasePath }))
  const sb = new StateBaseV3((keepLocalStatebase || !shared) ? legacyStatebasePath : null, roomKey, {
    roomSeed,
    encryptionKey,
    // Multi-DID: open on the daemon's SHARED Corestore (a per-DID namespace) + SHARED wakeup, and
    // create no swarm of our own — the daemon owns store+swarm+wakeup and drives replication
    // (bootMultiDevice). Single-DID keeps its own per-room store + swarm exactly as before.
    network: shared ? false : swarm,
    // RESYNC (fresh-corestore re-join): a poisoned autobase local writer lives in the SHARED store,
    // keyed by that store's primaryKey — a fresh namespace re-derives the SAME poisoned writer (pgv3 #4).
    // resyncStore is a DEDICATED per-DID Corestore (its own primaryKey → a CLEAN local writer) that joins
    // the SAME base (roomKey-derived key) as a fresh replica of the group's healthy state. Only the
    // StateBase base re-homes here; the overlay/membrane/control keypair stay on shared.store (below), so
    // the bridge-facing serverKey/coords are unchanged — the resync is transparent to the bridge. The old
    // poisoned cores stay on disk in shared.store (nothing deleted). Read-only until re-admitted (the tick
    // loop's ensureAdmitted rings the doorbell; a current indexer addWriters the fresh local key).
    // keepLocalStatebase: pass NO store so StateBaseV3 opens its own Corestore at the legacy path,
    // preserving the writer identity derived from that store's primaryKey.
    store: resyncStore || ((shared && !keepLocalStatebase) ? shared.store : undefined),
    // RESYNC heal: force this fresh-corestore re-join to open as a NON-creator (fresh local writer), so
    // the deterministic genesis writer becomes a REMOTE writer that linearizes — instead of an
    // un-sheddable local writer autobase won't linearize (diagnostic 1). Mirrors keet's fresh-writer rejoin.
    // A JOINER takes the same posture as a resync: never elect as creator, so the roomSeed-derived
    // gsKeyPair stays with the incumbent and becomes a REMOTE writer here that replicates + linearizes.
    // Without this, a joiner whose key sorts below the incumbent's wins the election and authors a rival
    // genesis (see the `joiner` note on bootSwarmDevice).
    forceNonCreator: !!resyncStore || !!joiner,
    // assume-genesis heal: a healthy co-owner re-assumes the deterministic genesis writer/indexer after
    // the original creator abandoned it via a resync. Mutually exclusive with resync (resync wins).
    forceCreator: forceCreator && !resyncStore,
    wakeup: shared ? shared.wakeup : undefined,
    swarmOpts,
    selfKey: deviceKp.publicKey,
    attest: makeAttest(deviceKp, proof),
    verifyAttest: makeVerifyAttest(rootPub),
    getMembers: async () => roster
  })
  await sb.ready()
  // Project the roster into __members__ so admission is attestation-gated. The CREATOR (lowest-key
  // member — StateBaseV3._amCreator) persists it; peers replicate it. ONE call: StateBaseV3 retains
  // the roster and re-projects EVENT-DRIVEN + self-terminating (on its promote-timer tick + every new
  // swarm connection) until __members__ lands in its view. This REPLACES the old bounded 12-try boot
  // loop, which gave up before writability settled whenever the creator became writable AFTER the
  // ~13s window (re-adopt + swarm flap) → __members__ never landed → admission verifiedRole=null →
  // members held their own lines (the wj62 fork; hyper-social #9, pear-git #4 lineage).
  //
  // ⚠ Keeps the BOOT-HANG fix: NO `sb.get('__members__')` base.update() poll here (that could hang a
  // freshly-admitted plain writer) — this call is non-blocking, boot proceeds, projection self-drives
  // in the background. A non-creator's call no-ops (peers replicate __members__).
  //
  // RESYNC anti-fork (part 2): on a fresh-corestore re-join the CREATOR's projectMembers would WRITE
  // __members__ into its EMPTY genesis core (base.local == base.key for the creator) BEFORE replication
  // backfills it — the same fork the boot-write gate below prevents (diagnosed via localLen=2 on the
  // supposedly converge-only side store, test/resync-creator-convergence.test.js). __members__ already
  // exists in the network (the base was admitting writers before), so it REPLICATES in; the creator must
  // NOT re-project it on a resync. Skip entirely — write nothing until the genesis core has backfilled.
  if (!resyncStore) await sb.projectMembers(roster).catch(() => {})

  // firehose:true — sign() persists each signed #commit into the shared __commit__ log, which the
  // membrane surface projects + serves to the bridge (Phase D). Without it there is nothing to front.
  // Optional file-based record store (records-as-files, O(current) — off the append-only StateBase).
  // Flag-gated (storeBackend:'overlay'); reserved "__" keys stay on the StateBase. Records are LOCAL
  // deduped files. hyper-overlay is pure content-addressed STORAGE/distribution here (dedup + serve
  // bytes by hash); it does NOT converge trees. Cross-device convergence is the atproto firehose:
  // the non-signer ingests the signer's firehose and writes record bytes to these files (task B).
  let recordStore = null
  let recordOverlay = null
  let repoRoot = null
  if (storeBackend === 'overlay') {
    repoRoot = join(storage, 'atproto-repo', b4a.toString(roomKey, 'hex').slice(0, 16))
    // HyperOverlay = content-addressed store for record bytes (+ blobs). Keyed by roomKey so N DIDs'
    // overlays coexist on the daemon's ONE shared connection for content SERVING (per-DID channel id).
    // Uses the shared corestore (namespaced) when multi-DID, else a dedicated one.
    const ovStore = shared ? shared.store : watchStore(openCorestore(join(storage, 'atproto-repo-overlay')), 'atproto-repo-overlay')
    recordOverlay = new HyperOverlay(ovStore, { namespace: 'atproto-repo-overlay:' + did, key: roomKey, destDir: join(repoRoot, '.fetch') })
    await recordOverlay.ready()
    recordStore = await new OverlayRepoStore(repoRoot, { overlay: recordOverlay }).ready()
    console.log(JSON.stringify({ msg: 'record store: overlay (content-addressed files)', did, path: repoRoot }))
  }
  const device = new SwarmDevice(sb, { did, privateKey: sharedKey, self, firehose: true, recordStore })
  // LABELER DID (`labeler: true` on its config entry; member labels, hyper-social #80). Moderators' label
  // intents and the signed label log live in this DID's own StateBase, signed with the DID key — so the
  // co-owners that sign its commits are exactly the ones that can sign its labels, with no second
  // election: `holds` is the repo signer's own gate (device.isSigner). Intents sit under a RESERVED name
  // because this base also holds the repo, and RepoBase reads any "/"-bearing non-"__" key as a record.
  const labelRoom = labeler ? new LabelRoom(sb, { src: did, privateKey: sharedKey, holds: () => device.isSigner(), ns: '__labelintent__' }) : null
  // NO unconditional boot claim: that let a booting device STEAL a still-valid lease before it had
  // replicated the current holder's lease (the split-brain root cause). The sign loop claims lazily
  // + safely via renewOrClaim (claim ONLY if the lease is free/TTL-lapsed), after replication has had
  // ticks to converge. The creator's fresh-account bootstrap force-signs its initial commit below.

  // adopt-into-swarm continuity: the writable (creator/origin) device imports the existing repo's
  // records + head so the swarm CONTINUES the chain (next sign: prev = adopted commit, prevData =
  // adopted data root) instead of re-authoring. Records replicate to peers via StateBaseV3. See
  // atproto-pds-ref §2/§7. adopt = { records:[{collection,rkey,record}], head:{rev, commitCid,
  // dataCid} } (CIDs may be strings — parsed here).
  // RESYNC (creator-aware anti-fork gate) — CONVERGE-ONLY boot, write NOTHING. A fresh-corestore re-join
  // is a REPLICA of an EXISTING base whose authoritative pointers + #commits already live in the network;
  // this device's local record FILES may be STALE. The block below appends to base.local (migratePointers
  // backfills file-pointers; restore/adopt/fresh seed head + commits). For the CREATOR that append is fatal:
  // its genesis writer key is DETERMINISTIC (_gsKeyPair), so a fresh corestore re-opens the SAME writer
  // EMPTY and immediately `writable` (NOT shed — test/resync-creator-fork.repro.mjs 4/4). Appending stale
  // file-pointers into that empty genesis core BEFORE replication backfills it FORKS the writer (the
  // df0c052a split-brain, docs/POSTMORTEM-2026-07-21-stortron-wj62-poison-heal.md). The heal: write nothing
  // at boot → base.local backfills from the healthy peer (proven test/creator-writer-backfill.probe.mjs 7/7)
  // → the base converges by replication → the tick loop's FORWARD-ONLY-gated sign() continues the ONE chain
  // at the tip. The network is authoritative for a resynced replica; nothing is deleted (files stay on disk),
  // and a converged migratePointers would be a no-op anyway (idempotent — pointers already replicated in).
  // A JOINER boots converge-only for the same reason a resync does: the authoritative pointers and
  // #commits already exist in the network, so anything written here before replication backfills is a
  // fork. Critically this also suppresses the FRESH-ACCOUNT bootstrap below — on a joiner the log reads
  // empty at boot, so that branch would author an initial commit and a rival head. That is exactly what
  // happened admitting a second machine to loser-club: it came up holding one self-authored record.
  if (resyncStore || joiner) {
    console.log(JSON.stringify({ msg: (resyncStore ? 'resync' : 'joiner') + ': converge-only boot — skipping all local writes (migratePointers/restore/adopt/fresh-account) until replication converges; forward-only sign() continues the chain', did }))
  } else if (sb.base.writable) {
    const cid = (v) => (typeof v === 'string' ? CID.parse(v) : v)
    // STEP 2a MIGRATION (files-only → pointers) — MUST run before any listRecords() below. A repo
    // written by the previous files-only backend (OverlayRepoSubstrate) has its record BYTES on disk
    // but NO pointers in the shared autobase; under PointerSubstrate listRecords() would read ZERO,
    // so the adopt/restore/fresh branch below would mis-classify a populated repo as empty AND the
    // first sign() would wipe it (the wj62 hazard). Backfill a {$oid,size} pointer per un-pointed
    // record file first (idempotent, writable-only, never clobbers a converged pointer/tombstone).
    // The sign() anti-wipe guard is the belt-and-suspenders backstop if this hasn't completed yet.
    // STEP 2a-0 MIGRATION (INLINE records → pointers) — MUST run before migratePointers and before any
    // listRecords() below, because it covers the one shape that would otherwise WIPE the repo.
    //
    // A DID written through the legacy substrate holds full RECORDS in the autobase where {$oid,size}
    // pointers belong, and no record FILES at all. Under PointerSubstrate that reads as an EMPTY record
    // set — _resolve passes ptr.$oid (undefined) to ensureLocal, which falls back to the local file, and
    // with no file it returns null so the key is dropped. The anti-wipe guard in sign() does NOT catch
    // this: it only aborts when record FILES exist, and here there are none. So the first sign() after
    // flipping such a DID to storeBackend:'overlay' would materialize an empty MST root and publish a
    // delete-every-record commit. That is e2jev's exact state (hyper-social#16 P1).
    //
    // migrateInline is the fix and its fromInline branch handles precisely this case — it writes the file
    // FROM the inline copy, then publishes the pointer. Content is preserved rather than chosen: the
    // pointer addresses the bytes that already resolve, asserted by hash, and it REFUSES any key whose
    // oid would change. Writable-only; peers converge the pointers by replication.
    //
    // MARKER-GUARDED (__inline_migrated__) because unlike backfill this REWRITES autobase entries, which
    // must not happen implicitly on every start. Idempotent regardless (a converted entry has $oid and is
    // skipped), so the marker is belt-and-braces, not the only protection.
    try {
      const inlineDone = await sb.get('__inline_migrated__').catch(() => null)
      if (!inlineDone) {
        const mi = await device.migrateInlinePointers()
        if (mi && !mi.skipped) {
          await sb.set('__inline_migrated__', { ts: Date.now(), converted: mi.converted, fromInline: mi.fromInline, total: mi.total })
          if (mi.converted || mi.mismatched || mi.failed) {
            console.log(JSON.stringify({ msg: 'step-2a-0: inline records converted to fetchable pointers', did, ...mi }))
          }
        }
      }
    } catch (e) { console.error(JSON.stringify({ msg: 'step-2a-0: inline migration failed — NOT flipping this DID is safer than signing an empty set', did, error: e.message })) }
    try {
      const mig = await device.migratePointers()
      if (mig && mig.written) console.log(JSON.stringify({ msg: 'step-2a: backfilled files-only records into pointers', did, ...mig }))
    } catch (e) { console.error(JSON.stringify({ msg: 'step-2a: pointer backfill failed (anti-wipe guard still protects)', did, error: e.message })) }
    // ONE-TIME firehose repair (resetFirehose flag): if the shared __commit__ log accumulated malformed
    // frames (full-set re-authors from before the restore fix, which a relay rejects on prevData), clear
    // it + re-emit a single clean continuation from the adoptCar baseline. Guarded by the durable
    // __firehose_reset__ marker so it fires EXACTLY once across the swarm (the origin does it; peers see
    // the marker + skip). NB: also move each device's membrane corestore aside before this boot so the
    // derived firehose Hypercore is rebuilt from the now-clean log (operational step in the runbook).
    const resetDone = resetFirehose ? await sb.get('__firehose_reset__').catch(() => null) : true
    if (resetFirehose && !resetDone && adopt && adopt.head) {
      const head = { rev: adopt.head.rev, commitCid: cid(adopt.head.commitCid), dataCid: cid(adopt.head.dataCid), signedCommit: adopt.head.signedCommit || null }
      const h = await device.resetFirehose(adopt.records || [], head)
      await sb.set('__firehose_reset__', { ts: Date.now(), rev: h ? h.rev : null })
      console.log(JSON.stringify({ msg: 'firehose reset: cleared malformed frames + re-emitted clean continuation', did, from: adopt.head.rev, rev: h ? h.rev : null }))
    } else {
      const existing = await device.repo.listRecords()
      if (existing.length === 0 && adopt && adopt.head) {
        // FIRST cutover only (empty repo): import the existing DID's repo so the swarm CONTINUES its
        // chain instead of re-authoring. On a RESTART the records are already in the shared state, so we
        // must NOT re-adopt (that resets head to the ORIGINAL adopted rev → the next sign re-emits the
        // posts) — we restore instead.
        const head = { rev: adopt.head.rev, commitCid: cid(adopt.head.commitCid), dataCid: cid(adopt.head.dataCid), signedCommit: adopt.head.signedCommit || null }
        try {
          await device.adopt(adopt.records || [], head)
          console.log(JSON.stringify({ msg: 'adopted repo into swarm', did, rev: head.rev, records: (adopt.records || []).length }))
          // Emit ONE baseline #commit frame so the membrane projects immediately — without this an
          // adopted repo with no subsequent write has an empty firehose log and the bridge can't front
          // it (getRepo/getLatestCommit fail) until the first post. See device.emitBaseline().
          const b = await device.emitBaseline()
          if (b) console.log(JSON.stringify({ msg: 'baseline firehose frame emitted (adopted repo is membrane-frontable)', did, rev: b.rev }))
        } catch (e) { console.log(JSON.stringify({ msg: 'adopt FAILED', error: e.message })) }
      } else if (existing.length > 0) {
        // RESTART with existing signed state: re-seed head + the diff baseline from the shared __commit__
        // log so the firehose stays INCREMENTAL (no full-set re-author) and the chain continues.
        const h = await device.restore()
        console.log(JSON.stringify({ msg: 'restored signed head', did, rev: h ? h.rev : null, records: existing.length }))
      } else {
        // FRESH empty account (no adopt, no existing state): author + sign an INITIAL commit so the
        // repo is non-empty and verifiable. A bridge authenticates a peer by replaying its signed
        // commit against the DID key and refuses to front an empty repo (PeerFront.discover), so
        // without this a brand-new swarm account is never frontable — and it can't post its way out
        // (the composer posts THROUGH the bridge). Mirrors the single-tenant path (index.js:112). Only
        // the writable creator authors it; peers receive the commit via __commit__ replication.
        await device.putRecord('app.bsky.feed.post', 'self.hello', {
          $type: 'app.bsky.feed.post',
          text: `hello from ${handle || did}`,
          createdAt: new Date().toISOString()
        })
        const h = await device.sign({ force: true }) // creator bootstrap: sole writable device, no other signer → force-sign is safe (boot claim removed)
        console.log(JSON.stringify({ msg: 'authored initial signed commit (fresh swarm account)', did, rev: h ? h.rev : null }))
      }
    }
  }

  // Ensure per-DID bootstrap records (a community's app.bsky.feed.generator / labeler.service etc.),
  // then sign — folds serve-loser-club.mjs's record-authoring into the supervised daemon. Writable
  // devices only (creator/lease-holder; a plain writer re-authors after admission). Idempotent.
  if (sb.base.writable && !resyncStore && Array.isArray(records) && records.length) {
    let wrote = 0
    for (const r of records) {
      try { const ex = await device.repo.getRecord(r.collection, r.rkey); if (!ex) { await device.putRecord(r.collection, r.rkey, r.record); wrote++ } } catch (e) { console.error(JSON.stringify({ msg: 'bootstrap record failed', did, collection: r.collection, rkey: r.rkey, error: e.message })) }
    }
    if (wrote) { try { await device.sign({ force: true }) } catch {} ; console.log(JSON.stringify({ msg: 'bootstrap records authored', did, wrote })) }
  }

  // sign loop: while we hold the lease, renew + materialize the linearized view into the chain, then
  // re-project the new commit + records into the membrane-served cores so the bridge sees it.
  let membraneServing = null
  // EVENT-DRIVEN sign/project — ZERO polling timer (was setInterval(signIntervalMs), the append-frequency
  // anti-pattern). One sb.watch(tick) fires on every StateBase advance — a local write OR a replicated
  // #commit (view apply) — AND on a signer-presence change (co-owner connect/disconnect →
  // SignerPresence.onChange → sb._fireWatchers). So the lease-holder re-materializes and EVERY device's
  // membrane re-projects exactly when there is something to do, never on an idle clock.
  //
  // The tick body is unchanged in effect but guarded + NON-BLOCKING (sign() reads the autoUpdate
  // snapshot; returns null on an unchanged data root; membrane.sync is incremental from seq.length), so
  // an idle event appends NOTHING. FAILOVER is opportunistic (the zero-timer design): a departed signer's
  // grace expiry is noticed on the next write/replication/presence event — acceptable latency, no data
  // loss (the write is durable in the log; it just materializes into a signed commit a beat later).
  // ensureAdmitted (re-admission doorbell) runs when not writable; requestWriter re-rings internally.
  // BYTE convergence for EVERY device, signer or not. sign() resolves record bytes as a side effect of
  // listRecords({now:true}) — but it returns at the ELECTION GATE first, so a non-signing co-owner
  // never pulled a single record byte and drifted toward "only what I authored" while its pointer set
  // stayed correct (see SwarmDevice.reconcileBytes). Runs on its OWN coalescing guard and is never
  // awaited by the tick, so a large backlog drains without delaying signing or stacking passes.
  // Bounded passes, and it stops as soon as a pass makes no progress — the next event retries.
  // Hoisted so the reconcile runner can reach the blob store: `blobs` below is block-scoped inside
  // `if (controlEnabled)`, and reconcile() is defined before it. Null until the blob stores exist.
  let blobStore = null
  let reconciling = false

  // ⛔ A FAILED FETCH IS NOT "NOTHING TO DO", AND THAT COST A POST. The pass loop below used to break on
  // `!r.fetched`, which collapses two opposite outcomes: "everything resolved" and "I tried and could not
  // reach the bytes". The comment said the next event would retry — and events are the ONLY driver here
  // (zero polling, by design: sb.watch on a StateBase advance or a presence change, plus reconcile() on
  // overlay attach when a peer connects). For the MOST RECENTLY converged pointer the advance that would
  // retry it has already happened, so a single transient miss leaves that record unresolved until some
  // unrelated write or a new peer connection shakes it loose.
  //
  // Measured on the handset 2026-08-18: a post converged at 21:26, its one fetch failed, and nothing
  // retried for 13 minutes. At the next boot the same fetch failed once more and SUCCEEDED 3 seconds
  // later — the miss was transient all along (the peer had not registered the oid yet: hyper-overlay's
  // oid→disk map is in-memory and rebuilt per boot, atproto-store invariant #5). Downstream, favela-web's
  // appview correctly refuses to author bytes hpp owns, so the record indexed but could not render: every
  // layer behaving as designed, and the post invisible.
  //
  // So retry on FAILURE specifically, bounded and self-disarming:
  //   - one pending retry at a time, never a queue (a burst of events must not stack timers)
  //   - exponential backoff, because a peer that is genuinely absent must not be hammered
  //   - a clean pass disarms and resets the budget, so steady state is still zero timers
  //   - a real EVENT resets the budget too: the world changed, so the previous exhaustion is stale
  //   - unref'd, because a convergence retry must never be the reason a process stays alive
  // The arithmetic and the budget live in lib/reconcile-retry.js (pure, tested in
  // test/reconcile-retry.test.mjs). This owns only the timer — and unrefs it, because a convergence
  // retry must never be the reason a process stays alive.
  let retryTimer = null
  let retryDelay = 0
  let retryAttempts = 0

  const reconcile = async ({ fromRetry = false } = {}) => {
    if (reconciling) return
    reconciling = true
    if (retryTimer) { clearTimeout(retryTimer); retryTimer = null }
    let failedRecords = 0
    let failedBlobs = 0
    try {
      for (let pass = 0; pass < 64; pass++) {
        const r = await device.reconcileBytes()
        if (!r || r.skipped) break
        failedRecords = r.failed || 0
        if (!r.more || !r.fetched) break // resolved everything reachable this pass
      }
      // BLOBS converge on the same events. reconcileBytes walks the RECORD pointer set and skips
      // `__`-prefixed keys, so `__blob__/<cid>` could never be covered by it — without this, a blob's
      // pointer replicates but its bytes only ever move when something happens to call get-blob.
      if (blobStore && typeof blobStore.reconcileBlobs === 'function') {
        for (let pass = 0; pass < 64; pass++) {
          const b = await blobStore.reconcileBlobs()
          if (!b || b.skipped) break
          failedBlobs = b.failed || 0
          if (!b.more || !b.fetched) break
        }
      }
    } catch {} finally { reconciling = false }

    const d = nextRetry({ failed: failedRecords, failedBlobs, fromRetry, attempts: retryAttempts, delay: retryDelay })
    retryAttempts = d.attempts
    retryDelay = d.delay
    if (d.giveUp) {
      console.log(JSON.stringify({ msg: 'reconcile: giving up until the next event', did: device.did, records: failedRecords, blobs: failedBlobs, attempts: d.attempts }))
      return
    }
    if (!d.arm) return
    console.log(JSON.stringify({ msg: 'reconcile: bytes unresolved — retry armed', did: device.did, records: failedRecords, blobs: failedBlobs, attempt: d.attempts, inMs: d.delay }))
    retryTimer = setTimeout(() => { retryTimer = null; reconcile({ fromRetry: true }).catch(() => {}) }, d.delay)
    if (retryTimer.unref) retryTimer.unref()
  }

  let ticking = false
  let tickAgain = false
  // ⛔ A SWALLOWED TICK IS INVISIBLE. 2026-10-14: loser-club's elected signer (stortron, holdsSigner true,
  // running the fixed atproto-room) never re-signed its two ver-less member labels, and nothing anywhere said
  // why — this catch ate whatever sign() or the label tick threw. Log it (once per distinct message, so an
  // event storm cannot flood the log), and log when the label room signs, so the next restart is evidence.
  let lastTickError = null
  const tick = async () => {
    if (ticking) { tickAgain = true; return } // coalesce bursts; re-run once if one arrived mid-flight
    ticking = true
    let step = 'admit'
    try {
      if (sb.base && !sb.base.writable) { try { await sb.ensureAdmitted() } catch {} }
      reconcile() // deliberately NOT awaited — byte convergence must not gate signing (or vice versa)
      step = 'sign'
      await device.sign()
      if (labelRoom) {
        step = 'labels'
        const signed = await labelRoom.tick()
        if (signed > 0) console.log(JSON.stringify({ msg: 'labels: signed', did: device.did, count: signed }))
      }
      step = 'project'
      if (membraneServing) { await membraneServing.sync(); await membraneServing.syncLabels() }
      lastTickError = null
    } catch (err) {
      const m = step + ': ' + ((err && err.message) || String(err))
      if (m !== lastTickError) console.error(JSON.stringify({ msg: 'tick failed', did: device.did, step, error: (err && err.message) || String(err) }))
      lastTickError = m
    } finally { ticking = false; if (tickAgain) { tickAgain = false; tick() } }
  }
  let unwatch = null
  if (signIntervalMs > 0) { // >0 = enable the (now event-driven) sign loop; 0 = disabled (tests drive sign() manually)
    unwatch = (typeof sb.watch === 'function') ? sb.watch(() => { tick() }) : null
    tick() // initial pass at boot (materialize any pending intents + project the membrane)
  }

  // Federation heartbeat — folds serve-loser-club.mjs's keep-alive into the daemon. A non-archival
  // relay only indexes LIVE #commits (atproto-bridge POSTMORTEM), so a low-activity account goes stale
  // once it stops emitting. Periodically write a fresh marker (net.favela.federation/self, a non-bsky
  // collection that never pollutes the public feed); the sign loop above materializes it into a #commit.
  let hbTimer = null
  if (federationHeartbeatMs > 0) {
    let hn = 0
    const heartbeat = async () => { try { hn++; await device.putRecord('net.favela.federation', 'self', { $type: 'net.favela.federation', seq: hn }) } catch {} }
    await heartbeat() // emit one now
    hbTimer = setInterval(heartbeat, federationHeartbeatMs)
    if (hbTimer && typeof hbTimer.unref === 'function') hbTimer.unref()
  }

  // control surface: a SEPARATE Hyperswarm so a client/app can submit writes to + observe this
  // device (StateBaseV3's own swarm only replicates the intent-log between devices). Reuses
  // swarmOpts (testnet bootstrap in tests). writers = allowlist of client Noise pubkeys; null =
  // read-only (secure default, matching lib/peer.js). Enabled by default when networked; pass
  // control:false to suppress. Returns { serverKey, discoveryTopic }.
  const controlEnabled = control == null ? swarm : control
  let serving = null
  let controlSwarm = null
  let membraneStore = null
  let udsServing = null
  if (controlEnabled) {
    // PERSISTENT control/serving keypair (same rationale as lib/swarm-keypair.js for the single-tenant
    // peer): the control+membrane surface is what the keyless bridge fronts. A RANDOM keypair per boot
    // (`new Hyperswarm()`) makes every restart a NEW serverKey — the bridge's old connection dies and
    // Hyperswarm can't retry the same DHT identity, so fronts go stale until a slow topic re-query. A
    // STABLE serverKey (persisted in <storage>/swarm-keypair.json) lets the bridge reconnect promptly on
    // restart → the swarm is restart-safe with no bridge bounce. repoKey/firehoseKey are already stable
    // (DID-named cores on a persisted corestore), so this closes the last rotating coordinate.
    // Per-DID sub-paths when hosted by the multi-DID daemon (bootMultiDevice): the control keypair
    // (this DID's bridge-facing serverKey) + the local UDS are per-DID so N DIDs don't collide; the
    // membrane cores share the daemon's ONE Corestore (a per-DID namespace). EXCEPT the `primary` DID
    // (the machine's own user DID, e.g. wj62): it keeps the FLAT control.sock + swarm-keypair + membrane
    // so its bridge-facing serverKey/coords and its co-located client's UDS stay STABLE across the
    // single-DID→multi-DID cutover (only its StateBase re-homes into the shared store). So a migrated
    // primary DID's clients (favela-web Writer) and the bridge see no change.
    const perDid = shared && !primary
    const ctlDir = perDid ? join(storage, 'dids', b4a.toString(roomKey, 'hex').slice(0, 16)) : storage
    if (perDid) fs.mkdirSync(ctlDir, { recursive: true })
    controlSwarm = new Hyperswarm({ ...(swarmOpts || {}), keyPair: loadOrCreateSwarmKeyPair(ctlDir) })
    // membrane surface (Phase D): project __commit__ + records into cores + serve get-coords on the
    // SAME peer-facing swarm, so the keyless membrane (atproto-bridge) fronts this device by DID.
    membraneStore = perDid ? shared.store.namespace('membrane:' + did) : watchStore(openCorestore(join(storage, 'membrane')), 'membrane')
    // Blobs (avatars/images), SHARED by both surfaces — blobs live in the PEER; the keyless bridge only
    // relays. get-blob is public (bsky reads avatars); upload-blob is authed. On an overlay-backed DID, write
    // NEW blobs to a content-addressed OverlayBlobStore (files under the shared repoRoot/blobs — ONE copy,
    // shared with a co-located consumer like favela-web) and DUAL-READ: fall back to the legacy Hyperbee
    // BlobStore for pre-migration blobs so existing avatars don't 404 (no bulk rewrite; old blobs age out).
    // Legacy-only for non-overlay DIDs. Same drop-in put/get/has API either way — serveDevice/Membrane unchanged.
    const legacyBlobs = await new BlobStore(membraneStore, { name: 'membrane-blobs:' + did }).ready()
    let blobs = legacyBlobs
    if (storeBackend === 'overlay' && repoRoot && recordOverlay) {
      const overlayBlobs = await new OverlayBlobStore(join(repoRoot, 'blobs'), { overlay: recordOverlay, indexStore: membraneStore, name: 'overlay-blobs:' + did }).ready()
      // The overlay bee ({mimeType,size,oid}) is NODE-LOCAL — it lives on membraneStore and never
      // replicates — and OverlayBlobStore.get() reads it FIRST, returning null before it looks at the
      // file or the overlay. So without this wrapper an upload here leaves the FILE fetchable by oid
      // from anywhere while the cid->oid mapping stays on this node forever, and a co-owner cannot
      // derive it (the atproto blob CID and the overlay oid are different hashes). The blob then
      // serves or 404s depending on which co-owner the bridge fronts. BlobPointerStore publishes
      // `__blob__/<cid>` = {$oid,size,mimeType} into the SHARED state — the same pointers-in-the-
      // autobase/bytes-in-the-overlay design PointerSubstrate already uses for records. Reserved "__"
      // key: splitKey rejects it, so it can never reach a signed commit.
      blobs = new DualBlobStore(new BlobPointerStore(sb, overlayBlobs, { did }), legacyBlobs)
      console.log(JSON.stringify({ msg: 'blob store: overlay content-addressed files + shared pointers + legacy dual-read', did, path: join(repoRoot, 'blobs') }))
    }
    // ⛔ ALWAYS, NOT ONLY ON THE OVERLAY PATH. This assignment lived inside the `if` above, so a DID
    // with no `storeBackend: 'overlay'` — which is every DID minted by favela-host, since it sets none
    // — returned `blobs: null` from bootMultiDevice while `legacyBlobs` was right here, working, and
    // already handed to serveDevice and serveMembrane. The two stores share a drop-in put/get/has API
    // by design, so there was never a reason for the handle to report one and not the other.
    //
    // ⚠ IT ONLY BITES AN IN-PROCESS CONSUMER, which is why it survived: every socket surface takes
    // `blobs` directly from this scope. The returned field exists precisely FOR the in-process case
    // (see the note where this node is returned), and that is the one case it got wrong.
    //
    // Measured: a handset published 11 posts to its atproto repo and NONE of them carried an image.
    // The same posts reached the fediverse with their jpegs attached, because that projection never
    // asks this object for a blob store. upload-blob threw 'this node has no blob store', BskySink
    // caught it and returned null, and each post went as text with a loss line nobody reads.
    blobStore = blobs
    // LOCAL UDS control surface — bring it up EARLY (before serveDevice/serveMembrane, whose sync/ready can
    // stall on an un-converged base's base.update), so a co-located client (favela-web) can ALWAYS reach the
    // device over local IPC even while convergence lags. Owner-gated by the filesystem → no writer-allowlist.
    try {
      // OTA respawn. This used to call `this.close()` — but this is inside bootSwarmDevice, a plain
      // FUNCTION, so in an ES module `this` is undefined and it threw a TypeError that serve-device-uds's
      // try/catch swallowed: the handler logged "respawning" and then silently did nothing, which is
      // exactly how it shipped in 0.3.100-0.3.103. The process lifecycle belongs to index.js (it owns
      // `daemon` and its close), so it registers the handler and this only signals.
      udsServing = await serveDeviceUds(device, {
        uds: join(ctlDir, 'control.sock'),
        blobs,
        labels: labelRoom,
        // EXPLICIT handler first. The module-global `otaHandler` below is a fallback kept for callers
        // that still use setOtaHandler(), but it is NOT the primary path any more: a module-level
        // variable is only shared if index.js and this file resolve to the SAME module instance, and
        // under pgh's content-addressed node_modules a specifier can resolve to two realpaths — Bare
        // caches by realpath, so each copy gets its OWN `otaHandler`, one set and the other read. That
        // is consistent with what shipped: 0.3.105 logged "release-ready", called through, and found
        // null. Threading the function through the call cannot be defeated that way.
        onRelease: (ver) => {
          if (typeof onRelease === 'function') return onRelease(ver)
          if (otaHandler) return otaHandler(ver)
          console.error(JSON.stringify({ msg: 'ota: release-ready arrived but NO respawn handler is registered — this daemon will not self-update' }))
        }
      })
      console.log(JSON.stringify({ msg: 'device UDS control surface', uds: join(ctlDir, 'control.sock') }))
    } catch (e) { console.error(JSON.stringify({ msg: 'UDS control surface failed (swarm surface still up)', error: e.message })) }
    const allow = Array.isArray(writers) ? writers.map((w) => String(w).toLowerCase()).filter(Boolean) : null
    const canWrite = allow && allow.length ? (hex) => allow.includes(String(hex).toLowerCase()) : null
    // bsky-login: the peer is the auth authority — build the session minter/verifier from the carried
    // appPasswordHash + jwtSecret so the membrane serve can answer the bridge's create/refresh-session.
    // Constructed BEFORE serveDevice because the device surface's get-service-auth gates on
    // sessions.verifyAccess — the bridge forwards the end user's bearer for exactly that. Pure factory,
    // no I/O, so the ordering is free.
    const sessions = makeSessions({ did, handle, appPasswordHash, jwtSecret })
    serving = serveDevice(device, controlSwarm, { canWrite, blobs, sessions })
    // ⛔ TIME-BOXED, because `ready()` on BOTH surfaces is `Promise.all([topic.flushed(), …])` — the DHT
    // ANNOUNCE completing, which is not the same thing as the surface working. `swarm.join()` has already
    // registered the topics and connections are served meanwhile: on the phone five `device control
    // connection`s were logged while boot was still sitting inside this await. On a workstation the flush
    // returns in milliseconds; behind carrier-grade NAT it can take minutes or never resolve, and awaiting
    // it wedged bootSwarmDevice → bootMultiDevice never returned → favela-android never sent `ready` and
    // the app sat on its splash screen with a node that was, in fact, up (hyper-social #40).
    //
    // So boot proceeds and the announce finishes in the background. Nothing is skipped and nothing is
    // swallowed silently: a timeout says so, naming the surface. The membrane projection is time-boxed on
    // the same reasoning — the event-driven tick re-syncs on the next StateBase advance anyway, so a slow
    // first pass costs a beat of freshness, never correctness.
    const awaitOrContinue = async (what, promise, ms = 15000) => {
      let timer = null
      const timeout = new Promise((resolve) => { timer = setTimeout(() => resolve('__timeout__'), ms); if (timer && timer.unref) timer.unref() })
      const outcome = await Promise.race([promise.then(() => '__ok__', (e) => e), timeout])
      clearTimeout(timer)
      if (outcome === '__timeout__') console.error(JSON.stringify({ msg: 'boot continued WITHOUT waiting for ' + what + ' — it is still in progress in the background; the surface already accepts connections', did, waitedMs: ms }))
      else if (outcome !== '__ok__') console.error(JSON.stringify({ msg: what + ' failed (boot continues)', did, error: (outcome && outcome.message) || String(outcome) }))
      return outcome === '__ok__'
    }
    await awaitOrContinue('the control-surface DHT announce', serving.ready())
    // projectRepoCore comes straight off the DID's config entry (bootMultiDevice spreads `...entry`),
    // exactly like storeBackend. FALSE = the stateless virtual device: no membrane-repo Hyperbee is
    // opened, record bytes exist only as the content-addressed files, and a consumer derives the repo
    // via the get-repo-car RPC (proven byte-identical in test/derive-vs-project). Defaults TRUE so
    // nothing changes until a DID is explicitly flipped — rolled out one DID and one node at a time.
    // Flipping back is instant: the existing core on disk is untouched, just not opened.
    membraneServing = await serveMembrane(device, { corestore: membraneStore, swarm: controlSwarm, did, handle, sessions, blobs, projectRepoCore, labels: labelRoom })
    await awaitOrContinue('the membrane DHT announce', membraneServing.ready())
    // sync() reads the device's __commit__ log through the StateBase. On a device that is not yet admitted
    // (roster projected by another machine — #40 §6) that base may never advance, so this too is bounded.
    await awaitOrContinue('the initial membrane projection', membraneServing.sync(), 30000)
    // ⛔ AND THE LABELS. The boot tick above ran before the membrane existed, so it signed but could not project,
    // and a quiet DID has no later event to tick on. Measured 2026-10-14: loser-club's re-signed (ver:1) member
    // labels sat in both co-owners' signed logs for hours (label-list projection:null) while the gateway served
    // the old ones and bsky showed no badge. Sign whatever is pending (election-gated) and project, bounded.
    if (labelRoom) {
      await awaitOrContinue('the initial label projection', (async () => {
        await labelRoom.tick()
        await membraneServing.syncLabels()
      })(), 30000)
    }
  }

  const close = async () => {
    if (unwatch) { try { unwatch() } catch {} } // stop the event-driven sign/project subscription
    if (hbTimer) clearInterval(hbTimer)
    if (serving) { try { serving.close() } catch {} }
    if (membraneServing) { try { membraneServing.close() } catch {} }
    if (udsServing) { try { await udsServing.close() } catch {} }
    if (controlSwarm) { try { await controlSwarm.destroy() } catch {} }
    if (membraneStore) { try { await membraneStore.close() } catch {} }
    if (recordStore) { try { await recordStore.close() } catch {} }
    if (recordOverlay) { try { await recordOverlay.close() } catch {} }
    try { await sb.close() } catch {}
  }

  // `reconcile` is exposed so the daemon can also trigger byte convergence on OVERLAY PEER ATTACH —
  // the moment bytes become reachable is not a StateBase event, so sb.watch alone would leave a
  // just-connected peer sitting on fetchable bytes until something else happened to fire.
  // _statebaseStore: a pre-cutover DID's OWN corestore, kept in place. It sits OUTSIDE the daemon's
  // shared store.replicate(conn), so — exactly like a resync side store — the daemon must replicate it
  // explicitly or the DID would never sync with anyone. Reported separately from _sideStore because
  // StateBaseV3 owns this one and closes it itself (sb.close); double-closing a resync store is what
  // _sideStore's own close path is for.
  // `blobs` is exposed because an IN-PROCESS consumer needs it: favela-android runs this daemon inside
  // the app, so its UI reaches blobs through the object rather than the `upload-blob`/`get-blob` RPCs the
  // co-located (favela-web over UDS) and remote (bridge over swarm) consumers use. Same store, no surface.
  // `uds` is the LOCAL control socket path (or null if that surface failed to come up). Exposed because
  // the co-located consumer needs to NAME it: favela-web reaches this device over that socket, and the
  // only alternative is for the caller to re-derive `<storage>/dids/<roomId>/control.sock` from hpp's
  // internal directory layout — a private path rebuilt by hand in another repo, which breaks the day this
  // moves and surfaces as "writes are disabled" nowhere near the cause. hpp knows the path; hpp says it.
  return { device, sb, did, self, blobs: blobStore, control: serving, membrane: membraneServing, controlSwarm, overlay: recordOverlay, repoRoot, reconcile, close, uds: udsServing ? udsServing.uds : null, _sideStore: resyncStore, _statebaseStore: keepLocalStatebase ? sb.store : null }
}

// ── RESYNC (fresh-corestore re-join) index ────────────────────────────────────────────────────────
// A per-DID directive persisted at <storage>/statebase-resync/index.json ({ "<did>": gen }). When a DID
// carries a gen, bootMultiDevice opens its StateBase from a FRESH Corestore at
// <storage>/statebase-resync/<did16>-r<gen> (its own primaryKey → a CLEAN local writer) instead of the
// poisoned shared-store base — the durable, restart-surviving heal for a poisoned autobase local writer
// (the SIGABRT in ApplyState._appendLocalNodes). Generic: any hosted DID, keyed by DID (mirrors pgv3's
// per-repo forceResync). resyncDid() BUMPS the gen; the daemon reopens on next boot — a poisoned base
// can't be live-closed (it aborts on apply), so boot-reopen (never opening the poisoned base) is the
// correct shape. Nothing is deleted: the old poisoned cores stay on disk in the shared store.
//
// ⛔ DO NOT "SIMPLIFY" THIS INTO autobase's setLocal(key,{keyPair}) WRITER ROTATION. That undocumented
// API (autobase/index.js:489) looks like it should replace the whole second-Corestore apparatus below —
// rotate onto a fresh keypair in place, drop the side store, drop the _sideStore.replicate ordering in
// bootMultiDevice. Characterized against 7.28.1 (2026-08-12) and it does NOT work here, for three
// independent reasons:
//   1. NOT A CLEAN SLATE. _rotateLocalWriter calls LocalState.moveTo(old,new), which copies the
//      linearizer updates AND the autobase/boot record onto the new writer, and the base stays in the
//      same store with its stale view cores. The fresh primaryKey is the whole point of a side store.
//   2. IT ASSERTS ON AN INDEXER. _rotateLocalWriter nulls localWriter before _updateLinearizer calls
//      _clearLocalIndexer, which asserts localWriter !== null — a bare AssertionError with an EMPTY
//      message, leaving a half-rotated base. A poisoned node is usually the creator, i.e. the sole
//      indexer. Even with that fixed, rotating the sole indexer leaves the room with ZERO indexers.
//   3. IT DE-ADMITS ANYWAY. The rotated key is not in the writer set, so an indexer still has to
//      addWriter it — the same online-indexer dependency the fresh-corestore re-join already has.
// So the second Corestore + the side-store replication ordering are load-bearing, not incidental.
// Evidence + probes: memory/reference_autobase-setlocal-writer-rotation, pear-git#35.
const RESYNC_DIR = 'statebase-resync'
const did16For = (did) => b4a.toString(crypto.hash([NS('room-key'), b4a.from(did)]), 'hex').slice(0, 16)

// Attaching a DID's overlay content protocol to a connection was wrapped in a bare `try {} catch {}` at
// BOTH attach sites. If it ever throws, that DID silently loses byte transfer on that connection — the
// pointers still converge via the autobase, so the repo looks fine while the BYTES never move, and
// `fetchFile` later reports only a generic null. Byte resolution gates signing, so this is a silent
// failure on a load-bearing path (hyper-social #13, lesson 3). Deduped per did+reason so a flapping
// swarm cannot spam the log.
// Record the OUTCOME of an attach, not just its exceptions. attachProtocol returns silently on every
// internal guard, so "no exception" never meant "attached" — that misreading cost a day. Logged once
// per did+peerCount, so it is quiet in steady state but re-reports the moment connectivity changes.
const _attachSeen = new Set()
function logOverlayAttachResult (did, overlay) {
  let peers = null
  try { peers = overlay.peerCount } catch {}
  const k = did + ':' + peers
  if (_attachSeen.has(k)) return
  _attachSeen.add(k)
  try {
    console.log(JSON.stringify({
      msg: peers > 0
        ? 'overlay attached — byte transfer is live for this DID on this connection'
        : 'overlay attachProtocol returned but peerCount is 0 — NO byte transfer on this connection',
      did, overlayPeers: peers
    }))
  } catch {}
}

const _attachFails = new Set()
function logOverlayAttachFail (did, err) {
  const reason = (err && err.message) || String(err)
  const k = did + ':' + reason
  if (_attachFails.has(k)) return
  _attachFails.add(k)
  try { console.error(JSON.stringify({ msg: 'overlay attachProtocol FAILED — this DID will not move record BYTES on this connection (pointers still converge, so the repo will look healthy)', did, error: reason })) } catch {}
}
const resyncSidePath = (storage, did, gen) => join(storage, RESYNC_DIR, did16For(did) + '-r' + gen)
export function readResyncIndex (storage) {
  try { return JSON.parse(fs.readFileSync(join(storage, RESYNC_DIR, 'index.json'), 'utf8')) } catch { return {} }
}
// Mark a DID for a fresh-corestore re-join on the next daemon boot (bumps its gen). Returns the new gen
// + the side-store path. The daemon must be restarted to apply (the poisoned base is not live-closable).
export function resyncDid (storage, did) {
  if (!storage || !did) throw new Error('resyncDid: storage + did required')
  const idx = readResyncIndex(storage)
  const gen = (idx[did] || 0) + 1
  idx[did] = gen
  fs.mkdirSync(join(storage, RESYNC_DIR), { recursive: true })
  fs.writeFileSync(join(storage, RESYNC_DIR, 'index.json'), JSON.stringify(idx, null, 2) + '\n')
  return { gen, sidePath: resyncSidePath(storage, did, gen) }
}

// bootMultiDevice — the MULTI-DID (multi-tenant) hpp daemon. Serves N self-custody DIDs from ONE
// process over ONE shared Corestore (one dir, one RocksDB lock — each DID is a namespace), ONE
// Hyperswarm + ONE ProtomuxWakeup for StateBase replication (the keet multi-room pattern), plus a
// per-DID control/membrane surface (each DID's own bridge-facing serverKey + local UDS). This is a
// user's own DID + the communities they co-own, all in one supervised daemon — retiring the bespoke
// per-community scripts. Foundation proven in atproto-room/test/multi-did.test.js.
// dids = [{ deviceMnemonic, did, signingKey, deviceProof, rootIdentityKey, handle, pdsUrl, members,
//           writers, adopt?, resetFirehose?, appPasswordHash?, jwtSecret? }].
//
// `deviceMnemonic` (the top-level one) is the DEVICE-SCOPED fallback — this machine's own keet identity,
// used by any entry that does not carry its own. A device key is derived from a mnemonic and NOTHING
// else (`deviceKeetKeyPair` takes no DID), so `coownerJoin` mints it at config ROOT and writes a `dids`
// entry WITHOUT one — that is what lets a phone mint its key before it knows which DID it is joining.
// Boot must read it the same way or a genuinely-joined DID dies on "deviceMnemonic required" and the
// daemon serves ZERO DIDs while its config, roster and attestation are all perfect (hyper-social #40 §3).
// Precedence is coownerJoin's, exactly: per-DID first (it may already be attested, and replacing it
// orphans the attestation), else device-scoped. Proof: test/coowner-join-boots.test.js.
export async function bootMultiDevice ({ storage, dids = [], deviceMnemonic = null, swarmOpts = null, signIntervalMs = 2000, onRelease = null } = {}) {
  if (typeof storage !== 'string') throw new Error('bootMultiDevice: storage path required')
  if (!Array.isArray(dids) || dids.length === 0) throw new Error('bootMultiDevice: at least one DID required')

  // ONE shared substrate for every DID's StateBase (+ per-DID membrane namespaces of the same store).
  const store = watchStore(openCorestore(join(storage, 'store')), 'store')
  await store.ready()
  // RESYNC directives: DIDs whose StateBase must open from a fresh side corestore (healed poisoned base).
  const resyncIdx = readResyncIndex(storage)
  const wakeup = new ProtomuxWakeup()
  const swarm = new Hyperswarm({ ...(swarmOpts || {}), keyPair: loadOrCreateSwarmKeyPair(join(storage, 'statebase-swarm')) })
  // `nodes` is declared BEFORE the connection handler so the handler's closure can attach each hosted
  // DID's signer-presence on every connection (S1 multi-DID). It is only ever pushed to (never reassigned),
  // so the closure always sees the current roster.
  const nodes = []

  // keet muxing: ONE store.replicate + ONE wakeup.addStream per connection carries EVERY DID's cores
  // AND every DID's optimistic/pending-writer announcements — so admission works for all DIDs over one
  // connection (proven: atproto-room/test/multi-did.test.js). No per-base replicate/wakeup.
  swarm.on('connection', (conn) => {
    conn.on('error', () => {})
    try { wakeup.addStream(conn) } catch {}
    // SIDE STORES REPLICATE FIRST — before the shared store below. Corestore keys hypercore channels by
    // DISCOVERY KEY on a Protomux that is SHARED per stream, so when two corestores on one connection both
    // hold the same core, whichever replicates FIRST claims (hypercore/alpha, discoveryKey) and the other's
    // attach is a duplicate that Protomux answers with null. The loser then syncs with NOBODY while looking
    // perfectly healthy locally: length 0, no error, no conflict — the hardest possible shape to diagnose.
    // Reproduced both ways in test/side-store-channel-priority.test.js.
    //
    // For a DID whose base lives in a side store, that side store is the AUTHORITATIVE copy and anything
    // left in the shared store is the abandoned pre-resync one, so the side store must win. Ordering is the
    // whole fix. (A stale core merely sitting ON DISK in the shared store does not collide — corestore does
    // not auto-open cores on remote interest — so this bites only when something opens it in-process too.)
    for (const n of nodes) { if (n._sideStore) { try { n._sideStore.replicate(conn) } catch {} } }
    // A PRE-CUTOVER DID's StateBase lives in its OWN corestore (kept in place rather than re-homed), so
    // like a resync side store it is outside store.replicate below and must be replicated explicitly —
    // otherwise that DID silently syncs with nobody while looking perfectly healthy locally.
    for (const n of nodes) { if (n._statebaseStore) { try { n._statebaseStore.replicate(conn) } catch {} } }
    try { store.replicate(conn) } catch {}
    // S1 multi-DID signer election: the StateBases run network:false (no swarm of their own) so their
    // internal per-connection presence handshake never fires here — the daemon must drive it. Attach EACH
    // hosted DID's ephemeral presence over this one shared connection; per-DID channel ids (roomKey) keep
    // them from colliding (attachSignerPresence passes id=this._roomKey). Without this, every co-owner of a
    // shared DID sees only itself present → holdsSigner()==true for all → they all sign → FORK. Double-attach
    // (a node booting while this connection exists) is a safe no-op (protomux returns null on the duplicate).
    for (const n of nodes) { try { n.sb.attachSignerPresence(conn) } catch {} }
    // Record CONVERGENCE is the StateBase autobase: a tiny {$oid,size} POINTER per record replicates
    // symmetrically like any control key (PointerSubstrate). hyper-overlay is the DISTRIBUTION layer —
    // it serves the record BYTES by content-hash (oid). Attach each overlay-backed DID's content
    // protocol here so a follower resolving the converged pointer set (store.ensureLocal) can pull the
    // bytes from a peer by oid. Per-DID channel ids (roomKey) let N overlays coexist on this one mux.
    const mux = Protomux.from(conn)
    for (const n of nodes) { if (n.overlay) { try { n.overlay.attachProtocol(mux); logOverlayAttachResult(n.did, n.overlay); if (n.reconcile) n.reconcile() } catch (err) { logOverlayAttachFail(n.did, err) } } }
  })
  const shared = { store, swarm, wakeup }

  // DID-manager (atproto account-manager analog): the durable roster of hosted DIDs — one Hyperbee on
  // the shared store, so a restart re-opens exactly the DIDs this node hosts.
  const registry = await new AccountRegistry(store, { name: 'hpp-dids' }).ready()

  for (const d of dids) {
    try {
      // RESYNC: if this DID is flagged, open its StateBase from a FRESH per-DID Corestore (clean local
      // writer) instead of the poisoned shared-store base. The daemon owns + closes this side store.
      let resyncStore = null
      if (resyncIdx[d.did]) {
        resyncStore = watchStore(openCorestore(resyncSidePath(storage, d.did, resyncIdx[d.did])), 'statebase-resync')
        await resyncStore.ready()
        console.log(JSON.stringify({ msg: 'multi-did: RESYNC — opening DID from a fresh side corestore', did: d.did, gen: resyncIdx[d.did], sidePath: resyncSidePath(storage, d.did, resyncIdx[d.did]) }))
      }
      // Per-DID adoptCar (notary-revival rebuild): a rebuilt DID's config entry may carry adoptCar (the
      // path to its authoritative CAR exported by `rebuild-did`) + forceCreator. Load it into `adopt`
      // {records, head} so the healthy node AUTHORS the genesis from the snapshot. COEXISTENCE RULE
      // (rebuild-2node-transition.test.js): the OLD base must be OFF the swarm first — a rebuild belongs
      // in FRESH storage (or a daemon that no longer hosts the old copy), never alongside the old cores.
      let entry = d
      if (d.adoptCar && !d.adopt) {
        try { entry = { ...d, adopt: await readAdoptRepo(fs.readFileSync(d.adoptCar)) }; console.log(JSON.stringify({ msg: 'multi-did: adoptCar loaded for rebuild', did: d.did, car: d.adoptCar, records: entry.adopt.records.length, rev: entry.adopt.head.rev })) } catch (e) { console.error(JSON.stringify({ msg: 'multi-did: adoptCar load FAILED', did: d.did, car: d.adoptCar, error: e.message })) }
      }
      // deviceMnemonic AFTER the spread, and explicit rather than relying on spread order: an entry may
      // carry the key with an undefined value, which would silently win and re-throw the original error.
      const node = await bootSwarmDevice({ storage, ...entry, deviceMnemonic: entry.deviceMnemonic || deviceMnemonic, swarm: true, swarmOpts, signIntervalMs, shared, resyncStore, onRelease })
      swarm.join(node.sb.base.discoveryKey, { server: true, client: true }) // daemon joins each DID's StateBase topic
      // resync side store: replicate on connections already open (future ones via the connection handler).
      if (node._sideStore) for (const conn of swarm.connections) { try { node._sideStore.replicate(conn) } catch {} }
      // Same catch-up for a kept-in-place StateBase on a DID that booted AFTER connections were open.
      if (node._statebaseStore) for (const conn of swarm.connections) { try { node._statebaseStore.replicate(conn) } catch {} }
      try { await registry.put(node.did, { did: node.did, handle: d.handle || null, active: true, addedAt: Date.now() }) } catch {}
      nodes.push(node)
      // A DID booting AFTER connections are already open: the connection handler ran before this node was in
      // `nodes`, so attach it to every existing connection now (the reverse of the handler's forward-attach).
      for (const conn of swarm.connections) { try { node.sb.attachSignerPresence(conn) } catch {} }
      // A DID booting AFTER connections exist: attach its overlay content protocol to each open conn too.
      if (node.overlay) for (const conn of swarm.connections) { try { node.overlay.attachProtocol(Protomux.from(conn)); logOverlayAttachResult(node.did, node.overlay); if (node.reconcile) node.reconcile() } catch (err) { logOverlayAttachFail(node.did, err) } }
      console.log(JSON.stringify({ msg: 'multi-did: serving DID', did: node.did, handle: d.handle || null, controlKey: node.control ? node.control.serverKey : null }))
    } catch (e) {
      // WITH THE STACK. A per-DID boot failure is reported by MESSAGE alone on every node in the fleet,
      // including a phone with no filesystem access and no debugger — so `Cannot read properties of
      // undefined (reading 'length')` named a JS idiom and nothing else, and localizing it meant guessing
      // at shapes and rebuilding an APK per guess. The message is the one thing that cannot identify the
      // site; the stack is free and is the whole diagnosis.
      console.error(JSON.stringify({ msg: 'multi-did: failed to boot DID', did: d.did, error: e.message, stack: e.stack }))
    }
  }
  await swarm.flush().catch(() => {})
  console.log(JSON.stringify({ msg: 'multi-did hpp serving', dids: nodes.length, storage }))

  const byDid = new Map(nodes.map((n) => [n.did, n]))
  const close = async () => {
    for (const n of nodes) { try { await n.close() } catch {} }
    for (const n of nodes) { if (n._sideStore) { try { await n._sideStore.close() } catch {} } } // daemon owns resync side stores
    try { await swarm.destroy() } catch {}
    try { await registry.close() } catch {}
    try { await store.close() } catch {}
  }
  return { nodes, byDid, shared, registry, close }
}