OverviewFilesCommitsIssuesDocsReleasesPeers

pear-git-v3 / lib / issue-registry.js

691 lines · javascript ref:
/**
 * IssueRegistry — per-repo + per-org HyperIssues instances.
 *
 * Every tracker is scoped to a repository OR an organization. There is
 * no global issue tracker — global state was retired in P.2. Each scope
 * gets its own HyperIssues instance (and therefore its own Autobase +
 * bootstrap key), created on demand and cached.
 *
 * Scope layout (namespaces):
 *   issues:repo:<repoName>    — one per repo
 *   issues:org:<orgName>      — one per org
 *
 * Peers opt into a shared instance by passing the matching bootstrapKey
 * via `mountOpts.issueRepos[<repoName>]` or `mountOpts.issueOrgs[<orgName>]`.
 */

import fs from '#node:fs'
import path from '#node:path'

import b4a from 'b4a'
import Corestore from 'corestore'
import { HyperIssues, joinRepoPairing, decodeRoomPairingInvite } from 'hyper-issues'

// pear-git #3 Increment 2c — keet-style repair-on-open. On a standalone genesis
// peer, a tracker whose autobase manifest was group-migrated (indexer-adds it
// can't reconcile incrementally) crash-loops. Keet lives with the SAME
// shared-corestore architecture by calling base.repair() (forceFastForward) on
// room open, gated by a repair-version (keet-core index.js:305
// internalEnsureRepaired). We do the same: on tracker open, if the repo hasn't
// been repaired at REPAIR_VERSION yet, force-fast-forward it once so it rewinds
// to a consistent checkpoint and FFs onto the group's signed state instead of
// draining incrementally into the null-view crash. No-op on healthy trackers.
// BUMP this when a new broken class needs a one-time repair-on-open sweep.
const REPAIR_VERSION = 1

export class IssueRegistry {
  constructor (corestore, opts = {}) {
    this._corestore = corestore
    this._repoBootstrapKeys = opts.repoBootstrapKeys || {}
    this._orgBootstrapKeys = opts.orgBootstrapKeys || {}
    this._repos = new Map() // repoName -> HyperIssues
    this._orgs = new Map()  // orgName  -> HyperIssues

    // In-flight open dedup. Concurrent first-time getForRepo/getForOrg calls
    // (e.g. the gip producer firing from ref-update-observed while an issue
    // page hits the same scope) must NOT each `new HyperIssues(...)` on the
    // same corestore namespace — two autobases on one namespace deadlock on
    // the corestore manifest. Share the open promise instead.
    this._reposOpening = new Map() // repoName -> Promise<HyperIssues>
    this._orgsOpening = new Map()  // orgName  -> Promise<HyperIssues>

    // Persisted tracker index. When indexPath is set, every tracker ever
    // opened on this daemon (keyed by name → keyHex) is recorded to
    // disk. keys() merges the on-disk index with the in-memory cache so
    // `device-invite --mirror` bundles a complete set of tracker
    // bootstrap keys — not just whatever happened to be lazily loaded
    // in this process's memory at invite time. (Yesterday's stortron
    // rollout hit this bug: deploymentBundle.issueRegistry.repos was
    // {} because no one had touched an issue page since daemon start.)
    this._indexPath = opts.indexPath || null
    this._index = this._loadIndex()

    // Strict-bootstrap mode: when true, getFor* refuses to auto-derive a
    // local autobase root if no bootstrap key is known yet. Used on
    // mirrors so a page-hit before the deployment bundle / event log
    // delivers the canonical tracker key never mints a sibling autobase.
    // Sibling autobases sharing the corestore-derived local writer with
    // an eventual canonical autobase are the source of the apply-state
    // crash documented in test/tracker-strict-bootstrap-e2e.test.js.
    this._requireBootstrap = !!opts.requireBootstrap

    // Linked-core org trackers (#42). When set, getForOrg(name) wraps the
    // org's record Room (which carries issue ops via composeSchemas) with
    // a non-owning HyperIssues instead of opening a separate
    // issues:org:<name> autobase. So org issues replicate WITH the org —
    // no separate tracker key to convey to peers/mirrors. fn: name → Room|null.
    this._orgRoomProvider = opts.orgRoomProvider || null

    // Self-heal (pear-git #3, Increment 2): when a per-repo tracker autobase
    // emits 'error' (a stale local state that can't reconcile a group manifest
    // migration — the standalone-peer failure that full-replica mirrors never
    // hit), reopen it under a FRESH corestore namespace so it re-syncs the full
    // state from peers like a clean mirror, instead of crash-looping on the
    // stale copy. `resyncGen[repo]` (persisted in the index) suffixes the
    // namespace; each error bumps it. The old namespace stays on disk as a
    // backup (nothing deleted). `onResync(repoName)` lets the daemon re-attach
    // the RefReconciler to the fresh tracker.
    this._onResync = typeof opts.onResync === 'function' ? opts.onResync : null
    this._resyncing = new Set()

    // forceResync (clean re-join) side stores: a FRESH corestore per resync'd
    // tracker. A fresh namespace does NOT shed a poisoned local writer —
    // autobase's local key is derived from the corestore primaryKey (shared by
    // .namespace()), NOT the namespace suffix (proven in
    // force-resync-participation.test.js). Only a fresh corestore (fresh
    // primaryKey) gives a clean local writer. Default: <indexDir>/tracker-resync.
    this._sideStoreDir = opts.sideStoreDir ||
      (this._indexPath ? path.join(path.dirname(this._indexPath), 'tracker-resync') : null)
    this._sideStores = new Map() // repoName -> Corestore (for replication + close)
  }

