UNPKG

run402-mcp

Version:

MCP server for Run402 — full-stack backend infrastructure for AI agents: Postgres, auth, storage, serverless functions and atomic deploys. Paid with x402/MPP. Includes $0.03 image generation.

4,502 lines • 278 kB
/**
 * `r.repos` — the host-blind encrypted Git remote, as the SDK's programmatic
 * API (add-vault task 5.10).
 *
 * ARCHITECTURAL LAW (client-surface spec, "All protocol logic lives in the
 * SDK"): every piece of vault protocol behaviour — crypto core, keystore,
 * creation journal, snapshot + capture, publication state machines, ref
 * transactions, verification budget, token exchange, repair — is implemented
 * ONCE here. `run402 repos …`, `git-remote-run402`, and the MCP tools are
 * adapters over this namespace: argument parsing, TTY output, exit codes, and
 * local file I/O only. Anything the CLI can do is reachable programmatically
 * with identical semantics.
 *
 * `r402s-verify` is the deliberate exception: an independent second lineage
 * that must NOT share implementation code with this namespace, because
 * differential verification is its entire purpose.
 *
 * ISOMORPHIC / NODE SPLIT. Vault reads (the record, heads listing, policy,
 * override completion) need nothing but the HTTP client and run anywhere. The
 * verbs that touch a git working tree or the on-disk keystore — `init`,
 * `push`, `compact`, `verify`, `deploy`, `restore` — are Node-only and are
 * reached through DYNAMIC imports of `../node/*`, so importing `@run402/sdk`
 * in a browser or worker never pulls `node:fs` into the graph.
 *
 * CACHING. Nothing here is memoised. Two of these responses are
 * secret-bearing — the maintenance lease's `holder_token` (returned exactly
 * once) and anything derived from the keystore — and per
 * docs/agent-response-design.md a secret-bearing response is never cached,
 * never persisted into an agent-surface result store, and never logged.
 */
import { gateSecret } from "../secret-gate.js";
import { LocalError, isRun402Error, isNetworkError } from "../errors.js";
import { VAULT_BYO_UNMIRRORED_REMEDY_STATEMENT, VAULT_DEGRADED_READ_STATEMENT, VAULT_DURABILITY_STATEMENT, VAULT_MIRROR_KEYSTORE_STILL_REQUIRED_STATEMENT, VAULT_MIRROR_VALIDITY_NOT_FRESHNESS_STATEMENT, VAULT_TERMINAL_LOSS_DOCTOR_TEXT, VAULT_TERMINAL_LOSS_STATEMENT, VAULT_UNMIRRORED_FINDING_STATEMENT, bytesToHex, computeKeystorePossessionProof, computeSigningKeyPossessionSignature, ed25519PublicKey, fromBase64url, hexToBytes, jcs, parseVaultStrict, randomBytes, sha256Hex, verifyVaultObject, toBase64url, } from "./vault.crypto.js";
/**
 * vault-agent-envelopes — per-process memo for the session-start
 * fulfilment (`<keystore root>|<repo_id>`) and the enroll-if-absent step
 * (`<keystore root>`), so a process that opens the same vault many times
 * (a `git push` runs several verbs) reconciles and enrolls once.
 */
const SESSION_RECONCILED = new Set();
/**
 * Round 3 blocker 2: integrity verdicts must PROPAGATE through every
 * best-effort reconcile boundary — converting tampering evidence into
 * `skipped_error` at the outer catch is exactly the swallow the inner
 * verifier exists to prevent. Availability and local-policy failures stay
 * best-effort.
 */
const RECONCILE_FATAL_CODES = new Set([
    "VAULT_ENVELOPE_ALTERED",
    "VAULT_SIGNATURE_INVALID",
    "VAULT_CREATION_CONFLICT",
]);
function rethrowFatalReconcile(e) {
    if (isRun402Error(e) && RECONCILE_FATAL_CODES.has(String(e.code)))
        throw e;
}
import { Rooms } from "./rooms.js";
/** A keystore path, or `null` when there is no id to derive it from (or it is malformed). */
function safePath(derive, repoId) {
    if (!repoId)
        return null;
    try {
        return derive();
    }
    catch {
        return null;
    }
}
async function nodeOnly(load, verb) {
    try {
        return await load();
    }
    catch (e) {
        throw new LocalError(`\`r.repos.${verb}\` needs the Node runtime (keystore + git); import it from a Node process, or use the read-only vault methods in a browser.`, `running vault ${verb}`, { code: "VAULT_NODE_ONLY", details: { cause: e instanceof Error ? e.message : String(e) } });
    }
}
/** Byte counts in the refusal text read as MiB — the unit an operator's tier is quoted in. */
function mib(bytes) {
    return `${(bytes / (1024 * 1024)).toFixed(1)} MiB`;
}
/**
 * A client-generated handoff id (RFC 4122 v4), minted BEFORE the mint call
 * (kygit-handoff design D3): `auth_secret`/`wrap_key` derive off it, so it
 * cannot be gateway-assigned the way `internal.vault_claims.id` alone
 * would suggest — this SDK supplies it, mirroring the SAME
 * client_creation_id/client_open_id convention this protocol family
 * already uses elsewhere for idempotent creation. `handoff()` refuses if
 * the gateway's minted `handoff_id` disagrees.
 */
function randomHandoffUuid() {
    const b = randomBytes(16);
    b[6] = ((b[6] ?? 0) & 0x0f) | 0x40;
    b[8] = ((b[8] ?? 0) & 0x3f) | 0x80;
    const hex = bytesToHex(b);
    return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
}
/**
 * The gateway names the vault by its three ids on the wire — `repo_id`,
 * `org_id`, `project_id` (docs/style.md's API-boundary vocabulary) — on
 * BOTH the handoff mint (`POST /vaults/v1/:vault_id/handoffs`) and
 * claim (`POST /vaults/v1/handoffs/:handoff_id/redeem`) responses. The SDK
 * groups them under `vault` with the `organization_id` spelling every other
 * SDK result uses. Neither response carries a slug-form address, so
 * `address` is `null` unless the caller already knows one (a slug-form
 * remote at mint time). Pure; exported for tests.
 */
export function handoffVaultFromWire(wire, address = null) {
    return { vault_id: wire.repo_id, address, organization_id: wire.org_id, project_id: wire.project_id };
}
/** The redeem response's `membership` block (`org_id` on the wire) in the SDK's `organization_id` spelling. Pure; exported for tests. */
export function handoffMembershipFromWire(wire) {
    return { organization_id: wire.org_id, role: wire.role, status: wire.status };
}
// ─── Invite / join result shapes (kygit-invite design D4/D5/D9) ─────────────
/**
 * An invite mints at `developer` unless `--role` narrows it or the minter's
 * own role is narrower — the ONE descriptor difference from a handoff, which
 * mints at the minter's own role (kygit-invite design D1). Declared here
 * because the client must predict the gateway's attenuated answer EXACTLY:
 * the `writer_admission_grant` is signed with `minted_role` inside it before
 * the mint call, and a disagreement is a `VALIDATION_FAILED` after the
 * checkpoint has already been captured and pushed.
 */
export const INVITE_DEFAULT_ROLE = "developer";
/**
 * vault-byo-primary-bucket task 3.3 — the number of `{key, object_kind}`
 * entries `VAULT_BYO_OBJECT_MISSING`'s `details.missing` lists before
 * truncating (`details.missing_count` always carries the true total, and
 * `details.missing_truncated` says whether the list above was cut). A
 * chain-referenced object list can be arbitrarily large; an error envelope
 * is not the place to reproduce it whole. Matches the platform's existing
 * `sample_keys`-class cap (asset-sync's plan response, `PLATFORM_INCIDENT_
 * FANOUT_CAP`) rather than inventing a new number.
 */
export const VAULT_BYO_OBJECT_MISSING_LIST_CAP = 50;
/**
 * D210 (rev 44) — the decision function behind `fsck`'s best-effort
 * proof-of-open submission, factored out of {@link Repos}'s private
 * `#submitFsckOpenProof` so it is directly unit-testable with injected
 * (fake) `resolvePrincipalId`/`submit` dependencies, independent of a real
 * HTTP-backed vault. `fsck()` itself supplies the REAL dependencies (`GET
 * /agent/v1/whoami`, {@link Repos.submitProofOfOpen}) — this function
 * contains no HTTP/transport code of its own.
 *
 * Gating (all LOCAL, no network call made when either holds):
 *   - `write === false` — a genuine audit mode; submitting a receipt is a
 *     real server-side mutation (idempotent or not), so it never fires.
 *   - `ekFingerprint === null` — no local encryption identity to submit
 *     evidence FOR.
 *
 * Otherwise: resolve `principal_id` (`resolvePrincipalId`), then submit
 * `evidence` VERBATIM — `chain_verified_to_generation`/
 * `decryptable_to_generation` pass through completely unchanged from the
 * caller, never recomputed or rounded here.
 *
 * FAILURE CONTAINMENT (the load-bearing property): this function NEVER
 * throws. Every failure — `resolvePrincipalId` resolving `null`,
 * `resolvePrincipalId`/`readerEntrypoint`/`submit` throwing for any reason
 * (network, `OPEN_PROOF_MISMATCH`, anything) — is caught and reported in
 * the returned outcome's `error`; the caller's own already-computed result
 * (`fsck`'s verdict) is never touched by this function's own failure.
 */
export async function computeOpenProofOutcome(input) {
    if (!input.write)
        return { attempted: false, submitted: false, deduplicated: null, receipt: null, error: null };
    if (!input.ekFingerprint)
        return { attempted: false, submitted: false, deduplicated: null, receipt: null, error: null };
    try {
        const principalId = await input.resolvePrincipalId();
        if (!principalId) {
            return {
                attempted: true,
                submitted: false,
                deduplicated: null,
                receipt: null,
                error: {
                    code: "VAULT_PROOF_OF_OPEN_PRINCIPAL_UNRESOLVED",
                    message: "GET /agent/v1/whoami returned no resolvable principal for this credential (grant-key bearers are not accepted by whoami) — submit via r.repos.submitProofOfOpen(repoId, principalId, evidence) directly instead",
                },
            };
        }
        const readerEntrypoint = await input.readerEntrypoint();
        const out = await input.submit(principalId, input.ekFingerprint, {
            chain_verified_to_generation: input.evidence.chain_verified_to_generation,
            decryptable_to_generation: input.evidence.decryptable_to_generation,
            reader_entrypoint: readerEntrypoint,
        });
        return { attempted: true, submitted: true, deduplicated: out.deduplicated, receipt: out.receipt, error: null };
    }
    catch (e) {
        return {
            attempted: true,
            submitted: false,
            deduplicated: null,
            receipt: null,
            error: { code: isRun402Error(e) && e.code ? e.code : "UNKNOWN", message: e instanceof Error ? e.message : String(e) },
        };
    }
}
/**
 * vault-byo-primary-bucket task 3.3 (design D6) — the decision function
 * behind `fsck`'s BYO presence check, factored out of {@link Repos}'s
 * private `#checkByoPresence` the same way {@link computeOpenProofOutcome}
 * is factored out of `#submitFsckOpenProof`, so it is directly unit-testable
 * with an injected (fake) `hasLocalConfig`/`verifyPresence`, independent of
 * a real keystore or HTTP client. `#checkByoPresence` supplies the REAL
 * dependencies (`readByoConfig` against the real keystore,
 * `verifyByoObjectsPresent` against the real client) — this function
 * contains no filesystem/HTTP code of its own.
 *
 * Runs AUTOMATICALLY for a `storage_profile: "byo"` vault — never behind a
 * new flag, so a returning agent's ordinary `fsck` catches a
 * silently-emptied bucket without having to know to ask for it. Returns
 * `undefined` for a managed vault (`vault?.storage_profile !== "byo"` is
 * the FIRST check, before `hasLocalConfig`/`verifyPresence` are ever
 * called) — a managed vault pays zero extra network/filesystem work and
 * this task changes nothing about it (design D6/D9).
 *
 * NO local credentials configured (`hasLocalConfig()` returns `false`) is
 * reported, never thrown — a BYO vault this machine cannot reach is an
 * honest "not checked", not a failure that should break `fsck` for a
 * credential-less returning agent (see {@link VaultFsckByoPresence}'s
 * own doc comment). A confirmed absence is the opposite: it THROWS
 * `VAULT_BYO_OBJECT_MISSING` — the same severity class as {@link
 * Repos.mirrorVerify}'s own `VAULT_MIRROR_NOT_CONFIGURED` throw when
 * `--mirror` is requested against an unconfigured vault — this is exactly
 * the honesty D6 names: "we can tell the customer what SHOULD exist and
 * does not", stated as a real refusal rather than a silently-embedded
 * finding. The listed entries are capped at
 * {@link VAULT_BYO_OBJECT_MISSING_LIST_CAP} (a chain-referenced object
 * list can be arbitrarily large and this is an error envelope, not a
 * report); `missing_count` always carries the true total regardless of how
 * many are listed.
 *
 * `verifyPresence()` is a pure read (HEAD checks only, via
 * `verifyByoObjectsPresent`) — this function persists nothing itself, so
 * it behaves identically whether `fsck` was called with `write: true` or
 * `write: false`.
 */
export async function computeByoPresenceOutcome(input) {
    if (input.vault?.storage_profile !== "byo")
        return undefined;
    const destination = input.vault.byo_destination ?? null;
    if (!input.hasLocalConfig()) {
        return {
            verified: false,
            destination,
            checked_count: 0,
            not_checked_reason: "no local BYO destination credentials are configured for this vault on this machine — presence could not be verified; configure the same destination (`run402 repos create --byo <destination>` again, or the equivalent local BYO config) to enable it",
        };
    }
    const report = await input.verifyPresence();
    if (report.missing.length > 0) {
        const missing = report.missing.slice(0, VAULT_BYO_OBJECT_MISSING_LIST_CAP);
        throw new LocalError(`the BYO destination for ${input.repoId} (${report.destination}) is missing ${report.missing.length} object(s) run402's own signed chain says should exist`, "running vault fsck", {
            code: "VAULT_BYO_OBJECT_MISSING",
            details: {
                repo_id: input.repoId,
                destination: report.destination,
                checked_count: report.checked,
                missing_count: report.missing.length,
                missing,
                missing_truncated: report.missing.length > missing.length,
            },
            next_actions: [
                {
                    action: "restore the listed object(s) to the destination bucket from your own backup, or run `run402 repos mirror <destination>` to add a second customer-held copy",
                    why: "run402 holds no payload copy for a BYO vault — it can only tell you what SHOULD exist from the signed chain, never restore it",
                },
            ],
        });
    }
    return { verified: true, destination: report.destination, checked_count: report.checked, not_checked_reason: null };
}
/**
 * {@link Repos.deploy}'s post-push glue: attach the best-effort
 * mirror/reconcile hooks to a deploy result, gated on whether this deploy
 * actually landed a new generation in the vault. Extracted as a standalone
 * function (rather than inlined in `deploy()`) purely so the gating can be
 * unit-tested with fake thunks — the real hooks (`#tryMirrorPush` /
 * `#tryReconcileEnvelopeRecipients`) are private class methods that already
 * catch everything and never reject, so this function does not need its own
 * try/catch: it only decides WHETHER to call them and how to merge what they
 * resolve to.
 *
 * `DEPLOYED_AND_VAULTED` and `DEPLOY_FAILED_VAULTED` are the only two
 * outcomes that carry a `generation` — a vault push actually landed. The
 * other three (`DEPLOY_BLOCKED_PUSH_FAILED`, `DEPLOY_FAILED_UNVAULTED`,
 * `DEPLOYED_UNVAULTED_OVERRIDE`) published nothing new, so `mirror_push` /
 * `reconcile_recipients` are OMITTED rather than a faked `skipped_*` value —
 * there is nothing this deploy did that either hook could report on.
 */
