/**
 * CredentialLocationLedger — the bookkeeping core of live credential re-pointing.
 *
 * Spec: docs/specs/live-credential-repointing-rebalancer.md §2.2.
 *
 * A machine-local durable ledger (`<stateDir>/credential-locations.json`) that records,
 * for each config-home SLOT, which pool account's credential currently lives there. It is
 * the single source of truth for "which account is in which home" once live re-pointing is
 * enabled — every consumer that today treats a pool account's enrollment `configHome` as its
 * live location instead resolves through `slotOf(accountId)` / `tenantOf(slot)` here.
 *
 * This module is Step 2 of Increment A: the bookkeeping + durability + seeding/recovery core.
 * It performs NO keychain writes itself — the staged swap executor (§2.3, Step 5) and the
 * write funnel (§2.2, Step 4) are separate. The identity oracle it seeds/recovers from is the
 * `IdentityOracle` interface (Step 3 implements it against the Anthropic OAuth profile endpoint,
 * `/api/oauth/profile`, routed through the existing intelligence-provider chokepoint).
 *
 * Durability posture (the §2.2 contract — NOT SubscriptionPool's posture):
 *   - missing file        → NEVER-SEEDED (mode 'ok', empty assignments). Reads return null →
 *                           consumers fall back to today's enrollment-home behavior. This is
 *                           normal, not a degradation.
 *   - corrupt/unparseable → UNKNOWN MODE (fail-closed for moves: every mutation refuses;
 *                           fail-open-LOUD for reads: reads return null AND one HIGH attention
 *                           item names the degradation). Recovery is a fresh `seedFromOracle()`.
 *                           This is deliberately NOT a silent fresh-start (No Silent Degradation):
 *                           a quiet reset here could place a session onto the wrong account.
 */