  // The corestore namespace for a repo tracker, suffixed by its resync
  // generation. gen 0 = canonical namespace; each 'error' bumps it so the next
  // open lands on a clean namespace (fresh local writer, no stale blocks) that
  // re-materializes the view from peers. #3.
  _repoNamespace (repoName) {
    const g = (this._index.resyncGen && this._index.resyncGen[repoName]) || 0
    return g > 0 ? 'issues:repo:' + repoName + '#r' + g : 'issues:repo:' + repoName
  }

  // Triggered when a repo tracker's autobase emits 'error'. Bump the
  // generation, drop the broken tracker, and hand off to the daemon (onResync)
  // to reopen under the fresh namespace + re-attach the reconciler. Guarded so
  // repeated errors for one repo coalesce into a single resync. #3.
  async _resyncRepo (repoName) {
    if (!repoName || this._resyncing.has(repoName)) return
    this._resyncing.add(repoName)
    try {
      if (!this._index.resyncGen) this._index.resyncGen = {}
      const cur = this._index.resyncGen[repoName] || 0
      // Safety cap: a fresh namespace should materialize cleanly (like a mirror
      // that never hit this). If it STILL errors after MAX_RESYNC fresh opens,
      // stop — leave the tracker isolated (Increment 1: not fatal, daemon stays
      // up, that one repo's issues/refs unavailable) rather than resync-loop and
      // spawn namespaces forever. #3.
      const MAX_RESYNC = 3
      if (cur >= MAX_RESYNC) {
        // eslint-disable-next-line no-console
        console.error('[issue-registry] repo ' + repoName + ' still erroring after ' + cur + ' fresh-namespace resync(s) — leaving isolated, no further resync')
        return
      }
      this._index.resyncGen[repoName] = cur + 1
      this._saveIndex()
      const old = this._repos.get(repoName)
      this._repos.delete(repoName)
      if (old) { try { await old.close() } catch {} }
      // eslint-disable-next-line no-console
      console.error('[issue-registry] self-heal: repo ' + repoName + ' tracker errored — reopening under fresh namespace ' + this._repoNamespace(repoName))
      if (this._onResync) { try { await this._onResync(repoName) } catch {} }
    } finally {
      this._resyncing.delete(repoName)
    }
  }