export async function attachVaultDeployHooks(result, mirror, reconcile) {
    if (result.outcome !== "DEPLOYED_AND_VAULTED" && result.outcome !== "DEPLOY_FAILED_VAULTED")
        return result;
    // Sequential, not Promise.all — same ordering capture() uses, so a reconcile
    // failure can never be misread as a mirror failure in a log line that
    // assumed ordering.
    const mirrorPush = await mirror();
    const reconcileRecipients = await reconcile();
    return { ...result, mirror_push: mirrorPush, reconcile_recipients: reconcileRecipients };
}
// ─── The namespace ───────────────────────────────────────────────────────────
export class Repos {
    #client;
    constructor(client) {
        this.#client = client;
    }
    // ── Control-plane reads (isomorphic) ──────────────────────────────────────
    /** The vault record — policy, allocation generation, storage + maintenance state. */
    async get(repoId) {
        return this.#client.request(`/vaults/v1/${encodeURIComponent(repoId)}`, { context: "reading the vault record" });
    }
    /**
     * Resolve a project's vault with no local state. This is the cold-restart
     * entry point: an agent that lost its machine but still holds authority on
     * the project learns its `repo_id` here.
     */
    async forProject(projectId) {
        return this.#client.request(`/vaults/v1?project_id=${encodeURIComponent(projectId)}`, { context: "resolving the project's vault" });
    }
    /**
     * Every vault the organization owns, one round trip — `repos list`'s bulk
     * read. See {@link
     * VaultOrgVaultsListing}'s doc comment for the FROZEN response shape and
     * the 404-until-shipped fallback contract.
     */
    async listByOrg(orgId) {
        // The gateway keyset-paginates (has_more/next_cursor, opaque store-and-
        // echo). This method's contract is "the org's vaults", so it follows the
        // cursor and aggregates — a silently-truncated page one would be the
        // Faithful breach agent-response-design.md names. The page bound is a
        // runaway guard, far above any real org; hitting it surfaces has_more:
        // true honestly instead of looping forever on a misbehaving cursor.
        const all = [];
        let cursor;
        for (let page = 0; page < 100; page++) {
            const url = `/vaults/v1?org_id=${encodeURIComponent(orgId)}` + (cursor ? `&cursor=${encodeURIComponent(cursor)}` : "");
            const res = await this.#client.request(url, { context: "listing the organization's vaults" });
            all.push(...res.vaults);
            if (!res.has_more || !res.next_cursor)
                return { vaults: all, has_more: false, next_cursor: null };
            cursor = res.next_cursor;
        }
        return { vaults: all, has_more: true, next_cursor: cursor ?? null };
    }
    /**
     * Resolve a vault by its address-form `org-slug/name` —
     * `GET /vaults/v1?repo=<org-slug>/<name>`.
     * `RESOURCE_NOT_FOUND` for no such org OR no such name (deliberately
     * collapsed — see the design's slug-namespace-probing note);
     * `SLUG_RELEASED` (read it with {@link vaultSlugReleasedInfo}) while the
     * slug is in its post-rename cooldown — never auto-followed.
     */
    async forRepo(address) {
        const repo = `${address.org_slug}/${address.repo_name}`;
        return this.#client.request(`/vaults/v1?repo=${encodeURIComponent(repo)}`, { context: "resolving the vault by repo address" });
    }
    /**
     * Resolve a parsed remote address (`parseVaultRemoteUrl`'s output),
     * dispatching on its form: id-form resolves exactly like
     * {@link forProject}; slug-form resolves via {@link forRepo}. A pure read —
     * no pinning, no creation. Node-only callers wanting BOTH should use
     * {@link resolveOrCreateAddress} instead, which also drives the local pin
     * and (opt-in) push-to-create.
     */
    async resolveAddress(address) {
        return vaultRemoteAddressForm(address) === "id"
            ? this.forProject(address.project_id)
            : this.forRepo({ org_slug: address.org_id, repo_name: address.project_id });
    }
    /**
     * One page of the heads listing (D186).
     *
     * `after_generation` is the REQUIRED verification anchor — a semantic input,
     * never a paging knob — and must stay CONSTANT across a page sequence.
     * `limit` is required. `cursor` is omitted on the first request and is then
     * the prior page's `next_cursor` echoed UNCHANGED: store and echo, never
     * parse. A malformed or stale cursor is `INVALID_CURSOR`; recover by
     * restarting from `after_generation` with no cursor.
     */
    async heads(repoId, request) {
        const { validateHeadsListingRequest } = await this.#publication();
        validateHeadsListingRequest(request);
        const qs = new URLSearchParams({ after_generation: request.after_generation, limit: request.limit });
        if (request.cursor !== undefined)
            qs.set("cursor", request.cursor);
        return this.#client.request(`/vaults/v1/${encodeURIComponent(repoId)}/heads?${qs.toString()}`, { context: "listing vault heads" });
    }
    /**
     * Walk the whole listing above the anchor, verifying page coupling, ordering,
     * and gaplessness as it goes (a gap is `CHAIN_BROKEN`, never a silent skip).
     */
    async allHeads(repoId, options) {
        const { verifyHeadsListingPage, nextListingRequest } = await this.#publication();
        let request = { after_generation: options.after_generation, limit: options.limit ?? "1000" };
        let progress = { after_generation: options.after_generation, last_generation: options.after_generation, delivered: 0 };
        const heads = [];
        let pages = 0;
        let total = null;
        while (request) {
            const page = await this.heads(repoId, request);
            progress = verifyHeadsListingPage(page, request, progress, repoId);
            heads.push(...page.heads);
            total = page.total;
            pages += 1;
            request = nextListingRequest(request, page);
        }
        return { heads, pages, total };
    }
    /**
     * Set the activation policy. Owner + step-up, audited, reason required.
     * `grandfathered` leaves a doctor-persistent warning until it returns to
     * `required`.
     */
    async setPolicy(repoId, input) {
        return this.#client.request(`/vaults/v1/${encodeURIComponent(repoId)}/policy`, {
            method: "PATCH",
            body: { vault_policy: input.vault_policy, ...(input.reason !== undefined ? { reason: input.reason } : {}) },
            context: "setting the vault activation policy",
        });
    }
    /**
     * Clear an unvaulted-override advisory by presenting a capture receipt that
     * matches the journaled operation on EVERY field. A partial match never
     * clears it.
     */
    async completeOverride(repoId, input) {
        return this.#client.request(`/vaults/v1/${encodeURIComponent(repoId)}/override-completions`, { method: "POST", body: input, context: "submitting the vault override completion" });
    }
    /**
     * Take the owner's maintenance reservation for a compact/prune cycle.
     *
     * SECRET-BEARING: `holder_token` is returned exactly ONCE and is the liveness
     * instrument for heartbeat/release. Never log it, never cache it, never place
     * it in an agent-surface result store.
     */
    // ── epoch rotation ceremonies + declarations (D193-D203, rev 42) ──────────
    //
    // Five isomorphic, no-local-key-material API calls (owner + step-up,
    // `gitvault.rotate`) — same "direct #client.request" shape as `setPolicy`/
    // `completeOverride` above. The actual ROTATION (sampling K_e, sealing
    // envelopes, submitting the head) needs the Node-only keystore + crypto
    // core and lives on `Vault` (`sdk/src/node/vault-publication.ts`);
    // see {@link rotateEpoch} / {@link rotateEpochForKeyRevocation} below.
    /** `POST …/recipients/:principal_id/confirm` (D197) — first-seen pin confirmation. */
    async confirmRecipient(repoId, principalId, newFingerprint) {
        return this.#client.request(`/vaults/v1/${encodeURIComponent(repoId)}/recipients/${encodeURIComponent(principalId)}/confirm`, {
            method: "POST", body: { new_fingerprint: newFingerprint }, context: "confirming a vault recipient's first pin",
        });
    }
    /** `POST …/recipients/:principal_id/repin` (D197) — re-pin ceremony. */
    async repinRecipient(repoId, principalId, input) {
        return this.#client.request(`/vaults/v1/${encodeURIComponent(repoId)}/recipients/${encodeURIComponent(principalId)}/repin`, {
            method: "POST", body: input, context: "re-pinning a vault recipient",
        });
    }
    /**
     * vault-agent-envelopes D3 — a KEY-HOLDER explicitly accepts a recipient's
     * CHANGED key. The session-start reconcile refuses to wrap under a
     * fingerprint that differs from this keystore's TOFU pin
     * (`pinned_key_mismatch`) — that refusal is the substitution defence and
     * is never bypassed automatically, not even after an owner's revoke: from
     * this machine's view an owner-driven re-key and a platform substitution
     * look identical, and the audit event is not proof against the platform.
     * Acceptance is a deliberate act naming the new fingerprint (the
     * out-of-band verification point — read it back with the recipient over any
     * channel; it is public data): this records the D197 re-pin receipt with the
     * gateway AND moves the local pin, so the next reconcile wraps. Refuses when
     * `new_fingerprint` is not what the org directory currently serves for the
     * principal (a stale or mistyped fingerprint never pins).
     */
    async acceptRecipientKeyChange(options) {
        const repoId = await this.#resolveRepoId(options);
        const { VaultKeystore } = await this.#keystore();
        const keystore = new VaultKeystore(options.keystore_root !== undefined ? { rootDir: options.keystore_root } : {});
        const repo = keystore.readRepo(repoId);
        if (!repo)
            throw new LocalError("this keystore holds no repo state for the vault — only a key-holder can accept a recipient's key change", "accepting a recipient key change", { code: "VAULT_REPO_STATE_MISSING", details: { repo_id: repoId } });
        const record = await this.get(repoId);
        const directory = await this.#client.request(`/orgs/v1/${encodeURIComponent(record.org_id)}/encryption-keys`, { context: "reading the org encryption-key directory" });
        const served = directory.keys.find((k) => k.principal_id === options.principal_id)?.ek_fingerprint ?? null;
        if (served !== options.new_fingerprint) {
            throw new LocalError(`the org directory currently serves ${served ?? "no key"} for principal ${options.principal_id}, not ${options.new_fingerprint} — refusing to pin a fingerprint the directory does not vouch for`, "accepting a recipient key change", { code: "PIN_CHANGE_UNCONFIRMED", details: { repo_id: repoId, principal_id: options.principal_id, directory_fingerprint: served, requested_fingerprint: options.new_fingerprint } });
        }
        const pins = { ...(repo.envelope_recipient_pins ?? {}) };
        const old = pins[options.principal_id] ?? null;
        let receipt = null;
        if (old && old !== options.new_fingerprint) {
            receipt = await this.repinRecipient(repoId, options.principal_id, { old_ek_fingerprint: old, new_fingerprint: options.new_fingerprint }).catch(() => null);
        }
        else if (!old) {
            receipt = await this.confirmRecipient(repoId, options.principal_id, options.new_fingerprint).catch(() => null);
        }
        pins[options.principal_id] = options.new_fingerprint;
        keystore.updateRepo(repoId, { envelope_recipient_pins: pins });
        return { repo_id: repoId, principal_id: options.principal_id, old_fingerprint: old, new_fingerprint: options.new_fingerprint, receipt };
    }
    /**
     * `POST …/recipients/:principal_id/key-revocation` (D199) — declares
     * `reason:"recipient_key_revoked"` admissible for the NEXT rotation this
     * org's vaults submit; org-scoped, advances the same watermark a member
     * removal does. Returns the D194 counters — the ONE client-visible read of
     * them, which is why {@link rotateEpochForKeyRevocation} exists as the
     * fully self-contained entry point.
     */
    async declareRecipientKeyRevoked(repoId, principalId) {
        return this.#client.request(`/vaults/v1/${encodeURIComponent(repoId)}/recipients/${encodeURIComponent(principalId)}/key-revocation`, {
            method: "POST", body: {}, context: "declaring a vault recipient key revoked",
        });
    }
    /**
     * `POST …/epoch-secret-exposure` (D199) — declares `reason:"epoch_secret_exposed"`
     * admissible for THIS vault (deliberately vault-scoped, not org-wide — one
     * vault's leaked `K_repo`/`K_e` is not evidence any sibling vault is
     * compromised). This is the rekey remedy for a leaked/exposed vault key:
     * declare exposure here, then drive a `rotate_epoch` with
     * `reason:"epoch_secret_exposed"` (the required counters must be supplied
     * from a source other than this call — see {@link rotateEpoch}'s doc
     * comment on the confirmed gap in what the gateway exposes today).
     */
    async declareEpochSecretExposed(repoId) {
        return this.#client.request(`/vaults/v1/${encodeURIComponent(repoId)}/epoch-secret-exposure`, { method: "POST", body: {}, context: "declaring a vault epoch secret exposed" });
    }
    /** `POST …/writer-authority/declare-unavailable` (D202) — an explicit, audited fact that the writer signing key is gone. */
    async declareWriterAuthorityUnavailable(repoId) {
        return this.#client.request(`/vaults/v1/${encodeURIComponent(repoId)}/writer-authority/declare-unavailable`, { method: "POST", body: {}, context: "declaring vault writer authority unavailable" });
    }
    /**
     * `POST …/recipients/:principal_id/proof-of-open` (D210, rev 44) — submit
     * `fsck`'s OWN `chain_verified_to_generation`/`decryptable_to_generation`
     * evidence VERBATIM as proof that `principalId` can open this vault under
     * its current epoch. `fsck()` itself calls this automatically (in write
     * mode, when a local encryption identity exists) — call it directly only
     * for a manual/explicit submission (e.g. resubmitting after fixing a
     * local keystore issue, or from a caller that already resolved its own
     * `principal_id` and does not want the extra `whoami` round trip
     * `fsck()`'s own auto-submission pays).
     *
     * Self-match only: the gateway requires `principalId` to equal the
     * AUTHENTICATED caller, never overridable by any credential class — a
     * mismatch is the ordinary 403 `VAULT_ACCESS_DENIED`. Idempotent on
     * `(repo_id, principal_id, ek_fingerprint, decryptable_to_generation)` —
     * `deduplicated: true` means the gateway returned the tuple's EXISTING
     * receipt (HTTP 200) rather than minting a fresh one (HTTP 201).
     */
    async submitProofOfOpen(repoId, principalId, evidence) {
        const res = await this.#client.requestWithResponse(`/vaults/v1/${encodeURIComponent(repoId)}/recipients/${encodeURIComponent(principalId)}/proof-of-open`, { method: "POST", body: evidence, context: "submitting a vault proof-of-open receipt" });
        return { receipt: res.body, deduplicated: res.status === 200 };
    }
    async acquireMaintenanceLease(request) {
        const { repo_id, ...body } = request;
        return this.#client.request(`/vaults/v1/${encodeURIComponent(repo_id)}/maintenance-leases`, {
            method: "POST",
            body: {
                base_head_sha256: body.base_head_sha256,
                current_checkpoint_hash: body.current_checkpoint_hash ?? null,
                r1_size_bytes: body.r1_size_bytes,
                r2_cap_size_bytes: body.r2_cap_size_bytes,
                p_before_c1_size_bytes: body.p_before_c1_size_bytes ?? "0",
                p_before_c2_size_bytes: body.p_before_c2_size_bytes ?? "0",
            },
            context: "acquiring the vault maintenance lease",
        });
    }
    // ── Node-only verbs ───────────────────────────────────────────────────────
    /**
     * Open the vault: keystore + HTTP transport + the protocol object. Every verb
     * below builds on this; call it directly when you need the raw protocol
     * surface (ref transactions, repair, checkpoint building).
     */
    async open(options = {}) {
        const [{ Vault, createVaultHttpTransport }, { VaultKeystore }] = await Promise.all([this.#publication(), this.#keystore()]);
        const repoId = await this.#resolveRepoId(options);
        const keystore = new VaultKeystore(options.keystore_root !== undefined ? { rootDir: options.keystore_root } : {});
        // vault-object-host-predial (design D1, task 1.2): this is the ONE
        // transport whose object reads back the returned handle's vault, so
        // wiring the origin-observation hook here (and nowhere else `open()`
        // is reached from — `openOrCreate`'s own allocation-only transport
        // never serves object reads) covers every read path.
        const transport = createVaultHttpTransport(this.#client, {
            onObjectStoreOriginObserved: (rid, origins) => keystore.recordObjectStoreOrigins(rid, origins),
            byoBackend: await this.#byoBackendResolver(keystore),
        });
        // vault-agent-envelopes D3: enroll the keystore's key if this principal
        // has none (publish + possession proof, one round trip each); a differing
        // current key REFUSES here — rotation is never automatic.
        const enrollment = await this.#ensureEnrolled(keystore);
        const vault = new Vault({
            keystore,
            transport,
            repo_id: repoId,
            ...(options.repo_dir !== undefined ? { repo_dir: options.repo_dir } : {}),
            ...(options.verification_budget !== undefined ? { verification_budget: options.verification_budget } : {}),
            ...(options.service_public_key !== undefined ? { service_public_key: options.service_public_key } : {}),
        });
        // D4: a cold keystore restores from its own envelope (or fails
        // VAULT_ENVELOPE_PENDING with the exact next actions). The lazy
        // fallback is an ALLOWLIST of typed transport/absence states, not a
        // denylist of known verdicts (consult round 2 §5): a network fault, an
        // older gateway missing the route (404/501), or a vault with no admitted
        // genesis yet (CHAIN_BROKEN — not born, nothing to restore or disclose)
        // leave the handle lazy so the verb fails exactly where it always did
        // (`VAULT_REPO_STATE_MISSING` at first use). EVERYTHING else —
        // integrity failures, parse failures, programming errors — propagates.
        // A keystore with NO identity has nothing to restore and nothing that
        // could be disclosed — stay lazy and let the verb hit the ordinary
        // KEYSTORE_MISSING wall (with its cross-profile hint) exactly where it
        // always did, AFTER the caller's own address-pin writes.
        let restored = null;
        try {
            restored = keystore.readIdentity() ? await vault.ensureRepoState() : null;
        }
        catch (e) {
            const code = isRun402Error(e) ? e.code : undefined;
            const status = isRun402Error(e) ? (e.status ?? null) : null;
            const transportAbsence = isNetworkError(e) || status === 404 || status === 501;
            if (code === "CHAIN_BROKEN" || transportAbsence) {
                restored = null;
            }
            else {
                throw e;
            }
        }
        // D5: a key-holder fulfils pending recipients once per process per vault.
        // A just-restored keystore holds K_repo too, but it just LEARNED it from
        // someone else's wrap — it is not the party that owes fulfilment on this
        // open, and wrapping from a first-contact restore before verifying the
        // chain would be premature; the next open in this process does it.
        const policy = options.reconcile ?? "auto";
        // Consult round 2 §4: reconcile WRAPS K_repo — a disclosure — so it runs
        // only when this open VERIFIED custody continuity against the gateway
        // (whoami answered and the current key is this keystore's). A transport
        // fault or unresolved principal is never fail-open into a wrap.
        // `signing_republished` (rev 47) belongs in this allowlist too: it is
        // reached only when the ENCRYPTION half was already active (continuity
        // already held) and a fresh, gateway-verified Ed25519 possession
        // signature was just proven for the signing half — strictly MORE
        // evidence of custody than `already_active` alone, not less.
        const custodyVerified = enrollment.outcome === "already_active" || enrollment.outcome === "enrolled" || enrollment.outcome === "activated_pending" || enrollment.outcome === "signing_republished";
        // Scoped to what was actually verified: keystore + vault + the exact
        // fingerprint whose continuity this open proved. Marked ONLY after a
        // successful reconcile — a transient failure stays retryable, and a
        // `deferred` policy never consumes the marker.
        const sessionKey = `${keystore.rootDir}|${repoId}|${enrollment.ek_fingerprint ?? "none"}`;
        let reconcileRecipients = null;
        if (policy === "forbidden") {
            reconcileRecipients = { attempted: false, outcome: "forbidden" };
        }
        else if (!restored && !custodyVerified) {
            reconcileRecipients = { attempted: false, outcome: "custody_unverified", ...(enrollment.error !== undefined ? { error: enrollment.error } : {}) };
        }
        else if (!restored && !SESSION_RECONCILED.has(sessionKey)) {
            if (policy === "deferred") {
                let pendingCount = null;
                try {
                    const coverage = await transport.listEnvelopeRecipients({ repo_id: repoId });
                    const covered = new Set(coverage.recipient_fingerprints);
                    pendingCount = coverage.desired ? coverage.desired.filter((d) => d.status === "active" && d.ek_fingerprint && !covered.has(d.ek_fingerprint)).length : null;
                }
                catch {
                    pendingCount = null;
                }
                reconcileRecipients = { attempted: false, outcome: "deferred_by_local_policy", pending_count: pendingCount };
            }
            else {
                try {
                    const result = await vault.reconcileEnvelopeRecipients();
                    reconcileRecipients = { attempted: true, outcome: "reconciled", result };
                    SESSION_RECONCILED.add(sessionKey);
                }
                catch (e) {
                    rethrowFatalReconcile(e);
                    reconcileRecipients = { attempted: true, outcome: "skipped_error", error: e instanceof Error ? e.message : String(e) };
                }
            }
        }
        // vault-multi-writer (task 5.7) — the SAME "session-start"/"read"
        // wiring point as the envelope reconcile above, but a genuinely simpler
        // policy: no disclosure risk, so only `forbidden` is honored — no
        // custody gate, no `deferred`/memoization branch.
        const writerReconcile = policy === "forbidden" ? { attempted: false, outcome: "forbidden" } : await this.#tryReconcileWriterAdmissions(vault);
        return { repo_id: repoId, keystore, transport, vault, restored, reconcile_recipients: reconcileRecipients, writer_reconcile: writerReconcile, enrollment };
    }
    /**
     * vault-agent-envelopes D3 — enroll-if-absent, checked FRESH on every
     * open (consult round 2 §4: memoizing this decision let a long-lived
     * process keep trusting a key an owner had since revoked). Reads
     * `GET /agent/v1/whoami` (the gateway folds the principal's current key
     * metadata in), then:
     *   - no current key → publish the keystore's X25519 key as
     *     `custody_scheme: keystore_v1`, answer the ECDH possession challenge,
     *     activate;
     *   - current key = this keystore's key, still `pending` → finish the
     *     activation (a crashed earlier enrollment);
     *   - current key = this keystore's key, `active` → no-op;
     *   - current key ≠ this keystore's key → `VAULT_KEY_ROTATION_REQUIRED`.
     *     Never published as a rotation: a stolen wallet key must not silently
     *     replace a member's decryption identity. The remedies are the
     *     gateway's — restore the keystore backup; an org owner revokes the
     *     stale key; or, for a sole-member principal, `replace_current`.
     * A credential that resolves to no enrolling principal (a service key)
     * skips enrollment — reads keep working; nothing pretends to be enrolled.
     */
    async #ensureEnrolled(keystore) {
        let identity = keystore.readIdentity();
        let whoami;
        try {
            whoami = await this.#client.request("/agent/v1/whoami", { context: "resolving the enrolling principal" });
        }
        catch (e) {
            // An older gateway, a credential whoami refuses, or a transport fault:
            // enrollment is best-effort on the way IN — the verb itself still runs
            // (and a cold open will say `VAULT_ENVELOPE_PENDING` honestly if it
            // needed an envelope this principal never enrolled for). A keystore
            // with no identity reaches the ordinary KEYSTORE_MISSING wall unchanged.
            return { outcome: identity ? "skipped_error" : "skipped_no_identity", ek_fingerprint: identity?.encryption_fingerprint ?? null, signing_fingerprint: identity?.signing_fingerprint ?? null, error: e instanceof Error ? e.message : String(e) };
        }
        if (!whoami.principal) {
            return { outcome: "skipped_no_principal", ek_fingerprint: identity?.encryption_fingerprint ?? null, signing_fingerprint: identity?.signing_fingerprint ?? null };
        }
        if (whoami.principal.type !== "human" && whoami.principal.type !== "agent") {
            // Only custody-eligible principal types enroll (consult round 2 §4):
            // the gateway would refuse the publish anyway, but by then an orphan
            // identity would already exist on disk.
            return { outcome: "skipped_not_enrollable", ek_fingerprint: identity?.encryption_fingerprint ?? null, signing_fingerprint: identity?.signing_fingerprint ?? null };
        }
        if (identity?.enrolled_principal_id && identity.enrolled_principal_id !== whoami.principal.id) {
            throw new LocalError(`this keystore identity enrolled as principal ${identity.enrolled_principal_id}, but the active credential resolves to ${whoami.principal.id} — refusing cross-profile reuse of a custody identity`, "enrolling the keystore encryption key", { code: "VAULT_IDENTITY_PROFILE_MISMATCH", details: { enrolled_principal_id: identity.enrolled_principal_id, principal_id: whoami.principal.id, keystore_root: keystore.rootDir } });
        }
        const current = whoami.encryption_key ?? null;
        // A member joining from a FRESH machine has no keystore identity yet.
        // Mint one only now — a real gateway confirmed an enrollable principal
        // with no published key, so this identity is about to become that
        // principal's key. (Vault creation mints its own via the six-stage
        // journal; nothing else ever mints.) A principal that already HAS a
        // published key but no local identity lost its keystore — that is the
        // rotation case below, never a fresh mint.
        if (!identity && !current)
            identity = keystore.ensureIdentity();
        if (!identity) {
            throw new LocalError(`this principal has a published encryption key (${current.ek_fingerprint}) but this keystore holds no identity — the keystore that enrolled it is not this one; rotation is never automatic`, "enrolling the keystore encryption key", {
                code: "VAULT_KEY_ROTATION_REQUIRED",
                details: { current_ek_fingerprint: current.ek_fingerprint, current_state: current.state, local_ek_fingerprint: null, keystore_root: keystore.rootDir },
                next_actions: [
                    { type: "edit_request", why: "restore the keystore backup that holds the current key (~/.config/run402/vault or the wallet profile's vault dir)" },
                    { type: "edit_request", why: "have an org owner revoke the stale key: DELETE /orgs/v1/:org_id/members/:principal_id/encryption-key (owner + step-up); your next vault operation then enrolls a fresh key" },
                    { type: "edit_request", why: "if this principal is the sole custody-eligible member of every org it belongs to: POST /agent/v1/whoami/encryption-key with replace_current: true" },
                ],
            });
        }
        if (current && current.ek_fingerprint !== identity.encryption_fingerprint) {
            throw new LocalError(`this principal's published encryption key (${current.ek_fingerprint}) is not this keystore's key (${identity.encryption_fingerprint}); rotation is never automatic`, "enrolling the keystore encryption key", {
                code: "VAULT_KEY_ROTATION_REQUIRED",
                details: { current_ek_fingerprint: current.ek_fingerprint, current_state: current.state, local_ek_fingerprint: identity.encryption_fingerprint, keystore_root: keystore.rootDir },
                next_actions: [
                    { type: "edit_request", why: "restore the keystore backup that holds the current key (~/.config/run402/vault or the wallet profile's vault dir)" },
                    { type: "edit_request", why: "have an org owner revoke the stale key: DELETE /orgs/v1/:org_id/members/:principal_id/encryption-key (owner + step-up); your next vault operation then enrolls this key" },
                    { type: "edit_request", why: "if this principal is the sole custody-eligible member of every org it belongs to: POST /agent/v1/whoami/encryption-key with replace_current: true" },
                ],
            });
        }
        // vault-multi-writer rev 47 (task 5.3, design D9) — the signing half's
        // OWN continuity check, parallel to the encryption half's: does the
        // directory's currently-published signing_fingerprint already equal
        // this keystore's? UNLIKE the encryption half this is never a rotation
        // refusal (D9: the signing half is always freely republishable) — a
        // mismatch just means the publish call below is still needed, even
        // when the encryption half is already fully active.
        const needsSigningPublish = identity.signing_fingerprint !== (current?.signing_fingerprint ?? null);
        if (current && current.state === "active" && !needsSigningPublish) {
            keystore.bindIdentityPrincipal(whoami.principal.id);
            return { outcome: "already_active", ek_fingerprint: current.ek_fingerprint, signing_fingerprint: identity.signing_fingerprint };
        }
        const keypair = keystore.encryptionKeypair(identity);
        if (!keypair) {
            throw new LocalError("the keystore identity has no X25519 private key — it cannot enroll or open envelopes", "enrolling the keystore encryption key", { code: "VAULT_UNRECOVERABLE", details: { statement: VAULT_TERMINAL_LOSS_STATEMENT } });
        }
        // The signing half rides the SAME publish call as the encryption half
        // (task 4.1's gateway contract: one round trip, both halves, all-or-
        // none). `keystore.signingKeypair` is `null` only for a read-only
        // recovery identity that lost its signing seed — that keystore can
        // still enroll its encryption half (existing behavior, unchanged); it
        // simply cannot become a vault writer until the seed is recovered.
        const signingKeypair = keystore.signingKeypair(identity);
        const signingFields = signingKeypair
            ? {
                signing_pubkey: identity.signing_pubkey,
                signing_fingerprint: identity.signing_fingerprint,
                possession_signature: computeSigningKeyPossessionSignature({
                    signing_seed: signingKeypair.seed,
                    principal_id: whoami.principal.id,
                    signing_pubkey: identity.signing_pubkey,
                    encryption_pubkey: identity.encryption_pubkey,
                }),
            }
            : {};
        try {
            const published = await this.#client.request("/agent/v1/whoami/encryption-key", { method: "POST", body: { public_key: identity.encryption_pubkey, ek_fingerprint: identity.encryption_fingerprint, custody_scheme: "keystore_v1", ...signingFields }, context: "publishing the keystore encryption key" });
            if (published.state !== "active") {
                if (!published.activation) {
                    throw new LocalError("the gateway published the key as pending without an activation challenge", "enrolling the keystore encryption key", { code: "KEY_NOT_PENDING", details: { state: published.state } });
                }
                const proof = computeKeystorePossessionProof({
                    private_key: keypair.private_key,
                    epk_b64u: published.activation.epk,
                    challenge_id: published.activation.challenge_id,
                    encryption_key_id: published.encryption_key_id,
                    public_key_b64u: identity.encryption_pubkey,
                });
                await this.#client.request("/agent/v1/whoami/encryption-key/activate", {
                    method: "POST",
                    body: { challenge_id: published.activation.challenge_id, proof },
                    context: "proving possession of the keystore encryption key",
                });
            }
        }
        catch (e) {
            // The ONE refusal that must surface is a rotation (the gateway saw a
            // different current key than whoami just reported — a race with a
            // concurrent publish); everything else stays best-effort.
            if (isRun402Error(e) && e.code === "KEY_ROTATION_REQUIRED")
                throw e;
            return { outcome: "skipped_error", ek_fingerprint: identity.encryption_fingerprint, signing_fingerprint: identity.signing_fingerprint ?? null, error: e instanceof Error ? e.message : String(e) };
        }
        keystore.bindIdentityPrincipal(whoami.principal.id);
        // The early `already_active` return above only fires when NEITHER half
        // needs work, so reaching here with `current.state === "active"` means
        // specifically that the ENCRYPTION half was already fine and only the
        // signing half triggered this publish — `signing_republished`, not
        // `activated_pending` (which would wrongly imply an ECDH activation
        // challenge was just completed; none was, `published.state` was
        // already `"active"` and the activation branch above never ran).
        const outcome = !current ? "enrolled" : current.state === "active" ? "signing_republished" : "activated_pending";
        return { outcome, ek_fingerprint: identity.encryption_fingerprint, signing_fingerprint: identity.signing_fingerprint ?? null };
    }
    /**
     * Open a vault, allocating it first when it does not exist yet (D2 — lazy
     * allocation on first push).
     *
     * This is the ONE primitive the remote helper and the capture lane
     * (`run402 repos capture`/`snapshot`) drive so that a push against an
     * unallocated project runs the six-stage creation journal inline instead of
     * refusing: it resolves `repo_id` from `project_id` exactly like
     * {@link open}, and when that resolution fails, tries to create the vault —
     * but ONLY when `org_id` was supplied. Without `org_id` this method is
     * byte-identical to `open()`: the original resolution failure is rethrown
     * unchanged, so every existing caller of `open()` that has no reason to
     * create anything sees no behavior change at all by switching to this.
     *
     * RESUMABILITY (client-surface spec, D2): before starting a fresh creation
     * attempt this looks for an INCOMPLETE local journal already matching this
     * exact `(org_id, project_id)` and resumes ITS `client_creation_id`, rather
     * than starting a second competing attempt every time the process is
     * interrupted mid-creation. Kill the process after ALLOCATED and call this
     * again: the same journal — not a new one — drives to ACTIVE, and exactly
     * one vault exists either way. An explicit `client_creation_id` (tests, or
     * a caller resuming a specific attempt by hand) always wins over that search.
     */
    async openOrCreate(options) {
        if (!options.repo_id && !options.project_id) {
            throw new LocalError("pass repo_id, or project_id to resolve it from the control plane", "opening the vault", { code: "VAULT_UNRESOLVED" });
        }
        // An explicit repo_id addresses the vault directly — there is nothing to
        // resolve or lazily create from an id alone, so this degrades to a plain
        // open (matches `open()`'s own precedence: repo_id always wins).
        if (options.repo_id) {
            const handle = await this.open(options);
            return { handle, found: true, created: null, terminal_loss_statement: VAULT_TERMINAL_LOSS_STATEMENT };
        }
        const [{ createVaultHttpTransport }, { VaultKeystore }, { openOrCreateVault }] = await Promise.all([this.#publication(), this.#keystore(), this.#openOrCreate()]);
        const keystore = new VaultKeystore(options.keystore_root !== undefined ? { rootDir: options.keystore_root } : {});
        const transport = createVaultHttpTransport(this.#client, { byoBackend: await this.#byoBackendResolver(keystore) });
        const result = await openOrCreateVault({
            keystore,
            transport,
            project_id: options.project_id,
            ...(options.org_id !== undefined ? { org_id: options.org_id } : {}),
            ...(options.client_creation_id !== undefined ? { client_creation_id: options.client_creation_id } : {}),
            ...(options.service_public_key !== undefined ? { service_public_key: options.service_public_key } : {}),
        });
        const handle = await this.open({ ...options, repo_id: result.repo_id });
        return result.found
            ? { handle, found: true, created: null, terminal_loss_statement: VAULT_TERMINAL_LOSS_STATEMENT }
            : { handle, found: false, created: result.created, terminal_loss_statement: VAULT_TERMINAL_LOSS_STATEMENT };
    }
    /**
     * Resolve a parsed remote address to an OPEN handle, pinning `repo_id` in
     * local git state on the first successful resolution of EITHER form
     * (vault-client-round-trips design D4 widens the original slug-form-only
     * pin to id-form too — an id-form address needs no pin to survive a
     * rename, but the resolution round trip it skips is the same either way),
     * and — when `allow_create` is set and resolution misses — push-to-create
     * it. This is what the remote helper and `vault snapshot` drive for a
     * `run402::<org>/<name>` remote, whichever form it names, so a caller need
     * not branch on the address's form itself. A pinned id/address that no
     * longer resolves (404) clears the pin and re-resolves once.
     *
     * `SLUG_RELEASED` is NEVER auto-followed — it rethrows unchanged; read it
     * with {@link vaultSlugReleasedInfo} for the successor slug and cooldown.
     */
    async resolveOrCreateAddress(options) {
        const [{ createVaultHttpTransport }, { VaultKeystore }, { resolveVaultAddress }] = await Promise.all([this.#publication(), this.#keystore(), this.#address()]);
        const keystore = new VaultKeystore(options.keystore_root !== undefined ? { rootDir: options.keystore_root } : {});
        const transport = createVaultHttpTransport(this.#client, { byoBackend: await this.#byoBackendResolver(keystore) });
        const resolution = await resolveVaultAddress({
            keystore,
            transport,
            address: options.address,
            ...(options.repo_dir !== undefined ? { repo_dir: options.repo_dir } : {}),
            ...(options.allow_create !== undefined ? { allow_create: options.allow_create } : {}),
            ...(options.client_creation_id !== undefined ? { client_creation_id: options.client_creation_id } : {}),
            ...(options.service_public_key !== undefined ? { service_public_key: options.service_public_key } : {}),
            ...(options.onVaultCreated !== undefined ? { onVaultCreated: options.onVaultCreated } : {}),
        });
        const handle = await this.open({ ...options, repo_id: resolution.repo_id });
        return {
            handle,
            found: resolution.via !== "created",
            created: resolution.created,
            terminal_loss_statement: VAULT_TERMINAL_LOSS_STATEMENT,
            resolution,
        };
    }
    /**
     * Stale-pin recovery for OFFLINE address resolutions
     * (vault-force-spelling-and-pin-fold): an id-carrying pin resolves with
     * zero network reads, so a pin gone stale (its vault deleted and the
     * project re-allocated a new one) surfaces as the VERB's first repo-scoped
     * read failing rather than as a resolution failure. Call this with that
     * failure: it answers a fresh opened handle + resolution to retry the verb
     * against exactly once, or `null` when there is nothing to recover — the
     * error is not a vault-absent signal, the resolution was not offline, or
     * re-resolution lands on the SAME `repo_id` (the pin was fine and the
     * original refusal is real; it is restored, and recovery never widens what
     * an unauthorized caller learns).
     */
    async recoverStalePin(options) {
        const [{ createVaultHttpTransport }, { VaultKeystore }, { recoverStaleVaultPin }] = await Promise.all([this.#publication(), this.#keystore(), this.#address()]);
        const keystore = new VaultKeystore(options.keystore_root !== undefined ? { rootDir: options.keystore_root } : {});
        const transport = createVaultHttpTransport(this.#client, { byoBackend: await this.#byoBackendResolver(keystore) });
        const fresh = await recoverStaleVaultPin({
            keystore,
            transport,
            address: options.address,
            repo_dir: options.repo_dir,
            resolution: options.resolution,
            error: options.error,
        });
        if (!fresh)
            return null;
        const handle = await this.open({ ...options, repo_id: fresh.repo_id });
        return {
            handle,
            found: true,
            created: null,
            terminal_loss_statement: VAULT_TERMINAL_LOSS_STATEMENT,
            resolution: fresh,
        };
    }
    /**
     * Create the vault for a project and scaffold the git remote.
     *
     * Runs the six-stage creation journal — `LOCAL_KEYS_PREPARED → ALLOCATED →
     * OBJECTS_PREPARED → OBJECTS_FINALIZED → GENESIS_PREPARED → ACTIVE`, each
     * fsynced before the next step, with no ciphertext existing before allocation
     * supplies the `repo_id` the key derivation needs. Crash-safe and resumable
     * on `client_creation_id`.
     *
     * An existing `origin` remote is never modified or claimed; only a remote
     * named `run402` is added.
     */
    async init(options) {
        const [{ createVaultHttpTransport }, { VaultKeystore }, { createVault }] = await Promise.all([this.#publication(), this.#keystore(), this.#creation()]);
        const { parseMirrorDestinationUrl, formatMirrorDestination } = await this.#mirrorConfig();
        const keystore = new VaultKeystore(options.keystore_root !== undefined ? { rootDir: options.keystore_root } : {});
        const transport = createVaultHttpTransport(this.#client, { byoBackend: await this.#byoBackendResolver(keystore) });
        let byoWriteTarget;
        let byoDestinationAddress;
        if (options.byo) {
            const destination = parseMirrorDestinationUrl(options.byo.destination_url, { region: options.byo.region, endpoint: options.byo.endpoint });
            byoWriteTarget = { destination, ...(options.byo.credential ? { credential: options.byo.credential } : {}) };
            byoDestinationAddress = formatMirrorDestination(destination);
            // vault-byo-primary-bucket task 3.1 (design D6) — probe BEFORE any
            // allocation request; a failed probe throws and nothing is created.
            const { probeVaultByoDestination } = await this.#byoProbe();
            await probeVaultByoDestination(destination, options.byo.credential);
        }
        const created = await createVault({
            keystore,
            transport,
            org_id: options.org_id,
            project_id: options.project_id,
            ...(options.client_creation_id !== undefined ? { client_creation_id: options.client_creation_id } : {}),
            ...(options.service_public_key !== undefined ? { service_public_key: options.service_public_key } : {}),
            ...(byoWriteTarget ? { storage_profile: "byo", byo_destination: byoDestinationAddress, byo_write_target: byoWriteTarget } : {}),
        });
        let remote = null;
        if (options.scaffold_git !== false && options.repo_dir) {
            remote = await this.scaffoldRemote({
                repo_dir: options.repo_dir,
                org_id: options.org_id,
                project_id: options.project_id,
                ...(options.nested !== undefined ? { nested: options.nested } : {}),
                ...(options.remote_name !== undefined ? { remote_name: options.remote_name } : {}),
                ...(options.remote_url !== undefined ? { remote_url: options.remote_url } : {}),
            });
        }
        // vault-byo-primary-bucket task 3.1/3.5 — read back the AUTHORITATIVE
        // storage_profile from the vault record rather than trusting what THIS
        // call requested: an idempotent replay against a pre-existing vault
        // (a different client_creation_id, a different machine) can legitimately
        // resolve to a vault whose real profile disagrees with what was just
        // asked for (`allocateVault`'s own doc comment: "a replay reads the
        // EXISTING row's profile, never re-derives it"). Only when the SERVER
        // confirms "byo" is the local write-credential config actually saved —
        // never speculatively, and never when it disagrees.
        let storageProfile = "managed";
        let byoDestination = null;
        if (byoWriteTarget) {
            const record = await transport.getVaultRecord({ repo_id: created.repo_id });
            storageProfile = record.storage_profile ?? "managed";
            byoDestination = record.byo_destination ?? null;
            if (storageProfile === "byo") {
                const { saveByoConfig } = await this.#byoConfig();
                saveByoConfig(keystore, { repo_id: created.repo_id, destination: byoWriteTarget.destination, ...(byoWriteTarget.credential ? { credential: byoWriteTarget.credential } : {}) });
            }
            // storageProfile !== "byo" here means this project's vault already
            // existed as a MANAGED vault under a different creation attempt —
            // nothing is saved locally, and the result below reports the TRUE
            // profile so the caller can tell the user honestly rather than
            // silently writing a bogus BYO config for a managed vault.
        }
        return {
            repo_id: created.repo_id,
            project_id: options.project_id,
            recovery_receipt: created.recovery_receipt,
            genesis_sha256: created.genesis_sha256,
            remote,
            deduplicated: created.how === "reconciled",
            terminal_loss_statement: VAULT_TERMINAL_LOSS_STATEMENT,
            storage_profile: storageProfile,
            byo_destination: byoDestination,
        };
    }
    /**
     * Add the run402 git remote, initialising the repository if absent.
     *
     * D1 — claim `origin` additively. LLM muscle memory is `git push origin
     * main` from a billion training examples, and a side-remote name costs
     * every agent a correction cycle, so when the repository has no `origin`
     * remote ours BECOMES `origin`. An existing `origin` is NEVER modified or
     * reclaimed — no matter what it points at, including a prior run of this
     * exact call — so the fallback is `run402`, and when THAT is also taken by
     * something else, nothing is added at all: the additive discipline holds
     * for every remote name this method ever touches, not just `origin`.
     *
     * An explicit `remote_name` is a caller override (no current caller passes
     * one) and skips the origin/run402 dance entirely — the caller already
     * decided the name; this method still never modifies an existing remote
     * under it.
     *
     * No key material or allocation is required — the cold-start path gains no
     * prompts or network dependencies from this.
     *
     * `nested: true` — the monorepo case. An app root that lies INSIDE another
     * repository (an agent workspace with many apps under one checkout) is
     * still made its own repository: `git init -b main` in `repo_dir`, the
     * remote added there, and ONE line — `/<relative path of repo_dir>/` —
     * appended to the ENCLOSING repository's local `.git/info/exclude`
     * (located via `rev-parse --git-path info/exclude`, so a worktree resolves
     * to its common dir) so the enclosing checkout never lists the nested
     * repository as untracked noise. Never `.gitignore` (tracked content),
     * never the index, never a submodule: nothing else in the enclosing
     * repository changes, and its remotes are untouched. Idempotent — a second
     * call on the already-nested app root adds nothing and re-reports the same
     * shape. Without `nested`, the same situation is reported `skipped` with a
     * `create_nested_repo` next_action naming this way out.
     */
    async scaffoldRemote(options) {
        const { hardenedGit } = await this.#snapshot();
        const url = options.remote_url ?? vaultRemoteUrl(options.org_id, options.project_id);
        let createdRepository = false;
        let enclosingToplevel = null;
        // The scaffold acts on the APP ROOT only (first-deploy-agent-dx, design
        // D3): a directory that is not a repository is initialized; a directory
        // that is itself a git toplevel gets the remote; a directory that merely
        // lies INSIDE some other repository is left exactly as it was — nobody
        // asked for a remote on that repository — unless `nested` says to make
        // it its own repository. The result says which.
        let insideRepository = true;
        try {
            await hardenedGit(options.repo_dir, ["rev-parse", "--git-dir"]);
        }
        catch {
            insideRepository = false;
        }
        const { realpathSync } = await import("node:fs");
        const here = realpathSync(options.repo_dir);
        if (insideRepository) {
            const toplevel = (await hardenedGit(options.repo_dir, ["rev-parse", "--show-toplevel"])).text().trim();
            if (realpathSync(toplevel) !== here) {
                if (!options.nested) {
                    return {
                        status: "skipped",
                        name: options.remote_name ?? "run402",
                        url,
                        created_repository: false,
                        already_present: false,
                        existing_url: null,
                        reason: `${options.repo_dir} is inside the repository at ${toplevel} — an enclosing repository is never touched; run from the app root, or make the app root its own repository`,
                        toplevel,
                        next_actions: [
                            {
                                type: "create_nested_repo",
                                command: `run402 repos create --nested --project ${options.project_id}`,
                                why: `The app lives inside the repository at ${toplevel}; a nested repo keeps an encrypted remote for the app without touching the enclosing repository.`,
                            },
                        ],
                    };
                }
                // Nested: the app root becomes its own repository, exactly the way a
                // directory that is not a repository at all would below. The
                // enclosing repository is only ever read (its toplevel) and, once
                // the nested repository exists, told to exclude it locally.
                enclosingToplevel = toplevel;
                insideRepository = false;
            }
        }
        // Not inside any repository at all: `nested` is moot, and the result
        // truthfully omits `nested: true`.
        if (insideRepository && options.nested) {
            // The app root is already its own toplevel. A previous nested scaffold
            // (or a hand-made nested repository) may still sit inside another
            // checkout — find it from the PARENT directory, so the idempotent
            // second call keeps the exclude line in place.
            enclosingToplevel = await this.#enclosingToplevelOf(here);
        }
        if (!insideRepository) {
            // `main`, not whatever `init.defaultBranch` (or the pre-2.28 hardcoded
            // `master`) happens to be — the docs and this helper's own remedy text
            // (`repoRefusalNote` in git-remote-run402) teach `git push origin main`,
            // and a first push of any OTHER branch leaves HEAD naming a ref that
            // does not exist yet (see this file's own KNOWN LIMITS note on
            // `push` never changing the vault's HEAD target). `-b` needs git 2.28+
            // (2020); the fallback for anything older is the same result by a
            // different route: init, then point HEAD at `refs/heads/main` directly
            // — an EMPTY repository has no ref for `symbolic-ref` to disturb, so
            // this is exactly as safe as `-b main` would have been.
            try {
                await hardenedGit(options.repo_dir, ["init", "-b", "main"]);
            }
            catch {
                await hardenedGit(options.repo_dir, ["init"]);
                await hardenedGit(options.repo_dir, ["symbolic-ref", "HEAD", "refs/heads/main"]);
            }
            createdRepository = true;
        }
        let nestedFields = {};
        if (enclosingToplevel !== null) {
            const excluded = await this.#excludeNestedRepoInEnclosing(enclosingToplevel, here);
            nestedFields = { nested: true, enclosing_toplevel: enclosingToplevel, excluded_in_enclosing: excluded };
        }
        // An EXISTING repository is never touched — only the branch a fresh `init`
        // above just created gets steered to `main`; this repository's own HEAD
        // (whatever branch it already uses) is left exactly as it was.
        const readRemote = async (name) => {
            try {
                const out = (await hardenedGit(options.repo_dir, ["remote", "get-url", name])).text().trim();
                return out.length > 0 ? out : null;
            }
            catch {
                return null;
            }
        };
        const add = async (name) => {
            await hardenedGit(options.repo_dir, ["remote", "add", name, url]);
        };
        // The remote is ALWAYS `run402` (or the explicit `remote_name`). `origin`
        // is never claimed, even when free: a repository with no remotes is
        // exactly the one whose owner is about to add their own `origin`.
        const name = options.remote_name ?? "run402";
        const existing = await readRemote(name);
        if (existing) {
            return {
                status: "scaffolded",
                name,
                url,
                created_repository: createdRepository,
                already_present: true,
                existing_url: existing,
                reason: existing === url ? `'${name}' already points here — nothing to add` : `'${name}' points at ${existing} — left unchanged, nothing was added`,
                ...nestedFields,
            };
        }
        await add(name);
        return { status: "scaffolded", name, url, created_repository: createdRepository, already_present: false, existing_url: null, reason: `no existing '${name}' remote — added`, ...nestedFields };
    }
    /**
     * The toplevel of the repository that ENCLOSES `dir` (a realpath that is
     * itself a git toplevel), found from its parent directory; `null` when the
     * parent lies in no repository at all. Read-only.
     */
    async #enclosingToplevelOf(dir) {
        const { hardenedGit } = await this.#snapshot();
        const { dirname } = await import("node:path");
        const parent = dirname(dir);
        if (parent === dir)
            return null;
        try {
            const toplevel = (await hardenedGit(parent, ["rev-parse", "--show-toplevel"])).text().trim();
            return toplevel.length > 0 ? toplevel : null;
        }
        catch {
            return null;
        }
    }
    /**
     * Append `/<relative path of nestedDir from toplevel>/` to the enclosing
     * repository's LOCAL `.git/info/exclude` — never `.gitignore`, which is
     * tracked content (the same discipline as `excludeMessagingCacheFromGit`
     * in `vault-restore.ts`). Located via `rev-parse --git-path
     * info/exclude` so a linked worktree resolves to its common dir. Creates
     * the file when absent; idempotent when the line is already there.
     * Returns whether the line is in place — `false` (never a throw) when the
     * enclosing repository could not be written, so a read-only checkout
     * still gets its nested repository and the result says the exclude is
     * missing.
     */
    async #excludeNestedRepoInEnclosing(toplevel, nestedDir) {
        try {
            const { hardenedGit } = await this.#snapshot();
            const { realpathSync, readFileSync, appendFileSync, mkdirSync } = await import("node:fs");
            const { dirname, isAbsolute, relative, resolve, sep } = await import("node:path");
            const top = realpathSync(toplevel);
            const rel = relative(top, nestedDir);
            if (rel.length === 0 || rel.startsWith("..") || isAbsolute(rel))
                return false;
            const entry = `/${rel.split(sep).join("/")}/`;
            const rawPath = (await hardenedGit(top, ["rev-parse", "--git-path", "info/exclude"])).text().trim();
            const excludePath = isAbsolute(rawPath) ? rawPath : resolve(top, rawPath);
            let existing = "";
            try {
                existing = readFileSync(excludePath, "utf-8");
            }
            catch {
                existing = "";
            }
            if (existing.split("\n").some((line) => line.trim() === entry))
                return true;
            mkdirSync(dirname(excludePath), { recursive: true });
            const needsLeadingNewline = existing.length > 0 && !existing.endsWith("\n");
            appendFileSync(excludePath, `${needsLeadingNewline ? "\n" : ""}${entry}\n`);
            return true;
        }
        catch {
            return false;
        }
    }
    /**
     * What this machine and the control plane each believe about the vault.
     *
     * Truthful for a VAULT-ONLY project (protocol D183): a project that has never
     * deployed raises no deploy-related warning, and the terminal-loss statement
     * is stated verbatim exactly as for any other vault.
     */
    async status(options = {}) {
        const { VaultKeystore } = await this.#keystore();
        const { listPendingOverrideJournals } = await this.#deploy();
        const keystore = new VaultKeystore(options.keystore_root !== undefined ? { rootDir: options.keystore_root } : {});
        let repoId = options.repo_id ?? null;
        let record = null;
        if (!repoId && options.project_id) {
            record = await this.forProject(options.project_id).catch(() => null);
            repoId = record?.repo_id ?? null;
        }
        else if (repoId) {
            record = await this.get(repoId).catch(() => null);
        }
        // READ, never `ensureIdentity()`: that mints an Ed25519 + X25519 keypair
        // and writes it to disk. `status` is an observation — it must not create
        // the very key material it is reporting on, and the client-surface spec is
        // explicit that no key material exists until first capture.
        let identityFingerprint = null;
        let canSign = false;
        let keystorePresent = false;
        try {
            const identity = keystore.readIdentity();
            if (identity) {
                keystorePresent = true;
                identityFingerprint = identity.signing_fingerprint;
                canSign = keystore.signingKeypair(identity) !== null;
            }
        }
        catch {
            keystorePresent = false;
        }
        let holdsRepoKey = false;
        let authenticated = null;
        let materialized = null;
        if (repoId && keystorePresent) {
            const repoFile = keystore.readRepo(repoId);
            holdsRepoKey = repoFile !== null;
            authenticated = repoFile?.head_pin?.generation ?? null;
            materialized = repoFile?.materialized_pin?.generation ?? null;
        }
        let pending = [];
        if (repoId && keystorePresent) {
            try {
                pending = listPendingOverrideJournals(keystore, repoId);
            }
            catch {
                pending = [];
            }
        }
        // Best-effort covering-recipient count (dogfood item 2): the same
        // envelope-recipients read `access()` uses. Decides whether the V0-A
        // single-principal terminal-loss statement is still honest to print for
        // THIS vault, or whether this client has locally proven a second covering
        // recipient. Only attempted when a vault record is already in hand
        // (`record` non-null means the credentials that fetched it already work)
        // — a session-less or offline caller never pays for a second failing
        // request, and any failure here falls back to exactly today's
        // single-principal behavior, never a new failure mode for `status`.
        let coveringRecipients = null;
        if (repoId && record) {
            try {
                const coverage = await this.#client.request(`/vaults/v1/${encodeURIComponent(repoId)}/envelope-recipients`, { context: "reading the vault envelope recipients" });
                coveringRecipients = coverage.recipient_fingerprints.length;
            }
            catch {
                coveringRecipients = null;
            }
        }
        const isMultiPrincipal = coveringRecipients !== null && coveringRecipients >= 2;
        const warnings = [...(record?.warnings ?? [])];
        if (keystorePresent && !canSign) {
            warnings.push({ kind: "read_only", message: "the signing key is missing from identity.json — this principal can decrypt and verify but cannot publish a new head" });
        }
        if (record?.vault_policy === "grandfathered") {
            warnings.push({ kind: "policy_grandfathered", message: "activation does not require vault admission on this project; return it to `required` when the migration is done" });
        }
        // D7 (repo-first-onramp task 2.7): a vault that has accrued enough value
        // at risk gets a STANDING warning instead of the one-time genesis note.
        // `record` is `null` for an unallocated project, which is the ordinary
        // "nothing to warn about" shape, not a gap in this check. Once this
        // client has locally proven >= 2 covering recipients, the SAME threshold
        // still fires — real value at risk still deserves a standing reminder —
        // but the copy downgrades to `keystore_backup_reminder`, which never
        // claims terminal loss (dogfood item 2: the single-principal premise
        // behind `terminal_loss_risk` is provably false for this vault).
        if (record) {
            const trip = vaultLossWarningTrip(record);
            if (vaultLossWarningTripped(trip)) {
                if (isMultiPrincipal) {
                    warnings.push({ kind: "keystore_backup_reminder", message: vaultKeystoreBackupReminderMessage(trip, coveringRecipients) });
                }
                else {
                    warnings.push({ kind: "terminal_loss_risk", message: vaultLossWarningMessage(trip) });
                }
            }
        }
        // The local id-pin, when there is a repository to read it from — a pure
        // read, same discipline as `remote` below. Read
        // BEFORE `remote` so a slug-form remote's `matches` comparison can use
        // it: the pin is the only LOCAL ground truth a slug-form address's own
        // URL text does not carry.
        let pinned = null;
        // kygit-invite design D9: whether `.git/info/exclude` already carries
        // `.run402/` — a pure read, computed alongside the pin.
        let messagingCacheExcluded = null;
        if (options.repo_dir) {
            const { readPinnedVaultRepo } = await this.#address();
            const p = await readPinnedVaultRepo(options.repo_dir);
            pinned = p ? { repo_id: p.repo_id, resolved_from: p.resolved_from, room: p.room } : null;
            const { isMessagingCacheExcludedFromGit } = await this.#restore();
            messagingCacheExcluded = await isMessagingCacheExcludedFromGit(options.repo_dir);
        }
        // The local git remote, when there is a repository to read it from. A
        // pure read: `status` must never write git configuration.
        //
        // Checks BOTH conventional names, `run402` first then `origin` —
        // matching `scaffoldRemote`'s own naming: it claims `origin`
        // additively when the repository has none yet, falling back to `run402`
        // only when `origin` is already taken by something else. The common
        // case is therefore an `origin` remote, not a `run402` one. A name whose URL exists but
        // does not parse as a run402 address (someone's own unrelated remote
        // happening to be named "run402") is skipped rather than reported, so
        // the other conventional name still gets a chance.
        let remote = null;
        if (options.repo_dir) {
            const { hardenedGit } = await this.#snapshot();
            const project = options.project_id ?? record?.project_id ?? null;
            for (const name of ["run402", "origin"]) {
                let url;
                try {
                    url = (await hardenedGit(options.repo_dir, ["remote", "get-url", name])).text().trim();
                }
                catch {
                    continue; // not a repository, or no such remote — try the other name
                }
                if (!url)
                    continue;
                let parsed;
                try {
                    parsed = parseVaultRemoteUrl(url);
                }
                catch {
                    continue; // another scheme's remote — not a vault remote
                }
                if (!parsed)
                    continue;
                if (vaultRemoteAddressForm(parsed) === "id") {
                    // Id-form: the URL's second half IS the real project id — the
                    // pre-existing, always-correct comparison.
                    remote = { name, url, matches: project === null || parsed.project_id === project, reason: null };
                }
                else {
                    // Slug-form: the URL's second half is a repo NAME, not a project
                    // id — comparing it against `project` would always mismatch, even for
                    // a perfectly-configured remote. The only
                    // LOCAL ground truth for a slug-form remote's real identity is the
                    // id-pin; without one there is nothing to compare against, and
                    // that absence is NOT evidence of a mismatch.
                    remote = pinned
                        ? { name, url, matches: repoId === null || pinned.repo_id === repoId, reason: null }
                        : { name, url, matches: null, reason: "name-form remote, not yet resolved on this machine" };
                }
                break;
            }
        }
        // The ref map, only when asked (see the `refs` option's doc). Best-effort:
        // a status that cannot materialize still reports everything else.
        let refs = null;
        let headTarget = null;
        if (options.refs === true && repoId && holdsRepoKey) {
            try {
                const handle = await this.open({ ...options, repo_id: repoId });
                const state = await handle.vault.materialize();
                refs = { ...(state.refs ?? {}) };
                headTarget = state.head_target ?? null;
            }
            catch (e) {
                warnings.push({ kind: "refs_unavailable", message: `the vault's ref map could not be materialized: ${e instanceof Error ? e.message : String(e)}` });
            }
        }
        else if (options.refs === true && !holdsRepoKey) {
            warnings.push({ kind: "refs_unavailable", message: "this machine does not hold K_repo for the vault, so its ref map cannot be decrypted here" });
        }
        // vault-agent-envelopes D3/D5: a KEY-HOLDER's ordinary read is a
        // session start — enroll this keystore's key if absent and fulfil every
        // pending desired recipient once per process (best-effort, reported,
        // never folded into the observation above). `status` on a machine that
        // holds no K_repo has nothing to fulfil and stays a pure read.
        let reconcileRecipients = null;
        let enrollment = null;
        if (repoId && holdsRepoKey && options.reconcile !== "forbidden") {
            try {
                const handle = await this.open({ ...options, repo_id: repoId });
                reconcileRecipients = handle.reconcile_recipients;
                enrollment = handle.enrollment;
            }
            catch (e) {
                warnings.push({ kind: "reconcile_unavailable", message: `the session-start envelope fulfilment could not run: ${e instanceof Error ? e.message : String(e)}` });
            }
        }
        const nextActions = [];
        // `run402 init` scaffolds the git remote and deliberately allocates
        // nothing; pointing at it here sent users to a command that silently did
        // not do what this line promised (dogfood #1, finding A).
        if (!record)
            nextActions.push({ action: "allocate the project's vault", command: "run402 repos create --project <project_id>" });
        else if (pending.length > 0)
            nextActions.push({ action: `complete ${pending.length} unvaulted-override journal(s)`, command: "run402 repos capture" });
        else if (record && !holdsRepoKey)
            nextActions.push({ action: "this machine holds no key for the vault — allocate resolves to the existing vault and is idempotent", command: "run402 repos create --project <project_id>" });
        return {
            repo_id: repoId,
            project_id: options.project_id ?? record?.project_id ?? null,
            vault: record,
            reconcile_recipients: reconcileRecipients,
            enrollment,
            keystore: {
                present: keystorePresent,
                identity_fingerprint: identityFingerprint,
                can_sign: canSign,
                holds_repo_key: holdsRepoKey,
                root: keystore.rootDir,
                paths: {
                    identity: keystore.identityPath,
                    repos: keystore.reposDir,
                    receipts: keystore.receiptsDir,
                    journal: keystore.journalDir,
                    audit_log: keystore.auditLogPath,
                    // Path derivation validates the id shape and refuses a malformed
                    // one; a bad `--repo` must not turn `status` into a stack trace.
                    repo: safePath(() => keystore.repoPath(repoId), repoId),
                    recovery_receipt: safePath(() => keystore.recoveryReceiptPath(repoId), repoId),
                },
            },
            remote,
            pinned,
            messaging_cache_excluded: messagingCacheExcluded,
            refs,
            head_target: headTarget,
            pins: { highest_authenticated: authenticated, highest_materialized: materialized },
            vault_policy: record?.vault_policy ?? null,
            pending_overrides: pending.length,
            covering_recipients: coveringRecipients,
            terminal_loss_statement: isMultiPrincipal ? null : VAULT_TERMINAL_LOSS_STATEMENT,
            terminal_loss_detail: isMultiPrincipal ? null : VAULT_TERMINAL_LOSS_DOCTOR_TEXT,
            durability_statement: isMultiPrincipal ? VAULT_DURABILITY_STATEMENT : null,
            warnings,
            next_actions: nextActions,
        };
    }
    /**
     * Capture the working tree and publish it — the push half of a deploy, run on
     * its own. Never gated on a deploy: a vault-only project pushes for months
     * without one.
     *
     * WHAT THIS PUBLISHES, and what it deliberately does not. This is the CAPTURE
     * lane: it publishes the protocol-owned `refs/run402/deploys/latest` plus the
     * `head_target` read off the local HEAD. The repository's OWN refs —
     * `refs/heads/*` and `refs/tags/*` — reach the vault through
     * `git push run402 …` via `git-remote-run402`, which is the lane the spec
     * charges with reproducing them ("the helper SHALL reproduce the exact set of
     * objects reachable from the declared canonical refs (`refs/heads/*`,
     * `refs/tags/*`, protocol-owned `refs/run402/*`), plus the `HEAD` target" —
     * vault-client-surface, "Faithful helper"; `run402 repos capture` is
     * separately defined as "capture and push outside a deploy" under "Source
     * verbs"). The split is forced by §6.6: a dirty tree captures as a SYNTHETIC
     * commit that sits on no branch, so moving `refs/heads/main` onto it would
     * rewrite the user's branch on every dirty push — exactly the history
     * clobbering §6.1's fast-forward + force-with-lease rules exist to prevent.
     *
     * Hence `protocol_refs: "allow"`: this lane BUILDS the protocol ref itself,
     * so it opts in. The remote helper must keep the `"refuse"` default, because
     * there the refnames are user-supplied and a user must not be able to squat
     * the `refs/run402/*` namespace.
     *
     * Before reporting a push as landed the vault compares finalization receipts
     * against its expected manifest and reads the admitted head back from
     * storage. A 200 alone is never enough (§0 client obligations).
     */
    async capture(options) {
        const [{ deployRefTransaction }, { captureSnapshot, vaultCommitLine }] = await Promise.all([this.#publication(), this.#snapshot()]);
        const openedByAddress = options.address ? await this.resolveOrCreateAddress({ ...options, address: options.address, allow_create: true }) : null;
        const opened = openedByAddress ?? (await this.openOrCreate(options));
        const addressResolution = openedByAddress?.resolution ?? null;
        if (!opened.found && opened.created)
            await options.onVaultCreated?.(opened.created);
        let handle = opened.handle;
        const repoDir = options.repo_dir ?? process.cwd();
        const snapshot = await captureSnapshot({ dir: repoDir, ...(options.snapshot ?? {}) });
        const line = vaultCommitLine(snapshot);
        options.onCommitLine?.(line);
        // An OFFLINE (id-carrying pin) resolution discovers a stale pin on this
        // first repo-scoped read — recover once and retry against the fresh
        // vault, per the client-surface id-pinning requirement.
        let materialized;
        try {
            materialized = await handle.vault.materialize();
        }
        catch (e) {
            const recovered = options.address && options.repo_dir && addressResolution?.offline
                ? await this.recoverStalePin({ ...options, address: options.address, repo_dir: options.repo_dir, resolution: addressResolution, error: e })
                : null;
            if (!recovered)
                throw e;
            handle = recovered.handle;
            materialized = await handle.vault.materialize();
        }
        const push = {
            transaction: deployRefTransaction(materialized.refs, snapshot.oid),
            head_target: snapshot.head,
            // The transaction above names `refs/run402/deploys/latest`, which
            // `evaluateRefTransaction` refuses under its `?? "refuse"` default. Same
            // opt-in the deploy lane makes for the identical move — see the doc
            // comment for why the remote helper must NOT make it.
            protocol_refs: "allow",
            ...(options.checkpoint ? { checkpoint: true } : {}),
        };
        const result = await handle.vault.push(push).catch((e) => { throw this.#enrichEpochRotationRequired(e, handle.repo_id); });
        // Capture-time mirror-write hook: fires only when a
        // mirror is configured; NEVER throws, NEVER alters the vault outcome
        // above (already returned/committed) — a mirror failure is a named
        // pending finding reported BESIDE the vault result, on its own field.
        const mirrorPush = await this.#tryMirrorPush(handle.repo_id, handle.keystore);
        // vault-byo-primary-bucket task 3.3 — the SAME non-blocking contract,
        // fired right beside the mirror hook (skipped_no_mirror with no network
        // call for any managed vault or any machine with no local BYO config).
        const byoChainCopy = await this.#tryByoChainCopyPush(handle.repo_id, handle.keystore);
        // Deploy-time reconcile hook: fires on every successful push,
        // best-effort — a reconcile failure (including a read-only principal
        // with no signing key) is reported BESIDE the vault result, never a
        // `capture()` throw, same non-blocking contract as the mirror hook above.
        const reconcileRecipients = await this.#tryReconcileEnvelopeRecipients(handle.vault);
        // vault-multi-writer (task 5.7) — the SAME best-effort contract,
        // fired right beside the encryption-recipient reconcile above: a fresh
        // push proves this session's own key is an active writer, so this is
        // the natural place a just-admitted member's own next push clears
        // whatever candidacy it left behind for OTHER pending members too.
        const writerReconcile = await this.#tryReconcileWriterAdmissions(handle.vault);
        return { ...result, snapshot, vault_commit: snapshot.oid, vault_commit_line: line, mirror_push: mirrorPush, byo_chain_copy: byoChainCopy, reconcile_recipients: reconcileRecipients, writer_reconcile: writerReconcile };
    }
    // ── Handoff / resume (kygit-handoff design D1-D10) ─────────────────────────
    /**
     * Mint a Handoff Key: capture a stash-shaped checkpoint (design D1),
     * push it (retained, on no branch — {@link VAULT_DEPLOY_REF} carries
     * it exactly like an ordinary `capture()`, never `refs/heads/*`), seal the
     * vault's current epoch key under a fresh `wrap_key`, and mint through
     * the gateway. The assembled `kgh1_…` key is returned exactly ONCE —
     * nothing here or downstream persists it.
     *
     * `options.note` omits `capture` — this method fills it with the real
     * capture figures and runs the client-side secret scan BEFORE the
     * handoff commit is written (design D10: no override flag).
     */
    async handoff(options) {
        gateSecret(this.#client, "repos.handoff", options);
        const [{ deployRefTransaction }, { captureHandoffSnapshot, snapshotCommitment }, ho] = await Promise.all([this.#publication(), this.#snapshot(), this.#handoff()]);
        const { assembleHandoffKey, deriveHandoffSecrets, sealHandoffEnvelopeV2, assertHandoffNoteHasNoSecret, buildWriterAdmissionGrant, deriveWriterAdmissionSeed } = ho;
        const handle = options.address ? (await this.resolveOrCreateAddress({ ...options, address: options.address, allow_create: false })).handle : await this.open(options);
        const repoDir = options.repo_dir ?? process.cwd();
        const repoFile = handle.keystore.readRepo(handle.repo_id);
        if (!repoFile) {
            throw new LocalError(`no local key material for ${handle.repo_id} — this principal is not yet a member with a materialized envelope (push once first)`, "minting a handoff", { code: "VAULT_UNRESOLVED" });
        }
        const kRepo = hexToBytes(repoFile.k_repo_hex);
        // vault-multi-writer rev 47 (task 5.5, design D4): only an ACTIVE
        // WRITER may mint — the gateway enforces this authoritatively at mint
        // time (`403 HANDOFF_MINT_REQUIRES_WRITER`), but failing that late
        // would mean this call already paid for a checkpoint capture + push +
        // envelope seal for nothing. `writer_set_pin` is freshly set by the
        // `verifyToNewest` this vault's `open()` above just ran (never stale by
        // more than this session's own most recent verify), so checking it
        // here is a cheap, typed, LOCAL fail-fast — never the check's source of
        // truth, which stays server-side.
        const identity = handle.keystore.readIdentity();
        const localWriterKeyId = identity?.signing_fingerprint ?? null;
        if (!localWriterKeyId || !repoFile.writer_set_pin?.writers.some((w) => w.writer_key_id === localWriterKeyId)) {
            throw new LocalError("this keystore's signing key is not an admitted writer of this vault — minting a handoff requires an ACTIVE writer (design D4); an existing writer must add this key first (`run402 orgs members add` / repos access sync), or push once to reconcile if a pending admission already exists", "minting a handoff", { code: "VAULT_WRITER_NOT_ADMITTED" });
        }
        // The grant's `minted_role` must EXACTLY predict what the gateway's own
        // role-attenuation (`services/vault/claims.ts mintHandoff`) will
        // compute, or the grant fails VALIDATION_FAILED after this call has
        // already paid for the checkpoint push below — `predictMintedRole`
        // mirrors that formula against the SAME `ORG_ROLE_RANK` lattice
        // `vault-writer-state.ts` already uses elsewhere in this codebase.
        const { predictMintedRole } = await this.#writerState();
        const vaultRecord = await this.get(handle.repo_id);
        const who = await this.#client.request("/agent/v1/whoami", { context: "resolving this principal's org role for the handoff grant" });
        const membership = who.memberships.find((m) => m.org_id === vaultRecord.org_id && m.status === "active");
        if (!membership) {
            throw new LocalError(`this principal has no active membership on ${vaultRecord.org_id} — an active writer must also be an active org member to mint a handoff`, "minting a handoff", { code: "VAULT_ACCESS_DENIED" });
        }
        const grantMintedRole = predictMintedRole(options.role, membership.role);
        const snapshot = await captureHandoffSnapshot({
            dir: repoDir,
            ...(options.includeSensitive !== undefined ? { includeSensitive: options.includeSensitive } : {}),
            message: (stats) => {
                const note = {
                    ...options.note,
                    capture: {
                        base_head: stats.base_head_oid,
                        branch: stats.branch,
                        modified_captured: stats.modified_captured.length,
                        untracked_captured: stats.untracked_captured.length,
                        sensitive_excluded: stats.sensitive_excluded,
                        ignored_not_transferred_count: stats.ignored_not_transferred_count,
                    },
                };
                assertHandoffNoteHasNoSecret(note);
                return JSON.stringify(note);
            },
        });
        options.onCommitLine?.(`handoff checkpoint ${snapshot.oid}`);
        const materialized = await handle.vault.materialize();
        const pushResult = await handle.vault.push({
            transaction: deployRefTransaction(materialized.refs, snapshot.oid),
            head_target: snapshot.head,
            protocol_refs: "allow",
        }).catch((e) => { throw this.#enrichEpochRotationRequired(e, handle.repo_id); });
        const snapshotOidHmac = snapshotCommitment(kRepo, handle.repo_id, repoFile.epoch, snapshot.oid);
        // Client-generated handoff_id (mirrors this protocol family's own
        // client_creation_id/client_open_id convention): needed to derive
        // auth_secret/wrap_key and seal the envelope BEFORE the mint call, so
        // it cannot be gateway-assigned. The gateway's own `handoff_id` in the
        // response is authoritative; a disagreement (an id collision the
        // gateway resolved differently) is refused rather than silently
        // trusted, since a mismatched id would make the recipient's derived
        // secrets useless anyway.
        const handoffId = randomHandoffUuid();
        const { key, handoff_id_bytes, master_secret } = assembleHandoffKey(handoffId, randomBytes(32));
        const secrets = deriveHandoffSecrets(handoff_id_bytes, master_secret);
        // vault-multi-writer rev 47 (task 5.5, design D4) — the MINTER's own
        // writer key signs `writer_admission_grant`, authorizing whoever redeems
        // this handoff to become a writer (D224/D225). This is a HARD
        // requirement distinct from the earlier `writer_set_pin` presence
        // check: that check only proves the fingerprint is admitted, not that
        // THIS checkout still holds the seed to sign with — a read-only
        // recovery identity (no local `signing_seed_hex`) can be an admitted
        // writer's fingerprint yet be structurally unable to mint from here.
        //
        // Built BEFORE the envelope is sealed — design D4's "no hash cycle:
        // grant first, then seal" — so the v2 envelope below can embed this
        // grant's own stored-bytes SHA-256, letting the redeemer's `resume()`
        // (task 5.6) cross-check the redeem response's grant against what this
        // call actually sealed, independent of anything the gateway could alter.
        const signingKeypair = handle.keystore.signingKeypair(identity); // non-null: the writer precheck above already required identity.signing_fingerprint
        if (!signingKeypair) {
            throw new LocalError("this keystore has no local signing seed — it can read the vault's writer identity but cannot sign a writer_admission_grant from here (a read-only recovery identity); mint from a checkout that holds the full signing seed", "minting a handoff", { code: "VAULT_UNRECOVERABLE" });
        }
        const handoffAdmissionSeed = deriveWriterAdmissionSeed(handoff_id_bytes, master_secret);
        const grant = buildWriterAdmissionGrant({
            repo_id: handle.repo_id,
            handoff_id: handoffId,
            auth_hash: secrets.auth_hash_hex,
            checkpoint_generation: pushResult.generation,
            checkpoint_head_sha256: pushResult.head_sha256,
            minted_role: grantMintedRole,
            // Mirrors the gateway's own HANDOFF_DEFAULT_TTL_SECONDS (3600) —
            // `claim_not_after` is NOT cross-validated against the mint's actual
            // TTL server-side (only well-formedness is checked), so this only
            // needs to be a reasonable bound, not an exact prediction.
            claim_not_after: new Date(Date.now() + (options.ttlSeconds ?? 3600) * 1000).toISOString(),
            grantor_signing_seed: signingKeypair.seed,
            handoff_admission_pubkey: ed25519PublicKey(handoffAdmissionSeed),
        });
        const grantSha256Local = sha256Hex(jcs(grant));
        const sealed = sealHandoffEnvelopeV2(handoff_id_bytes, secrets.wrap_key, {
            v: 2,
            kind: "handoff",
            repo_id: handle.repo_id,
            epoch: repoFile.epoch,
            k_e_hex: repoFile.k_repo_hex,
            // Every epoch key this keystore holds, not only the current one: a
            // handoff minted AFTER an epoch rotation must let the recipient open
            // the pre-rotation generations too ("membership grants FULL history"),
            // and no rotation ever re-wraps old epochs to a principal that did not
            // exist then. Found live: a recipient redeemed a post-removal handoff
            // and its clone died VAULT_EPOCH_NOT_OPENABLE at generation 1.
            epoch_keys: { ...(repoFile.epoch_keys ?? {}), [repoFile.epoch]: repoFile.k_repo_hex },
            checkpoint: { generation: pushResult.generation, commit_oid: snapshot.oid },
            note_schema: "kygit.handoff-note.v1",
            writer_admission_grant_sha256: grantSha256Local,
        });
        // The wire shape is the gateway's documented one (llms-full.txt
        // "Handoff / resume"): `role` (the minted role), `repo_id` / `org_id` /
        // `project_id`, a verbatim `warning` sentence plus its machine-readable
        // `warnings[]` twin. Read those names exactly — an SDK-side spelling
        // that the gateway never sends surfaces as "role undefined" at the CLI.
        const response = await this.#client.request(`/vaults/v1/${encodeURIComponent(handle.repo_id)}/handoffs`, {
            method: "POST",
            body: {
                handoff_id: handoffId,
                ...(options.role !== undefined ? { role: options.role } : {}),
                ...(options.ttlSeconds !== undefined ? { expires_in_seconds: options.ttlSeconds } : {}),
                checkpoint: { generation: pushResult.generation, snapshot_oid_hmac: snapshotOidHmac },
                sealed_envelope: sealed.sealed_envelope,
                envelope_kind: sealed.envelope_kind,
                auth_hash: secrets.auth_hash_hex,
                writer_admission_grant: toBase64url(jcs(grant)),
            },
            context: "minting a handoff key",
        });
        if (response.handoff_id !== handoffId) {
            throw new LocalError(`the gateway minted a different handoff_id (${response.handoff_id}) than requested (${handoffId}) — the assembled key would not match; retry`, "minting a handoff key", { code: "HANDOFF_ID_MISMATCH", details: { requested: handoffId, minted: response.handoff_id } });
        }
        // The gateway names back the SHA-256 of the EXACT writer_admission_grant
        // bytes it stored — verified against this call's own local computation
        // (never the other way around) so a gateway that silently altered the
        // grant is caught here, before the key is ever handed to a recipient.
        if (response.writer_admission_grant_sha256 !== grantSha256Local) {
            throw new LocalError(`the gateway echoed a writer_admission_grant_sha256 (${response.writer_admission_grant_sha256}) that does not match what this call sent (${grantSha256Local}) — the stored grant may not be the one this call signed; do not distribute this handoff key`, "minting a handoff key", { code: "HANDOFF_MINT_GRANT_MISMATCH", details: { expected: grantSha256Local, received: response.writer_admission_grant_sha256 } });
        }
        return {
            handoff_key: key,
            handoff_id: response.handoff_id,
            kind: response.kind,
            minted_role: response.role,
            expires_at: response.expires_at,
            // A slug-form remote is the one address the minter already knows;
            // an id-form one carries nothing a directory name should be built from.
            vault: handoffVaultFromWire(response, options.address && vaultRemoteAddressForm(options.address) === "slug" ? `${options.address.org_id}/${options.address.project_id}` : null),
            checkpoint: response.checkpoint,
            capture: {
                modified_captured: snapshot.modified_captured.length,
                untracked_captured: snapshot.untracked_captured.length,
                sensitive_excluded: snapshot.sensitive_excluded,
                ignored_not_transferred_count: snapshot.ignored_not_transferred_count,
            },
            snapshot,
            warnings: response.warnings ?? [],
            next_actions: response.next_actions ?? [],
        };
    }
    /** List a vault's handoffs (ids, kind, state, role, expiry, redeemed_by — never the hash or envelope). */
    async listHandoffs(options) {
        const repoId = await this.#resolveRepoId(options);
        return this.#client.request(`/vaults/v1/${encodeURIComponent(repoId)}/handoffs`, { context: "listing handoffs" });
    }
    /** Revoke a handoff (idempotent — a second revoke of an already-revoked/redeemed/expired row still answers `200`). */
    async revokeHandoff(handoffId, options) {
        const repoId = await this.#resolveRepoId(options);
        return this.#client.request(`/vaults/v1/${encodeURIComponent(repoId)}/handoffs/${encodeURIComponent(handoffId)}`, { method: "DELETE", context: "revoking a handoff" });
    }
    // ── Invite / join (kygit-invite design D1-D10) ─────────────────────────────
    /**
     * Mint an Invite Key: capture a stash-shaped checkpoint exactly as
     * {@link Repos.handoff} does (design D1), push it (retained, on no
     * branch), register the INVITER's own presence in the invite's room
     * (design D4 — before the mint, so the row can carry
     * `inviter_presence_id`; a registration failure is reported and the mint
     * proceeds without one), seal the vault's current epoch key under a fresh
     * `wrap_key`, mint through the gateway at `developer` (or `role`,
     * attenuated to never exceed the minter's own), and post ONE message from
     * the inviter's presence naming the checkpoint and the invite id (never
     * the key) — a message-post failure is reported in `room_message` and never
     * voids the mint (design D4). The assembled `kgi1_…` key is returned
     * exactly ONCE — nothing here or downstream persists it. Creating an
     * invite never touches the inviter's worktree, index, branch, refs, or
     * access.
     *
     * `options.note` omits `capture` — this method fills it with the real
     * capture figures and runs the client-side secret scan BEFORE the invite
     * commit is written (design D10/kygit-invite D3: no override flag).
     */
    async invite(options) {
        gateSecret(this.#client, "repos.invite", options);
        const [{ deployRefTransaction }, { captureHandoffSnapshot, snapshotCommitment }] = await Promise.all([this.#publication(), this.#snapshot()]);
        const ho = await nodeOnly(() => import("../node/vault-handoff.js"), "invite");
        const { assembleInviteKey, deriveInviteSecrets, sealInviteEnvelope, assertInviteNoteHasNoSecret, buildWriterAdmissionGrant, deriveInviteWriterAdmissionSeed, INVITE_ENVELOPE_V2_KIND } = ho;
        const handle = options.address ? (await this.resolveOrCreateAddress({ ...options, address: options.address, allow_create: false })).handle : await this.open(options);
        const repoDir = options.repo_dir ?? process.cwd();
        const repoFile = handle.keystore.readRepo(handle.repo_id);
        if (!repoFile) {
            throw new LocalError(`no local key material for ${handle.repo_id} — this principal is not yet a member with a materialized envelope (push once first)`, "minting an invite", { code: "VAULT_UNRESOLVED" });
        }
        const kRepo = hexToBytes(repoFile.k_repo_hex);
        // vault-multi-writer rev 47 (kygit-invite design D4): only an ACTIVE
        // WRITER may mint — the gateway enforces it authoritatively
        // (`403 INVITE_MINT_REQUIRES_WRITER`, carrying `request_writer_sync`),
        // but failing that late would mean this call already paid for a
        // checkpoint capture + push + envelope seal for nothing. Identical
        // local fail-fast to `handoff()`'s, against the `writer_set_pin` this
        // vault's `open()` just refreshed — cheap and typed, never the source
        // of truth.
        const identity = handle.keystore.readIdentity();
        const localWriterKeyId = identity?.signing_fingerprint ?? null;
        if (!localWriterKeyId || !repoFile.writer_set_pin?.writers.some((w) => w.writer_key_id === localWriterKeyId)) {
            throw new LocalError("this keystore's signing key is not an admitted writer of this vault — minting an invite requires an ACTIVE writer (the gateway's own refusal is INVITE_MINT_REQUIRES_WRITER); run `run402 repos access sync` from a live writer, or push once to reconcile if a pending admission already exists", "minting an invite", { code: "VAULT_WRITER_NOT_ADMITTED", details: { next_action: "request_writer_sync", command: "run402 repos access sync" } });
        }
        // The grant's `minted_role` must EXACTLY predict what the gateway's own
        // role attenuation will compute (invite defaults to `developer` rather
        // than the minter's own role — the ONE descriptor difference, design
        // D1), or the grant fails VALIDATION_FAILED after this call has already
        // paid for the checkpoint push below.
        const { predictMintedRole } = await this.#writerState();
        const vaultRecord = await this.get(handle.repo_id);
        const whoMints = await this.#client.request("/agent/v1/whoami", { context: "resolving this principal's org role for the invite grant" });
        const inviterMembership = whoMints.memberships.find((m) => m.org_id === vaultRecord.org_id && m.status === "active");
        if (!inviterMembership) {
            throw new LocalError(`this principal has no active membership on ${vaultRecord.org_id} — an active writer must also be an active org member to mint an invite`, "minting an invite", { code: "VAULT_ACCESS_DENIED" });
        }
        const grantMintedRole = predictMintedRole(options.role ?? INVITE_DEFAULT_ROLE, inviterMembership.role);
        const snapshot = await captureHandoffSnapshot({
            dir: repoDir,
            ...(options.includeSensitive !== undefined ? { includeSensitive: options.includeSensitive } : {}),
            message: (stats) => {
                const note = {
                    ...options.note,
                    capture: {
                        base_head: stats.base_head_oid,
                        branch: stats.branch,
                        modified_captured: stats.modified_captured.length,
                        untracked_captured: stats.untracked_captured.length,
                        sensitive_excluded: stats.sensitive_excluded,
                        ignored_not_transferred_count: stats.ignored_not_transferred_count,
                    },
                };
                assertInviteNoteHasNoSecret(note);
                return JSON.stringify(note);
            },
        });
        options.onCommitLine?.(`invite checkpoint ${snapshot.oid}`);
        const materialized = await handle.vault.materialize();
        const pushResult = await handle.vault.push({
            transaction: deployRefTransaction(materialized.refs, snapshot.oid),
            head_target: snapshot.head,
            protocol_refs: "allow",
        }).catch((e) => { throw this.#enrichEpochRotationRequired(e, handle.repo_id); });
        const snapshotOidHmac = snapshotCommitment(kRepo, handle.repo_id, repoFile.epoch, snapshot.oid);
        // design D4: room resolution — the project's default room (its own id)
        // unless a named org room was given.
        const roomKey = options.roomKey ?? repoFile.project_id;
        const rooms = new Rooms(this.#client);
        // design D4: register the inviter's OWN presence BEFORE minting, so the
        // row can carry `inviter_presence_id` — a failure here is reported and
        // the mint proceeds without one (never blocks the mint).
        let inviterPresence = null;
        let inviterPresenceReport = { registered: false };
        try {
            // No default `task`: a session key RESUMES the inviter's existing
            // presence, and a resumption refreshes `task` — a made-up label here
            // would overwrite whatever the agent is actually working on. The CLI
            // passes the harness's own thread title when it has one.
            const registration = await rooms.registerPresence(repoFile.org_id, roomKey, {
                ...(options.task !== undefined ? { task: options.task } : {}),
                ...(options.program !== undefined ? { program: options.program } : {}),
                ...(options.model !== undefined ? { model: options.model } : {}),
                ...(options.sessionKey !== undefined ? { sessionKey: options.sessionKey } : {}),
            });
            inviterPresence = { presence_id: registration.presence_id, name: registration.name };
            inviterPresenceReport = { registered: true, presence_id: registration.presence_id, name: registration.name };
        }
        catch (e) {
            inviterPresenceReport = { registered: false, error: e instanceof Error ? e.message : String(e) };
        }
        const inviteId = randomHandoffUuid();
        const { key, invite_id_bytes, master_secret } = assembleInviteKey(inviteId, randomBytes(32));
        const secrets = deriveInviteSecrets(invite_id_bytes, master_secret);
        // vault-multi-writer rev 47 (kygit-invite design D4) — the MINTER's
        // own writer key signs `writer_admission_grant`, authorizing whoever
        // redeems this invite to become a writer under its OWN key. Built and
        // SIGNED BEFORE the envelope is sealed ("no hash cycle: grant first,
        // then seal") so the v2 envelope below can embed the grant's own
        // stored-bytes SHA-256 and `join()` can cross-check the claim
        // response's grant against what this call actually sealed, independent
        // of anything the gateway could alter.
        //
        // The grant object, the `add_writer_key` authorization kind, and the
        // acceptance signature domain are all spelled `handoff` on the wire and
        // stay that way (design D11): the r402s/v0 bytes are frozen by
        // conformance vectors, so an invite admission rides the SAME bytes with
        // the INVITE id in `handoff_id`. One admission door, two product doors.
        const signingKeypair = handle.keystore.signingKeypair(identity); // non-null: the writer precheck above already required identity.signing_fingerprint
        if (!signingKeypair) {
            throw new LocalError("this keystore has no local signing seed — it can read the vault's writer identity but cannot sign a writer_admission_grant from here (a read-only recovery identity); mint from a checkout that holds the full signing seed", "minting an invite", { code: "VAULT_UNRECOVERABLE" });
        }
        const inviteAdmissionSeed = deriveInviteWriterAdmissionSeed(invite_id_bytes, master_secret);
        const grant = buildWriterAdmissionGrant({
            repo_id: handle.repo_id,
            handoff_id: inviteId,
            auth_hash: secrets.auth_hash_hex,
            checkpoint_generation: pushResult.generation,
            checkpoint_head_sha256: pushResult.head_sha256,
            minted_role: grantMintedRole,
            claim_not_after: new Date(Date.now() + (options.ttlSeconds ?? 3600) * 1000).toISOString(),
            grantor_signing_seed: signingKeypair.seed,
            handoff_admission_pubkey: ed25519PublicKey(inviteAdmissionSeed),
        });
        const grantSha256Local = sha256Hex(jcs(grant));
        const sealed = sealInviteEnvelope(invite_id_bytes, secrets.wrap_key, {
            v: 2,
            kind: "invite",
            repo_id: handle.repo_id,
            epoch: repoFile.epoch,
            k_e_hex: repoFile.k_repo_hex,
            // Every epoch key this keystore holds, not only the current one: an
            // invite minted AFTER an epoch rotation must let the joiner open the
            // pre-rotation generations too ("membership grants FULL history"),
            // and no rotation ever re-wraps old epochs to a principal that did
            // not exist then.
            epoch_keys: { ...(repoFile.epoch_keys ?? {}), [repoFile.epoch]: repoFile.k_repo_hex },
            checkpoint: { generation: pushResult.generation, commit_oid: snapshot.oid },
            note_schema: "kygit.invite-note.v1",
            writer_admission_grant_sha256: grantSha256Local,
        });
        // The wire shape mirrors the handoff mint's documented one (llms-full.txt
        // "Handoff / resume", vault-invite spec): `role` (the minted role),
        // `repo_id` / `org_id` / `project_id`, `room`, a verbatim `warning`
        // sentence plus its machine-readable `warnings[]` twin.
        const response = await this.#client.request(`/vaults/v1/${encodeURIComponent(handle.repo_id)}/invites`, {
            method: "POST",
            body: {
                invite_id: inviteId,
                ...(options.role !== undefined ? { role: options.role } : {}),
                ...(options.ttlSeconds !== undefined ? { expires_in_seconds: options.ttlSeconds } : {}),
                // An explicit room_key is validated as a slug by the gateway; an
                // empty one (a keystore file with no project id) is OMITTED so the
                // gateway applies its own default — the vault's project — instead
                // of refusing a blank.
                ...(roomKey ? { room_key: roomKey } : {}),
                ...(inviterPresence ? { inviter_presence_id: inviterPresence.presence_id } : {}),
                checkpoint: { generation: pushResult.generation, snapshot_oid_hmac: snapshotOidHmac },
                sealed_envelope: sealed.sealed_envelope,
                envelope_kind: sealed.envelope_kind ?? INVITE_ENVELOPE_V2_KIND,
                auth_hash: secrets.auth_hash_hex,
                writer_admission_grant: toBase64url(jcs(grant)),
            },
            context: "minting an invite key",
        });
        if (response.invite_id !== inviteId) {
            throw new LocalError(`the gateway minted a different invite_id (${response.invite_id}) than requested (${inviteId}) — the assembled key would not match; retry`, "minting an invite key", { code: "INVITE_ID_MISMATCH", details: { requested: inviteId, minted: response.invite_id } });
        }
        // The gateway names back the SHA-256 of the EXACT writer_admission_grant
        // bytes it stored — verified against this call's own local computation
        // (never the other way around) so a gateway that silently altered the
        // grant is caught here, before the key is ever handed to a joiner.
        if (response.writer_admission_grant_sha256 !== grantSha256Local) {
            throw new LocalError(`the gateway echoed a writer_admission_grant_sha256 (${response.writer_admission_grant_sha256}) that does not match what this call sent (${grantSha256Local}) — the stored grant may not be the one this call signed; do not distribute this invite key`, "minting an invite key", { code: "INVITE_MINT_GRANT_MISMATCH", details: { expected: grantSha256Local, received: response.writer_admission_grant_sha256 } });
        }
        // design D4: post the message AFTER the mint succeeds — a mint refusal must
        // leave no orphan message, and a message-post failure never voids a mint
        // the caller may already have copied the key from.
        let roomMessage = { posted: false, reason: "inviter presence was not registered" };
        if (inviterPresence) {
            const receiptShort = snapshot.oid.slice(0, 12);
            const inviteShort = response.invite_id.slice(0, 8);
            try {
                const sent = await rooms.sendMessage(repoFile.org_id, roomKey, {
                    body: `Invited another agent from checkpoint ${receiptShort} (invite ${inviteShort}, expires ${response.expires_at}).`,
                    presenceId: inviterPresence.presence_id,
                    ...(options.sessionKey !== undefined ? { sessionKey: options.sessionKey } : {}),
                    idempotencyKey: `invite:${response.invite_id}:minted`,
                });
                roomMessage = { posted: true, message_id: sent.message_id, cursor: sent.cursor };
            }
            catch (e) {
                roomMessage = { posted: false, reason: e instanceof Error ? e.message : String(e) };
            }
        }
        const nextActions = [...(response.next_actions ?? [])];
        if (!nextActions.some((a) => a.type === "join_invite")) {
            // design D9: "CLI-synthesized, the recipient's exact command".
            nextActions.push({
                type: "join_invite",
                command: `run402 repos join ${key}`,
                why: "Run this on the other agent's machine to redeem the invite.",
                safe_to_auto_execute: false,
            });
        }
        if (!nextActions.some((a) => a.type === "wait_room")) {
            nextActions.push({ type: "wait_room", command: "run402 messages wait", why: "Block until the joining agent speaks; silence returns who is still here." });
        }
        return {
            invite_key: key,
            invite_id: response.invite_id,
            kind: response.kind,
            minted_role: response.role,
            expires_at: response.expires_at,
            vault: handoffVaultFromWire(response, options.address && vaultRemoteAddressForm(options.address) === "slug" ? `${options.address.org_id}/${options.address.project_id}` : null),
            room: { organization_id: response.room?.org_id ?? repoFile.org_id, room_key: response.room?.room_key ?? roomKey },
            checkpoint: response.checkpoint,
            capture: {
                modified_captured: snapshot.modified_captured.length,
                untracked_captured: snapshot.untracked_captured.length,
                sensitive_excluded: snapshot.sensitive_excluded,
                ignored_not_transferred_count: snapshot.ignored_not_transferred_count,
            },
            snapshot,
            inviter_presence: inviterPresenceReport,
            room_message: roomMessage,
            warnings: response.warnings ?? [],
            next_actions: nextActions,
        };
    }
    /** List a vault's invites (ids, kind, state, role, room, expiry, redeemed_by — never the hash or envelope). */
    async listInvites(options) {
        const repoId = await this.#resolveRepoId(options);
        return this.#client.request(`/vaults/v1/${encodeURIComponent(repoId)}/invites`, { context: "listing invites" });
    }
    /** Revoke an invite (idempotent — a second revoke of an already-revoked/redeemed/expired row still answers `200`). */
    async revokeInvite(inviteId, options) {
        const repoId = await this.#resolveRepoId(options);
        return this.#client.request(`/vaults/v1/${encodeURIComponent(repoId)}/invites/${encodeURIComponent(inviteId)}`, { method: "DELETE", context: "revoking an invite" });
    }
    /**
     * Create this machine's wallet (the wallet file) when the credentials
     * provider supports one and none exists yet. A keypair on disk — no
     * faucet, no tier, no payment. Pure no-op for a provider without the
     * optional wallet methods, or when a wallet already exists.
     */
    async #ensureLocalWallet(onLine) {
        const creds = this.#client.credentials;
        if (!creds.readWallet || !creds.createWallet || !creds.saveWallet)
            return;
        if (await creds.readWallet())
            return;
        const created = await creds.createWallet();
        await creds.saveWallet(created);
        onLine?.(`wallet created for this machine: ${created.address} (a keypair on disk — no payment)`);
    }
    /**
     * Resume a Handoff Key: parse → ensure this machine has a wallet (the
     * claim is bare SIWX, so on a fresh machine the wallet file is created
     * here — a keypair on disk, no faucet, no tier, no payment, ever; design
     * D5) → redeem → open the
     * sealed envelope → write the repo file to the keystore BEFORE touching
     * disk → clone at the base HEAD → `git stash apply --index` → local
     * git-config pins only → the session-start reconcile so a principal
     * envelope supersedes the bearer one.
     */
    /**
     * vault-multi-writer rev 47 (task 5.6, design D4/D5) — order: parse →
     * ensure wallet → ensure identity → derive auth/wrap/admission → build
     * acceptance → redeem → verify grant → open envelope → check the grant
     * hash → persist repo file with `pending_writer_admission` → clone →
     * verify chain → submit the ref-neutral activation head → reconcile
     * principal envelope → apply the checkpoint. Reported as
     * `writer_activation` and `reconcile_recipients` (D5's "recipient
     * coverage"), two blocks, because they are two properties.
     *
     * Crash-resumable by construction, not by a special-cased retry branch:
     * the acceptance is derived fresh every call from the (deterministic)
     * admission seed + this checkout's own identity, so re-running this
     * ENTIRE method after a crash anywhere before activation lands is safe —
     * the redeem route is itself idempotent (same-redeemer replay returns the
     * SAME grant). The one step that is NOT safely repeatable is submitting
     * the activation head a second time (the chain burns `handoff_id`
     * single-use) — `submitWriterActivationHead` handles that by checking
     * whether this checkout's own key is ALREADY in the freshly-verified
     * writer set before ever building a transition, exactly mirroring
     * `handoff()`'s own writer precheck (task 5.5), just inverted: there
     * "not yet a writer" refuses; here "already a writer" means skip.
     */
    async resume(options) {
        gateSecret(this.#client, "repos.resume", options);
        const [ho, { VaultKeystore }, restore] = await Promise.all([this.#handoff(), this.#keystore(), this.#restore()]);
        const { parseHandoffKey, deriveHandoffSecrets, deriveWriterAdmissionSeed, buildWriterAcceptance, openHandoffEnvelopeV2 } = ho;
        const { cloneVaultRemote, applyHandoffCheckpoint, resolveResumeTargetDir, readGitCommitMessage, excludeMessagingCacheFromGit } = restore;
        // parse
        const parsed = parseHandoffKey(options.key);
        // ensure wallet — A fresh machine has no wallet, and the redeem route
        // accepts ONLY a SIWX wallet signature (a control-plane session,
        // grant key, or service key is refused HANDOFF_REDEEM_REQUIRES_WALLET —
        // the keystore key the redemption publishes is what makes the recipient a
        // real key-holder). Nothing upstream creates the wallet for an
        // unpaid request, so `resume` does it here, exactly as `repos resume
        // --help` promises: a keypair written to the wallet file, no
        // faucet, no tier, no payment. Without it a bare machine answers
        // AUTH_REQUIRED. A provider without
        // wallet support (an isomorphic one) is left alone — it
        // authenticates however it authenticates.
        await this.#ensureLocalWallet(options.onLine);
        // ensure identity — BEFORE redeem (D5): the acceptance below needs this
        // checkout's own signing key, which the redeem REQUEST body carries.
        const keystore = new VaultKeystore(options.keystore_root !== undefined ? { rootDir: options.keystore_root } : {});
        const identity = keystore.ensureIdentity();
        const signingKeypair = keystore.signingKeypair(identity);
        if (!signingKeypair) {
            throw new LocalError("this keystore has no local signing seed — resuming a vault-multi-writer handoff requires signing writer_acceptance with a full Ed25519 seed, which a read-only recovery identity does not hold", "resuming a handoff", { code: "VAULT_UNRECOVERABLE" });
        }
        const redeemerEncryptionPubkeyRaw = fromBase64url(identity.encryption_pubkey, "identity.encryption_pubkey");
        // derive auth/wrap/admission
        const secrets = deriveHandoffSecrets(parsed.handoff_id_bytes, parsed.master_secret);
        const admissionSeed = deriveWriterAdmissionSeed(parsed.handoff_id_bytes, parsed.master_secret);
        // build acceptance — before redeem: design D4's own point is that the
        // redeemer can construct BOTH signatures before ever seeing the stored
        // grant, since `handoff_id`/`auth_hash` are already independently known.
        const acceptance = buildWriterAcceptance({
            handoff_id: parsed.handoff_id,
            auth_hash: secrets.auth_hash_hex,
            admission_seed: admissionSeed,
            redeemer_signing_seed: signingKeypair.seed,
            redeemer_encryption_pubkey_raw: redeemerEncryptionPubkeyRaw,
        });
        // claim
        const redeem = await this.#client.request(`/vaults/v1/handoffs/${encodeURIComponent(parsed.handoff_id)}/redeem`, {
            method: "POST",
            // Base64url, per the documented wire contract (openapi: "Base64url — the
            // HKDF-derived auth_secret half of the parsed kgh1_ key"). The gateway
            // decodes base64/base64url and substitutes 32 zero bytes for anything
            // else, so a hex-encoded secret never matches any stored hash and every
            // claim answers HANDOFF_KEY_INVALID. Each side's own tests can pass
            // independently; only the cross-side vector below catches this class.
            body: { auth_secret: toBase64url(secrets.auth_secret), writer_acceptance: toBase64url(jcs(acceptance)) },
            context: "redeeming a handoff key",
        });
        // The claim names the vault by id only (no slug-form address rides the
        // wire), so the default target directory falls back to the vault id —
        // `--to <dir>` names it explicitly.
        const vault = handoffVaultFromWire(redeem);
        // verify grant — a light structural/binding check, NOT the full
        // cryptographic verification (which needs the vault's writer set to
        // resolve the grantor's pubkey, and therefore waits until the chain is
        // walked below — protocol §4.17's own admission ordering, not skipped,
        // just not reachable from here yet).
        let grantBytes;
        try {
            grantBytes = fromBase64url(redeem.writer_admission_grant, "writer_admission_grant");
        }
        catch {
            throw new LocalError("the redeem response's writer_admission_grant is not valid base64url", "resuming a handoff", { code: "VALIDATION_FAILED", details: { field: "writer_admission_grant" } });
        }
        let grant;
        try {
            grant = JSON.parse(new TextDecoder().decode(grantBytes));
        }
        catch {
            throw new LocalError("the redeem response's writer_admission_grant does not decode to valid JSON", "resuming a handoff", { code: "VALIDATION_FAILED", details: { field: "writer_admission_grant" } });
        }
        if (grant.handoff_id !== parsed.handoff_id || grant.auth_hash !== secrets.auth_hash_hex || grant.repo_id !== vault.vault_id) {
            throw new LocalError("the redeem response's writer_admission_grant does not bind this handoff — refusing", "resuming a handoff", { code: "VALIDATION_FAILED", details: { field: "writer_admission_grant" } });
        }
        const grantSha256 = sha256Hex(grantBytes);
        // open envelope — v2 (task 5.4): the v2 payload's own
        // writer_admission_grant_sha256 is what the next step cross-checks;
        // opening under the SAME v1/v2-requiring rule that refuses a
        // pre-vault-multi-writer mint's envelope outright.
        const payload = openHandoffEnvelopeV2(parsed.handoff_id_bytes, secrets.wrap_key, redeem.sealed_envelope, redeem.envelope_kind);
        if (payload.repo_id !== vault.vault_id) {
            throw new LocalError("the opened envelope's repo_id does not match the redeem response's vault — refusing", "resuming a handoff", { code: "HANDOFF_ENVELOPE_INVALID" });
        }
        // check the grant hash — design D4's own integrity binding: the SEALED
        // envelope (wrap_key-authenticated, the gateway never holds wrap_key)
        // names the grant it was minted alongside; if the redeem response's
        // grant doesn't match, something between mint and redeem substituted a
        // different one — refuse before spending anything on this handoff.
        if (payload.writer_admission_grant_sha256 !== grantSha256) {
            throw new LocalError(`the redeem response's writer_admission_grant (sha256 ${grantSha256}) does not match the hash sealed into the envelope at mint time (${payload.writer_admission_grant_sha256}) — refusing to activate under a substituted grant`, "resuming a handoff", { code: "HANDOFF_REDEEM_WRITER_KEY_MISMATCH", details: { expected: payload.writer_admission_grant_sha256, received: grantSha256 } });
        }
        // Genesis must be pinned before ANY materialize call can succeed
        // (`Vault.genesis()` requires a keystore repo file — this is
        // the one read that happens BEFORE one exists, via the transport
        // directly, mirroring `restoreRepoFromEnvelope`'s own signature check).
        const { createVaultHttpTransport } = await this.#publication();
        const transport = createVaultHttpTransport(this.#client);
        const genesisBytes = await transport.getGenesis({ repo_id: vault.vault_id });
        if (!genesisBytes) {
            throw new LocalError("the vault has no admitted genesis", "resuming a handoff", { code: "CHAIN_BROKEN", details: { repo_id: vault.vault_id } });
        }
        const genesis = parseVaultStrict(new TextDecoder().decode(genesisBytes));
        if (!verifyVaultObject(genesis, genesis.creator_signing_pubkey)) {
            throw new LocalError("vault_genesis signature does not verify", "resuming a handoff", { code: "VAULT_SIGNATURE_INVALID", details: { repo_id: vault.vault_id } });
        }
        const genesisSha = sha256Hex(genesisBytes);
        // persist repo file with pending_writer_admission — BEFORE clone/chain
        // work below (design D10's "write to keystore before touching disk",
        // extended): a crash from here on leaves a durable record of the
        // ALREADY-VERIFIED grant, so a retry never needs to re-redeem (the
        // acceptance is trivially re-derivable from data already in hand, per
        // this method's own doc comment above; only the grant is not).
        const myWriterKeyId = identity.signing_fingerprint;
        keystore.saveRepo({
            repo_id: vault.vault_id,
            org_id: vault.organization_id,
            project_id: vault.project_id ?? "",
            k_repo_hex: payload.k_e_hex,
            epoch: payload.epoch,
            // Prior epochs ride the envelope (a minter that predates them holds
            // them all); the current epoch's key is always the authoritative one.
            epoch_keys: { ...(payload.epoch_keys ?? {}), [payload.epoch]: payload.k_e_hex },
            genesis_sha256: genesisSha,
            head_pin: null,
            last_ref_transaction: null,
            provenance: "restored_from_handoff",
            pending_writer_admission: { handoff_id: parsed.handoff_id, writer_admission_grant: grant, claimed_writer_key_id: myWriterKeyId },
        });
        const targetDir = resolveResumeTargetDir(options.to, vault.address, vault.vault_id);
        options.onLine?.(`resuming into ${targetDir}`);
        const remoteUrl = vaultRemoteUrl(vault.organization_id, vault.project_id);
        await cloneVaultRemote(remoteUrl, targetDir);
        // Local-only pins (design D10) — never a worktree file, never the
        // global active project. Reuses the SAME pin-writer every other
        // vault resolution path uses, which also writes `r402.room`.
        const { pinVaultRepo } = await this.#address();
        const addressParts = vault.address ? vault.address.split("/") : null;
        await pinVaultRepo(targetDir, vault.vault_id, addressParts && addressParts.length === 2 ? { org_slug: addressParts[0], repo_name: addressParts[1] } : undefined, { project_id: vault.project_id, org_id: vault.organization_id });
        // kygit-invite design D5: restore is kind-agnostic, so the same
        // `.git/info/exclude` write `join` performs happens on `resume` too —
        // best-effort, never a `resume()` throw.
        try {
            await excludeMessagingCacheFromGit(targetDir);
        }
        catch {
            // Best-effort: an unwritable checkout still completes the resume.
        }
        // verify chain — `open()` here mainly constructs the `Vault`
        // instance: its own `ensureRepoState()` (the cold-open restore path)
        // no-ops the instant it sees a repo file already on disk — and the
        // `saveRepo` call above just wrote one. The chain walk this method
        // actually needs happens a moment later, the FIRST time
        // `submitWriterActivationHead` calls `materialize()` on `handle.vault`
        // below — `materialize()` unconditionally runs `verifyToNewest()` on
        // every call, cold-open or not, which is what freshly pins
        // `writer_set_pin`. `options.reconcile: "forbidden"` here: the
        // encryption-envelope reconcile is deliberately deferred to its own
        // explicit step AFTER activation (D5's ordering), not run implicitly
        // and possibly twice.
        const handle = await this.open({ repo_id: vault.vault_id, repo_dir: targetDir, keystore_root: options.keystore_root, reconcile: "forbidden" });
        // `added_writer.principal_id` names the redeemer's OWN control-plane
        // principal — the redeem response never carries it (its `membership`
        // block names the ORG, not the principal), so resolve it fresh here,
        // the same one-call pattern `handoff()` already uses for its own role
        // resolution (task 5.5).
        const who = await this.#client.request("/agent/v1/whoami", { context: "resolving this principal's id for the writer activation head" });
        // submit the ref-neutral activation head
        const activation = await handle.vault.submitWriterActivationHead({
            addedWriterKeyId: myWriterKeyId,
            addedSigningPubkeyB64u: identity.signing_pubkey,
            addedPrincipalId: who.principal.id,
            handoffId: parsed.handoff_id,
            grant,
            acceptance: acceptance,
        });
        const activationGeneration = activation.outcome === "activated" ? activation.result.generation : activation.generation;
        // Clearing pending_writer_admission now that the activation head is
        // (or already was — the idempotent-skip case) admitted mirrors
        // writer_status flipping to "active" at the same moment, per this
        // field's own doc comment in vault-keystore.ts.
        keystore.updateRepo(vault.vault_id, { pending_writer_admission: null });
        // reconcile principal envelope — the bearer envelope is superseded
        // within minutes of use; run the same reconcile `capture()` runs,
        // best-effort (never a `resume()` throw), NOW that this checkout is an
        // admitted writer and the reconcile's own wrap step is meaningful.
        const reconcile = await this.#tryReconcileEnvelopeRecipients(handle.vault);
        // apply the checkpoint — LAST (D5): a failure anywhere above this line
        // leaves the working tree untouched (freshly cloned, nothing stashed),
        // the cleanest possible state to retry `resume()` from.
        const restored = await applyHandoffCheckpoint({ dir: targetDir, stash_oid: payload.checkpoint.commit_oid });
        const senderIsOwner = redeem.membership.role === "owner";
        const nextActions = [...(redeem.next_actions ?? [])];
        if (redeem.kind === "handoff" && senderIsOwner && !nextActions.some((a) => a.type === "remove_member")) {
            nextActions.push({
                type: "remove_member",
                why: "The previous agent is still an owner; if its environment is gone for good, remove it.",
                destructive: true,
                requires_approval: true,
            });
        }
        if (!nextActions.some((a) => a.type === "push_repo")) {
            nextActions.push({ type: "push_repo", command: "git push origin main", why: "Publish continued work back to the vault." });
        }
        let note = null;
        let noteRaw = null;
        try {
            noteRaw = (await readGitCommitMessage(targetDir, payload.checkpoint.commit_oid)) ?? null;
            if (noteRaw)
                note = JSON.parse(noteRaw);
        }
        catch {
            note = null;
        }
        return {
            handoff_id: redeem.handoff_id,
            kind: redeem.kind,
            deduplicated: redeem.deduplicated,
            note,
            note_raw: noteRaw,
            restored: { dir: targetDir, branch: restored.branch, base_head_oid: restored.base_head_oid, stash_oid: restored.stash_oid },
            membership: handoffMembershipFromWire(redeem.membership),
            members: redeem.members ?? [],
            expires_at: redeem.expires_at,
            writer_activation: { outcome: "active", writer_key_id: myWriterKeyId, generation: activationGeneration },
            reconcile_recipients: reconcile,
            next_actions: nextActions,
        };
    }
    /**
     * Join an Invite Key: parse (refusing a `kgh1_` handoff key by name,
     * pointing at `resume` — design D9) → ensure this machine has a wallet
     * (design D5, mirroring {@link Repos.resume}'s own bare-wallet
     * backstop; the CALLER folds the fuller cold-start chain — wallet,
     * faucet, one x402 prototype payment — before invoking this, never
     * blocking the redemption itself) → redeem → open the sealed envelope → write
     * the repo file to the keystore BEFORE touching disk → clone at the base
     * HEAD → `git stash apply --index` → local git-config pins (including
     * `r402.room` set to the invite's OWN room, never just the project id) →
     * append `.run402/` to `.git/info/exclude` → the session-start reconcile
     * → register THIS session's presence in the room and post ONE arrival
     * fact naming it and the checkpoint (idempotent on the invite id;
     * best-effort — a presence or fact failure never blocks arrival) → read
     * the most recent messages for the arrival view.
     */
    async join(options) {
        gateSecret(this.#client, "repos.join", options);
        const [ho, { VaultKeystore }, { createVaultHttpTransport }] = await Promise.all([
            nodeOnly(() => import("../node/vault-handoff.js"), "join"),
            this.#keystore(),
            this.#publication(),
        ]);
        const restore = await nodeOnly(() => import("../node/vault-restore.js"), "join");
        const { parseInviteKey, deriveInviteSecrets, deriveInviteWriterAdmissionSeed, buildWriterAcceptance, openInviteEnvelope } = ho;
        const { cloneVaultRemote, applyHandoffCheckpoint, resolveResumeTargetDir, readGitCommitMessage, excludeMessagingCacheFromGit } = restore;
        const parsed = parseInviteKey(options.key);
        const secrets = deriveInviteSecrets(parsed.invite_id_bytes, parsed.master_secret);
        // Bare-wallet backstop, exactly like `resume` (design D5) — the claim
        // route accepts ONLY a SIWX wallet signature; the fuller cold-start
        // chain (faucet + one x402 prototype payment) is the CALLER's to fold
        // before this call, and never blocks the redemption either way.
        await this.#ensureLocalWallet(options.onLine);
        // ensure identity BEFORE the redemption (design D5): the `writer_acceptance`
        // below needs THIS checkout's own signing key, and the redeem REQUEST
        // body carries it. Nothing about the joiner's key is copied from the
        // inviter — the joiner generates it and pushes under it.
        const keystore = new VaultKeystore(options.keystore_root !== undefined ? { rootDir: options.keystore_root } : {});
        const identity = keystore.ensureIdentity();
        const signingKeypair = keystore.signingKeypair(identity);
        if (!signingKeypair) {
            throw new LocalError("this keystore has no local signing seed — joining an invite requires signing writer_acceptance with a full Ed25519 seed, which a read-only recovery identity does not hold", "joining an invite", { code: "VAULT_UNRECOVERABLE" });
        }
        const redeemerEncryptionPubkeyRaw = fromBase64url(identity.encryption_pubkey, "identity.encryption_pubkey");
        // Both signatures are constructible BEFORE the stored grant is ever
        // seen — the invite id and `auth_hash` are already independently known
        // (design D4). The protocol field is spelled `handoff_id` and the
        // domain `handoff-writer-accept/v1` by design D11: the r402s/v0 bytes
        // are frozen, so an invite admission rides them with the INVITE id.
        const admissionSeed = deriveInviteWriterAdmissionSeed(parsed.invite_id_bytes, parsed.master_secret);
        const acceptance = buildWriterAcceptance({
            handoff_id: parsed.invite_id,
            auth_hash: secrets.auth_hash_hex,
            admission_seed: admissionSeed,
            redeemer_signing_seed: signingKeypair.seed,
            redeemer_encryption_pubkey_raw: redeemerEncryptionPubkeyRaw,
        });
        const redeem = await this.#client.request(`/vaults/v1/invites/${encodeURIComponent(parsed.invite_id)}/redeem`, {
            method: "POST",
            // Base64url, per the documented wire contract — the gateway decodes
            // base64/base64url and substitutes 32 zero bytes for anything else,
            // so a hex-encoded secret never matches any stored hash and every
            // claim answers INVITE_KEY_INVALID. Each side's own tests can pass
            // independently; only a cross-side vector catches this class.
            body: { auth_secret: toBase64url(secrets.auth_secret), writer_acceptance: toBase64url(jcs(acceptance)) },
            context: "redeeming an invite key",
        });
        // The claim names the vault by id only (no slug-form address rides the
        // wire), so the default target directory falls back to the vault id —
        // `--to <dir>` names it explicitly.
        const vault = handoffVaultFromWire(redeem);
        // verify grant — a light structural/binding check, NOT the full
        // cryptographic verification (which needs the vault's writer set to
        // resolve the grantor's pubkey, and therefore waits until the chain is
        // walked below — protocol §4.17's own admission ordering).
        let grantBytes;
        try {
            grantBytes = fromBase64url(redeem.writer_admission_grant, "writer_admission_grant");
        }
        catch {
            throw new LocalError("the redeem response's writer_admission_grant is not valid base64url", "joining an invite", { code: "VALIDATION_FAILED", details: { field: "writer_admission_grant" } });
        }
        let grant;
        try {
            grant = JSON.parse(new TextDecoder().decode(grantBytes));
        }
        catch {
            throw new LocalError("the redeem response's writer_admission_grant does not decode to valid JSON", "joining an invite", { code: "VALIDATION_FAILED", details: { field: "writer_admission_grant" } });
        }
        // `handoff_id` is the frozen protocol spelling of the key id (D11) —
        // for an invite it carries the INVITE id.
        if (grant.handoff_id !== parsed.invite_id || grant.auth_hash !== secrets.auth_hash_hex || grant.repo_id !== vault.vault_id) {
            throw new LocalError("the redeem response's writer_admission_grant does not bind this invite — refusing", "joining an invite", { code: "VALIDATION_FAILED", details: { field: "writer_admission_grant" } });
        }
        const grantSha256 = sha256Hex(grantBytes);
        const payload = openInviteEnvelope(parsed.invite_id_bytes, secrets.wrap_key, redeem.sealed_envelope, redeem.envelope_kind);
        if (payload.repo_id !== vault.vault_id) {
            throw new LocalError("the opened envelope's repo_id does not match the redeem response's vault — refusing", "joining an invite", { code: "INVITE_ENVELOPE_INVALID" });
        }
        // design D5's own integrity binding: the SEALED envelope
        // (wrap_key-authenticated, and the gateway never holds wrap_key) names
        // the grant it was minted alongside. A gateway-substituted grant fails
        // this comparison LOCALLY, before anything is written to disk or the
        // keystore.
        if (payload.writer_admission_grant_sha256 !== grantSha256) {
            throw new LocalError(`the redeem response's writer_admission_grant (sha256 ${grantSha256}) does not match the hash sealed into the envelope at mint time (${payload.writer_admission_grant_sha256}) — refusing to activate under a substituted grant`, "joining an invite", { code: "INVITE_REDEEM_WRITER_KEY_MISMATCH", details: { expected: payload.writer_admission_grant_sha256, received: grantSha256 } });
        }
        // Genesis must be pinned before ANY materialize call can succeed — the
        // one read that happens BEFORE a keystore repo file exists, via the
        // transport directly (mirrors `resume`'s own signature check).
        const transport = createVaultHttpTransport(this.#client);
        const genesisBytes = await transport.getGenesis({ repo_id: vault.vault_id });
        if (!genesisBytes) {
            throw new LocalError("the vault has no admitted genesis", "joining an invite", { code: "CHAIN_BROKEN", details: { repo_id: vault.vault_id } });
        }
        const genesis = parseVaultStrict(new TextDecoder().decode(genesisBytes));
        if (!verifyVaultObject(genesis, genesis.creator_signing_pubkey)) {
            throw new LocalError("vault_genesis signature does not verify", "joining an invite", { code: "VAULT_SIGNATURE_INVALID", details: { repo_id: vault.vault_id } });
        }
        const genesisSha = sha256Hex(genesisBytes);
        // Write the repo file to the keystore BEFORE touching disk (design D5),
        // carrying EVERY epoch key the envelope holds so a joiner arriving after
        // a rotation can still open the pre-rotation generations, and the
        // already-verified grant as `pending_writer_admission` so a crash from
        // here on never needs to re-redeem (the acceptance is trivially
        // re-derivable from data already in hand; only the grant is not).
        const myWriterKeyId = identity.signing_fingerprint;
        keystore.saveRepo({
            repo_id: vault.vault_id,
            org_id: vault.organization_id,
            project_id: vault.project_id ?? "",
            k_repo_hex: payload.k_e_hex,
            epoch: payload.epoch,
            epoch_keys: { ...(payload.epoch_keys ?? {}), [payload.epoch]: payload.k_e_hex },
            genesis_sha256: genesisSha,
            head_pin: null,
            last_ref_transaction: null,
            provenance: "restored_from_invite",
            pending_writer_admission: { handoff_id: parsed.invite_id, writer_admission_grant: grant, claimed_writer_key_id: myWriterKeyId },
        });
        const targetDir = resolveResumeTargetDir(options.to, vault.address, vault.vault_id);
        options.onLine?.(`joining into ${targetDir}`);
        const remoteUrl = vaultRemoteUrl(vault.organization_id, vault.project_id);
        await cloneVaultRemote(remoteUrl, targetDir);
        // The ROW's room key — a named room, or the project id when the mint
        // omitted one — is what makes `messages wait` in the joined checkout
        // address the right room with zero flags (design D5).
        const roomKey = redeem.room?.room_key ?? vault.project_id ?? "";
        // Local-only pins (design D5) — never a worktree file, never the global
        // active project. Reuses the SAME pin-writer every other vault
        // resolution path uses, this time with the invite's OWN room key.
        const { pinVaultRepo } = await this.#address();
        const addressParts = vault.address ? vault.address.split("/") : null;
        await pinVaultRepo(targetDir, vault.vault_id, addressParts && addressParts.length === 2 ? { org_slug: addressParts[0], repo_name: addressParts[1] } : undefined, { project_id: vault.project_id, org_id: vault.organization_id, room_key: roomKey });
        // design D5/D9 risk list: `.git/info/exclude` rather than `.gitignore`
        // — the ignore file is part of the captured tree and touching it would
        // break exact-state on the first `git status`. Best-effort.
        try {
            await excludeMessagingCacheFromGit(targetDir);
        }
        catch {
            // Best-effort: an unwritable checkout still completes the join.
        }
        // verify chain — `open()` here mainly constructs the `Vault`
        // instance (its cold-open restore path no-ops the instant it sees the
        // repo file `saveRepo` just wrote). The chain walk this method needs
        // happens the FIRST time `submitWriterActivationHead` calls
        // `materialize()` below, which unconditionally runs `verifyToNewest()`
        // and freshly pins `writer_set_pin`. `reconcile: "forbidden"` defers
        // the envelope reconcile to its own explicit step AFTER activation
        // (design D5's ordering), never implicitly and possibly twice.
        const handle = await this.open({ repo_id: vault.vault_id, repo_dir: targetDir, keystore_root: options.keystore_root, reconcile: "forbidden" });
        // design D5: activate as a writer BEFORE `join()` returns, through the
        // SAME `add_writer_key` door `resume` drives — so the joiner's first
        // `git push` is an ordinary push under its OWN key, not a
        // `VAULT_WRITER_NOT_ADMITTED` refusal. `added_writer.principal_id`
        // names the redeemer's own control-plane principal, which the claim
        // response never carries (its `membership` block names the ORG), so
        // resolve it fresh here.
        //
        // D9's not-stranded rule: an activation that does not land (a
        // concurrent rotation, a lost network) leaves this key a PENDING writer
        // of the vault, which any live writer's next push or `repos access
        // sync` admits. Report it and carry `request_writer_sync` rather than
        // throwing away a redemption already spent and a tree about to be restored.
        let writerActivation;
        try {
            const who = await this.#client.request("/agent/v1/whoami", { context: "resolving this principal's id for the writer activation head" });
            const activation = await handle.vault.submitWriterActivationHead({
                addedWriterKeyId: myWriterKeyId,
                addedSigningPubkeyB64u: identity.signing_pubkey,
                addedPrincipalId: who.principal.id,
                handoffId: parsed.invite_id,
                grant,
                acceptance: acceptance,
            });
            writerActivation = {
                outcome: "active",
                writer_key_id: myWriterKeyId,
                generation: activation.outcome === "activated" ? activation.result.generation : activation.generation,
            };
            // Clearing pending_writer_admission now that the activation head is
            // (or already was — the idempotent-skip case) admitted mirrors
            // writer_status flipping to "active" at the same moment.
            keystore.updateRepo(vault.vault_id, { pending_writer_admission: null });
        }
        catch (e) {
            writerActivation = { outcome: "pending", writer_key_id: myWriterKeyId, reason: e instanceof Error ? e.message : String(e) };
        }
        // The bearer envelope is superseded within minutes of use — run the
        // same reconcile `capture()` runs, best-effort (never a `join()` throw),
        // NOW that this checkout is an admitted writer and the reconcile's own
        // wrap step is meaningful.
        const reconcile = await this.#tryReconcileEnvelopeRecipients(handle.vault);
        // apply the checkpoint — LAST (design D5): a failure anywhere above
        // this line leaves the working tree untouched (freshly cloned, nothing
        // stashed), the cleanest possible state to retry `join()` from.
        const restored = await applyHandoffCheckpoint({ dir: targetDir, stash_oid: payload.checkpoint.commit_oid });
        // design D5: register THIS session's presence, then post the ONE
        // arrival fact — both best-effort, neither ever throws `join()`.
        const rooms = new Rooms(this.#client);
        const inviteShort = redeem.invite_id.slice(0, 8);
        let myPresence = null;
        let presenceFailure = null;
        try {
            const registration = await rooms.registerPresence(redeem.org_id, roomKey, {
                task: options.task ?? `joined via invite ${inviteShort}`,
                ...(options.program !== undefined ? { program: options.program } : {}),
                ...(options.model !== undefined ? { model: options.model } : {}),
                ...(options.sessionKey !== undefined ? { sessionKey: options.sessionKey } : {}),
            });
            myPresence = { presence_id: registration.presence_id, name: registration.name };
        }
        catch (e) {
            // best-effort — arrival still completes without a presence, and says why
            presenceFailure = e instanceof Error ? e.message : String(e);
        }
        let arrivalCursor = null;
        if (myPresence) {
            const receiptShort = payload.checkpoint.commit_oid.slice(0, 12);
            try {
                const posted = await rooms.sendMessage(redeem.org_id, roomKey, {
                    body: `Joined as ${myPresence.name} from checkpoint ${receiptShort}.`,
                    presenceId: myPresence.presence_id,
                    ...(options.sessionKey !== undefined ? { sessionKey: options.sessionKey } : {}),
                    idempotencyKey: `invite:${redeem.invite_id}:joined`,
                });
                if (typeof posted.cursor === "string")
                    arrivalCursor = posted.cursor;
            }
            catch {
                // best-effort — never blocks arrival
            }
        }
        let livePresences = redeem.live_presences ?? [];
        let recentMessages = [];
        let cursor = redeem.cursor ?? null;
        try {
            const page = await rooms.listMessages(redeem.org_id, roomKey, {
                order: "desc",
                limit: options.recentMessagesLimit ?? 10,
                ...(myPresence ? { presenceId: myPresence.presence_id } : {}),
            });
            recentMessages = page.messages ?? [];
            // The page is newest-first, so its continuation cursor is the OLDEST
            // position; the cursor a joined checkout stores must be the NEWEST —
            // its own arrival fact when posted, else the newest message read —
            // so the joiner's first `messages wait` is woken by what comes NEXT,
            // never by its own arrival.
            const newestRead = recentMessages[0]?.cursor;
            if (arrivalCursor)
                cursor = arrivalCursor;
            else if (typeof newestRead === "string")
                cursor = newestRead;
        }
        catch {
            // best-effort — arrival still reports the arrival fact's cursor, else the redemption's own
            if (arrivalCursor)
                cursor = arrivalCursor;
        }
        const nextActions = [...(redeem.next_actions ?? [])];
        if (!nextActions.some((a) => a.type === "push_repo")) {
            nextActions.push({ type: "push_repo", command: "git push origin main", why: "Publish continued work back to the vault." });
        }
        if (!nextActions.some((a) => a.type === "wait_room")) {
            nextActions.push({ type: "wait_room", command: "run402 messages wait", why: "Block until the inviter (or anyone else) speaks; silence returns who is still here." });
        }
        if (redeem.inviter && !nextActions.some((a) => a.type === "send_room_message")) {
            nextActions.push({ type: "send_room_message", command: `run402 messages send "…" --to ${redeem.inviter.name}`, why: `${redeem.inviter.name} invited you and may still be live.` });
        }
        // design D9: a joiner whose activation did not land is not stranded —
        // its key is a PENDING writer, and any live writer's reconcile admits
        // it. The vocabulary is the shipped member-key one, never an
        // invite-specific error.
        if (writerActivation.outcome === "pending" && !nextActions.some((a) => a.type === "request_writer_sync")) {
            nextActions.push({
                type: "request_writer_sync",
                command: "run402 repos access sync",
                why: `This key is a pending writer of the vault (${writerActivation.reason}); ask any live writer to run this — or push once — and the first push from here will land.`,
            });
        }
        let note = null;
        let noteRaw = null;
        try {
            noteRaw = (await readGitCommitMessage(targetDir, payload.checkpoint.commit_oid)) ?? null;
            if (noteRaw)
                note = JSON.parse(noteRaw);
        }
        catch {
            note = null;
        }
        return {
            invite_id: redeem.invite_id,
            kind: redeem.kind,
            deduplicated: redeem.deduplicated,
            note,
            note_raw: noteRaw,
            restored: { dir: targetDir, branch: restored.branch, base_head_oid: restored.base_head_oid, stash_oid: restored.stash_oid },
            membership: handoffMembershipFromWire(redeem.membership),
            members: redeem.members ?? [],
            room: { organization_id: redeem.room?.org_id ?? redeem.org_id, room_key: roomKey },
            inviter: redeem.inviter ?? null,
            presence: myPresence,
            presence_failure: presenceFailure,
            live_presences: livePresences,
            cursor,
            recent_messages: recentMessages,
            expires_at: redeem.expires_at,
            writer_activation: writerActivation,
            reconcile_recipients: reconcile,
            next_actions: nextActions,
        };
    }
    /** Best-effort mirror write: catches EVERYTHING, including the lazy module import itself, so a mirror problem can never surface as a `capture()` throw. */
    /**
     * `EPOCH_ROTATION_REQUIRED` (D193) is left THROWN — never swallowed into a
     * silent auto-rotation — because this call site cannot legally decide the
     * two D194 counters (`recipient_state_version`/`recipient_revocation_version`)
     * a rotation attempt must be fenced against: no shipped gateway route
     * exposes them for `reason:"member_removed"`/`"elective_rekey"`/
     * `"epoch_secret_exposed"` (verified against the live gateway route
     * source — see `Vault.rotateEpoch`'s own doc comment). Submitting
     * a GUESSED pair would either fail loudly (`RECIPIENT_SET_MISMATCH`, the
     * honest outcome) or — worse — never be reachable at all for a fresh
     * counter row. Rather than let a caller decode `details.migration_required`/
     * `revocation_outstanding`/`exposure_outstanding` themselves, this
     * decorates the SAME thrown error with the exact next command for each
     * cause (`repos access repair` / `repos access revoke-key` / `repos
     * access declare-exposure`) so "the next push" surfaces its own remedy
     * instead of an opaque 409 — "wire into the natural path" without
     * pretending a blind retry could ever succeed.
     */
    #enrichEpochRotationRequired(e, repoId) {
        if (!isRun402Error(e) || e.code !== "EPOCH_ROTATION_REQUIRED")
            return e;
        // Mirrors `epochRotationRequiredNextActions` in
        // `../node/vault-deploy.ts` byte-for-byte (same three causes, same
        // three commands) — kept as a small standalone copy rather than a
        // shared import so this namespace-level module (isomorphic — Deno/Bun/V8
        // isolates, no Node-only imports) never has to pull in the Node-only
        // deploy module just to decode three booleans. Update BOTH on drift.
        const details = e.details ?? {};
        const nextActions = [];
        if (details.migration_required)
            nextActions.push({ action: "run402 repos access repair", why: "this vault predates rev-42 epoch rotation and must complete one first-ever rotation (owner + step-up)" });
        if (details.revocation_outstanding)
            nextActions.push({ action: "run402 repos access revoke-key <principal_id>", why: "an org membership removal or key revocation is outstanding for this vault; a surviving writer's push completes a membership removal automatically (reason member_removed), so this push was refused because that rotation could not run here — an owner can rotate explicitly with this command (owner + step-up)" });
        if (details.exposure_outstanding)
            nextActions.push({ action: "run402 repos access declare-exposure", why: "this vault's own epoch secret has been declared exposed (owner + step-up)" });
        return new LocalError(`this vault requires a rotate_epoch admission before an ordinary push is admissible (repo_id ${repoId})`, "pushing to vault", { code: "EPOCH_ROTATION_REQUIRED", details, next_actions: nextActions.length > 0 ? nextActions : undefined, cause: e });
    }
    /**
     * The capture-time copies a plain `git push` through the remote helper
     * owes exactly as {@link capture} and {@link deploy} do: the opt-in mirror
     * mirror write and, on a BYO vault, the signed-chain copy into the customer
     * bucket (vault-byo-primary-bucket task 3.3). The helper publishes
     * through `Vault.push` directly, so it calls this right after a
     * generation lands. Same contract as the private hooks it composes:
     * best-effort, NEVER throws, never alters the push already committed —
     * each outcome is reported on its own field (`skipped_no_mirror` costs no
     * network call).
     */
    async postPublishCopies(input) {
        const mirror_push = await this.#tryMirrorPush(input.repo_id, input.keystore);
        const byo_chain_copy = await this.#tryByoChainCopyPush(input.repo_id, input.keystore);
        return { mirror_push, byo_chain_copy };
    }
    async #tryMirrorPush(repoId, keystore) {
        try {
            const { mirrorPushForGeneration } = await this.#mirror();
            return await mirrorPushForGeneration(this.#client, repoId, { keystore });
        }
        catch (e) {
            return { attempted: false, outcome: "skipped_no_mirror", error: e instanceof Error ? e.message : String(e) };
        }
    }
    /**
     * vault-byo-primary-bucket task 3.3 — the chain's every-push dual-write
     * into a BYO vault's own destination, mirroring {@link #tryMirrorPush}'s
     * exact contract byte-for-byte (best-effort, catches EVERYTHING, never
     * throws, never alters the vault outcome already committed above — a
     * chain-copy failure is a named pending finding reported BESIDE the vault
     * result on its own field, never a `capture()`/`deploy()` throw).
     */
    async #tryByoChainCopyPush(repoId, keystore) {
        try {
            const { byoChainCopyPushForGeneration } = await this.#mirror();
            return await byoChainCopyPushForGeneration(this.#client, repoId, { keystore });
        }
        catch (e) {
            return { attempted: false, outcome: "skipped_no_mirror", error: e instanceof Error ? e.message : String(e) };
        }
    }
    /** Best-effort envelope-recipient reconcile: availability and local-policy failures never surface as a `capture()` throw (mirroring {@link #tryMirrorPush}), but INTEGRITY verdicts do — `RECONCILE_FATAL_CODES` propagates tampering evidence instead of demoting it to `skipped_error` (round 3 blocker 2). */
    async #tryReconcileEnvelopeRecipients(vault) {
        try {
            const result = await vault.reconcileEnvelopeRecipients();
            return { attempted: true, outcome: "reconciled", result };
        }
        catch (e) {
            rethrowFatalReconcile(e);
            return { attempted: false, outcome: "skipped_error", error: e instanceof Error ? e.message : String(e) };
        }
    }
    /**
     * vault-multi-writer (task 5.7) — best-effort writer-admission
     * reconcile, the SAME non-blocking contract as {@link
     * #tryReconcileEnvelopeRecipients} beside it: availability failures
     * (network, an older gateway missing `pending_writers`/`signing_pubkey`,
     * a CAS-conflict-exhausted admission) never surface as a `capture()`/
     * `deploy()`/`open()` throw — `Vault.reconcileWriterAdmissions()`
     * ALREADY returns an empty result rather than throwing for the ordinary
     * "I am not a writer yet" case, so `skipped_error` here is reserved for
     * genuinely unexpected failures (a transport fault mid-loop, a thrown
     * chain-integrity verdict from the `materialize()` this reconcile's own
     * admission attempts re-run). `rethrowFatalReconcile` is reused as-is:
     * its `RECONCILE_FATAL_CODES` set is envelope-specific today (no writer-
     * chain-integrity code is in it yet), so this call is a no-op unless a
     * future code is added there — cheap, forward-compatible, and consistent
     * with the sibling method rather than a silently different policy.
     */
    async #tryReconcileWriterAdmissions(vault) {
        try {
            const result = await vault.reconcileWriterAdmissions();
            return { attempted: true, outcome: "reconciled", result };
        }
        catch (e) {
            rethrowFatalReconcile(e);
            return { attempted: false, outcome: "skipped_error", error: e instanceof Error ? e.message : String(e) };
        }
    }
    /**
     * A REAL preview of what {@link capture} would publish
     * — never publishes anything, and never allocates. `run402 repos
     * capture --dry-run` and `git-remote-run402`'s `option dry-run true` are
     * both thin adapters over this method.
     *
     * "Refusing beats fake success" (this file's own architectural law) governs
     * the shape of what happens when the vault does not exist yet: unlike
     * {@link capture}, this NEVER allocates one (push-to-create must not allocate
     * on a dry run) — it resolves READ-ONLY (`open`/`resolveOrCreateAddress`
     * with `allow_create: false`), and when that resolution misses, returns
     * `allocation_needed: true` with every sizing field `null` rather than a
     * guess. Sizing is genuinely unknowable before allocation: encryption uses
     * a `K_repo` this project has not been assigned yet, so there is no key to
     * preview a push under — only the real capture (this method still runs
     * `captureSnapshot`, which is local, filter-free work) can be computed.
     *
     * Everything else is the SAME real local pipeline `push` runs — capture,
     * pack building, sealing/encryption — computed by
     * {@link import("../node/vault-publication.js").Vault.planPush},
     * which is what actually stops short of the two network mutations
     * (`uploadObjects`, `admitHead`). See its doc comment for what
     * `would_admit_generation` does and does not promise.
     */
    async planCapture(options) {
        const { captureSnapshot, vaultCommitLine } = await this.#snapshot();
        const repoDir = options.repo_dir ?? process.cwd();
        // READ-ONLY resolution — mirrors `capture()`'s own dispatch on `address`,
        // but with `allow_create: false` and no `openOrCreate` fallback: a dry
        // run must never mutate the control plane, and allocation is the one
        // push-time mutation that happens before a single byte is built.
        let handle = null;
        try {
            handle = options.address
                ? (await this.resolveOrCreateAddress({ ...options, address: options.address, allow_create: false })).handle
                : await this.open(options);
        }
        catch (e) {
            if (!isVaultResolutionMiss(e))
                throw e;
            handle = null; // no vault to preview a push against yet
        }
        const snapshot = await captureSnapshot({ dir: repoDir, ...(options.snapshot ?? {}) });
        const line = vaultCommitLine(snapshot);
        options.onCommitLine?.(line);
        if (!handle) {
            return {
                base_generation: null, would_admit_generation: null, would_admit_generation_decimal: null,
                form: null, refs: {}, head_target: null, objects: [], object_count: null,
                encrypted_bytes: null, raw_bytes: null,
                allocation_needed: true, snapshot, vault_commit: snapshot.oid, vault_commit_line: line,
            };
        }
        const { deployRefTransaction } = await this.#publication();
        const materialized = await handle.vault.materialize();
        const push = {
            transaction: deployRefTransaction(materialized.refs, snapshot.oid),
            head_target: snapshot.head,
            protocol_refs: "allow", // same opt-in `capture()` makes for the SAME move — see its doc comment.
            ...(options.checkpoint ? { checkpoint: true } : {}),
        };
        const plan = await handle.vault.planPush(push);
        return { ...plan, allocation_needed: false, snapshot, vault_commit: snapshot.oid, vault_commit_line: line };
    }
    /**
     * Compaction's transient-storage preflight
     * (vault-compaction-headroom-preflight, design D1/D2).
     *
     * Three postures, in order of information quality:
     *   1. the check answers "won't fit" → typed refusal, before any upload;
     *   2. the caller passed the override → proceed, marked `overridden`;
     *   3. the check CANNOT be answered → proceed after one stderr note.
     *
     * (3) is deliberate: failing closed on ignorance would make compaction's
     * availability depend on an ADVISORY read, which is wrong for a maintenance
     * verb whose refusals must come from the authority that owns the quota.
     *
     * The size proxy is the vault's billed `source_bytes` (D1). The true
     * checkpoint-pack size is unknowable before packing, but it is bounded above
     * by — and usually near — the vault's live content. Deliberately
     * conservative when history exceeds live content; the override exists
     * precisely because a proxy can overestimate.
     */
    /**
     * Read compaction's transient-storage arithmetic WITHOUT compacting — the
     * same figures `compact` preflights on, with none of its policy. `repos gc`
     * uses it to disclose headroom on the `--submit` half, where no compaction
     * runs but the numbers are just as worth showing. `null` when the pooled
     * figures cannot be read.
     */
    async compactHeadroom(options = {}) {
        // Deliberately resolves the repo id rather than `open()`ing: this is two
        // control-plane reads and arithmetic, so it needs no keystore, no
        // transport, and no Node runtime.
        return this.#readCompactHeadroom(await this.#resolveRepoId(options));
    }
    /**
     * Open this vault's compaction headroom grant directly (vault-checkpoint-cadence
     * design D3) — `compact()` already does this internally; this standalone
     * entry point exists for callers that need to inspect or drive the grant
     * without also running a full compaction cycle (tests; a future
     * operator/diagnostic surface). Throws `VAULT_COMPACTION_GRANT_ACTIVE`
     * (409) verbatim when another compaction already holds this project's
     * grant.
     */
    async openCompactionGrant(options = {}) {
        const handle = await this.open(options);
        return handle.vault.openCompactionGrant();
    }
    /** Close this vault's compaction headroom grant directly — idempotent; `{closed: false}` when nothing was active. */
    async closeCompactionGrant(options = {}) {
        const handle = await this.open(options);
        return handle.vault.closeCompactionGrant();
    }
    /**
     * The arithmetic, with no policy: the block, or `null` when unanswerable.
     *
     * `effectiveLimitOverride` (vault-checkpoint-cadence design D3): when a
     * compaction grant is active for this cycle, `compact()` passes the
     * grant's `effective_pool_limit_bytes` here so `ok`/`projected_transient_bytes`
     * reflect the RAISED limit — `pool_limit_bytes` itself always stays the
     * tier's own figure (never the raised one), so disclosure never implies
     * the tier grew.
     */
    async #readCompactHeadroom(repoId, effectiveLimitOverride) {
        /** Posture (3): one note, on stderr, and `null` — never a throw. */
        const unanswerable = (why) => {
            globalThis.console?.error?.(`run402: could not check compaction storage headroom (${why}); proceeding — the platform's storage quota remains authoritative.`);
            return null;
        };
        let poolUsed;
        let poolLimit;
        let sourceBytes;
        try {
            const [record, tier] = await Promise.all([
                this.get(repoId),
                this.#client.request("/tiers/v1/status", {
                    context: "reading pooled tier storage for the compaction headroom preflight",
                }),
            ]);
            poolUsed = Number(tier.pool_usage?.total_storage_bytes);
            poolLimit = Number(tier.pool_usage?.storage_bytes_limit);
            sourceBytes = Number(record.storage?.source_bytes ?? "0");
        }
        catch (e) {
            return unanswerable(e instanceof Error ? e.message : String(e));
        }
        // Shape drift in the tier-status response reads the same as an unreadable
        // one — an advisory that cannot be computed must not be guessed at.
        if (!Number.isFinite(poolUsed) || !Number.isFinite(poolLimit) || !Number.isFinite(sourceBytes) || poolLimit <= 0) {
            return unanswerable("tier status carried no usable pooled-storage figures");
        }
        // The grant's byte fields arrive as STRINGS on the wire (gateway BIGINT
        // serialization, verified live) — coerce BEFORE the finiteness gate, or
        // `Number.isFinite("472697418")` (false, no coercion) silently discards
        // an opened grant and the preflight refuses with the unraised limit.
        const overrideNum = effectiveLimitOverride === undefined ? Number.NaN : Number(effectiveLimitOverride);
        const effectiveLimit = Number.isFinite(overrideNum) && overrideNum > 0 ? overrideNum : poolLimit;
        const projected = poolUsed + sourceBytes;
        return {
            pool_used_bytes: poolUsed,
            pool_limit_bytes: poolLimit,
            vault_source_bytes: sourceBytes,
            projected_transient_bytes: projected,
            ok: projected <= effectiveLimit,
            overridden: false,
            ...(effectiveLimit !== poolLimit ? { effective_pool_limit_bytes: effectiveLimit } : {}),
        };
    }
    /** The reader plus compaction's policy (refuse / override / proceed). */
    async #compactHeadroomPreflight(repoId, ignore, effectiveLimitOverride) {
        const headroom = await this.#readCompactHeadroom(repoId, effectiveLimitOverride);
        if (headroom === null)
            return null;
        const { pool_used_bytes: poolUsed, pool_limit_bytes: poolLimit, vault_source_bytes: sourceBytes, projected_transient_bytes: projected, ok, effective_pool_limit_bytes: effectiveLimit } = headroom;
        if (!ok && !ignore) {
            const limitForMessage = effectiveLimit ?? poolLimit;
            throw new LocalError(`compaction needs about ${mib(sourceBytes)} of transient headroom and the org's pooled storage has ${mib(Math.max(limitForMessage - poolUsed, 0))} free ` +
                `(${mib(poolUsed)} used of ${mib(limitForMessage)}${effectiveLimit !== undefined ? ` — includes an active compaction grant raising the ${mib(poolLimit)} tier limit` : ""}; projected ${mib(projected)}). Compaction transiently holds BOTH the new checkpoint and the ` +
                `not-yet-pruned history — roughly 2x source_bytes — until a prune completes. Free storage, raise the tier, or re-run with --force-headroom ` +
                `(the platform's own quota enforcement stays authoritative either way).`, "preflighting vault compaction storage headroom", {
                code: "VAULT_COMPACT_INSUFFICIENT_HEADROOM",
                details: {
                    pool_used_bytes: poolUsed,
                    pool_limit_bytes: poolLimit,
                    vault_source_bytes: sourceBytes,
                    projected_transient_bytes: projected,
                    ...(effectiveLimit !== undefined ? { effective_pool_limit_bytes: effectiveLimit } : {}),
                    override: "--force-headroom",
                },
            });
        }
        return { ...headroom, overridden: !ok && ignore };
    }
    /** A 409 `VAULT_COMPACTION_GRANT_ACTIVE` refusal — another compaction already holds this project's grant. */
    #isCompactionGrantActive(e) {
        const err = e;
        return Boolean(err && err.code === "VAULT_COMPACTION_GRANT_ACTIVE");
    }
    /**
     * Open this vault's compaction headroom grant, shared by `compact()` and
     * `prune({ submit })` — both write ceremony
     * objects into the vault and both need the same single-flight-per-vault
     * headroom slot. Four outcomes:
     *   - opened: the grant is returned, carrying `effective_pool_limit_bytes`.
     *   - 409 `VAULT_COMPACTION_GRANT_ACTIVE`: another maintenance op (this
     *     process, another process, auto-gc, or a manual `repos gc`) already
     *     holds this project's one-at-a-time grant — refuse rather than race
     *     it, surfaced as the identical single-flight-conflict `LocalError`
     *     every caller of this helper throws.
     *   - 404/absent route (an older gateway): proceed ungranted exactly as
     *     before this grant existed — returns `null`.
     *   - any OTHER failure (network, 5xx): also proceeds ungranted — a grant
     *     that could not be confirmed open must never be ASSUMED open, and
     *     failing the whole caller because an ancillary raise-the-limit call
     *     errored would make routine maintenance depend on an availability
     *     property narrower than the caller itself needs.
     */
    async #openMaintenanceGrantOrConflict(handle, context) {
        try {
            return await handle.vault.openCompactionGrant();
        }
        catch (e) {
            if (this.#isCompactionGrantActive(e)) {
                throw new LocalError(`another compaction is already in progress for this vault (its headroom grant expires ${e.details?.expires_at ?? "soon"}) — refusing to race it.`, context, { code: "VAULT_COMPACTION_IN_PROGRESS", details: { expires_at: e.details?.expires_at ?? null } });
            }
            // 404/absent-route or any other failure: proceed ungranted.
            return null;
        }
    }
    /**
     * Publish a checkpoint covering the canonical refs, every root unexpired at
     * the cutoff, and the `HEAD` target — under a maintenance lease so a
     * concurrent cycle cannot race it.
     *
     * The lease's `holder_token` is held in memory for the duration and released
     * in a `finally`; it is never returned to the caller, logged, or cached.
     * A repository beyond the V0 checkpoint maximum is refused at preflight with
     * `CHECKPOINT_SET_LIMIT_EXCEEDED`.
     */
    async compact(options = {}) {
        const handle = await this.open(options);
        // vault-checkpoint-cadence design D3: open the compaction headroom
        // grant BEFORE the preflight (which needs its raised limit) and before
        // any checkpoint is built or uploaded — same ordering rule as the
        // preflight itself. Outcomes are documented on
        // {@link VaultNamespace.#openMaintenanceGrantOrConflict}.
        const grant = await this.#openMaintenanceGrantOrConflict(handle, "opening the vault compaction headroom grant");
        // Everything from here on is bracketed by the grant's own lifetime —
        // including the preflight's OWN possible throw (`VAULT_COMPACT_
        // INSUFFICIENT_HEADROOM`) — so a refusal still closes a grant this call
        // opened rather than leaking it until the gateway's hourly sweep.
        try {
            return await this.#compactAfterGrant(handle, options, grant);
        }
        finally {
            if (grant)
                await handle.vault.closeCompactionGrant().catch(() => undefined);
        }
    }
    /** The rest of `compact()`, run under an already-opened-or-skipped grant — split out so the grant's `finally` above covers every exit path, including the preflight's own throw. */
    async #compactAfterGrant(handle, options, grant) {
        // BEFORE any checkpoint is built or uploaded (that ordering is the whole
        // point — see VaultCompactHeadroom).
        const headroom = await this.#compactHeadroomPreflight(handle.repo_id, options.ignoreHeadroom === true, grant?.effective_pool_limit_bytes);
        const base = await handle.vault.materialize();
        let lease = null;
        if (options.lease !== false) {
            lease = await this.acquireMaintenanceLease({
                repo_id: handle.repo_id,
                base_head_sha256: base.head_sha256,
                current_checkpoint_hash: base.head?.checkpoint?.claim_set.stored_bytes_sha256 ?? null,
                r1_size_bytes: options.r1_size_bytes ?? "0",
                r2_cap_size_bytes: options.r2_cap_size_bytes ?? "0",
            }).catch(() => null);
        }
        try {
            // A cutoff ticket lets EXPIRED roots leave the map. Without one the roots
            // are RETAINED — expiry is permissive by design, so an unavailable ticket
            // costs storage, never history.
            let cutoffBound = true;
            let published;
            try {
                published = await handle.vault.publishCheckpoint({ cutoff: {} });
            }
            catch (e) {
                if (!isMissingCutoffRoute(e))
                    throw e;
                cutoffBound = false;
                published = await handle.vault.publishCheckpoint({ cutoff: false });
            }
            return {
                generation: published.generation,
                head_sha256: published.head_sha256,
                form: published.form,
                maintenance_lease_id: lease?.maintenance_lease_id ?? null,
                cutoff_bound: cutoffBound,
                covered_refs: Object.keys(published.refs).length,
                covered_roots: base.roots.length,
                headroom: headroom && grant ? { ...headroom, compaction_grant: { granted_bytes: Number(grant.granted_bytes), expires_at: grant.expires_at } } : headroom,
            };
        }
        finally {
            if (lease) {
                await this.#client
                    .request(`/vaults/v1/${encodeURIComponent(handle.repo_id)}/maintenance-leases/${encodeURIComponent(lease.maintenance_lease_id)}`, { method: "DELETE", body: { holder_token: lease.holder_token }, context: "releasing the vault maintenance lease" })
                    .catch(() => undefined);
            }
            // The compaction headroom grant (if one was opened) is closed by the
            // caller — `compact()`'s own `finally`, which wraps this ENTIRE
            // method so a preflight refusal closes it too. Design D3's rationale
            // for closing promptly (prune only ever REMOVES objects, so nothing
            // past the checkpoint publish needs the raised limit) lives there.
        }
    }
    /**
     * Plan a prune — and, with both verifier receipts in hand, submit it.
     *
     * TWO PHASES, because the protocol is two-phase (§7.3) and no amount of API
     * sugar can collapse it:
     *
     *   1. `prune()` walks the verified chain, computes the GC root set, subtracts
     *      it from the pruneable universe, and returns a SIGNED
     *      `prune_intent_core` plus its `intent_core_sha256`. Nothing is
     *      submitted and nothing is deleted.
     *   2. Run `r402s-verify` against that core, then call
     *      `prune({ submit: { core, verifier_receipt } })` with the core
     *      ROUND-TRIPPED VERBATIM. This SDK produces its own `run402-cli` receipt
     *      by restoring the latest checkpoint and recomputing its commitments;
     *      the second receipt is `r402s-verify`'s and is never synthesized here,
     *      because two receipts from one lineage prove nothing.
     *
     * The `deleted` list in the result comes from the control-plane-signed
     * completion and nothing else. `present_after_attempt` is a FAILED deletion
     * and is never counted as one.
     */
    async prune(options = {}) {
        const pub = await this.#publication();
        const prune = await this.#prune();
        const { isRootEligibleForRemoval, VAULT_RETENTION_MIN_DAYS } = pub;
        const handle = await this.open(options);
        // vault-byo-primary-bucket: prune deletion on a BYO vault is not
        // shipped (the gateway's worker deletes from run402's own storage, which
        // holds none of this vault's payload; client-executed deletion is the
        // open design item), and the gateway refuses an intent by name
        // (VAULT_BYO_PRUNE_NOT_SUPPORTED). Say so before any planning or
        // ceremony — a plan here would only invite a refused submit.
        const record = await handle.vault.transport.getVaultRecord({ repo_id: handle.repo_id });
        if (record.storage_profile === "byo") {
            return {
                candidates: [],
                eligible_count: 0,
                retained_count: 0,
                object_candidates: [],
                deferred_object_count: 0,
                blocked_reason: "this vault keeps its payload in your own bucket (storage_profile byo); run402 cannot delete from a bucket it holds no credential to, and client-executed prune deletion has not shipped — the gateway refuses a prune intent on a BYO vault by name (VAULT_BYO_PRUNE_NOT_SUPPORTED); compaction still works and superseded history stays in your bucket",
                intent_core: null,
                intent_core_sha256: null,
                attestation: null,
                submitted: false,
                intent: null,
                confirmation: null,
                note: "prune is not available on a bring-your-own-bucket vault yet",
            };
        }
        const base = await handle.vault.materialize();
        const cutoffAt = (options.now ?? (() => new Date()))().toISOString();
        const resolve = options.effective_admitted_at ?? (() => null);
        // ── the retention-ROOT view (unchanged) ──
        const candidates = base.roots.map((root) => {
            const admittedAt = resolve(root.dropped_at_generation);
            const eligible = admittedAt !== null && isRootEligibleForRemoval(admittedAt, cutoffAt);
            return {
                ref: root.ref,
                oid: root.oid,
                dropped_at_generation: root.dropped_at_generation,
                eligible,
                reason: admittedAt === null
                    ? "this client cannot resolve the drop's effective_admitted_at, which RETAINS the root — expiry is permissive by design"
                    : eligible
                        ? `dropped more than ${VAULT_RETENTION_MIN_DAYS} days before the cutoff; removable at the next checkpoint-bearing generation, then at the next successful prune`
                        : `still inside the ${VAULT_RETENTION_MIN_DAYS}-day retention window`,
            };
        });
        const rootView = {
            candidates,
            eligible_count: candidates.filter((c) => c.eligible).length,
            retained_count: candidates.filter((c) => !c.eligible).length,
        };
        // ── the OBJECT view: universe − GC root set ──
        const entries = await handle.vault.chainEntries();
        const plan = prune.planPruneCandidates(entries);
        const objectCandidates = plan.candidates.map((r) => ({ object_id: r.object_id, object_kind: r.object_kind, size_bytes: r.size_bytes }));
        const blocked = plan.root_set.blocked_reason ??
            (plan.candidates.length === 0 ? "every stored object is still inside the GC root set — nothing is superseded yet" : null);
        if (blocked || !plan.root_set.latest_checkpoint) {
            return {
                ...rootView,
                object_candidates: objectCandidates,
                deferred_object_count: plan.deferred_count,
                blocked_reason: blocked,
                intent_core: null,
                intent_core_sha256: null,
                attestation: null,
                submitted: false,
                intent: null,
                confirmation: null,
                note: `no prune intent was built: ${blocked}. Retention is an operational promise of the platform, not a cryptographic guarantee against it.`,
            };
        }
        // ── restore-and-verify the latest checkpoint: the receipt's evidence ──
        const latest = plan.root_set.latest_checkpoint;
        const attestation = await handle.vault.verifyStoredCheckpoint(latest.head, latest.head_sha256);
        if (attestation.cutoff_ticket_sha256 === null) {
            return {
                ...rootView,
                object_candidates: objectCandidates,
                deferred_object_count: plan.deferred_count,
                blocked_reason: "the latest checkpoint binds no retention_cutoff ticket, so no root's window can be evaluated and no prune is authorizable",
                intent_core: null,
                intent_core_sha256: null,
                attestation,
                submitted: false,
                intent: null,
                confirmation: null,
                note: "run `run402 repos gc` to publish a checkpoint bound to a fresh retention_cutoff ticket, then plan the prune again.",
            };
        }
        const gcRootSetHmac = handle.vault.keyedDigest("gcrootset", { receipts: plan.root_set.receipts });
        const core = options.submit?.core ??
            prune.buildPruneIntentCore({
                repo_id: handle.repo_id,
                gc_epoch: record.gc_epoch,
                authorizing_head_sha256: base.head_sha256,
                checkpoint_claim_set_sha256: attestation.claim_set_sha256,
                gc_root_set_hmac: gcRootSetHmac,
                retention_state_hmac: attestation.retention_state_hmac,
                delete_set: plan.candidates,
            }, handle.vault.signer());
        const coreSha = prune.pruneIntentCoreSha256(core);
        // A supplied core may have been planned against a chain that has since
        // moved. The rule lives in the prune module (and is unit-tested there); the
        // namespace only supplies the current facts.
        if (options.submit) {
            prune.assertPruneCoreStillCurrent(core, { repo_id: handle.repo_id, gc_epoch: record.gc_epoch, checkpoint_claim_set_sha256: attestation.claim_set_sha256 });
        }
        // Faithful: when a core was supplied, report ITS candidates — the ones
        // actually submitted — not a freshly-planned set that may differ.
        const reportedCandidates = options.submit
            ? core.delete_set.map((r) => ({ object_id: r.object_id, object_kind: r.object_kind, size_bytes: r.size_bytes }))
            : objectCandidates;
        const planned = {
            ...rootView,
            object_candidates: reportedCandidates,
            deferred_object_count: plan.deferred_count,
            blocked_reason: null,
            intent_core: core,
            intent_core_sha256: coreSha,
            attestation,
            submitted: false,
            intent: null,
            confirmation: null,
            note: "planned, not submitted. Run r402s-verify against this intent_core, then call prune({ submit: { core, verifier_receipt } }) " +
                "with the core round-tripped verbatim — a rebuilt core carries a different nonce and the receipt would no longer bind to it.",
        };
        if (!options.submit)
            return planned;
        // ── attest, upload both receipts, submit the exact bytes ──
        const rootsEvolution = await this.#checkRootsEvolution(handle, entries, resolve, isRootEligibleForRemoval, attestation.cutoff_at);
        const candidateIds = new Set(core.delete_set.map((r) => r.object_id));
        const outsideRoots = plan.root_set.receipts.every((r) => !candidateIds.has(r.object_id));
        const ours = prune.buildVerifierReceipt({
            repo_id: handle.repo_id,
            intent_core_sha256: coreSha,
            checkpoint_head_sha256: attestation.checkpoint_head_sha256,
            cutoff_ticket_sha256: attestation.cutoff_ticket_sha256,
            restored_object_set_hmac: attestation.restored_object_set_hmac,
            // Both booleans are OBSERVATIONS. A false one produces a `failed`
            // receipt and `buildPruneIntent` then refuses — which is the point:
            // the SDK never signs an attestation it did not earn.
            retention_evolution_ok: rootsEvolution.ok && attestation.object_set_matches && attestation.ref_state_matches && attestation.retention_roots_matches,
            candidates_outside_roots_ok: outsideRoots,
            implementation_id: prune.VAULT_SDK_VERIFIER_IMPLEMENTATION,
            implementation_version: prune.VAULT_SDK_VERIFIER_VERSION,
        }, handle.vault.signer());
        const intent = prune.buildPruneIntent(core, [ours, options.submit.verifier_receipt], handle.vault.signer());
        // The submission writes ~8 KB of ceremony
        // objects (both verifier receipts + the intent) into the vault — on a
        // vault that is over-quota BECAUSE it just compacted, that write is
        // refused QUOTA_EXCEEDED, so the deletion's own paperwork is blocked by
        // the storage the deletion would free. Bracket the upload+submit with
        // the SAME compaction headroom grant `compact()` uses (design D3, via
        // the shared `#openMaintenanceGrantOrConflict`) — opened before the
        // upload, closed in a `finally` regardless of outcome.
        const grant = await this.#openMaintenanceGrantOrConflict(handle, "opening the vault compaction headroom grant for prune submission");
        let stored;
        try {
            await this.#uploadVerifierReceipts(handle, pub, [ours, options.submit.verifier_receipt]);
            stored = await handle.vault.transport.submitPruneIntent({ repo_id: handle.repo_id, intent_bytes: prune.pruneIntentBytes(intent) });
        }
        finally {
            if (grant)
                await handle.vault.closeCompactionGrant().catch(() => undefined);
        }
        let intentRecord = stored;
        const wait = options.submit.wait;
        if (wait) {
            const attempts = wait.attempts ?? 20;
            const intervalMs = wait.interval_ms ?? 3_000;
            for (let i = 0; i < attempts && !(intentRecord?.completion ?? null); i++) {
                await new Promise((r) => setTimeout(r, intervalMs));
                intentRecord = await handle.vault.transport.getPruneIntent({ repo_id: handle.repo_id, prune_intent_object_id: intent.object_id });
            }
        }
        const confirmation = prune.summarizePruneCompletion(core.delete_set.map((r) => r.object_id), intentRecord);
        return {
            ...planned,
            submitted: true,
            intent: intentRecord,
            confirmation,
            note: confirmation.outcome === null
                ? "the intent is accepted and the gateway's worker drives deletion; poll it until a signed completion appears. Nothing is deleted until the completion says so."
                : `the signed completion confirms ${confirmation.deleted.length} object(s) deleted and ${confirmation.present.length} still present. Only \`deleted\` means the bytes are gone.`,
        };
    }
    /** Upload both receipts so the gateway can claim them at the intent's fence. */
    async #uploadVerifierReceipts(handle, pub, receipts) {
        const { storedBytes, sha256Hex } = await import("./vault.crypto.js");
        const objects = receipts.map((r) => {
            const bytes = storedBytes(r);
            return {
                path: pub.vaultPaths.verifierReceipt(r.object_id),
                object_kind: "verifier_receipt",
                object_id: r.object_id,
                bytes,
                sha256: sha256Hex(bytes),
                size_bytes: String(bytes.length),
            };
        });
        try {
            await handle.vault.transport.uploadObjects({ repo_id: handle.repo_id, objects });
        }
        catch (e) {
            // Receipt object ids are DETERMINISTIC —
            // the same core + the same signer produce the same id on every retry —
            // and Ed25519 signing is deterministic too, so the SAME id can only
            // mean the SAME bytes when nothing about the receipt's content
            // changed. Ids are never reusable server-side (correct, the ledger's
            // rule) — but that means ANY failed submit AFTER this upload landed
            // (a later validation refusal, a network blip, a crash) burns both
            // ids for every future retry, with no recovery but a full re-plan
            // (new core, new nonce, new r402s-verify run). Recover instead: for
            // each id the gateway names as reused, read the STORED object back
            // and compare its bytes to ours — same bytes means already-uploaded,
            // different bytes means the id is genuinely burned.
            const reused = this.#reusedObjectIds(e);
            if (reused === null)
                throw e;
            const remaining = [];
            for (const o of objects) {
                if (!reused.includes(o.object_id)) {
                    remaining.push(o);
                    continue;
                }
                const existing = await handle.vault.transport.getObject({ repo_id: handle.repo_id, path: o.path });
                const existingSha256 = existing ? sha256Hex(existing) : null;
                if (existing && existingSha256 === o.sha256)
                    continue; // identical bytes already landed — nothing to do
                throw new LocalError(existing
                    ? `verifier receipt ${o.object_id} already exists in the vault with DIFFERENT bytes than this attempt's (stored sha256 ${existingSha256}, this attempt's sha256 ${o.sha256}) — the object id is burned and cannot be reused; re-plan the prune (new core, new nonce, new r402s-verify run).`
                    : `verifier receipt ${o.object_id} was refused as an already-used id, but its stored bytes could not be read back to confirm they match — re-plan the prune.`, "uploading vault verifier receipts", { code: "VAULT_RECEIPT_ID_REUSED_DIFFERENT", details: { object_id: o.object_id, existing_sha256: existingSha256, this_attempt_sha256: o.sha256 } });
            }
            // Every reused id was confirmed identical to ours; anything NOT
            // reused (a genuinely new upload, or the other side of a partial
            // race) still needs to land.
            if (remaining.length > 0)
                await handle.vault.transport.uploadObjects({ repo_id: handle.repo_id, objects: remaining });
        }
    }
    /**
     * `VALIDATION_FAILED` with `details.reused_object_ids`
     * (`services/vault/upload-sessions.ts`: "an object id in the manifest is
     * already used") — object ids a prior attempt already landed. `null` for
     * any other failure, so the caller rethrows it untouched.
     */
    #reusedObjectIds(e) {
        if (!isRun402Error(e) || e.code !== "VALIDATION_FAILED")
            return null;
        const details = e.details;
        if (!details || typeof details !== "object" || Array.isArray(details))
            return null;
        const ids = details.reused_object_ids;
        return Array.isArray(ids) && ids.every((id) => typeof id === "string") ? ids : null;
    }
    /** Did every retention root that LEFT the map leave legally? A `false` here is honest, not fatal. */
    async #checkRootsEvolution(handle, entries, resolve, isEligible, _cutoffAt) {
        const { checkRetentionEvolution } = await this.#prune();
        const rootsByGeneration = new Map();
        for (const e of entries) {
            const carrier = await handle.vault.openRetentionRootsAt(e.head.retention_roots);
            rootsByGeneration.set(e.head.generation, carrier.roots.map((r) => ({ ref: r.ref, oid: r.oid, dropped_at_generation: r.dropped_at_generation })));
        }
        return checkRetentionEvolution(entries, (entry) => rootsByGeneration.get(entry.head.generation) ?? [], resolve, isEligible);
    }
    /**
     * Verify the head chain from the authenticated pin up to the newest listed
     * generation, then return the verified state.
     *
     * Fails closed: a regression below the pin is `GENERATION_REGRESSION`, a gap
     * or bad link is `CHAIN_BROKEN`, an unvalidatable transition descriptor is
     * `UPGRADE_REQUIRED` (read-only past it, never skipped). The verification
     * budget is resumable — a `VERIFICATION_BUDGET_EXCEEDED` client continues
     * from its verified prefix rather than restarting.
     */
    async verify(options = {}) {
        const handle = await this.open(options);
        return handle.vault.verifyToNewest({ persist: options.persist ?? true });
    }
    /**
     * `repos fsck` (repo-surface-consolidation D2/D3): walk the head chain
     * AND materialize the ref map, reporting
     * BOTH local trust pins — authenticated and materialized — before and
     * after, with an explicit `local_state_changed` flag. This is the one
     * place chain materialization and pin advance live; `view` never
     * calls it.
     *
     * `options.write` (default `true`) is the inverse of the CLI's
     * `--no-write`: `false` still walks, verifies, and decrypt-validates
     * everything (the returned `refs`/`chain_verified_to_generation`/
     * `decryptable_to_generation` are real, computed answers, not estimates)
     * but persists neither pin — a genuine audit mode, not a simulation.
     * `options.mirror` additionally runs the keyless mirror verification
     * ({@link mirrorVerify}) and folds its report in; that half proves the
     * mirror's validity, never its freshness (its own honesty statements ride
     * the result unchanged).
     *
     * **Request 1 (decrypt-validation, D193-D203 rev 42).** `fsck` walks the
     * chain in TOLERANT decrypt-validation mode
     * (`verifyToNewest({decryptValidate: true, strict: false})`) rather than
     * the ordinary fail-closed `materialize()` — chain/signature verification
     * ALWAYS reaches the true newest generation regardless of decrypt
     * capability (an admitted `rotate_epoch` is keylessly structural), and a
     * generation this call cannot actually DECRYPT is never again reported as
     * bare "verified": `chain_verified_to_generation` and
     * `decryptable_to_generation` are reported separately and can genuinely
     * differ — the incident-shape truth ("chain verified to 10, decryptable
     * to 7, `VAULT_EPOCH_NOT_OPENABLE` at epoch 2/rotation …") is exactly
     * what this split makes representable. `refs`/`head_target`/
     * `pin_after.highest_materialized` all reflect `decryptable_to_generation`.
     *
     * **vault-byo-primary-bucket task 3.3 (design D6).** For a
     * `storage_profile: "byo"` vault, `fsck` ALSO adjudicates the customer's
     * own bucket against run402's signed chain — see {@link
     * #checkByoPresence} for the full contract (automatic, never a flag;
     * fails soft with no local credentials; throws
     * `VAULT_BYO_OBJECT_MISSING` on a confirmed absence). A managed vault
     * is byte-identical to before this task.
     */
    async fsck(options = {}) {
        const before = await this.status(options);
        const repoId = before.repo_id;
        if (!repoId) {
            throw new LocalError(options.repo_id || options.project_id
                ? "no vault is allocated for this project yet — nothing to fsck"
                : "pass repo_id, or project_id to resolve it from the control plane", "running vault fsck", { code: "VAULT_UNRESOLVED", details: { project_id: options.project_id ?? null } });
        }
        const write = options.write ?? true;
        // vault-agent-envelopes D5: fsck is observational — it never wraps an
        // envelope, in write mode or not. Investigating a suspicious pending
        // recipient must not complete the disclosure being investigated.
        const handle = await this.open({ ...options, repo_id: repoId, reconcile: "forbidden" });
        const state = await handle.vault.verifyToNewest({ persist: write, decryptValidate: true, strict: false });
        const mirror = options.mirror ? await this.mirrorVerify({ ...options, repo_id: repoId }) : null;
        const decrypt = state.decrypt; // decryptValidate: true always populates this
        const decryptableToGeneration = decrypt.decryptable_to_generation;
        const refs = decrypt.ref_state ? { ...decrypt.ref_state.refs } : {};
        const roots = decrypt.retention_roots ? decrypt.retention_roots.roots.map((r) => ({ ...r })) : [];
        const headTarget = decrypt.ref_state?.head_target ?? null;
        const pinBefore = { highest_authenticated: before.pins.highest_authenticated, highest_materialized: before.pins.highest_materialized };
        const pinAfter = write
            ? { highest_authenticated: state.generation, highest_materialized: decryptableToGeneration }
            : pinBefore;
        const localStateChanged = write && (pinBefore.highest_authenticated !== pinAfter.highest_authenticated || pinBefore.highest_materialized !== pinAfter.highest_materialized);
        // clone-installs-retained-refs (D2, task 1.3): the SAME reconcile the
        // fetch path drives, run here so `repos fsck` heals a pre-change clone
        // (or a checkout whose original write degraded per D3) in one call.
        // `write: false` stays a genuine audit mode — nothing local is persisted,
        // refs included — and no `repo_dir` (or one that is not itself a git
        // repository — fsck addresses a vault by repo_id/project_id alone as
        // often as by a checkout) is an ordinary, silent no-op. Reconciled AS OF
        // `decryptableToGeneration` — the retained-refs bookkeeping is only ever
        // meaningful for state this call actually decrypted.
        let retainedRefs = null;
        if (write && options.repo_dir) {
            const { reconcileRetainedTipRefs } = await this.#publication();
            retainedRefs = await reconcileRetainedTipRefs(options.repo_dir, { refs, roots, head_target: headTarget ?? { kind: "symref", ref: "refs/heads/main" } });
        }
        // D210 (rev 44): best-effort proof-of-open submission — see
        // #submitFsckOpenProof's own doc comment for the write/audit-mode
        // gating and why a failure here never touches anything above.
        const openProof = await this.#submitFsckOpenProof(write, repoId, handle, {
            chain_verified_to_generation: state.generation,
            decryptable_to_generation: decryptableToGeneration,
        });
        // vault-byo-primary-bucket task 3.3 (design D6) — LAST, deliberately:
        // everything above (chain verify + its pin persist, retained-refs
        // reconcile, open-proof submission) is real, valid work regardless of
        // what the customer's own bucket holds — the signed chain is run402-
        // authoritative either way (design D9) — so a missing-object refusal
        // below must never cost any of it. See `#checkByoPresence`'s own doc
        // comment for the throw/no-throw split.
        const byoPresence = await this.#checkByoPresence(before.vault, repoId, handle.keystore);
        return {
            repo_id: repoId,
            write,
            verified_from_generation: pinBefore.highest_authenticated,
            verified_to_generation: state.generation,
            chain_verified_to_generation: state.generation,
            decryptable_to_generation: decryptableToGeneration,
            epoch_decrypt_failure: decrypt.failure,
            local_state_changed: localStateChanged,
            pin_before: pinBefore,
            pin_after: pinAfter,
            refs,
            head_target: headTarget,
            mirror,
            retained_refs: retainedRefs,
            open_proof: openProof,
            ...(byoPresence !== undefined ? { byo_presence: byoPresence } : {}),
        };
    }
    /**
     * D210 (rev 44) — `fsck`'s own best-effort proof-of-open submission. Thin
     * wrapper over {@link computeOpenProofOutcome} (the testable decision
     * function) supplying THIS instance's real `whoami`/`submit`
     * dependencies. See that function's own doc comment for the full
     * write/audit-mode gating and failure-containment contract.
     */
    async #submitFsckOpenProof(write, repoId, handle, evidence) {
        return computeOpenProofOutcome({
            write,
            ekFingerprint: handle.keystore.readIdentity()?.encryption_fingerprint ?? null,
            evidence,
            resolvePrincipalId: async () => {
                const who = await this.#client.request("/agent/v1/whoami", {
                    context: "resolving this principal's identity for a vault proof-of-open submission",
                });
                return who.principal?.id ?? null;
            },
            readerEntrypoint: async () => {
                const { vaultReaderEntrypoint } = await this.#publication();
                return vaultReaderEntrypoint("fsck");
            },
            submit: (principalId, ekFingerprint, ev) => this.submitProofOfOpen(repoId, principalId, {
                ek_fingerprint: ekFingerprint,
                chain_verified_to_generation: ev.chain_verified_to_generation,
                decryptable_to_generation: ev.decryptable_to_generation,
                reader_entrypoint: ev.reader_entrypoint,
            }),
        });
    }
    /**
     * vault-byo-primary-bucket task 3.3 (design D6) — `repos fsck`'s
     * wiring of the shipped read-half primitive
     * {@link import("../node/vault-mirror.js").verifyByoObjectsPresent}.
     * Thin wrapper over {@link computeByoPresenceOutcome} (the testable
     * decision function, factored out the same way {@link
     * computeOpenProofOutcome} is factored out of `#submitFsckOpenProof`)
     * supplying THIS instance's real `hasLocalConfig`/`verifyPresence`
     * dependencies (`readByoConfig` against the real keystore,
     * `verifyByoObjectsPresent` against the real HTTP client). See that
     * function's own doc comment for the full gating/throw contract.
     */
    async #checkByoPresence(vault, repoId, keystore) {
        const { readByoConfig } = await this.#byoConfig();
        const { verifyByoObjectsPresent } = await this.#mirror();
        return computeByoPresenceOutcome({
            repoId,
            vault,
            hasLocalConfig: () => readByoConfig(keystore, repoId) !== null,
            verifyPresence: () => verifyByoObjectsPresent(this.#client, repoId, { keystore }),
        });
    }
    /**
     * Wrap this vault's current epoch key to every org member who has
     * published an encryption key but has no `key_envelope` on this vault yet
     * — vault-human-envelopes task 4.1's ADD-path workaround. See {@link
     * import("../node/vault-publication.js").Vault.
     * reconcileEnvelopeRecipients} for the full design (TOFU pinning, the
     * gateway `public_key` gap, and why this is a workaround rather than the
     * eventual epoch-rotation design).
     *
     * No CLI verb runs this directly — `repos access` is the inspection
     * read. `deploy()` runs it, best-effort,
     * whenever a deploy lands a new generation in the vault — design D5's
     * "deploy time" hook, "the same 'one command every agent runs' argument
     * that decided deploy-implies-capture." `capture()` (capture-and-publish
     * outside a deploy) runs the identical hook after every successful
     * publish, for the vault-only-project cadence. See
     * `#tryReconcileEnvelopeRecipients` below for both call sites, and
     * {@link Repos.access} for the READ half repo-surface-consolidation
     * ships in its place.
     */
    async reconcileEnvelopeRecipients(options = {}) {
        const handle = await this.open(options);
        return handle.vault.reconcileEnvelopeRecipients();
    }
    /**
     * vault-multi-writer (task 5.7) — admit every eligible `pending_writers[]`
     * candidate (active org membership at role developer+, a published,
     * possession-verified signing key, not yet in the writer set) via a fresh
     * `add_writer_key{"writer"}` head per candidate. This session's OWN key
     * must already be an active writer — see {@link
     * import("../node/vault-publication.js").Vault.
     * reconcileWriterAdmissions}'s doc comment for why that is checked
     * locally, once, rather than surfaced as N identical gateway refusals.
     *
     * The explicit standalone entry point for the SAME reconcile task 5.7
     * also wires onto session-start/push/snapshot/deploy — calling it here
     * is idempotent-safe alongside whatever `open()` above already ran
     * internally (an already-admitted candidate is simply reported under
     * `already_covered`, never re-admitted), mirroring {@link
     * reconcileEnvelopeRecipients}'s own identical redundancy-tolerant shape.
     */
    async reconcile(options = {}) {
        const handle = await this.open(options);
        return handle.vault.reconcileWriterAdmissions();
    }
    /**
     * Drive one epoch rotation (D193-D203, rev 42) — the client half of
     * epoch rotation: sample a fresh `K_e`, compute the H-partition from live
     * desired-recipient state + the effective pin manifest, seal one
     * `key_envelope` per included recipient, submit the create-only
     * `rotation_attempt_descriptor`, submit the `rotate_epoch` head, verify
     * this principal's own envelope opens to the committed key (when it is
     * itself a recipient), and advance the local keystore's epoch pointer.
     * See {@link import("../node/vault-publication.js").Vault.
     * rotateEpoch}'s own doc comment for the full obligations and — load-
     * bearing — the confirmed gap in what the gateway exposes for
     * `recipient_state_version`/`recipient_revocation_version` outside the
     * `recipient_key_revoked` reason, and (also load-bearing) `options.
     * pending_confirmations` for folding a receipted `/confirm`/`/repin`
     * result into THIS rotation's head instead of a separately-gated
     * `publishPinManifestUpdate` call.
     */
    async rotateEpoch(options) {
        const handle = await this.open(options);
        return handle.vault.rotateEpoch(options);
    }
    /**
     * The ONE fully self-contained rotation entry point: declares
     * `reason:"recipient_key_revoked"` for `principalId` (owner + step-up)
     * and drives the rotation from that call's OWN returned counters — no
     * external counter source needed. The rekey remedy for the exact witness
     * task 5.0 records: a specific member's key is compromised/should no
     * longer be trusted.
     */
    /**
     * The writer-capable rotation that completes an org membership removal
     * (`reason:"member_removed"`, vault-multi-writer D6): counters come off
     * the envelope-recipients read, no declaration, no owner step-up — any
     * surviving writer can run it. `capture()` runs it automatically when the
     * gate names an outstanding removal; this is the explicit entry point
     * (`run402 orgs members rm` drives it on every vault the caller can).
     */
    async rotateEpochForMemberRemoval(options = {}) {
        const handle = await this.open(options);
        return handle.vault.rotateEpochForMemberRemoval(options);
    }
    async rotateEpochForKeyRevocation(principalId, options = {}) {
        const handle = await this.open(options);
        return handle.vault.rotateEpochForKeyRevocation(principalId, options);
    }
    /**
     * Publish a receipted `recipient_pin_manifest` update (D197) — the
     * publication half of the `/confirm`/`/repin` ceremonies above. Call
     * after {@link confirmRecipient}/{@link repinRecipient} returns a receipt;
     * this is `gitvault.writer`-sufficient (the owner-gated half already
     * happened at the ceremony route).
     *
     * **This publish is an ORDINARY admission** and is therefore itself
     * refused `EPOCH_ROTATION_REQUIRED` while this vault has a migration/
     * revocation/exposure condition outstanding (D193).
     * `#enrichEpochRotationRequiredForPinManifest`
     * below decorates that refusal with the remedy: fold the SAME receipt
     * into `rotateEpoch({..., pending_confirmations: [...]})` instead, which
     * durably publishes it on a `rotate_epoch` admission (the gate's own
     * escape valve) rather than a separately-gated ordinary one.
     */
    async publishPinManifestUpdate(input) {
        const handle = await this.open(input);
        return handle.vault
            .publishPinManifestUpdate({ principal_id: input.principal_id, ek_fingerprint: input.ek_fingerprint, confirmed_by: "operator_confirmation", receipt: input.receipt })
            .catch((e) => { throw this.#enrichEpochRotationRequiredForPinManifest(e, handle.repo_id); });
    }
    /**
     * `EPOCH_ROTATION_REQUIRED` from a `publishPinManifestUpdate` call names
     * the SAME three causes {@link #enrichEpochRotationRequired} decodes for
     * `capture()`, but the remedy is different: a manifest-only publish should
     * fold into the NEXT `rotateEpoch` call via `pending_confirmations`
     * rather than run a separate migration/revocation/exposure rotation
     * first and publish again afterward — one admission instead of two, and
     * the only path that works at all while EVERY predecessor-confirmed
     * principal is already exhausted (see `Vault.rotateEpoch`'s own
     * doc comment for the honest residual: this does not rescue a vault with
     * zero ever-confirmed principals, which needs an operator-side fix).
     */
    #enrichEpochRotationRequiredForPinManifest(e, repoId) {
        if (!isRun402Error(e) || e.code !== "EPOCH_ROTATION_REQUIRED")
            return e;
        return new LocalError(`this vault requires a rotate_epoch admission before an ordinary pin-manifest publish is admissible (repo_id ${repoId}) — fold this SAME receipt into rotateEpoch({..., pending_confirmations: [{principal_id, ek_fingerprint, receipt}]}) instead of retrying this call`, "publishing a recipient_pin_manifest update", { code: "EPOCH_ROTATION_REQUIRED", details: e.details, next_actions: [{ action: "r.repos.rotateEpoch({..., pending_confirmations: [{principal_id, ek_fingerprint, receipt}]})", why: "the manifest publish rides the SAME head as the required rotation, which is EPOCH_ROTATION_REQUIRED's own escape valve — a standalone publish never is" }], cause: e });
    }
    /**
     * `repos access` (repo-surface-consolidation D5/D10) — a READ-ONLY report
     * of who can open this vault, composed from whatever the live gateway
     * surface exposes today. Never wraps, never mutates a `key_envelope` —
     * that mutating half stays {@link reconcileEnvelopeRecipients}, reachable
     * only through the deploy/push best-effort hooks until `access repair`
     * ships (gated on `vault-human-envelopes`' epoch-rotation work). See
     * {@link VaultAccessResult}'s doc comment for the honest gap this
     * reports rather than invents.
     */
    async access(options = {}) {
        const repoId = await this.#resolveRepoId(options);
        const record = await this.get(repoId).catch(() => null);
        const orgId = record?.org_id ?? null;
        // vault-agent-envelopes D3/D5: `access` is one of the ordinary reads a
        // KEY-HOLDER's session starts with — enroll this keystore's key if absent
        // and fulfil every pending desired recipient once per process, BEFORE the
        // roster below is read, so the roster reflects the fulfilment. Reported,
        // never folded into the roster; a machine holding no K_repo stays a pure
        // read.
        let reconcileRecipients = null;
        let enrollment = null;
        if (options.reconcile !== "forbidden") {
            try {
                const { VaultKeystore } = await this.#keystore();
                const probe = new VaultKeystore(options.keystore_root !== undefined ? { rootDir: options.keystore_root } : {});
                if (probe.readRepo(repoId)) {
                    const handle = await this.open({ ...options, repo_id: repoId });
                    reconcileRecipients = handle.reconcile_recipients;
                    enrollment = handle.enrollment;
                }
            }
            catch {
                // best-effort — the roster read below is the verb's own result
            }
        }
        const [directory, coverage] = await Promise.all([
            orgId
                ? this.#client.request(`/orgs/v1/${encodeURIComponent(orgId)}/encryption-keys`, { context: "reading the org encryption-key directory" })
                : Promise.resolve({ org_id: "", keys: [] }),
            this.#client.request(`/vaults/v1/${encodeURIComponent(repoId)}/envelope-recipients`, { context: "reading the vault envelope recipients" }),
        ]);
        const covered = new Set(coverage.recipient_fingerprints);
        // `desired` is OPTIONAL on the wire (see VaultEnvelopeRecipientsResponse):
        // absent means an older gateway that predates this field — genuinely
        // unknown, not "no desired recipients." Distinguish "absent" from
        // "present and empty" via the array itself, not a boolean flag, so a
        // null/undefined check is the single source of truth for both this
        // method and any future caller of the same wire type.
        const desiredList = coverage.desired ?? null;
        const envelopeStateAvailable = desiredList !== null;
        const desiredByPrincipal = new Map(desiredList?.map((d) => [d.principal_id, d]) ?? []);
        // Node-only, best-effort: THIS machine's own local TOFU pins, and this
        // keystore's own encryption-key fingerprint (used below to keep the
        // vault creator's own machine out of `unmatched_covered_fingerprints` —
        // the org directory only lists human-enrolled keys, so a wallet-principal
        // creator's fingerprint is legitimately absent from it, not orphaned).
        // Never fails the whole read — a browser/worker caller, or a machine that
        // has never wrapped anyone, simply reports every `tofu_pin` as `null` and
        // `this_keystore` as `null`.
        let pins = {};
        let ownFingerprint = null;
        try {
            const { VaultKeystore } = await this.#keystore();
            const keystore = new VaultKeystore(options.keystore_root !== undefined ? { rootDir: options.keystore_root } : {});
            pins = keystore.readRepo(repoId)?.envelope_recipient_pins ?? {};
            ownFingerprint = keystore.readIdentity()?.encryption_fingerprint ?? null;
        }
        catch {
            // Not Node, or no local keystore for this repo — pins/ownFingerprint stay empty/null.
        }
        const recipients = directory.keys.map((k) => {
            const pinned = pins[k.principal_id];
            const desired = desiredByPrincipal.get(k.principal_id) ?? null;
            const envelopeState = desired == null ? null : desired.status === "pending_removal" ? "pending_removal" : covered.has(k.ek_fingerprint) ? "converged" : "pending";
            return {
                principal_id: k.principal_id,
                display_name: k.display_name,
                fingerprint: k.ek_fingerprint,
                covered: covered.has(k.ek_fingerprint),
                envelope_state: envelopeState,
                tofu_pin: pinned !== undefined ? { fingerprint: pinned, matches_directory: pinned === k.ek_fingerprint } : null,
            };
        });
        const directoryFingerprints = new Set(directory.keys.map((k) => k.ek_fingerprint));
        // Removed members (desired status pending_removal) whose fingerprint is
        // STILL covered: real, continuing access that membership removal did not
        // revoke, because vault v0 has no epoch-rotation mechanism yet. See
        // VaultAccessResult's doc comment.
        const staleAccess = (desiredList ?? [])
            .filter((d) => d.status === "pending_removal" && d.ek_fingerprint != null && covered.has(d.ek_fingerprint))
            .map((d) => ({ principal_id: d.principal_id, display_name: d.display_name, fingerprint: d.ek_fingerprint }));
        const staleAccessFingerprints = new Set(staleAccess.map((s) => s.fingerprint));
        // Genuinely unexplained: covering fingerprints that match neither a
        // current directory entry NOR a desired-state row (already broken out
        // above as stale_access) — orphaned, externally revoked, or a recipient
        // outside this org's membership model entirely. THIS keystore's own
        // fingerprint is a separate, locally-provable case: the org directory
        // only lists human-enrolled keys, so the vault creator's own
        // wallet-principal keystore legitimately never appears there — that is
        // not orphaned/external, it is provably this machine, so it is broken
        // out into `this_keystore` instead of left to read as a misconfiguration.
        const unmatchedRaw = coverage.recipient_fingerprints.filter((fp) => !directoryFingerprints.has(fp) && !staleAccessFingerprints.has(fp));
        // vault-agent-envelopes: THIS machine's keystore is reported for every
        // principal type — enrolled or not, covered or not — never `null` for a
        // real keystore (only when there is no local identity at all). The
        // directory now lists agent keys too, so "own fingerprint in the
        // directory" is the enrolled case; an unenrolled creator's genesis
        // envelope still shows as covered-but-unenrolled.
        let publishState = "unknown";
        if (ownFingerprint !== null) {
            try {
                const who = await this.#client.request("/agent/v1/whoami", { context: "reading this keystore's enrollment state" });
                const key = who.encryption_key ?? null;
                publishState = !key ? "absent" : key.ek_fingerprint !== ownFingerprint ? "rotation_required" : key.state === "active" ? "active" : "pending";
            }
            catch {
                publishState = "unknown";
            }
        }
        const thisKeystore = ownFingerprint !== null
            ? {
                fingerprint: ownFingerprint,
                enrolled: publishState === "active",
                publish_state: publishState,
                covered_on_this_vault: covered.has(ownFingerprint),
                covered: covered.has(ownFingerprint),
                ...(!covered.has(ownFingerprint) && publishState === "active"
                    ? { next_actions: [
                            { action: "run402 repos access", why: "poll — covered_on_this_vault flips true once a key-holder has wrapped this vault to your key" },
                            { action: "ask a key-holder to run any vault operation (run402 repos view / git push)", why: "a key-holding client wraps every pending desired recipient on its next operation" },
                        ] }
                    : {}),
                ...(publishState === "rotation_required"
                    ? { next_actions: [{ action: "restore the keystore backup, or have an org owner revoke the stale key (DELETE /orgs/v1/:org_id/members/:principal_id/encryption-key)", why: "this keystore's key differs from the principal's published key; rotation is never automatic" }] }
                    : {}),
            }
            : null;
        const unmatched = thisKeystore ? unmatchedRaw.filter((fp) => fp !== thisKeystore.fingerprint) : unmatchedRaw;
        const gap = envelopeStateAvailable
            ? "history_scope (which epochs each recipient can read) is not available: this read has no per-epoch view, only the vault's " +
                "CURRENT coverage — a recipient covered here may still be excluded from a PAST epoch's key (forward revocation is exactly what " +
                "an epoch rotation buys). envelope_state per recipient IS available today, from the " +
                `gateway's desired-recipient-state substrate (desired_state_version ${String(coverage.desired_state_version ?? "unknown")}): ` +
                "\"converged\" means desired and covered, \"pending\" means desired but not yet wrapped, \"pending_removal\" means membership " +
                "removed them but this vault has not yet completed a rotation away from them. pending_removal does NOT mean revoked — with " +
                "covered:true they still decrypt this vault until an owner runs `repos access repair` (or, for a targeted key, `repos access " +
                "revoke-key`); see stale_access for exactly who, and next_actions for the exact command."
            : "the gateway did not report desired-recipient state (desired[]) for this vault_id — likely an older gateway than this SDK " +
                "expects, so per-recipient envelope_state and stale_access are unavailable. history_scope is unavailable regardless: vault " +
                "protocol v0 pins a single fixed epoch, so there is no per-epoch scope to report. This reports what the read surface has today: " +
                "the org's directory of encryption-key-holding members, which of the vault's current envelope-recipient fingerprints match a " +
                "directory entry (covered), and this machine's own local TOFU pin per principal when it has ever wrapped one (tofu_pin) — never a server-side fact.";
        return {
            repo_id: repoId,
            org_id: orgId,
            reconcile_recipients: reconcileRecipients,
            enrollment,
            recipients,
            unmatched_covered_fingerprints: unmatched,
            this_keystore: thisKeystore,
            stale_access: staleAccess,
            envelope_state_available: envelopeStateAvailable,
            history_scope_available: false,
            gap,
            ...(staleAccess.length > 0
                ? {
                    next_actions: [
                        { action: "run402 repos access repair", why: "re-key this vault's current epoch away from every principal in stale_access at once (owner + step-up)" },
                        { action: "run402 repos access revoke-key <principal_id>", why: "target exactly one stale_access principal for revocation-triggered rotation (owner + step-up)" },
                    ],
                }
                : {}),
        };
    }
    /**
     * The vault-gated deploy: both lanes under one fresh `capture_id`, resolving
     * to exactly one of five outcomes — `DEPLOYED_AND_VAULTED`,
     * `DEPLOY_BLOCKED_PUSH_FAILED`, `DEPLOY_FAILED_VAULTED`,
     * `DEPLOY_FAILED_UNVAULTED`, `DEPLOYED_UNVAULTED_OVERRIDE`.
     *
     * The push is never gated on deploy success, and a build that fails before a
     * canonical apply plan exists still captures (with a null plan digest, so no
     * activation token can be minted from it).
     *
     * `run402 deploy` is design D5's PRIMARY envelope-recipient reconcile hook
     * ("the same 'one command every agent runs' argument that decided
     * deploy-implies-capture") and design D6's primary mirror-write trigger
     * — both fire HERE, not inside {@link runVaultDeploy} itself, mirroring
     * {@link capture}'s exact non-blocking contract: best-effort, NEVER throw,
     * NEVER alter the deploy outcome already resolved above, reported BESIDE it
     * on `mirror_push` / `reconcile_recipients`. They fire only when this
     * deploy actually landed a new generation in the vault
     * (`DEPLOYED_AND_VAULTED` / `DEPLOY_FAILED_VAULTED` — the outcomes carrying
     * a `generation`); the other three outcomes published nothing new, so
     * there is nothing to mirror or reconcile against ({@link
     * import("../node/vault-mirror.js").mirrorPushForGeneration}'s own
     * contract: "fires after an ordinary vault push/deploy produces a new
     * generation"). Both fields are therefore OMITTED — never a faked
     * `skipped_*` outcome — when no generation landed.
     */
    async deploy(options) {
        const { runVaultDeploy } = await this.#deploy();
        const handle = await this.open(options);
        const result = await runVaultDeploy({ ...options, vault: handle.vault, repo_dir: options.repo_dir ?? process.cwd() });
        // Same ordering capture() uses (mirror, then reconcile) — sequential, not
        // Promise.all, so a reconcile failure can never be misread as a mirror
        // failure in a log line that assumed ordering. Extracted to a standalone
        // function so the outcome-gating is unit-testable with fake thunks,
        // without standing up a live vault — see repos-deploy-hooks.test.ts.
        const hooked = await attachVaultDeployHooks(result, () => this.#tryMirrorPush(handle.repo_id, handle.keystore), () => this.#tryReconcileEnvelopeRecipients(handle.vault));
        // vault-byo-primary-bucket task 3.3 / vault-multi-writer task 5.7
        // — composed AFTER, not inside, `attachVaultDeployHooks` (kept
        // untouched so its own unit-tested outcome-gating contract stays
        // byte-for-byte): the SAME "did this deploy actually land a generation"
        // gate `mirror_push`'s presence already encodes, so no new
        // outcome-inspection logic here.
        if (hooked.mirror_push === undefined)
            return hooked;
        return { ...hooked, byo_chain_copy: await this.#tryByoChainCopyPush(handle.repo_id, handle.keystore), writer_reconcile: await this.#tryReconcileWriterAdmissions(handle.vault) };
    }
    /**
     * Drain every unvaulted-override journal on this machine: push the exact
     * journaled snapshot and present the capture receipt for full-field
     * comparison. A partial match never clears the advisory.
     */
    async drainOverrides(options = {}) {
        const { drainOverrideJournals } = await this.#deploy();
        const handle = await this.open(options);
        return drainOverrideJournals(handle.vault);
    }
    /**
     * Reproduce the vault's object database into a git repository — the clone-back
     * path. Needs only git and a surviving keystore: no deployment artifact, CAS
     * entry, or apply operation is consulted.
     */
    async restore(options) {
        const handle = await this.open(options);
        const out = await handle.vault.restoreObjectsInto(options.target_dir);
        return { refs: out.refs, generation: out.generation, retained_refs: out.retained_refs };
    }
    // ── degraded read (vault-byo-primary-bucket, design D4, task 3.4 — mirror half) ──
    /**
     * Wrap an ALREADY-RESOLVED vault read with degraded-read fallback: run
     * `options.attemptLive` (the caller's own live call — its
     * `vault.materialize()` for a `list`-shaped read, or
     * `vault.restoreObjectsInto(dir)` for a `fetch`-shaped one), and on a
     * network-class failure (never on an authorization refusal or any other
     * 4xx — see `isNetworkClassVaultReadError`), fall back to the vault's
     * configured mirror via the SAME `r402s-recover` engine `recover()` uses.
     *
     * Deliberately takes an already-open `vault`/`keystore`/`repo_id` rather
     * than resolving them itself: named-address resolution (slug-form vs.
     * id-form), stale-pin recovery, and cross-command vault-instance reuse
     * within one `git-remote-run402` session are CLI-layer concerns that
     * already live in `cli/lib/remote-helper-session.mjs` — this method owns
     * only the trigger discipline and the fallback engine, never vault
     * resolution, so it composes with that existing flow instead of
     * duplicating it.
     *
     * `out_dir` is REQUIRED for the fallback to ever run — pass the resolved,
     * git-proven repository directory (`resolveGitInvocationRepo`'s own
     * `repo_dir`, never a guess from `cwd`). `null` (no resolvable repository,
     * e.g. a bare `git ls-remote` outside any checkout) disables the fallback
     * entirely: the original gateway error surfaces exactly as it always did.
     */
    async withDegradedRead(options) {
        const { tryVaultDegradedRead } = await this.#degradedRead();
        return tryVaultDegradedRead(options);
    }
    /**
     * vault-byo-primary-bucket (design D4): the degraded read for the OPEN
     * itself. {@link withDegradedRead} wraps a handle's live read, but a plain
     * `git fetch`/`clone` needs the gateway once BEFORE any read — to open the
     * handle (enrollment, the vault record). When that open fails
     * network-class and this checkout already knows the vault — a pinned
     * `repo_id`, or the keystore's own project→repo file for an id-form
     * address (vault-offline-clone-resolve) — the whole read is served from
     * the vault's BYO destination or configured mirror, exactly as
     * `withDegradedRead` would have after a failed live read. `null` when this
     * cannot help (a non-network failure, no local identity for the vault, no
     * local copy source), so the caller rethrows its ORIGINAL error unchanged.
     * Writes are never rerouted.
     */
    async degradedOpenFallback(input) {
        const [{ isNetworkClassVaultReadError, tryVaultDegradedRead }, { VaultKeystore }, { readPinnedVaultRepo, resolveVaultAddress }, { createVaultHttpTransport }] = await Promise.all([this.#degradedRead(), this.#keystore(), this.#address(), this.#publication()]);
        if (!isNetworkClassVaultReadError(input.error))
            return null;
        const keystore = new VaultKeystore({});
        let repoId = input.repo_id ?? null;
        if (!repoId) {
            const pinned = await readPinnedVaultRepo(input.repo_dir).catch(() => null);
            repoId = pinned?.repo_id ?? null;
        }
        if (!repoId && input.address) {
            // Offline-first resolution: the git-config pin, then the keystore's own
            // project→repo file. A resolution that had to reach the gateway throws
            // here for the same reason the open did, and that is the answer: no
            // local identity, nothing to serve from.
            try {
                const resolution = await resolveVaultAddress({ keystore, transport: createVaultHttpTransport(this.#client), address: input.address, repo_dir: input.repo_dir, allow_create: false });
                if (resolution.offline)
                    repoId = resolution.repo_id;
            }
            catch {
                return null;
            }
        }
        if (!repoId)
            return null;
        try {
            return await tryVaultDegradedRead({ attemptLive: () => Promise.reject(input.error), keystore, repo_id: repoId, out_dir: input.repo_dir });
        }
        catch {
            return null;
        }
    }
    // ── mirror (vault-mirror-and-recover, design D1/D2/D7) ─────────────────
    /**
     * Configure (or replace) the customer-owned mirror destination for one
     * vault. Client-side ONLY — the destination + credential NAME are written
     * beside the keystore (design D2); run402 never sees or stores a raw
     * secret value. `s3://<bucket>/<prefix>` needs `credential`; a plain
     * filesystem path needs none.
     */
    async mirrorSet(options) {
        const [{ VaultKeystore }, { parseMirrorDestinationUrl, saveMirrorConfig }] = await Promise.all([this.#keystore(), this.#mirrorConfig()]);
        const repoId = await this.#resolveRepoId(options);
        const keystore = new VaultKeystore(options.keystore_root !== undefined ? { rootDir: options.keystore_root } : {});
        const destination = parseMirrorDestinationUrl(options.destination_url, { region: options.region, endpoint: options.endpoint });
        return saveMirrorConfig(keystore, { repo_id: repoId, destination, ...(options.credential ? { credential: options.credential } : {}) });
    }
    /** Remove the mirror config for one vault. Never touches the mirror's own bytes (design: config removal ≠ data deletion). */
    async mirrorRemove(options = {}) {
        const [{ VaultKeystore }, { removeMirrorConfig }] = await Promise.all([this.#keystore(), this.#mirrorConfig()]);
        const repoId = await this.#resolveRepoId(options);
        const keystore = new VaultKeystore(options.keystore_root !== undefined ? { rootDir: options.keystore_root } : {});
        return { repo_id: repoId, ...removeMirrorConfig(keystore, repoId) };
    }
    /**
     * What this machine and the mirror each believe: whether a mirror is
     * configured, the newest generation the MIRROR holds and can chain-verify
     * (keyless — never touches keys), the LIVE vault's newest generation (one
     * read of the vault record), and — when they disagree — the closing
     * command. Both honesty statements (design D8) ride every response.
     */
    async mirrorStatus(options = {}) {
        const [{ VaultKeystore }, { readMirrorConfig, formatMirrorDestination }, { openVaultMirrorBackend }, { verifyVaultMirror }] = await Promise.all([
            this.#keystore(), this.#mirrorConfig(), this.#mirrorBackend(), this.#recovery(),
        ]);
        const repoId = await this.#resolveRepoId(options);
        const keystore = new VaultKeystore(options.keystore_root !== undefined ? { rootDir: options.keystore_root } : {});
        const config = readMirrorConfig(keystore, repoId);
        const base = { repo_id: repoId, validity_not_freshness: VAULT_MIRROR_VALIDITY_NOT_FRESHNESS_STATEMENT, keystore_still_required: VAULT_MIRROR_KEYSTORE_STILL_REQUIRED_STATEMENT };
        const lastSuccessAt = config?.last_success_at ?? null;
        if (!config) {
            // vault-mirror-default: the unconfigured branch stays gateway-blind
            // by construction — it returns before ANY network call, finding included.
            // `options.is_byo` is a caller-supplied hint (vault-byo-primary-bucket
            // task 3.5) — NEVER derived here, which is what keeps this branch
            // network-call-free; `repos view` already has `storage_profile` from
            // its own `status()` read and passes it in.
            return {
                ...base, configured: false, destination: null, credential_kind: null, mirrored_generation: null, newest_generation: null, is_current: null, closing_command: null,
                last_success_at: lastSuccessAt,
                finding: vaultUnmirroredFinding({ configured: false, last_success_at: lastSuccessAt, mirrored_generation: null, is_byo: options.is_byo }),
            };
        }
        let mirroredGeneration = null;
        try {
            const backend = openVaultMirrorBackend(config.destination, repoId, config.credential);
            const report = await verifyVaultMirror(backend, { keystore });
            mirroredGeneration = report.recovered_generation;
        }
        catch {
            // A configured-but-unreachable/empty mirror is still `configured: true` — the honest report is "unknown", not a thrown status call.
        }
        let newestGeneration = null;
        try {
            newestGeneration = (await this.get(repoId)).newest_generation;
        }
        catch {
            /* the live vault read is best-effort here; the mirror half of status still answers */
        }
        const isCurrent = mirroredGeneration !== null && newestGeneration !== null ? mirroredGeneration === newestGeneration : null;
        return {
            ...base, configured: true, destination: formatMirrorDestination(config.destination), credential_kind: config.credential?.kind ?? null,
            mirrored_generation: mirroredGeneration, newest_generation: newestGeneration, is_current: isCurrent,
            closing_command: isCurrent === false ? "run402 repos mirror --backfill" : null,
            last_success_at: lastSuccessAt,
            finding: vaultUnmirroredFinding({ configured: true, last_success_at: lastSuccessAt, mirrored_generation: mirroredGeneration, is_byo: options.is_byo }),
        };
    }
    /** List the vault's stored objects, diff against the mirror, fetch + hash-verify + write what's missing, in admission order. Resumable and idempotent. */
    async mirrorSync(options = {}) {
        const [{ VaultKeystore }, { mirrorSync }] = await Promise.all([this.#keystore(), this.#mirror()]);
        const repoId = await this.#resolveRepoId(options);
        const keystore = new VaultKeystore(options.keystore_root !== undefined ? { rootDir: options.keystore_root } : {});
        return mirrorSync(this.#client, repoId, { keystore });
    }
    /** Keyless integrity probe against the CONFIGURED mirror: discovery + chain verification + closure/absence adjudication, never decryption (`run402 repos fsck --mirror`). */
    async mirrorVerify(options = {}) {
        const [{ VaultKeystore }, { readMirrorConfig }, { openVaultMirrorBackend }, { verifyVaultMirror }] = await Promise.all([
            this.#keystore(), this.#mirrorConfig(), this.#mirrorBackend(), this.#recovery(),
        ]);
        const repoId = await this.#resolveRepoId(options);
        const keystore = new VaultKeystore(options.keystore_root !== undefined ? { rootDir: options.keystore_root } : {});
        const config = readMirrorConfig(keystore, repoId);
        if (!config)
            throw new LocalError(`no mirror is configured for ${repoId}`, "verifying vault mirror", { code: "VAULT_MIRROR_NOT_CONFIGURED", details: { repo_id: repoId }, next_actions: [{ action: "run402 repos mirror <destination>" }] });
        const backend = openVaultMirrorBackend(config.destination, repoId, config.credential);
        return verifyVaultMirror(backend, { keystore });
    }
    /**
     * `r402s-recover` (design D4): rebuild a working git repository straight
     * from a mirrored `source/<repo_id>/` prefix — NO SERVER INVOLVED. `source`
     * is `s3://<bucket>[/<prefix>]` or a local directory; when it names more
     * than one mirrored vault, pass `repo_id` explicitly. Recovery proves
     * validity, never freshness (both honesty statements ride every result).
     *
     * vault-recovery-custody: a human member with no keystore recovers with
     * `member_bundle` (the exported `r402s-member-recovery-bundle/v1`, or the
     * mirror's own `member-recovery-bundles/` sidecar when omitted) +
     * `source_recovery_code`; a raw WebAuthn PRF output is NOT a supported
     * input. The recovery-receipt pin stays the trust anchor either way —
     * pass `recovery_receipt` when there is no keystore holding one.
     */
    async recover(options) {
        const [{ VaultKeystore }, { parseMirrorDestinationUrl }, { openVaultMirrorBackend, discoverMirroredRepoIds }, { recoverVaultMirror }] = await Promise.all([
            this.#keystore(), this.#mirrorConfig(), this.#mirrorBackend(), this.#recovery(),
        ]);
        const keystore = new VaultKeystore(options.keystore_root !== undefined ? { rootDir: options.keystore_root } : {});
        const destination = parseMirrorDestinationUrl(options.source, { region: options.region, endpoint: options.endpoint });
        let repoId = options.repo_id;
        if (!repoId) {
            const found = await discoverMirroredRepoIds(destination, options.credential);
            if (found.length === 0)
                throw new LocalError(`${options.source} holds no mirrored vault (no source/<repo_id>/ prefix found)`, "recovering vault", { code: "VAULT_MIRROR_EMPTY", details: { source: options.source } });
            if (found.length > 1)
                throw new LocalError(`${options.source} holds ${found.length} mirrored vaults; pass repo_id to pick one`, "recovering vault", { code: "VAULT_MIRROR_AMBIGUOUS", details: { source: options.source, repo_ids: found }, next_actions: found.map((id) => ({ action: `recover --repo ${id}` })) });
            repoId = found[0];
        }
        const backend = openVaultMirrorBackend(destination, repoId, options.credential);
        return recoverVaultMirror({
            backend, out_dir: options.out_dir, keystore,
            ...(options.recovery_receipt ? { recovery_receipt: options.recovery_receipt } : {}),
            ...(options.member_bundle ? { member_bundle: options.member_bundle } : {}),
            ...(options.source_recovery_code != null ? { source_recovery_code: options.source_recovery_code } : {}),
            ...(options.rp_id != null ? { rp_id: options.rp_id } : {}),
        });
    }
    /**
     * Re-apply the local immutable-object cache's eviction window (design D3,
     * task 4.2) for one vault — a periodic backstop wired into `run402 repos
     * gc`, catching any orphaned cache file a crashed write might have left
     * behind. Purely local: no network call beyond resolving `repo_id` when
     * only `project_id` was supplied. Idempotent and safe to call on a repo
     * with no cache directory at all (a no-op).
     */
    async sweepObjectCache(options) {
        const [{ VaultKeystore }] = await Promise.all([this.#keystore()]);
        const repoId = await this.#resolveRepoId(options);
        const keystore = new VaultKeystore(options.keystore_root !== undefined ? { rootDir: options.keystore_root } : {});
        keystore.sweepObjectCache(repoId);
    }
    // ── internals ─────────────────────────────────────────────────────────────
    async #resolveRepoId(options) {
        if (options.repo_id)
            return options.repo_id;
        if (options.project_id)
            return (await this.forProject(options.project_id)).repo_id;
        throw new LocalError("pass repo_id, or project_id to resolve it from the control plane", "opening the vault", { code: "VAULT_UNRESOLVED" });
    }
    #publication() {
        return nodeOnly(() => import("../node/vault-publication.js"), "open");
    }
    #deploy() {
        return nodeOnly(() => import("../node/vault-deploy.js"), "deploy");
    }
    #keystore() {
        return nodeOnly(() => import("../node/vault-keystore.js"), "open");
    }
    #creation() {
        return nodeOnly(() => import("../node/vault-creation-journal.js"), "init");
    }
    #snapshot() {
        return nodeOnly(() => import("../node/vault-snapshot.js"), "push");
    }
    #prune() {
        return nodeOnly(() => import("../node/vault-prune.js"), "prune");
    }
    #openOrCreate() {
        return nodeOnly(() => import("../node/vault-open-or-create.js"), "openOrCreate");
    }
    #address() {
        return nodeOnly(() => import("../node/vault-address.js"), "resolveOrCreateAddress");
    }
    #mirror() {
        return nodeOnly(() => import("../node/vault-mirror.js"), "mirrorSync");
    }
    #mirrorConfig() {
        return nodeOnly(() => import("../node/vault-mirror-config.js"), "mirrorSet");
    }
    #mirrorBackend() {
        return nodeOnly(() => import("../node/vault-mirror-backend.js"), "mirrorSet");
    }
    #recovery() {
        return nodeOnly(() => import("../node/vault-recover.js"), "recover");
    }
    #handoff() {
        return nodeOnly(() => import("../node/vault-handoff.js"), "handoff");
    }
    #writerState() {
        return nodeOnly(() => import("../node/vault-writer-state.js"), "handoff");
    }
    #restore() {
        return nodeOnly(() => import("../node/vault-restore.js"), "resume");
    }
    #degradedRead() {
        return nodeOnly(() => import("../node/vault-degraded-read.js"), "list");
    }
    #byoConfig() {
        return nodeOnly(() => import("../node/vault-byo-config.js"), "init");
    }
    /**
     * vault-byo-primary-bucket: the transport's `byoBackend` option — where
     * THIS machine reads a BYO vault's payload from (its `byo/<repo_id>.json`
     * config; destination + credential NAME). One backend per vault per
     * transport; `null` for a vault this machine has no config for, so the
     * transport refuses `VAULT_BYO_NOT_CONFIGURED` by name rather than
     * reporting a false absence. Managed vaults never reach it.
     */
    async #byoBackendResolver(keystore) {
        const [{ readByoConfig }, { openVaultDestinationBackend }] = await Promise.all([this.#byoConfig(), this.#mirrorBackend()]);
        const cache = new Map();
        return (repoId) => {
            if (!cache.has(repoId)) {
                // The RAW destination backend, keyed by the gateway's full
                // `source/<repo_id>/…` key — the same pairing the write path uses
                // (`openVaultDestinationBackend` + the session's `key`). The
                // repo-scoped backend the mirror engine opens would prefix
                // `source/<repo_id>/` a second time.
                const config = readByoConfig(keystore, repoId);
                cache.set(repoId, config ? openVaultDestinationBackend(config.destination, config.credential) : null);
            }
            return cache.get(repoId);
        };
    }
    #byoProbe() {
        return nodeOnly(() => import("../node/vault-byo-probe.js"), "init");
    }
}
// ─── D7 — progressive terminal-loss warning (repo-first-onramp task 2.7) ────
//
// The §0 terminal-loss statement (`VAULT_TERMINAL_LOSS_STATEMENT`) never
// changes — this governs only PROMINENCE and CADENCE. A quiet stderr line
// already runs at genesis (the one-shot receipt path in `push`/`openOrCreate`
// callers). This is the OTHER half: once the vault has accrued enough value
// at risk, `status`/`doctor` carry a STANDING warning instead of a one-time
// note nobody re-reads.
/**
 * The composite escalation trigger (design D7): ANY of these crossing its
 * threshold escalates the quiet genesis-time note into a standing warning.
 * Deliberately an OR — any single metric alone is gameable by triviality
 * (an agent that pads one dimension to stay under a lone threshold). Shipped
 * as defaults; tunable in this ONE place, not a protocol contract.
 */
export const VAULT_LOSS_WARNING_THRESHOLDS = {
    generations: 10,
    source_bytes: 10 * 1024 * 1024,
    days_since_genesis: 14,
};
/**
 * vault-clone-scaling (bench P3): generations-since-checkpoint at or
 * above this advises `run402 repos gc`. Same one-place-to-tune convention
 * as {@link VAULT_LOSS_WARNING_THRESHOLDS}; an advisory, never a gate.
 */
export const VAULT_CHECKPOINT_ADVISORY_GENERATIONS = 25;
/**
 * Pure staleness computation over two 16-hex generations. TOTAL: garbage
 * or unknown coverage (`null`) yields `{0, advised: false}` — advisory
 * call sites must never be able to break a verb, so unparseable input is
 * silence, not an error. Coverage `null` means "this checkout has not
 * locally learned the vault's checkpoint coverage" (see the keystore's
 * `checkpoint_covers_through` doc), which is deliberately indistinguishable
 * from fresh here.
 */
export function vaultCheckpointStaleness(input) {
    try {
        if (input.covers_through_generation === null)
            return { generations_since_checkpoint: 0, advised: false };
        if (!/^[0-9a-f]{16}$/.test(input.newest_generation) || !/^[0-9a-f]{16}$/.test(input.covers_through_generation)) {
            return { generations_since_checkpoint: 0, advised: false };
        }
        const newest = BigInt(`0x${input.newest_generation}`);
        const covered = BigInt(`0x${input.covers_through_generation}`);
        const since = newest > covered ? Number(newest - covered) : 0;
        return { generations_since_checkpoint: since, advised: since >= VAULT_CHECKPOINT_ADVISORY_GENERATIONS };
    }
    catch {
        return { generations_since_checkpoint: 0, advised: false };
    }
}
/**
 * D7: has this vault crossed the composite terminal-loss escalation trigger?
 * Pure — no I/O, no clock dependency beyond the optional `now` override
 * (tests). `admitted_generations` and `storage.source_bytes` are decimal
 * strings (protocol convention for values that could exceed safe-integer
 * precision in principle; comfortably within it here).
 *
 * There is deliberately no companion "is this resolved" function on the
 * TRIP computation itself: whether the generations/bytes/days thresholds
 * crossed is independent of how many principals cover the vault, and this
 * function stays a pure threshold check forever — a caller does not un-trip
 * it, ever, in V0. What DOES now exist, one call site up in `status()`, is a
 * way to downgrade the COPY once tripped: a locally-provable read of the
 * vault's covering-recipient count (the same envelope-recipients read
 * `Repos.access` uses) can show this is no longer the single-principal
 * case the message below describes. See
 * {@link vaultKeystoreBackupReminderMessage} for that downgraded form.
 */
export function vaultLossWarningTrip(record, now = new Date()) {
    const generations = Number(record.admitted_generations ?? "0");
    const sourceBytes = Number(record.storage?.source_bytes ?? "0");
    const genesisAt = record.genesis_admitted_at ? new Date(record.genesis_admitted_at) : null;
    const daysSinceGenesis = genesisAt && !Number.isNaN(genesisAt.getTime()) ? (now.getTime() - genesisAt.getTime()) / 86_400_000 : null;
    return {
        generations: Number.isFinite(generations) && generations >= VAULT_LOSS_WARNING_THRESHOLDS.generations,
        source_bytes: Number.isFinite(sourceBytes) && sourceBytes >= VAULT_LOSS_WARNING_THRESHOLDS.source_bytes,
        days_since_genesis: daysSinceGenesis !== null && daysSinceGenesis >= VAULT_LOSS_WARNING_THRESHOLDS.days_since_genesis,
    };
}
/** Any composite metric tripped — the standing warning threshold itself. */
export function vaultLossWarningTripped(trip) {
    return trip.generations || trip.source_bytes || trip.days_since_genesis;
}
/** Shared by both D7 message forms below — which threshold(s) tripped, in prose. */
function vaultLossWarningReasons(trip) {
    const reasons = [];
    if (trip.generations)
        reasons.push(`≥${VAULT_LOSS_WARNING_THRESHOLDS.generations} generations`);
    if (trip.source_bytes)
        reasons.push(`≥${Math.round(VAULT_LOSS_WARNING_THRESHOLDS.source_bytes / (1024 * 1024))} MB of source`);
    if (trip.days_since_genesis)
        reasons.push(`≥${VAULT_LOSS_WARNING_THRESHOLDS.days_since_genesis} days since genesis`);
    return reasons;
}
/**
 * The standing warning text (design D7): names what tripped, states the
 * resolution honestly (a second principal or human envelope — nothing this
 * client can verify yet), and never claims an attestation would clear it.
 *
 * This is the SINGLE-PRINCIPAL form — use it only when the vault's
 * covering-recipient count is unknown or <= 1. Once a caller has locally
 * proven >= 2 covering recipients, its premise ("only one principal can open
 * it") is false; use {@link vaultKeystoreBackupReminderMessage} instead.
 */
export function vaultLossWarningMessage(trip) {
    const reasons = vaultLossWarningReasons(trip);
    return (`this vault has accrued real value at risk (${reasons.join(", ")}) while only one principal can open it. ` +
        "Only a second principal — another keystore, or later a human envelope — demonstrably able to open the vault clears this warning; " +
        "this client cannot verify that yet, so it stands until you add one. No attestation or flag clears it.");
}
/**
 * D7's downgraded form (dogfood item 2): once `status()` has locally proven
 * this vault carries >= 2 covering recipients (via the same envelope-recipients
 * read `Repos.access` uses), the single-principal premise behind
 * {@link vaultLossWarningMessage} is false for THIS vault — printing it
 * anyway would be a false terminal-loss claim. The composite threshold still
 * fires the SAME way (this function does not touch `vaultLossWarningTrip`/
 * `vaultLossWarningTripped` at all — a real value-at-risk signal is still
 * worth a reminder), but the copy switches to the protocol's own
 * keystore-qualified durability sentence instead of asserting single-principal
 * risk, and never claims terminal loss.
 */
export function vaultKeystoreBackupReminderMessage(trip, coveringRecipients) {
    const reasons = vaultLossWarningReasons(trip);
    return (`this vault has accrued real value at risk (${reasons.join(", ")}), but it is covered by ${coveringRecipients} recipients today — not the single-principal case. ` +
        `${VAULT_DURABILITY_STATEMENT} Back up this machine's keystore anyway: losing it does not lose the vault, but it does lose YOUR access to it.`);
}
/**
 * vault-mirror-default — the pure `vault_unmirrored` computation, in ONE
 * place so doctor and `repos view` echo the same finding instead of each
 * deriving their own (the loss-warning pattern above). Present when no mirror
 * is configured, OR when one is configured but has no success evidence yet —
 * either the local `last_success_at` stamp (survives a transiently unreachable
 * mirror) or a chain-verified `mirrored_generation` read from the mirror
 * itself (covers mirrors synced before the stamp existed). Informational,
 * never blocking; every input is client-local or read from the CUSTOMER'S
 * mirror — nothing here touches the gateway.
 */
export function vaultUnmirroredFinding(state) {
    if (state.configured && (state.last_success_at !== null || state.mirrored_generation !== null))
        return null;
    return {
        kind: "vault_unmirrored",
        message: state.is_byo ? VAULT_BYO_UNMIRRORED_REMEDY_STATEMENT : VAULT_UNMIRRORED_FINDING_STATEMENT,
        setup_command: state.configured ? "run402 repos mirror --backfill" : "run402 repos mirror <destination>",
    };
}
/**
 * vault-byo-primary-bucket (design D4) — the ONE stderr line a degraded
 * chain/payload read prints: the fallback's own destination (never a
 * credential) plus the canonical, mechanism-only statement. `list`/`fetch`
 * degrading in the SAME `git-remote-run402` session each print their own
 * line (one per degraded READ, not one per session) — see
 * `Repos.withDegradedRead`'s own doc comment.
 */
export function vaultDegradedReadNote(source) {
    return `degraded read from ${source.destination}: ${VAULT_DEGRADED_READ_STATEMENT}`;
}
/** `run402::<org_id>/<project_id>` — what `git-remote-run402` resolves. */
export function vaultRemoteUrl(orgId, projectId) {
    return `run402::${orgId}/${projectId}`;
}
/**
 * `run402::<org-slug>/<repo-name>` — the address-form remote builder
 * (repo-first-onramp task 4, design D6). Same string shape as
 * {@link vaultRemoteUrl} (the wire slot admits both forms undiscriminated
 * — see {@link vaultRemoteAddressForm}); kept as its own named function so
 * a call site states which form it means rather than reusing the id-form
 * builder for a semantically different pair of arguments.
 */
export function vaultRemoteUrlForRepo(orgSlug, repoName) {
    return `run402::${orgSlug}/${repoName}`;
}
/**
 * Parse a `run402::<org>/<project>` remote URL into its two address halves.
 * `run402::` is the one remote scheme: `null` for anything that is not a
 * `<scheme>::` address at all, and a typed `REMOTE_SCHEME_UNSUPPORTED`
 * refusal naming the scheme for any other `<scheme>::` spelling.
 */
export function parseVaultRemoteUrl(url) {
    const trimmed = url.trim();
    const m = /^run402::([^/]+)\/(.+)$/.exec(trimmed);
    if (m)
        return { org_id: m[1], project_id: m[2] };
    const other = /^([A-Za-z][A-Za-z0-9+.-]*)::/.exec(trimmed);
    if (other && other[1] !== "run402") {
        throw new LocalError(`remote scheme '${other[1]}::' is not supported; the one vault remote scheme is run402::<org>/<name>`, "parsing a vault remote address", { code: "REMOTE_SCHEME_UNSUPPORTED", details: { scheme: other[1] } });
    }
    return null;
}
const VAULT_UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
const VAULT_PROJECT_ID_RE = /^prj_/;
/**
 * Discriminate a parsed address's form (design D6, "resolved"): "org UUIDs
 * and `prj_` prefixes make slug-vs-id in the same slot unambiguous." Both
 * halves must look id-shaped for the address to be treated as id-form — a
 * genuine `run402::<org_uuid>/<prj_id>` address always satisfies both at
 * once, since real org ids are UUIDs and real project ids are always
 * `prj_`-prefixed; anything else (an org slug half, or a bare repo-name half)
 * is slug-form. Resolution accepts either (task 4.3).
 */
export function vaultRemoteAddressForm(address) {
    return VAULT_UUID_RE.test(address.org_id) && VAULT_PROJECT_ID_RE.test(address.project_id) ? "id" : "slug";
}
/**
 * `SLUG_RELEASED` (a renamed/deleted org slug, still inside its ~90-day
 * cooldown) surfaces as a typed, actionable fact — NEVER auto-followed
 * (design D6: "no redirects, deliberately"). `null` when `err` is not a
 * `SLUG_RELEASED` refusal.
 */
export function vaultSlugReleasedInfo(err) {
    const e = err;
    if (!e || e.code !== "SLUG_RELEASED")
        return null;
    const source = (e.details ?? e.body ?? {});
    const str = (v) => (typeof v === "string" ? v : null);
    return { successor_slug: str(source.successor_slug), released_at: str(source.released_at), cooldown_until: str(source.cooldown_until) };
}
/** A 404/absent-route refusal for the unshipped `retention-cutoffs` endpoint. */
function isMissingCutoffRoute(e) {
    const err = e;
    return Boolean(err && (err.status === 404 || err.code === "RESOURCE_NOT_FOUND" || err.code === "ROUTE_NOT_FOUND"));
}
/**
 * "No vault exists for this address/project yet" — the same 404 shape as
 * {@link isMissingCutoffRoute}, named separately because the two mean
 * different things to their callers (an absent OPTIONAL route vs. an absent
 * RESOURCE). Used by {@link Repos.planCapture} to distinguish "nothing to
 * preview a push against" from a genuine failure worth rethrowing.
 */
function isVaultResolutionMiss(e) {
    const err = e;
    return Boolean(err && (err.status === 404 || err.code === "RESOURCE_NOT_FOUND" || err.code === "ROUTE_NOT_FOUND"));
}
//# sourceMappingURL=repos.js.map