import fs from 'node:fs';
import path from 'node:path';
/** The on-disk schema version this module writes. Bumped only on a breaking shape change. */
export const CREDENTIAL_LEDGER_SCHEMA_VERSION = 1;
/** Thrown when a mutation is attempted while the ledger is in UNKNOWN mode (fail-closed). */
export class CredentialLedgerUnknownModeError extends Error {
    constructor(message) {
        super(message);
        this.name = 'CredentialLedgerUnknownModeError';
    }
}
const MAX_COMPLETED_JOURNAL = 50;
const NON_CLAUDE_FRAMEWORKS = new Set(['codex-cli', 'gemini-cli', 'pi-cli']);
/** A claude-code account is the implicit default (framework undefined or 'claude-code'). */
function isClaudeCodeAccount(a) {
    return !a.framework || a.framework === 'claude-code';
}
export class CredentialLocationLedger {
    storePath;
    pool;
    oracle;
    emitAttention;
    now;
    store;
    /** UNKNOWN mode — set when the on-disk ledger was present but corrupt. Fail-closed for moves. */
    unknownMode = false;
    /** Dedupe the UNKNOWN-mode attention item so a re-read storm raises it once. */
    unknownAttentionRaised = false;
    /** Last NON-DESTRUCTIVE identity audit pass (auditIdentities) — surfaced in status. */
    lastAuditReport = null;
    /** In-memory index: accountId → slot (rebuilt on every mutation/load). */
    slotByAccount = new Map();
    /** In-memory index: slot → accountId. */
    accountBySlot = new Map();
    constructor(deps) {
        this.storePath = path.join(deps.stateDir, 'credential-locations.json');
        this.pool = deps.pool;
        this.oracle = deps.oracle;
        this.emitAttention = deps.emitAttention;
        this.now = deps.now ?? (() => new Date().toISOString());
        this.store = this.load();
        this.reindex();
        if (this.unknownMode)
            void this.raiseUnknownModeAttention();
    }
    // ─── Load / save (atomic tmp+rename — the SubscriptionPool.save pattern) ──────────
    load() {
        if (!fs.existsSync(this.storePath)) {
            // NEVER-SEEDED — not corrupt. Empty ledger; reads fall back to enrollment homes.
            return { version: 0, assignments: [], journal: [] };
        }
        try {
            const data = JSON.parse(fs.readFileSync(this.storePath, 'utf-8'));
            if (data &&
                typeof data.version === 'number' &&
                Array.isArray(data.assignments) &&
                Array.isArray(data.journal)) {
                return data;
            }
            // Present but wrong shape → corrupt.
            this.unknownMode = true;
        }
        catch {
            // Present but unparseable → corrupt. NOT a silent fresh-start: UNKNOWN mode is entered
            // and a HIGH attention item is raised by the constructor. (No Silent Degradation.)
            this.unknownMode = true;
        }
        return { version: 0, assignments: [], journal: [] };
    }
    save() {
        try {
            const dir = path.dirname(this.storePath);
            fs.mkdirSync(dir, { recursive: true });
            const tmpPath = `${this.storePath}.${process.pid}.tmp`;
            fs.writeFileSync(tmpPath, JSON.stringify(this.store, null, 2) + '\n');
            fs.renameSync(tmpPath, this.storePath);
        }
        catch {
            // @silent-fallback-ok — a persistence failure leaves the in-memory store authoritative
            // for this process; the next mutation retries the write. The ledger is bookkeeping, not
            // a credential, so an unwritten entry never strands a login — at worst a recovery probe
            // re-derives it. Surfacing every transient fs hiccup would be noise, not signal.
        }
    }
    reindex() {
        this.slotByAccount.clear();
        this.accountBySlot.clear();
        for (const a of this.store.assignments) {
            // Tenant-less quarantine markers (accountId === '') carry no resolvable tenant — a
            // seed refusal / unavailable probe records the slot as quarantined-unknown, and a read
            // of it must be null (caller falls back), never the empty string.
            if (!a.accountId)
                continue;
            this.slotByAccount.set(a.accountId, a.slot);
            this.accountBySlot.set(a.slot, a.accountId);
        }
    }
    // ─── Sync in-memory reads (never disk/parse per spawn; never throw) ───────────────
    /** Current slot holding `accountId`'s credential, or null (UNKNOWN mode / never-seeded). */
    slotOf(accountId) {
        if (this.unknownMode)
            return null;
        return this.slotByAccount.get(accountId) ?? null;
    }
    /** Account currently tenanting `slot`, or null (UNKNOWN mode / never-seeded). */
    tenantOf(slot) {
        if (this.unknownMode)
            return null;
        return this.accountBySlot.get(slot) ?? null;
    }
    isUnknownMode() {
        return this.unknownMode;
    }
    /** True when the ledger has at least one assignment (has been seeded). */
    isSeeded() {
        return !this.unknownMode && this.store.assignments.length > 0;
    }
    getAssignments() {
        return this.store.assignments.slice();
    }
    getAssignment(slot) {
        return this.store.assignments.find((a) => a.slot === slot) ?? null;
    }
    getJournal() {
        return this.store.journal.slice();
    }
    get version() {
        return this.store.version;
    }
    // ─── Mutations (single-writer; refuse in UNKNOWN mode — fail-closed for moves) ─────
    assertMutable(op) {
        if (this.unknownMode) {
            throw new CredentialLedgerUnknownModeError(`ledger is in UNKNOWN mode (corrupt on-disk state) — ${op} refused; recover via seedFromOracle()`);
        }
    }
    /** Record (or re-point) `slot`'s tenant to `accountId`. Single-writer; bumps version. */
    recordAssignment(slot, accountId, opts) {
        this.assertMutable('recordAssignment');
        const at = this.now();
        const existing = this.store.assignments.find((a) => a.slot === slot);
        // A slot's tenant changing means the previous tenant is no longer here — drop any stale
        // assignment that claimed the SAME accountId in a different slot (one home per credential).
        this.store.assignments = this.store.assignments.filter((a) => a.slot !== slot && a.accountId !== accountId);
        const assignment = {
            slot,
            accountId,
            since: existing && existing.accountId === accountId ? existing.since : at,
            lastVerifiedAt: opts?.verifiedAt !== undefined ? opts.verifiedAt : (existing?.lastVerifiedAt ?? null),
            quarantined: false,
        };
        this.store.assignments.push(assignment);
        this.bumpAndJournal({ op: opts?.op ?? 'swap', phase: 'done', slots: [slot], detail: `tenant=${accountId}` });
        this.reindex();
        this.save();
        return assignment;
    }
    /** Mark a slot quarantined (excluded from balancing) — oracle unavailable / divergence. */
    quarantineSlot(slot, reason) {
        this.assertMutable('quarantineSlot');
        const a = this.store.assignments.find((x) => x.slot === slot);
        if (a) {
            a.quarantined = true;
        }
        else {
            // Quarantine a slot we have no confirmed tenant for: record a tenant-less quarantine
            // marker so the slot is durably excluded until a clean probe clears it.
            this.store.assignments.push({ slot, accountId: '', since: this.now(), lastVerifiedAt: null, quarantined: true });
        }
        this.bumpAndJournal({ op: 'quarantine', phase: 'done', slots: [slot], detail: reason });
        this.reindex();
        this.save();
    }
    /** Lift a quarantine after a clean re-probe. */
    unquarantineSlot(slot) {
        this.assertMutable('unquarantineSlot');
        const a = this.store.assignments.find((x) => x.slot === slot);
        if (a) {
            a.quarantined = false;
            this.bumpAndJournal({ op: 'unquarantine', phase: 'done', slots: [slot] });
            this.reindex();
            this.save();
        }
    }
    /** Stamp a slot's lastVerifiedAt after an identity-oracle confirmation. */
    markVerified(slot, at) {
        this.assertMutable('markVerified');
        const a = this.store.assignments.find((x) => x.slot === slot);
        if (a) {
            a.lastVerifiedAt = at ?? this.now();
            this.save();
        }
    }
    /** Append a journal entry for an in-flight swap phase (used by the executor in Step 5). */
    appendJournal(entry) {
        this.assertMutable('appendJournal');
        return this.bumpAndJournal(entry);
    }
    bumpAndJournal(entry) {
        this.store.version += 1;
        const full = {
            seq: this.store.version,
            op: entry.op,
            phase: entry.phase,
            slots: entry.slots ?? [],
            ...(entry.detail !== undefined ? { detail: entry.detail } : {}),
            at: entry.at ?? this.now(),
        };
        this.store.journal.push(full);
        this.pruneJournal();
        return full;
    }
    /** Keep all non-terminal (in-flight) entries + the last MAX_COMPLETED_JOURNAL terminal ones. */
    pruneJournal() {
        const terminal = (p) => p === 'done' || p === 'aborted';
        const inFlight = this.store.journal.filter((e) => !terminal(e.phase));
        const completed = this.store.journal.filter((e) => terminal(e.phase));
        const keptCompleted = completed.slice(-MAX_COMPLETED_JOURNAL);
        this.store.journal = [...inFlight, ...keptCompleted].sort((a, b) => a.seq - b.seq);
    }
    // ─── Seeding / recovery via the identity oracle (§2.2) ────────────────────────────
    /**
     * Derive each slot's tenant by probing its CURRENT credential via the oracle, then mapping
     * email → accountId through the pool. Clears UNKNOWN mode on a clean pass. Per §2.2:
     *   - oracle unavailable for a slot → quarantine that slot (never guess), continue.
     *   - email maps to exactly one claude-code account → record the assignment (verified now).
     *   - ambiguous (≥2 accounts share the email) or unknown email → REFUSE auto-assign for that
     *     slot + raise an attention item; never guess.
     * Slots = the distinct enrollment `configHome`s of claude-code pool accounts.
     */
    async seedFromOracle() {
        const claudeAccounts = this.pool.list().filter(isClaudeCodeAccount);
        const slots = Array.from(new Set(claudeAccounts.map((a) => a.configHome))).filter(Boolean);
        const outcomes = [];
        // A clean slate for the rebuild; UNKNOWN mode is cleared as soon as we begin a real probe
        // pass (the rebuild is the recovery path the spec names).
        this.store = { version: this.store.version, assignments: [], journal: this.store.journal };
        this.unknownMode = false;
        this.unknownAttentionRaised = false;
        this.bumpAndJournal({ op: 'seed', phase: 'begin', slots, detail: `${slots.length} slot(s)` });
        for (const slot of slots) {
            let result;
            try {
                result = await this.oracle.resolveSlotTenant(slot);
            }
            catch (err) {
                // An oracle that THROWS is treated identically to unavailable (§2.11 — never a guess,
                // never a mismatch). This is loud at the slot level (quarantine), not a silent swallow.
                result = { unavailable: true, reason: `oracle threw: ${err?.message ?? 'unknown'}` };
            }
            if (result.unavailable || !result.email) {
                this.store.assignments.push({ slot, accountId: '', since: this.now(), lastVerifiedAt: null, quarantined: true });
                outcomes.push({ slot, result: 'unavailable', reason: result.reason ?? 'oracle unavailable' });
                continue;
            }
            const matches = claudeAccounts.filter((a) => a.email && a.email === result.email);
            if (matches.length === 1) {
                this.store.assignments.push({
                    slot,
                    accountId: matches[0].id,
                    since: this.now(),
                    lastVerifiedAt: this.now(),
                    quarantined: false,
                });
                outcomes.push({ slot, result: 'assigned', accountId: matches[0].id, email: result.email });
            }
            else if (matches.length > 1) {
                // Ambiguous — two pool records, one email (legal under multi-grant). Never guess.
                this.store.assignments.push({ slot, accountId: '', since: this.now(), lastVerifiedAt: null, quarantined: true });
                outcomes.push({ slot, result: 'ambiguous', email: result.email, reason: `${matches.length} pool accounts share ${result.email}` });
                await this.raiseSeedRefusalAttention(slot, 'ambiguous', `${matches.length} accounts share ${result.email}`);
            }
            else {
                // Probed email matches no pool account.
                this.store.assignments.push({ slot, accountId: '', since: this.now(), lastVerifiedAt: null, quarantined: true });
                outcomes.push({ slot, result: 'unknown-email', email: result.email, reason: `no pool account for ${result.email}` });
                await this.raiseSeedRefusalAttention(slot, 'unknown-email', `no pool account for ${result.email}`);
            }
        }
        this.bumpAndJournal({ op: 'seed', phase: 'done', slots, detail: `${outcomes.filter((o) => o.result === 'assigned').length} assigned` });
        this.reindex();
        this.save();
        return outcomes;
    }
    /**
     * Periodic, NON-DESTRUCTIVE identity re-verification (the §2.4 scheduled identity audit).
     *
     * Unlike `seedFromOracle()` — which WIPES + rebuilds the whole ledger — this re-probes each
     * EXISTING tracked slot and updates ONLY its verification/quarantine state. It is the missing
     * piece that keeps a healthy slot's `lastVerifiedAt` FRESH: without a periodic re-stamp the
     * rebalancer's `targetVerifiedRecent` gate (default 6h) decays every slot to "not recently
     * verified", leaving every objective (wall-rescue AND use-it-or-lose-it drain) with zero
     * eligible targets — a permanently-inert optimizer. It ALSO lets a slot that has since become
     * resolvable EXIT quarantine.
     *
     * Safety direction (§2.11 never-guess / quarantine-never-repair):
     *   - oracle unavailable/throws → HOLD: NEVER quarantine a currently-healthy slot on a
     *     transient probe failure (that would itself induce the inert-rebalancer failure this
     *     method exists to prevent). An already-quarantined slot stays quarantined.
     *   - email → exactly one pool account:
     *       · matches the slot's current healthy tenant → markVerified (refresh — the core fix).
     *       · slot is quarantined / tenant-less → RECOVER: record the assignment verified-now and
     *         lift the quarantine.
     *       · matches a DIFFERENT account than the recorded healthy tenant → a CONFIRMED login
     *         divergence (the oracle never guesses a mismatch — an `email` is identity-confirmed)
     *         → quarantine (safe direction).
     *   - ambiguous / unknown-email on a currently-healthy slot → quarantine (its login became
     *     unrecognizable); an already-quarantined slot stays quarantined.
     *
     * This NEVER triggers a credential swap — it only refreshes the ledger verification state the
     * rebalancer reads on its next pass. No-op in UNKNOWN mode (recovery is seedFromOracle's job).
     */
    async auditIdentities() {
        const at = this.now();
        const outcomes = [];
        if (this.unknownMode) {
            const empty = { at, outcomes, refreshed: 0, recovered: 0, quarantined: 0, unresolved: 0 };
            this.lastAuditReport = empty;
            return empty;
        }
        const claudeAccounts = this.pool.list().filter(isClaudeCodeAccount);
        // Snapshot the slot list up-front: the mutators below rewrite `assignments`.
        const slots = this.getAssignments().map((a) => a.slot);
        for (const slot of slots) {
            const existing = this.store.assignments.find((x) => x.slot === slot);
            if (!existing)
                continue; // disappeared mid-pass (defensive) — nothing to re-verify.
            let result;
            try {
                result = await this.oracle.resolveSlotTenant(slot);
            }
            catch (err) {
                result = { unavailable: true, reason: `oracle threw: ${err?.message ?? 'unknown'}` };
            }
            if (result.unavailable || !result.email) {
                // HOLD — a transient probe failure must never demote a healthy slot.
                outcomes.push({
                    slot,
                    result: existing.quarantined ? 'still-quarantined' : 'unavailable-held',
                    reason: result.reason ?? 'oracle unavailable',
                });
                continue;
            }
            const matches = claudeAccounts.filter((a) => a.email && a.email === result.email);
            if (matches.length === 1) {
                const matchId = matches[0].id;
                if (!existing.quarantined && existing.accountId === matchId) {
                    this.markVerified(slot, at);
                    outcomes.push({ slot, result: 'refreshed', accountId: matchId, email: result.email });
                }
                else if (existing.quarantined || existing.accountId === '') {
                    // A quarantined / tenant-less slot now resolves cleanly → recover + un-quarantine.
                    this.recordAssignment(slot, matchId, { verifiedAt: at, op: 'restore' });
                    outcomes.push({ slot, result: 'recovered', accountId: matchId, email: result.email });
                }
                else {
                    // Healthy slot, but the confirmed email belongs to a DIFFERENT account → divergence.
                    this.quarantineSlot(slot, `audit: slot diverged — now ${result.email} (${matchId}), recorded tenant ${existing.accountId}`);
                    outcomes.push({ slot, result: 'diverged-quarantined', accountId: matchId, email: result.email, reason: `diverged from ${existing.accountId}` });
                }
            }
            else if (matches.length > 1) {
                if (existing.quarantined) {
                    outcomes.push({ slot, result: 'still-quarantined', email: result.email, reason: `ambiguous (${matches.length})` });
                }
                else {
                    this.quarantineSlot(slot, `audit: ambiguous — ${matches.length} accounts share ${result.email}`);
                    outcomes.push({ slot, result: 'unverifiable-quarantined', email: result.email, reason: `ambiguous (${matches.length})` });
                }
            }
            else {
                if (existing.quarantined) {
                    outcomes.push({ slot, result: 'still-quarantined', email: result.email, reason: 'unknown-email' });
                }
                else {
                    this.quarantineSlot(slot, `audit: unknown email ${result.email} — no pool account`);
                    outcomes.push({ slot, result: 'unverifiable-quarantined', email: result.email, reason: 'unknown-email' });
                }
            }
        }
        const report = {
            at,
            outcomes,
            refreshed: outcomes.filter((o) => o.result === 'refreshed').length,
            recovered: outcomes.filter((o) => o.result === 'recovered').length,
            quarantined: outcomes.filter((o) => o.result === 'diverged-quarantined' || o.result === 'unverifiable-quarantined').length,
            unresolved: outcomes.filter((o) => o.result === 'unavailable-held' || o.result === 'still-quarantined').length,
        };
        this.lastAuditReport = report;
        return report;
    }
    /** The last NON-DESTRUCTIVE identity audit pass, or null if none has run. */
    getLastAuditReport() {
        return this.lastAuditReport;
    }
    // ─── Attention (LOUD degradation surfaces) ───────────────────────────────────────
    async raiseUnknownModeAttention() {
        if (this.unknownAttentionRaised || !this.emitAttention)
            return;
        this.unknownAttentionRaised = true;
        try {
            await this.emitAttention({
                id: 'credential-ledger-unknown-mode',
                title: 'Credential location ledger is in UNKNOWN mode',
                summary: 'The credential-locations ledger on disk was unreadable. Credential swaps are refused (fail-closed) and credential reads fall back to enrollment homes until a fresh identity-oracle probe rebuilds it.',
                description: 'Recovery: re-seed via the identity oracle (POST /credentials/locations re-probe, or seedFromOracle()). No credential was moved while in this state.',
                category: 'credential-repointing',
                priority: 'HIGH',
                sourceContext: 'credential-location-ledger',
            });
        }
        catch {
            // @silent-fallback-ok — the attention emitter is best-effort; a delivery failure must
            // not throw out of the ledger constructor and crash boot. The UNKNOWN-mode posture
            // (reads-null + mutations-refuse) is still in force regardless of whether the notice
            // was delivered, so the safety behavior does not depend on this emit.
        }
    }
    async raiseSeedRefusalAttention(slot, kind, detail) {
        if (!this.emitAttention)
            return;
        try {
            await this.emitAttention({
                id: `credential-ledger-seed-refusal:${kind}:${slot}`,
                title: `Credential ledger could not auto-assign a slot (${kind})`,
                summary: `Seeding refused to guess the tenant of ${slot}: ${detail}. The slot is quarantined (excluded from balancing) until resolved.`,
                category: 'credential-repointing',
                priority: 'HIGH',
                sourceContext: 'credential-location-ledger',
            });
        }
        catch {
            // @silent-fallback-ok — best-effort notice; the quarantine (the actual safety action) is
            // already durably recorded in the ledger whether or not the notice is delivered.
        }
    }
}
/**
 * The boot-seed decision (B3a). Pure + isolated so the wiring decision is unit-testable without
 * booting server.ts. Returns true ONLY when the re-pointing feature is enabled AND the ledger is
 * not already seeded — `isSeeded()` is false for BOTH never-seeded and UNKNOWN (corrupt) mode, so
 * a true result also covers the named recovery path, and a seeded ledger is always skipped (so the
 * boot-seed is idempotent across restarts). The actual seed (`seedFromOracle()`) is non-destructive
 * and already unit-tested; this guard is the new logic the wiring introduced.
 */
export function shouldBootSeedCredentialLedger(enabled, isSeeded) {
    return enabled && !isSeeded;
}
/**
 * The periodic identity-audit gating decision (B3c). Pure + isolated so the scheduled-audit wiring
 * is unit-testable without booting server.ts. Returns true ONLY when ALL hold:
 *   - `enabled` — the re-pointing dev-gate is on (strict no-op / no oracle probe on the dark fleet);
 *   - `isSeeded` — there is a ledger to re-verify (boot-seed + recovery is seedFromOracle's job);
 *   - NOT `unknownMode` — a corrupt ledger refuses mutations (auditIdentities is a no-op there anyway);
 *   - NOT `inFlight` — the prior pass has not finished (reentrancy guard; a slow oracle never overlaps).
 * The audit (`auditIdentities()`) is non-destructive (verification state only; zero credential moves)
 * and unit-tested; this guard is the new logic the scheduled wiring introduced.
 */
export function shouldRunIdentityAudit(enabled, isSeeded, unknownMode, inFlight) {
    return enabled && isSeeded && !unknownMode && !inFlight;
}
//# sourceMappingURL=CredentialLocationLedger.js.map