  _loadIndex () {
    if (!this._indexPath) return { repos: {}, orgs: {}, resyncGen: {}, repairedVersion: {}, sideStore: {} }
    try {
      const raw = fs.readFileSync(this._indexPath, 'utf8')
      const parsed = JSON.parse(raw)
      return {
        repos: (parsed && parsed.repos) || {},
        orgs: (parsed && parsed.orgs) || {},
        resyncGen: (parsed && parsed.resyncGen) || {},
        repairedVersion: (parsed && parsed.repairedVersion) || {},
        // Persisted forceResync side stores: repo → gen. When set, getForRepo
        // opens the tracker from <sideStoreDir>/<repo>-r<gen> (fresh corestore,
        // clean local writer) instead of the poisoned main-corestore tracker —
        // so the heal SURVIVES a daemon restart.
        sideStore: (parsed && parsed.sideStore) || {}
      }
    } catch {
      return { repos: {}, orgs: {}, resyncGen: {}, repairedVersion: {}, sideStore: {} }
    }
  }

  // Increment 2c: keet-style repair-on-open. Force-fast-forward a repo tracker
  // once per REPAIR_VERSION (persisted) so one that can't incrementally reconcile
  // a group manifest migration rewinds to a consistent checkpoint and FFs onto
  // the group's signed state (via the daemon's live replication) instead of
  // crash-looping. Runs on the tracker's OWN instance right after ready(), before
  // the async drain that crashes — matching keet's internalEnsureRepaired. No-op
  // (or a cheap FF) on healthy trackers. Errors are swallowed: repair is
  // best-effort recovery, and the Room 'error' handler (Inc1) remains the
  // backstop if it can't heal.
  async _repairOnOpen (repoName, tracker) {
    if (!this._index.repairedVersion) this._index.repairedVersion = {}
    const cur = this._index.repairedVersion[repoName] || 0
    if (cur >= REPAIR_VERSION) return
    const base = tracker && tracker.base
    try {
      if (base && typeof base.repair === 'function') {
        await base.repair()
        // eslint-disable-next-line no-console
        console.error('[issue-registry] repair-on-open: force-fast-forwarded ' + repoName + ' (REPAIR_VERSION ' + REPAIR_VERSION + ')')
      }
    } catch (err) {
      // eslint-disable-next-line no-console
      console.error('[issue-registry] repair-on-open ' + repoName + ' failed (Room error handler remains backstop): ' + (err && err.message))
    }
    // Mark repaired regardless — one attempt per version; if it didn't heal, the
    // Inc1 error handler isolates it and we bump REPAIR_VERSION to retry.
    this._index.repairedVersion[repoName] = REPAIR_VERSION
    this._saveIndex()
  }

  /**
   * Force a CLEAN RE-JOIN of one repo tracker (the canonical trigger the auto
   * self-heal lacks). The auto path (`_resyncRepo`) only fires on a base
   * 'error'; a standalone peer whose tracker is stuck but NOT erroring (its
   * poisoned local writer materializes a frozen view without throwing) has no
   * way to recover.
   *
   * The stuck tracker's poisoned local writer lives in the MAIN corestore, found
   * by key. A fresh NAMESPACE does not help: autobase's local writer key is
   * derived from the corestore primaryKey (shared across `.namespace()`), NOT
   * the namespace suffix — proven in force-resync-participation.test.js (a
   * `#r1` namespace re-derived the identical local key). So `forceResync` opens
   * the tracker in a FRESH per-tracker CORESTORE (its own primaryKey → a clean
   * local writer with no poisoned apply context) that replicates the group's
   * healthy state as a READ-ONLY replica — exactly the recovery proven in
   * broken-tracker-rejoin-recovery.test.js.
   *
   * Read-only is the autobase golden rule: the fresh writer is not yet in the
   * tracker's writer set. To restore WRITE participation a current indexer must
   * `addWriter` the fresh local key (`repo-add-writer` / `pgh repo grant-writer`)
   * — the same admission every org peer goes through, and keet's addDevice shape.
   *
   * Returns the re-joined tracker (whose `._sideStore` the daemon must wire into
   * swarm replication). The old poisoned cores stay on disk (nothing deleted).
   */
  async forceResync (repoName) {
    if (!repoName) throw new Error('forceResync requires a repoName')
    if (!this._sideStoreDir) throw new Error('forceResync requires sideStoreDir (per-tracker fresh corestore)')
    const bootstrapKey = this._repoBootstrapKeys[repoName] || this._index.repos[repoName] || null
    if (!bootstrapKey) throw new Error('forceResync: no bootstrap key known for ' + repoName)

    if (!this._index.resyncGen) this._index.resyncGen = {}
    if (!this._index.repairedVersion) this._index.repairedVersion = {}
    const gen = (this._index.resyncGen[repoName] || 0) + 1
    this._index.resyncGen[repoName] = gen
    // PERSIST the side-store choice so getForRepo opens from it on the NEXT boot
    // too — the heal must SURVIVE a daemon restart (otherwise boot re-opens the
    // poisoned main-corestore tracker and reverts the fix).
    if (!this._index.sideStore) this._index.sideStore = {}
    this._index.sideStore[repoName] = gen
    // Suppress the Inc2c repair-on-open canonical reset + the failing repair.
    this._index.repairedVersion[repoName] = REPAIR_VERSION
    this._saveIndex()

    // Capture the doorbell VOUCHER: the MAIN-CORESTORE local writer, which is
    // reliably in the group's writer set (this peer's ORIGINAL writer key — on a
    // demoted-genesis peer, the `demote-genesis-*` entry) and which this peer
    // controls. NOT a previous SIDE-STORE key: those are fresh per resync and may
    // never have been admitted (an unadmitted voucher fails the apply check). The
    // autobase local key is primaryKey-derived (namespace-independent), so a
    // throwaway main-corestore tracker's base.local IS that original writer.
    // Best-effort: no voucher → re-join still valid, just no doorbell (manual
    // repo-add-writer fallback).
    // Capture the doorbell VOUCHER from the OLD tracker's local writer, BEFORE
    // closing it. In steady state `old` is the PREVIOUS side-store tracker (its
    // corestore opens cleanly), whose key is a current writer once it was admitted
    // — a valid voucher for rotating to the new key. The doorbell apply gates on
    // writer-membership, so an unadmitted voucher (e.g. the very FIRST heal from a
    // fully poisoned state, before any admission) is safely rejected → manual
    // repo-add-writer for that first round, then subsequent re-joins auto-admit.
    // We do NOT open the poisoned main-corestore tracker — its base.local is
    // unavailable after the apply error (proven live: probe hits the crash).
    const old = this._repos.get(repoName)
    this._repos.delete(repoName)
    let voucherKeyPair = null
    try {
      const kp = old && old.base && old.base.local && old.base.local.keyPair
      if (kp && kp.secretKey) voucherKeyPair = { publicKey: b4a.from(kp.publicKey), secretKey: b4a.from(kp.secretKey) }
    } catch {}
    if (old) { try { await old.close() } catch {} }
    const prevSide = this._sideStores.get(repoName)
    if (prevSide) { try { await prevSide.close() } catch {} this._sideStores.delete(repoName) }

    // Fresh corestore = fresh primaryKey = clean local writer.
    const tracker = await this._openSideStoreTracker(repoName, gen, bootstrapKey)
    tracker._voucherKeyPair = voucherKeyPair // daemon rings the doorbell with this
    this._repos.set(repoName, tracker)
    this._recordTracker('repos', repoName, tracker.keyHex)
    // eslint-disable-next-line no-console
    console.error('[issue-registry] forceResync: repo ' + repoName + ' re-joined in FRESH corestore (gen ' + gen + ')')
    return tracker
  }

  // Open a repo tracker from its dedicated forceResync side corestore
  // (<sideStoreDir>/<repo>-r<gen>): fresh primaryKey → clean local writer.
  // Shared by forceResync (create) + getForRepo (reopen on boot). Caches the
  // side store in _sideStores; the daemon wires it into swarm replication
  // (mount + activateRoom) via `_index.sideStore` at boot + on repo-resync.
  async _openSideStoreTracker (repoName, gen, bootstrapKey) {
    const sidePath = path.join(this._sideStoreDir, repoName + '-r' + gen)
    const sideStore = new Corestore(sidePath)
    await sideStore.ready()
    const tracker = new HyperIssues(sideStore, {
      namespace: 'issues:repo:' + repoName,
      bootstrapKey
    })
    await tracker.ready()
    tracker._sideStore = sideStore
    if (tracker.room && typeof tracker.room.on === 'function') {
      tracker.room.on('base-error', () => { this._resyncRepo(repoName).catch(() => {}) })
    }
    this._sideStores.set(repoName, sideStore)
    return tracker
  }

  _saveIndex () {
    if (!this._indexPath) return
    try {
      fs.mkdirSync(path.dirname(this._indexPath), { recursive: true })
      fs.writeFileSync(this._indexPath, JSON.stringify(this._index, null, 2))
    } catch {}
  }

  _recordTracker (scope, name, keyHex) {
    if (!this._indexPath) return
    if (!keyHex) return
    if (this._index[scope][name] === keyHex) return
    this._index[scope][name] = keyHex
    this._saveIndex()
  }

  /**
   * Get (creating if needed) the HyperIssues tracker for a repository.
   *
   * Strict-bootstrap mode (`requireBootstrap: true` at construction):
   *   if no bootstrap key is known for `repoName`, throws ETRACKER_NOT_READY
   *   instead of auto-deriving a sibling root. Caller (RPC handler / SSR
   *   page) translates to a "syncing — bootstrap not yet replicated"
   *   response. The bootstrap arrives via deployment bundle on boot or
   *   `bindRepoBootstrapKey` after RepoEventLog delivery; the next call
   *   succeeds.
   */
  async getForRepo (repoName) {
    if (!repoName) throw new Error('getForRepo requires a repoName')
    if (this._repos.has(repoName)) return this._repos.get(repoName)
    if (this._reposOpening.has(repoName)) return this._reposOpening.get(repoName)

    const bootstrapKey = this._repoBootstrapKeys[repoName] || this._index.repos[repoName] || null
    if (!bootstrapKey && this._requireBootstrap) {
      const err = new Error(`tracker bootstrap key not yet known for repo:${repoName}`)
      err.code = 'ETRACKER_NOT_READY'
      throw err
    }
    const opening = (async () => {
      // Durable forceResync heal: if this repo was re-joined into a dedicated
      // side corestore (persisted in _index.sideStore), reopen from THERE — a
      // clean local writer — never the poisoned main-corestore tracker. This is
      // what makes the heal survive a daemon restart. The daemon wires the side
      // store into swarm replication at boot (see _index.sideStore consumer).
      const sideGen = this._index.sideStore && this._index.sideStore[repoName]
      if (sideGen) {
        const tracker = await this._openSideStoreTracker(repoName, sideGen, bootstrapKey)
        this._repos.set(repoName, tracker)
        this._recordTracker('repos', repoName, tracker.keyHex)
        return tracker
      }
      // Increment 2c: if a repair is due and Inc2 left a bumped resyncGen (a
      // fresh-namespace dead-end that never healed), reset to the canonical
      // namespace so repair operates on the REAL tracker (its genesis writer +
      // authored state), the way keet repairs a room's own base — not an empty
      // sibling namespace.
      const dueRepair = ((this._index.repairedVersion && this._index.repairedVersion[repoName]) || 0) < REPAIR_VERSION
      if (dueRepair && this._index.resyncGen && this._index.resyncGen[repoName]) {
        this._index.resyncGen[repoName] = 0
        this._saveIndex()
      }
      const tracker = new HyperIssues(this._corestore, {
        namespace: this._repoNamespace(repoName),
        bootstrapKey
      })
      await tracker.ready()
      // Increment 2c: keet-style repair-on-open. Force-fast-forward once per
      // REPAIR_VERSION, immediately after ready() and BEFORE the async drain that
      // crash-loops a migration-stuck tracker, so it rewinds to a consistent
      // checkpoint and FFs onto the group state via the daemon's live
      // replication. No-op on healthy trackers.
      await this._repairOnOpen(repoName, tracker)
      // Self-heal wiring (#3): a tracker autobase 'error' (isolated by the Room
      // handler, re-emitted as 'base-error') triggers a fresh-namespace reopen.
      // The _resyncRepo guard + its internal awaits keep the reopen out of the
      // error emit's synchronous context.
      if (tracker.room && typeof tracker.room.on === 'function') {
        tracker.room.on('base-error', () => { this._resyncRepo(repoName).catch(() => {}) })
      }
      this._repos.set(repoName, tracker)
      this._recordTracker('repos', repoName, tracker.keyHex)
      return tracker
    })()
    this._reposOpening.set(repoName, opening)
    try {
      return await opening
    } finally {
      this._reposOpening.delete(repoName)
    }
  }

  /**
   * Open a repo tracker for a repair() attempt, TOLERATING the base 'error'
   * (the manifest-migration null-view crash) instead of rejecting like
   * getForRepo does. Returns { tracker, baseErr } so the caller can invoke
   * base.repair() (force fast-forward — keet's canonical recovery for this
   * class) while the daemon is LIVE and peers are reachable, so the FF has the
   * group's signed head to fast-forward onto. Does NOT cache in _repos (this is
   * a recovery probe, not the serving path). #3 Increment 2c.
   */
  async openTrackerForRepair (repoName) {
    if (!repoName) return { error: 'repoName required' }
    if (this._repos.has(repoName)) return { tracker: this._repos.get(repoName), baseErr: null }
    const bootstrapKey = this._repoBootstrapKeys[repoName] || this._index.repos[repoName] || null
    if (!bootstrapKey) return { error: 'no bootstrap key known for ' + repoName }
    const tracker = new HyperIssues(this._corestore, {
      namespace: this._repoNamespace(repoName),
      bootstrapKey
    })
    let baseErr = null
    if (tracker.room && typeof tracker.room.on === 'function') {
      tracker.room.on('base-error', (e) => { baseErr = e })
    }
    try { await tracker.ready() } catch (e) { baseErr = baseErr || e }
    return { tracker, baseErr }
  }

  /**
   * Bind (or replace) the bootstrap key for a per-repo issue tracker.
   *
   * Used by a peer's RepoSync when it learns a new repo's issue-tracker
   * key from an origin peer's RepoEventLog. The next call to
   * `getForRepo(repoName)` instantiates the tracker against the provided
   * key, joining the origin's Autobase.
   *
   * If a tracker is already open with a different root, the bind is a
   * no-op — the in-process tracker stays on its existing root. The new
   * key is NOT applied at runtime: closing+reopening an autobase against
   * a different root in the same corestore namespace leaves the
   * namespace's auto-derived local writer carrying blocks under the
   * old apply context, which crashes the new autobase's apply at
   * `apply-state.js`. The strict-bootstrap mode prevents this scenario
   * from arising on mirrors; if you reach this branch on a primary or a
   * legacy mirror with a sibling root, the recovery is daemon restart
   * (next boot picks up the new key from `_index` / bundle) or, for a
   * corrupted corestore, re-pair via `pgh device join`.
   *
   * Returns true if the key was newly set, false on no-op.
   */
  bindRepoBootstrapKey (repoName, bootstrapKey) {
    return this._bind('repos', repoName, bootstrapKey)
  }

  /** Same as bindRepoBootstrapKey but for per-org trackers. */
  bindOrgBootstrapKey (orgName, bootstrapKey) {
    return this._bind('orgs', orgName, bootstrapKey)
  }

  /**
   * Internal: shared bind implementation. No-ops if a tracker is
   * already open under this name (regardless of whether the bootstrap
   * matches) — see bindRepoBootstrapKey doc-comment for why we don't
   * close+reopen at runtime.
   */
  _bind (scope, name, bootstrapKey) {
    if (!name) return false
    const hex = typeof bootstrapKey === 'string'
      ? bootstrapKey
      : (bootstrapKey && bootstrapKey.toString
          ? bootstrapKey.toString('hex')
          : null)
    if (!hex) return false

    const liveMap = scope === 'orgs' ? this._orgs : this._repos
    const keyMap  = scope === 'orgs' ? this._orgBootstrapKeys : this._repoBootstrapKeys

    if (liveMap.has(name)) return false
    if (keyMap[name] === hex) return false

    keyMap[name] = hex
    return true
  }

  /**
   * Get (creating if needed) the HyperIssues tracker for an organization.
   * Strict-bootstrap behavior: see getForRepo doc-comment.
   */
  async getForOrg (orgName) {
    if (!orgName) throw new Error('getForOrg requires an orgName')
    if (this._orgs.has(orgName)) return this._orgs.get(orgName)
    if (this._orgsOpening.has(orgName)) return this._orgsOpening.get(orgName)

    const opening = (async () => {
      // Linked-core (#42): if the org's record Room is mounted, its issues
      // live in that autobase. Wrap it (non-owning) so org issues replicate
      // WITH the org — no separate issues:org:<name> autobase.
      if (this._orgRoomProvider) {
        const room = this._orgRoomProvider(orgName)
        if (room) {
          const tracker = new HyperIssues(this._corestore, { room, ownsRoom: false })
          await tracker.ready()
          this._orgs.set(orgName, tracker)
          // Record the tracker key in the index (#123) — under linked-core the
          // tracker IS the org room, so this is the org-record autobase key.
          // Surfacing it in `keys()` makes dump-keys / device-invite bundles /
          // resync-from-origin include the org's issues like a repo tracker.
          this._recordTracker('orgs', orgName, tracker.keyHex)
          return tracker
        }
      }

      const bootstrapKey = this._orgBootstrapKeys[orgName] || this._index.orgs[orgName] || null
      if (!bootstrapKey && this._requireBootstrap) {
        const err = new Error(`tracker bootstrap key not yet known for org:${orgName}`)
        err.code = 'ETRACKER_NOT_READY'
        throw err
      }
      const tracker = new HyperIssues(this._corestore, {
        namespace: 'issues:org:' + orgName,
        bootstrapKey
      })
      await tracker.ready()
      this._orgs.set(orgName, tracker)
      this._recordTracker('orgs', orgName, tracker.keyHex)
      return tracker
    })()
    this._orgsOpening.set(orgName, opening)
    try {
      return await opening
    } finally {
      this._orgsOpening.delete(orgName)
    }
  }

  // ── Per-repo-autobase invites / join / enroll (issue #35) ─────
  //
  // A repo tracker is already its own autobase, so scope isolation is
  // structural: a peer that joins ONE repo via an invite replicates only
  // that repo's autobase. These mirror the HyperOrgs org-invite surface
  // (createOrgInvite/joinOrg/enrollFromInvite/listOrgKeys), delegating to
  // the per-instance HyperIssues invite primitives (createInvite /
  // enrollFromInvite, shared via hyper-rooms).

  /**
   * Mint a blind-pairing invite for one repo's issue tracker (P2P; the
   * invite code is the whole UX). A joiner pairs over the swarm and, for
   * contributor+, is addWritered DURING pairing — no HTTP, no separate
   * enroll. Requires the daemon's shared swarm.
   * @returns {Promise<{ invitation: string, close: () => Promise<void> }>}
   */
  async createRepoInvite (repoName, opts = {}) {
    if (!opts.swarm) throw new Error('createRepoInvite requires { swarm } (P2P pairing transport)')
    const tracker = await this.getForRepo(repoName)
    return tracker.createInvite({ name: repoName, role: opts.role || 'reader', swarm: opts.swarm })
  }

  /**
   * Consume a repo invite via blind-pairing: mount ONLY that repo's tracker
   * over the swarm and, for contributor+, become a writer DURING pairing.
   * The mounted tracker is registered + persisted under the canonical
   * `issues:repo:<name>` namespace (so getForRepo re-derives the same local
   * writer key across restarts). Requires the daemon's shared swarm.
   * @returns {Promise<{ name, role }>}
   */
  async joinRepo (invitation, opts = {}) {
    if (!opts.swarm) throw new Error('joinRepo requires { swarm } (P2P pairing transport)')
    const dec = decodeRoomPairingInvite(invitation)
    if (!dec.meta || dec.meta.kind !== 'repo') throw new Error('invite is not a repo pairing invite')
    const name = dec.meta.name
    if (!name) throw new Error('repo pairing invite missing name (cannot key the tracker)')
    if (this._repos.has(name)) return { name, role: dec.role }
    this.bindRepoBootstrapKey(name, dec.bootstrapKey)
    const { tracker, role } = await joinRepoPairing(invitation, {
      corestore: this._corestore,
      swarm: opts.swarm,
      namespace: 'issues:repo:' + name
    })
    this._repos.set(name, tracker)
    this._recordTracker('repos', name, tracker.keyHex)
    return { name, role }
  }

  /**
   * Re-register a persisted repo-invite pairing member after a restart so an
   * already-shared invite keeps working. Mounts the tracker if needed.
   * @returns {Promise<void>}
   */
  async resumeRepoPairing (repoName, descriptor, swarm) {
    if (!swarm) throw new Error('resumeRepoPairing requires { swarm }')
    const tracker = await this.getForRepo(repoName)
    tracker.resumePairing(descriptor, swarm)
  }

  /**
   * Every repo tracker key this daemon knows about (live + persisted +
   * bootstrap-carried), as [{ name, bootstrapKey }]. The primary exposes
   * this so a mirror can mount each → full replica (mirror mode). Peers
   * never call this — they only get specific invites (peer mode), which
   * is what prevents the post-mortem leak.
   * @returns {Promise<Array<{ name, bootstrapKey }>>}
   */
  async listRepoKeys () {
    const { repos } = await this.keys()
    return Object.entries(repos).map(([name, bootstrapKey]) => ({ name, bootstrapKey }))
  }

  /**
   * Names of repos that have active trackers (instantiated this process).
   */
  listRepos () { return Array.from(this._repos.keys()) }

  /**
   * Names of orgs that have active trackers (instantiated this process).
   */
  listOrgs () { return Array.from(this._orgs.keys()) }

  /**
   * Names of every per-repo tracker this daemon knows about — live,
   * persisted in the on-disk index, OR carried in via bootstrap keys
   * (e.g. from a deployment bundle on a mirror peer).
   *
   * Used by the mirror-writer announce path: a mirror needs to enroll
   * its localKeyHex on every shared tracker, but trackers are
   * lazy-loaded so listRepos() is empty at boot. Iterating this set and
   * calling getForRepo() opens each one so its localKeyHex is available.
   */
  listAllKnownRepos () {
    const names = new Set()
    for (const n of this._repos.keys()) names.add(n)
    for (const n of Object.keys(this._index.repos)) names.add(n)
    for (const n of Object.keys(this._repoBootstrapKeys)) names.add(n)
    return Array.from(names)
  }

  /** Same as listAllKnownRepos but for per-org trackers. */
  listAllKnownOrgs () {
    const names = new Set()
    for (const n of this._orgs.keys()) names.add(n)
    for (const n of Object.keys(this._index.orgs)) names.add(n)
    for (const n of Object.keys(this._orgBootstrapKeys)) names.add(n)
    return Array.from(names)
  }

  /**
   * Bootstrap keys for every tracker this daemon has ever opened,
   * grouped by scope. Used by dump-keys and device-invite bundles so
   * peers join EVERY tracker — not just whatever's lazily loaded in
   * this process's memory.
   *
   * Sourced from two places:
   *   1. Persisted index on disk (populated by getForRepo/getForOrg)
   *   2. In-memory cache (live trackers, authoritative if the index
   *      file is stale or missing)
   *
   * Also includes any bootstrapKeys passed in at construction — covers
   * the joining-peer case where trackers haven't been opened yet but
   * keys were carried in from the origin bundle.
   */
  async keys () {
    const repos = { ...this._index.repos, ...this._repoBootstrapKeys }
    const orgs = { ...this._index.orgs, ...this._orgBootstrapKeys }
    // Live trackers beat both — they reflect what's actually running
    for (const [name, tracker] of this._repos) repos[name] = tracker.keyHex
    for (const [name, tracker] of this._orgs) orgs[name] = tracker.keyHex
    return { repos, orgs }
  }

  /**
   * Aggregate counts across every active tracker.
   */
  async counts () {
    let total = 0
    let open = 0
    let closed = 0
    for (const tracker of this._repos.values()) {
      const c = await tracker.counts()
      total += c.total || 0
      open += c.open || 0
      closed += c.closed || 0
    }
    for (const tracker of this._orgs.values()) {
      const c = await tracker.counts()
      total += c.total || 0
      open += c.open || 0
      closed += c.closed || 0
    }
    return { total, open, closed, repos: this._repos.size, orgs: this._orgs.size }
  }

  async close () {
    for (const tracker of this._repos.values()) await tracker.close()
    for (const tracker of this._orgs.values()) await tracker.close()
    // Close forceResync side stores (their own Corestores) so their RocksDB
    // locks release — a restart must be able to reopen them.
    for (const store of this._sideStores.values()) { try { await store.close() } catch {} }
    this._repos.clear()
    this._orgs.clear()
    this._sideStores.clear()
  }
}