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.
5,357 lines • 363 kB
JavaScript
/**
* vault — publication (protocol rev 41 §6 + §4.4–4.7 + §5A client side;
* task 5.4).
*
* What lives here:
* - the PURE evaluators: ref transactions (§6.1 — force-with-lease only,
* fast-forward for non-force branch updates, immutable tags, deletes need
* expected-old, pairwise-distinct refs refused BEFORE evaluation, the
* §6.5 cardinality bounds), retention-root evolution (§4.5 — map keyed
* `(ref, oid)`, renewal, expiry ONLY at checkpoints against the pre-signing
* cutoff ticket, strict `effective + 90d < cutoff_at`), the heads-listing
* page contract (§6.3 / D186 — anchor echo, cursor echo, has_more/
* next_cursor coupling, strictly-above-anchor, gapless across pages,
* truthful total), chain linkage (`CHAIN_BROKEN`), and the transition
* fail-closed rule (`UPGRADE_REQUIRED`);
* - {@link VaultTransport} — the control-plane + bucket operations the
* vault needs (extends task 5.3's creation transport) — and
* {@link createVaultHttpTransport}, the `fetch`-backed implementation
* over the SDK kernel (upload sessions → presigned PUT with
* `If-None-Match: *` → finalize);
* - {@link Vault} — discover/verify to newest with the RESUMABLE
* verification budget, materialize (decrypt ref_state + retention_roots →
* the materialized pin), push (pack_set or a checkpoint-bearing head when
* the delta exceeds the 64-receipt budget, upload with receipt-compare,
* admission with 409 re-apply-and-retry, head read-back before any pin
* advances), checkpoint build + acceptance self-check, and repair (the
* mandatory fresh checkpoint + the exact repair-root algebra).
*
* Dual pins (§6.4) live in the keystore repo file: `head_pin` is
* `highest_authenticated`, `materialized_pin` is `highest_materialized` — the
* ONLY push base. A regression below the authenticated pin is
* `GENERATION_REGRESSION`; an authenticated-but-undecryptable head is
* `CHAIN_UNUSABLE` (read-only at the materialized pin); an admitted non-null
* transition is `UPGRADE_REQUIRED` (read-only past it, no publish).
*/
import { mkdtempSync, readdirSync, readFileSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { LocalError, isRun402Error } from "../errors.js";
import { fetchVaultObjectBytes } from "./vault-edge-fetch.js";
import { openVaultDestinationBackend } from "./vault-mirror-backend.js";
import { VAULT_FORMAT, VAULT_GENESIS_EPOCH, VAULT_GENESIS_GENERATION, VAULT_HEX16_RE, VAULT_OID40_RE, VAULT_SUITE, attemptKeyCommitment, bytesToHex, checkFreshEpochKeyAgainstPriorKeys, checkHPartition, computeRotationId, computeTargetPartitionDigest, deriveDigestKey, deriveObjectKey, ekFingerprint, vkFingerprint, VAULT_TERMINAL_LOSS_DOCTOR_TEXT, VAULT_TERMINAL_LOSS_STATEMENT, epochRotationKeyCommitment, formatVaultTimestamp, fromBase64url, hexToBytes, jcs, keyEnvelopeLedgerId, keyedCommitment, newVaultId, newHex32, nextEpoch, objectsetContent, openBindingPreimage, openEpochRotationForRecipient, openFrame, parseAddWriterKeyPayload, parseVaultStrict, parseRotateEpochPayload, pinManifestLedgerId, randomBytes, sealFrame, sealKeyEnvelope, sha256Hex, signVaultObject, storedBytes, storedBytesSha256, toBase64url, verifyVaultObject, } from "../namespaces/vault.crypto.js";
import { VAULT_ZERO_SHA256_SENTINEL } from "../namespaces/vault.types.js";
import { vaultCheckpointStaleness } from "../namespaces/repos.js";
import { crossProfileVaultHint } from "./vault-profile-scan.js";
import { applyAddWriterKey, applyWriterSetUpdate, buildAddWriterKeyActivationPayload, buildWriterDoorAddWriterKeyPayload, initialWriterState, resolveActiveWriter, resolveKnownWriter, validateAddWriterKeyPayload, validateWriterSetUpdate, writerKeyIdOf, } from "./vault-writer-state.js";
import { VAULT_DEPLOY_REF, hardenedGit, hasObject, hasObjects, isAncestor } from "./vault-snapshot.js";
// ─── Constants (constants.json) ──────────────────────────────────────────────
export const VAULT_MAX_CANONICAL_REFS = 10_000;
export const VAULT_MAX_REF_UPDATES_PER_TRANSACTION = 1_000;
export const VAULT_MAX_RETENTION_ROOT_ENTRIES = 50_000;
export const VAULT_MAX_REPAIR_ADDED_ROOTS = 10_000;
export const VAULT_MAX_WAL_RECEIPTS_PER_HEAD = 64;
export const VAULT_MAX_CHECKPOINT_PACKS = 4_096;
export const VAULT_MAX_CHECKPOINT_TOTAL_STORED_BYTES = 858993459200n;
export const VAULT_MULTI_OBJECT_PACK_TARGET_BYTES = 201_326_592;
export const VAULT_MAX_REF_STATE_OBJECT_BYTES = 33_554_432;
export const VAULT_MAX_HEADS_PER_LISTING_PAGE = 1_000;
export const VAULT_VERIFICATION_BUDGET_HEADS = 100_000;
export const VAULT_RETENTION_MIN_DAYS = 90;
/** Default admission-conflict retries before the push gives up (each retry re-verifies + re-applies to the winner). */
export const VAULT_PUSH_CONFLICT_RETRIES = 5;
const CANONICAL_REF_RE = /^(?!.*\.\.)(?!.*\/\/)(?!.*@\{)refs\/(heads|tags|run402)\/[^\u0000-\u0020\u007f~^:?*[\\]+$/;
const BRANCH_REF_RE = /^(?!.*\.\.)(?!.*\/\/)(?!.*@\{)refs\/heads\/[^\u0000-\u0020\u007f~^:?*[\\]+$/;
const CURSOR_RE = /^[A-Za-z0-9_-]{1,256}$/;
const LIMIT_RE = /^([1-9]|[1-9][0-9]|[1-9][0-9][0-9]|1000)$/;
function fail(code, message, context, details, nextActions) {
throw new LocalError(message, context, { code, details, ...(nextActions ? { next_actions: nextActions } : {}) });
}
// ─── Reader-provenance strings (D209/D210) ───────────────────────────────────
/**
* Resolved once from this package's own `package.json` — mirrors
* `node/index.ts`'s `readSdkPackageVersion`, duplicated locally rather than
* imported to avoid a dependency edge from this file (imported by the
* cross-runtime `namespaces/repos.ts` via a dynamic `import()`, task
* 5.0's public crypto surface) back onto the Node CLI/SDK entry module.
*/
const VAULT_SDK_PACKAGE_VERSION = (() => {
try {
const pkg = JSON.parse(readFileSync(new URL("../../package.json", import.meta.url), "utf8"));
return typeof pkg.version === "string" ? pkg.version : "0.0.0";
}
catch {
return "0.0.0";
}
})();
/**
* `run402@<version>/<entrypoint>` — the audit-provenance string named on
* `self_open_attestation.reader_entrypoint` (D209) and
* `recipient_open_receipt.reader_entrypoint` (D210): "names the client
* implementation + entry point that produced the evidence." Never an
* authorization input; no wire grammar is promised for it (protocol-v0.md
* §4.14, `rotate_epoch_payload.json`'s own field `$comment`).
*/
export function vaultReaderEntrypoint(entrypoint) {
return `run402@${VAULT_SDK_PACKAGE_VERSION}/${entrypoint}`;
}
// ─── Generations ─────────────────────────────────────────────────────────────
export function generationToBigInt(generation) {
if (!VAULT_HEX16_RE.test(generation))
fail("CHAIN_BROKEN", `malformed generation: ${generation}`, "parsing generation");
return BigInt(`0x${generation}`);
}
export function bigIntToGeneration(value) {
if (value < 0n || value > 0xffffffffffffffffn)
fail("CHAIN_BROKEN", "generation out of range", "formatting generation");
return value.toString(16).padStart(16, "0");
}
export function nextGeneration(generation) {
return bigIntToGeneration(generationToBigInt(generation) + 1n);
}
/**
* Evaluate a §6.1 transaction against the materialized map. Refusals in
* order: pairwise-distinct refs (before evaluation), grammar, the update cap,
* then per-update semantics collected into ONE `REF_EXPECTED_OLD_MISMATCH`
* (every failing update listed), then the resulting-state cardinality.
*/
export async function evaluateRefTransaction(current, transaction, options) {
const updates = transaction.updates;
if (!Array.isArray(updates) || updates.length === 0)
fail("REF_TRANSACTION_INVALID", "a ref transaction needs at least one update", "evaluating ref transaction");
if (updates.length > VAULT_MAX_REF_UPDATES_PER_TRANSACTION) {
fail("REF_STATE_LIMIT_EXCEEDED", `${updates.length} updates exceed the ${VAULT_MAX_REF_UPDATES_PER_TRANSACTION}-update transaction bound`, "evaluating ref transaction", { updates: updates.length, bound: VAULT_MAX_REF_UPDATES_PER_TRANSACTION });
}
const seen = new Set();
for (const u of updates) {
if (seen.has(u.ref))
fail("REF_TRANSACTION_DUPLICATE_REF", `two updates name ${u.ref}; a transaction must name pairwise-distinct refs (refused before evaluation, never last-wins)`, "evaluating ref transaction", { ref: u.ref });
seen.add(u.ref);
}
for (const u of updates) {
if (!CANONICAL_REF_RE.test(u.ref))
fail("REFNAME_UNSUPPORTED", `${JSON.stringify(u.ref)} is not a canonical refs/heads|tags|run402 name`, "evaluating ref transaction", { ref: u.ref });
if (u.ref.startsWith("refs/run402/") && (options.protocol_refs ?? "refuse") === "refuse") {
fail("REFNAME_UNSUPPORTED", `${u.ref} is protocol-owned (refs/run402/*); user pushes to it are refused`, "evaluating ref transaction", { ref: u.ref });
}
if (u.expected_old_oid !== null && !VAULT_OID40_RE.test(u.expected_old_oid))
fail("REF_TRANSACTION_INVALID", `expected_old_oid for ${u.ref} is not a 40-hex oid`, "evaluating ref transaction", { ref: u.ref });
if (u.new_oid !== null && !VAULT_OID40_RE.test(u.new_oid))
fail("REF_TRANSACTION_INVALID", `new_oid for ${u.ref} is not a 40-hex oid`, "evaluating ref transaction", { ref: u.ref });
if (u.new_oid === null && u.expected_old_oid === null)
fail("REF_TRANSACTION_INVALID", `${u.ref}: a delete requires expected_old_oid`, "evaluating ref transaction", { ref: u.ref });
}
const failures = [];
const next = { ...current };
const dropped = [];
for (const u of updates) {
const cur = current[u.ref] ?? null;
if (cur !== u.expected_old_oid) {
failures.push({ ref: u.ref, reason: "expected_old_mismatch", expected_old_oid: u.expected_old_oid, current_oid: cur });
continue;
}
if (u.new_oid === null) {
// delete — expected-old already matched and is non-null by grammar
delete next[u.ref];
dropped.push({ ref: u.ref, oid: cur, reason: "deleted" });
continue;
}
if (cur === null) {
next[u.ref] = u.new_oid;
continue;
} // creation (the only null-expected-old case)
if (cur === u.new_oid)
continue; // no-op update — legal, nothing dropped
const isTag = u.ref.startsWith("refs/tags/");
if (isTag && !u.force) {
failures.push({ ref: u.ref, reason: "tag_immutable", expected_old_oid: u.expected_old_oid, current_oid: cur });
continue;
}
const ff = isTag ? false : await options.isAncestor(cur, u.new_oid);
if (!u.force && !ff) {
failures.push({ ref: u.ref, reason: "non_fast_forward", expected_old_oid: u.expected_old_oid, current_oid: cur });
continue;
}
next[u.ref] = u.new_oid;
if (!ff)
dropped.push({ ref: u.ref, oid: cur, reason: "force_displaced" });
}
if (failures.length > 0) {
const nextActions = [{ action: "refetch, reapply the transaction to the winner's map, retry" }];
// The force-with-lease inversion (vault-force-spelling-and-pin-fold):
// git's remote-helper protocol carries force only as a `+` refspec
// prefix, which `--force-with-lease` never sets — the lease cannot cross
// the helper boundary, so a rebased tip arrives as a NON-force update
// and lands here as `non_fast_forward`. Plain `--force` through this
// helper is with-lease-safe by construction (expected-old is mandatory
// and CASed against the freshly materialized base), so the refusal
// names the spelling that works.
if (failures.some((f) => f.reason === "non_fast_forward")) {
nextActions.push({
action: "git push --force",
why: "history was rewritten; this remote enforces expected-old server-side, so --force here carries force-with-lease safety — --force-with-lease itself cannot cross git's remote-helper boundary and arrives as a non-force push",
});
}
fail("REF_EXPECTED_OLD_MISMATCH", `${failures.length} update(s) refused: ${failures.map((f) => `${f.ref} (${f.reason})`).join(", ")}`, "evaluating ref transaction", { failures }, nextActions);
}
assertRefMapCardinality(next);
return { refs: next, dropped };
}
/** §6.5 bound: ≤ 10 000 canonical refs and the serialized map ≤ 32 MiB. */
export function assertRefMapCardinality(refs) {
const n = Object.keys(refs).length;
if (n > VAULT_MAX_CANONICAL_REFS)
fail("REF_STATE_LIMIT_EXCEEDED", `${n} canonical refs exceed the ${VAULT_MAX_CANONICAL_REFS} bound`, "checking ref-state cardinality", { refs: n });
if (jcs(refs).length > VAULT_MAX_REF_STATE_OBJECT_BYTES)
fail("REF_STATE_LIMIT_EXCEEDED", "the serialized ref map exceeds 32 MiB", "checking ref-state cardinality");
}
/** The §4.4 deploy-ref move: force-with-lease `refs/run402/deploys/latest` → `oid` (creation when absent). */
export function deployRefTransaction(current, oid) {
const old = current[VAULT_DEPLOY_REF] ?? null;
return { updates: [{ ref: VAULT_DEPLOY_REF, expected_old_oid: old, new_oid: oid, force: old !== null }] };
}
// ─── §4.5 Retention roots (pure) ─────────────────────────────────────────────
/** `effective_admitted_at = max(prepared_at, storage creation time of the winning admission record)` (§4.10). */
export function effectiveAdmittedAt(preparedAt, recordStorageCreatedAt) {
return Date.parse(preparedAt) >= Date.parse(recordStorageCreatedAt) ? preparedAt : recordStorageCreatedAt;
}
/** A root may be removed iff `effective_admitted_at + 90 days < cutoff_at` (STRICT; §4.5a). */
export function isRootEligibleForRemoval(effectiveAdmittedAtIso, cutoffAtIso, retentionDays = VAULT_RETENTION_MIN_DAYS) {
const expiry = Date.parse(effectiveAdmittedAtIso) + retentionDays * 24 * 60 * 60 * 1000;
return expiry < Date.parse(cutoffAtIso);
}
export function compareRoots(a, b) {
if (a.dropped_at_generation !== b.dropped_at_generation)
return a.dropped_at_generation < b.dropped_at_generation ? -1 : 1;
if (a.ref !== b.ref)
return a.ref < b.ref ? -1 : 1;
return a.oid === b.oid ? 0 : a.oid < b.oid ? -1 : 1;
}
/** roots(g+1) = roots(g) ∪ dropped (RENEWING an existing `(ref, oid)` key) ∖ {expired, only at a checkpoint with a ticket}. */
export function evolveRetentionRoots(previous, options) {
const map = new Map();
for (const r of previous)
map.set(`${r.ref}\0${r.oid}`, { ...r });
for (const d of options.dropped)
map.set(`${d.ref}\0${d.oid}`, { ref: d.ref, oid: d.oid, dropped_at_generation: options.generation });
let roots = [...map.values()];
if (options.checkpoint_cutoff) {
const { cutoff_at, effectiveAdmittedAt: resolve } = options.checkpoint_cutoff;
roots = roots.filter((r) => {
const eff = resolve(r.dropped_at_generation);
return eff === null ? true : !isRootEligibleForRemoval(eff, cutoff_at);
});
}
roots.sort(compareRoots);
if (roots.length > VAULT_MAX_RETENTION_ROOT_ENTRIES)
fail("REF_STATE_LIMIT_EXCEEDED", `${roots.length} retention roots exceed the ${VAULT_MAX_RETENTION_ROOT_ENTRIES} bound`, "evolving retention roots", { roots: roots.length });
return roots;
}
// ─── clone-installs-retained-refs (D1-D5): local refs/r402/retain/* bookkeeping ──
/**
* The client-local, protocol-owned namespace (design D4). Distinct from the
* VAULT-side `refs/run402/*` (e.g. `VAULT_DEPLOY_REF`) that rides the wire
* as part of `ref_state` — `refs/r402/*` never rides the wire at all; it is
* written directly into the local `.git` by the materializer and reconciled
* on every later fetch/fsck. A push naming any ref under it is refused by the
* remote helper before a transaction is ever built (see
* `git-remote-run402.mjs`'s `partitionProtectedRefPushes`) — this constant is
* the ONE place the namespace string is spelled, shared by both sides.
*/
export const VAULT_R402_REF_NAMESPACE = "refs/r402/";
/** Where a retained (branch-unreachable) deploy-capture tip gets its local ref (design D1/D2). */
export const VAULT_RETAIN_REF_PREFIX = `${VAULT_R402_REF_NAMESPACE}retain/`;
/**
* D1's ref-identity choice, recorded here because the design doc's own
* assumption did not hold: `VaultRetentionRoot` carries no per-capture
* stable id (only `{ref, oid, dropped_at_generation}`) — the capture id that
* DOES exist (`VaultHead.capture_binding.capture_id`) lives on the head
* that INTRODUCED a tip onto a canonical ref, not on the retention-root entry
* recording its later displacement, and correlating the two would require
* walking the chain further back than materialization already reads (D2
* forbids new reads here). The commit oid itself is already in hand, is
* content-addressed (so it is exactly as stable as a capture id — neither
* ever changes for the same history), and needs no correlation at all — the
* ref name IS the tip's own identity.
*/
export function vaultRetainedRefName(oid) {
return `${VAULT_RETAIN_REF_PREFIX}${oid}`;
}
/** `git for-each-ref --format='%(objectname) %(refname)' <prefix>` → `Map<refname, oid>`. Namespace-scoped by construction (the prefix argv). */
async function listRefsUnderPrefix(repoDir, prefix) {
const r = await hardenedGit(repoDir, ["for-each-ref", "--format=%(objectname) %(refname)", prefix]);
const out = new Map();
for (const raw of r.lines()) {
const line = raw.trim();
if (!line)
continue;
const sp = line.indexOf(" ");
if (sp === -1)
continue;
out.set(line.slice(sp + 1), line.slice(0, sp));
}
return out;
}
/**
* D2: install/remove local `refs/r402/retain/<oid>` refs so every retained
* (branch-unreachable) tip the vault's materialized retention roots name is
* locally referenced — the git-ecosystem `refs/pull/*` precedent, so a fresh
* `git fsck` is silent and `git for-each-ref refs/r402/` names what is
* retained and why (D6).
*
* Skips a root tip already reachable from a canonical ref (`state.refs`) or
* the HEAD target when detached — no redundant refs (D2). Reconciliation is
* namespace-scoped: only `refs/r402/retain/*` is ever read, written, or
* deleted; nothing else is touched, even when other bookkeeping under
* `refs/r402/*` exists.
*
* D3 — warn, never fail: driven from a SINGLE try/catch around the whole
* operation (list existing → compute the desired set → one atomic
* `update-ref --stdin` transaction for every create/update/delete). Any
* failure anywhere in that sequence returns a `warning` string and touches
* nothing further; it never throws, so a clone/fetch/fsck calling this can
* never fail on it. Called only when `repoDir` is an actual git repository —
* `repos fsck` addresses a vault by `repo_id`/`project_id` alone as often as
* by a local checkout, and "no local repo here" is a normal, silent no-op,
* never a warning.
*/
/**
* Name prefix of the scratch directory {@link Vault.buildPacks}
* allocates INSIDE the repository's git common dir. Dotted and clearly named
* so it can never be mistaken for `objects/`, `refs/`, or a pack directory.
*/
export const VAULT_PACK_SCRATCH_PREFIX = ".run402-vault-packs-";
/**
* Allocate the pack-objects scratch directory on the OBJECT STORE's own
* filesystem: `<git-common-dir>/.run402-vault-packs-XXXXXX`, never under
* `os.tmpdir()`.
*
* `git pack-objects <base>` writes its temporary pack under
* `<git-common-dir>/objects/pack/` and then `rename(2)`s it onto
* `<base>-<hash>.pack`. When `/tmp` is a different filesystem (tmpfs in a
* cloud container) that rename fails INSIDE git with EXDEV ("unable to rename
* temporary file … Invalid cross-device link") and no JS-side fallback can
* catch it — so the base path has to share the object store's filesystem.
* Git ignores unknown siblings of `objects/` and `refs/` (fsck, gc, and
* object enumeration never walk them), so a leftover from a crash is inert;
* the caller still removes it on every path.
*/
export async function allocatePackScratchDir(repoDir) {
const commonDir = (await hardenedGit(repoDir, ["rev-parse", "--path-format=absolute", "--git-common-dir"])).text().trim();
return mkdtempSync(join(commonDir, VAULT_PACK_SCRATCH_PREFIX));
}
export async function reconcileRetainedTipRefs(repoDir, state) {
const empty = (warning = null) => ({ written: [], deleted: [], retained_count: 0, warning });
let isRepo;
try {
const probe = await hardenedGit(repoDir, ["rev-parse", "--git-dir"], { okStatuses: [128] });
isRepo = probe.status === 0;
}
catch {
return empty(); // no repository here (or it vanished) — nothing to reconcile, not a failure
}
if (!isRepo)
return empty();
try {
// Reachability basis = refs git actually WRITES locally on clone/fetch
// (refs/heads/*, refs/tags/*, plus a detached HEAD). A vault-canonical
// protocol ref (refs/run402/*) exists only in the vault's ref map — git's
// clone refspec never materializes it as a local ref, so its tip would
// dangle locally exactly like a displaced retention root. Its tip
// therefore joins the candidate set instead of the reachability basis
// (live-acceptance catch: the current deploy-capture tip dangled).
const locallyWritten = [];
const protocolTips = [];
for (const [ref, oid] of Object.entries(state.refs)) {
if (ref.startsWith("refs/heads/") || ref.startsWith("refs/tags/"))
locallyWritten.push(oid);
else
protocolTips.push(oid);
}
const reachableTips = [...new Set(locallyWritten)];
if (state.head_target.kind === "detached")
reachableTips.push(state.head_target.oid);
const candidateOids = [...new Set([...state.roots.map((r) => r.oid), ...protocolTips])].sort();
const retainedOids = [];
for (const oid of candidateOids) {
if (!(await hasObject(repoDir, oid)))
continue; // not present locally — nothing to reference, not a failure
let reachable = false;
for (const tip of reachableTips) {
if ((await hasObject(repoDir, tip)) && (await isAncestor(repoDir, oid, tip))) {
reachable = true;
break;
}
}
if (!reachable)
retainedOids.push(oid);
}
const existing = await listRefsUnderPrefix(repoDir, VAULT_RETAIN_REF_PREFIX);
const desired = new Map(retainedOids.map((oid) => [vaultRetainedRefName(oid), oid]));
const toWrite = [];
for (const [ref, oid] of desired)
if (existing.get(ref) !== oid)
toWrite.push([ref, oid]);
const toDelete = [...existing.keys()].filter((ref) => !desired.has(ref));
if (toWrite.length === 0 && toDelete.length === 0) {
return { written: [], deleted: [], retained_count: retainedOids.length, warning: null };
}
// ONE `update-ref --stdin` transaction for every create/update/delete —
// git applies the whole batch atomically, so a mid-batch failure leaves
// the namespace exactly as it was before this call (D3's "degrades to
// exactly today's behavior", not a half-reconciled namespace).
const lines = [];
for (const [ref, oid] of toWrite)
lines.push(`update ${ref} ${oid}\n`);
for (const ref of toDelete)
lines.push(`delete ${ref}\n`);
await hardenedGit(repoDir, ["update-ref", "--stdin"], { input: lines.join("") });
return { written: toWrite.map(([ref]) => ref), deleted: toDelete, retained_count: retainedOids.length, warning: null };
}
catch (e) {
return empty(`refs/r402/retain bookkeeping failed: ${e instanceof Error ? e.message : String(e)}`);
}
}
/** Validate a listing request before it is sent (the request schema, D186). */
export function validateHeadsListingRequest(request) {
if (!VAULT_HEX16_RE.test(request.after_generation))
fail("VAULT_LISTING_REQUEST_INVALID", "after_generation is REQUIRED and must be 16 lowercase hex (the verification anchor)", "listing heads", { after_generation: request.after_generation });
if (!LIMIT_RE.test(request.limit))
fail("VAULT_LISTING_REQUEST_INVALID", "limit is REQUIRED and must be 1..1000", "listing heads", { limit: request.limit });
if (request.cursor !== undefined && !CURSOR_RE.test(request.cursor))
fail("INVALID_CURSOR", "cursor violates the opaque-token grammar; restart from after_generation without a cursor and echo each page's next_cursor unchanged", "listing heads", { cursor: request.cursor });
}
/**
* Validate one page against the request and the sequence so far. Returns the
* advanced progress. Refusals: anchor not echoed / wrong vault / coupling
* violation / retired member → `VAULT_LISTING_PAGE_INVALID`; an entry at or
* below the anchor → `GENERATION_REGRESSION`; a gap within or across pages →
* `CHAIN_BROKEN`; an untruthful final `total` → `CHAIN_BROKEN`.
*/
export function verifyHeadsListingPage(page, request, progress, expectedRepoId) {
const p = page;
if (p.format !== VAULT_FORMAT)
fail("VAULT_LISTING_PAGE_INVALID", "listing page has the wrong format", "listing heads");
if ("next_after_generation" in p)
fail("VAULT_LISTING_PAGE_INVALID", "listing page carries the retired next_after_generation member (D186 forbids it)", "listing heads");
if (expectedRepoId && p.repo_id !== expectedRepoId)
fail("VAULT_LISTING_PAGE_INVALID", "listing page is for a different vault", "listing heads", { repo_id: p.repo_id });
if (p.after_generation !== request.after_generation || p.after_generation !== progress.after_generation)
fail("VAULT_LISTING_PAGE_INVALID", "the page did not echo the request's after_generation anchor", "listing heads", { echoed: p.after_generation, anchor: request.after_generation });
if (typeof p.has_more !== "boolean")
fail("VAULT_LISTING_PAGE_INVALID", "has_more must be a boolean", "listing heads");
if (p.has_more === false && p.next_cursor !== null)
fail("VAULT_LISTING_PAGE_INVALID", "has_more=false with a non-null next_cursor is contradictory", "listing heads");
if (p.has_more === true && (typeof p.next_cursor !== "string" || !CURSOR_RE.test(p.next_cursor)))
fail("VAULT_LISTING_PAGE_INVALID", "has_more=true requires a non-null opaque next_cursor", "listing heads");
if (!Array.isArray(p.heads) || p.heads.length > VAULT_MAX_HEADS_PER_LISTING_PAGE || p.heads.length > Number(request.limit))
fail("VAULT_LISTING_PAGE_INVALID", "heads[] exceeds the page limit", "listing heads");
if (p.total !== null && !/^(0|[1-9][0-9]{0,14})$/.test(p.total))
fail("VAULT_LISTING_PAGE_INVALID", "total must be an exact decimal string or null", "listing heads");
let last = generationToBigInt(progress.last_generation);
const anchor = generationToBigInt(progress.after_generation);
let delivered = progress.delivered;
for (const entry of p.heads) {
const g = generationToBigInt(entry.generation);
if (!/^[0-9a-f]{64}$/.test(entry.stored_bytes_sha256))
fail("CHAIN_BROKEN", `malformed stored_bytes_sha256 at generation ${entry.generation}`, "listing heads");
if (g <= anchor)
fail("GENERATION_REGRESSION", `the listing delivered generation ${entry.generation}, at or below the verification anchor ${progress.after_generation}`, "listing heads", { generation: entry.generation, anchor: progress.after_generation });
if (g !== last + 1n)
fail("CHAIN_BROKEN", `generation gap: expected ${bigIntToGeneration(last + 1n)}, got ${entry.generation}`, "listing heads", { expected: bigIntToGeneration(last + 1n), got: entry.generation });
last = g;
delivered += 1;
}
if (p.has_more === false && p.total !== null && BigInt(p.total) !== BigInt(delivered)) {
fail("CHAIN_BROKEN", `the final page claims total ${p.total} but ${delivered} generations were delivered above the anchor; total is exact or null, never a nearby number`, "listing heads", { total: p.total, delivered });
}
return { after_generation: progress.after_generation, last_generation: bigIntToGeneration(last), delivered };
}
/**
* The continuation request for `page`, or `null` when the sequence is complete.
* The anchor stays CONSTANT and `next_cursor` is echoed UNCHANGED — the cursor
* is stored and echoed, never parsed or edited (a client that re-anchors or
* edits a byte earns `INVALID_CURSOR` from the platform, D186).
*/
export function nextListingRequest(request, page) {
if (!page.has_more)
return null;
if (page.next_cursor === null)
fail("VAULT_LISTING_PAGE_INVALID", "has_more=true requires a non-null opaque next_cursor", "listing heads");
return { after_generation: request.after_generation, cursor: page.next_cursor, limit: request.limit };
}
/**
* §6.4: the vault's newest generation may never fall BELOW the authenticated
* pin. A listing (or a storage read) that says otherwise is a rollback, not a
* quiet vault — `GENERATION_REGRESSION`, no publish.
*/
export function checkGenerationRegression(listedNewestGeneration, pinnedGeneration) {
if (generationToBigInt(listedNewestGeneration) < generationToBigInt(pinnedGeneration)) {
fail("GENERATION_REGRESSION", `the vault's newest generation ${listedNewestGeneration} is below the authenticated pin ${pinnedGeneration}; refusing to publish over a rollback`, "verifying vault chain", { listed_newest_generation: listedNewestGeneration, pinned_generation: pinnedGeneration }, [{ action: "do not publish; escalate — the vault regressed below a generation this client authenticated" }]);
}
}
/** `SHA-256("r402s/v0/open-binding" ‖ lp(client_open_id) ‖ lp(base_head) ‖ lp_opt(prior) ‖ lp(cap))`. */
export function openBindingDigest(clientOpenId, record) {
return sha256Hex(openBindingPreimage(clientOpenId, record.base_head_sha256, record.prior_checkpoint_claim_set_sha256, record.r2_cap_size_bytes));
}
/**
* Recompute the binding from the record's OWN fields and compare it bytewise
* with the signed issuance digest. The error registry has no dedicated code for
* the fence inequality (D145), so the client surfaces
* `VAULT_OPEN_BINDING_MISMATCH`; a same-`client_open_id` retry that carries a
* DIFFERENT binding is the registry's `CLIENT_OPEN_ID_CONFLICT`.
*/
export function checkOpenBinding(clientOpenId, record, issuanceOpenBindingSha256, options = {}) {
const recomputed = openBindingDigest(clientOpenId, record);
if (recomputed === issuanceOpenBindingSha256)
return;
if (options.retry) {
fail("CLIENT_OPEN_ID_CONFLICT", `client_open_id ${clientOpenId} was already opened with a different request binding`, "checking maintenance open binding", { recomputed, issued: issuanceOpenBindingSha256 });
}
fail("VAULT_OPEN_BINDING_MISMATCH", `the C1 record does not recompute to the signed issuance binding for ${clientOpenId}`, "checking maintenance open binding", { recomputed, issued: issuanceOpenBindingSha256 });
}
/** Verify one link: bytes hash to the listing, strict parse, generation, prev linkage, epoch pin, repo, writer signature. */
export function checkChainLink(input) {
const h = input.head;
const hash = sha256Hex(input.stored_bytes);
if (hash !== input.listed_sha256)
fail("CHAIN_BROKEN", `head ${input.expected_generation}: stored bytes hash ${hash} ≠ the listing's ${input.listed_sha256}`, "verifying head chain", { generation: input.expected_generation });
if (h.format !== VAULT_FORMAT || h.object_kind !== "head" || h.suite !== VAULT_SUITE)
fail("CHAIN_BROKEN", `head ${input.expected_generation}: not an r402s/v0 head`, "verifying head chain");
if (h.repo_id !== input.repo_id)
fail("CHAIN_BROKEN", `head ${input.expected_generation}: repo_id ${h.repo_id} ≠ ${input.repo_id}`, "verifying head chain");
if (h.generation !== input.expected_generation)
fail("CHAIN_BROKEN", `head generation ${h.generation} ≠ expected ${input.expected_generation} (generation must equal newest+1)`, "verifying head chain", { got: h.generation, expected: input.expected_generation });
if (h.prev_sha256 !== input.prev_sha256)
fail("CHAIN_BROKEN", `head ${h.generation}: prev_sha256 does not name the predecessor's stored bytes`, "verifying head chain", { prev_sha256: h.prev_sha256, expected: input.prev_sha256 });
// D194, rev 42 (relaxed by D193): epoch continuity — a head's `epoch`
// equals its predecessor's UNLESS THIS head admits a `rotate_epoch`
// transition, in which case it equals `nextEpoch(predecessor.epoch)`
// exactly (increment-by-one, no skip). Pure and keyless: this checks only
// the head's own signed `epoch` FIELD against the predecessor's, never
// opens any envelope — decrypting under the resulting epoch is a separate,
// keyed step (`Vault`'s rotation-envelope open, `openEpochRotationForRecipient`).
const isRotation = h.transition !== null && h.transition.kind === "rotate_epoch";
const permittedEpoch = isRotation ? nextEpoch(input.prev_epoch) : input.prev_epoch;
if (h.epoch !== permittedEpoch) {
fail("CHAIN_BROKEN", `head ${h.generation}: epoch ${h.epoch} does not match the expected ${permittedEpoch} (${isRotation ? "post-rotation increment of the predecessor's epoch" : "predecessor continuity — only an admitted rotate_epoch transition may advance the epoch"})`, "verifying head chain", { generation: h.generation, got: h.epoch, expected: permittedEpoch, prev_epoch: input.prev_epoch, is_rotation: isRotation });
}
if (h.writer_key_id !== input.writer_key_id)
fail("CHAIN_BROKEN", `head ${h.generation}: writer_key_id ${h.writer_key_id} is not the registered writer`, "verifying head chain");
if (!verifyVaultObject(h, input.writer_public_key))
fail("CHAIN_BROKEN", `head ${h.generation}: signature does not verify under the registered writer key`, "verifying head chain");
if ((h.checkpoint === null) !== (h.checkpoint_purpose === null))
fail("CHAIN_BROKEN", `head ${h.generation}: checkpoint/checkpoint_purpose must be null together`, "verifying head chain");
if (h.checkpoint !== null && h.wal_entries.length !== 0)
fail("CHAIN_BROKEN", `head ${h.generation}: a checkpoint-bearing head must carry an empty WAL set`, "verifying head chain");
if ((h.repair !== null) !== (h.checkpoint_purpose === "repair"))
fail("CHAIN_BROKEN", `head ${h.generation}: repair ⇔ checkpoint_purpose "repair"`, "verifying head chain");
if (h.wal_entries.length > VAULT_MAX_WAL_RECEIPTS_PER_HEAD)
fail("CHAIN_BROKEN", `head ${h.generation}: ${h.wal_entries.length} WAL receipts exceed the 64 budget`, "verifying head chain");
}
/**
* The transition fail-closed rule: a V0 client that encounters an ADMITTED
* transition kind it cannot validate stops advancing — read-only at the
* materialized pin, no publish past it, `UPGRADE_REQUIRED`. Unknown kinds
* are a parse reject.
*
* `rotate_epoch` is EXEMPT from this rule as of rev 42 (D193: "epoch
* rotation is ACTIVATED") — `checkChainLink`'s own D194 epoch-continuity
* check already validates its structural admissibility, and
* {@link parseRotateEpochPayload} / the caller's own envelope-open step
* (`Vault.verifyToNewest`) handle it fully. `add_writer_key` is
* EXEMPT as of rev 47 (vault-multi-writer) the same way — {@link
* parseAddWriterKeyPayload} + `validateAddWriterKeyPayload`
* (`vault-writer-state.js`) handle it fully, INSIDE `verifyToNewest`'s
* own loop, BEFORE this function ever runs on that head (so a transition
* that fails writer validation never reaches here at all — this function
* only ever sees ones that already passed). The other two kinds
* (`add_envelope`, `transfer_binding`) remain genuinely unactivated and stay
* fail-closed exactly as before.
*/
export function assertNoTransition(head) {
if (head.transition === null)
return;
if (head.transition.kind === "rotate_epoch" || head.transition.kind === "add_writer_key")
return;
const kinds = ["add_envelope", "rotate_epoch", "add_writer_key", "transfer_binding"];
if (!kinds.includes(head.transition.kind))
fail("CHAIN_BROKEN", `head ${head.generation}: unknown transition kind ${String(head.transition.kind)} (closed enum)`, "verifying head chain");
fail("UPGRADE_REQUIRED", `head ${head.generation} carries an admitted "${head.transition.kind}" transition this client cannot validate; staying read-only at the materialized pin and refusing to publish past it`, "verifying head chain", { generation: head.generation, kind: head.transition.kind }, [{ action: "upgrade the client; the vault stays readable at the materialized pin" }]);
}
const HEX16 = "[0-9a-f]{16}";
const SHA256 = "[0-9a-f]{64}";
/** Ordered longest-suffix-first so `.ticket.json` is never eaten by `.enc`, and the rotation-scoped envelope pattern before the plain one so it never matches short. */
const PATH_PATTERNS = [
[new RegExp(`^head/(${HEX16})$`), (m) => ({ kind: "head", generation: m[1] })],
[new RegExp(`^admissions/(${HEX16})$`), (m) => ({ kind: "admission", generation: m[1] })],
[new RegExp(`^envelopes/(${HEX16})/(${SHA256})/(ek_[0-9a-f]{32})$`), (m) => ({ kind: "object", read: { object_kind: "key_envelope", epoch: m[1], rotation_id: m[2], recipient_fingerprint: m[3] } })],
[/^envelopes\/([0-9a-f]{16})\/(ek_[0-9a-f]{32})$/, (m) => ({ kind: "object", read: { object_kind: "key_envelope", epoch: m[1], recipient_fingerprint: m[2] } })],
[new RegExp(`^recipient-pins/(${HEX16})\\.json$`), (m) => ({ kind: "object", read: { object_kind: "recipient_pin_manifest", pin_manifest_version: m[1] } })],
[/^wal\/(wal_[0-9a-f]{32})\.pack\.enc$/, (m) => ({ kind: "object", read: { object_kind: "wal_pack", object_id: m[1] } })],
[/^refs\/(refs_[0-9a-f]{32})\.enc$/, (m) => ({ kind: "object", read: { object_kind: "ref_state", object_id: m[1] } })],
[/^retention\/(rr_[0-9a-f]{32})\.enc$/, (m) => ({ kind: "object", read: { object_kind: "retention_roots", object_id: m[1] } })],
[/^checkpoints\/(chk_[0-9a-f]{32})\.manifest\.enc$/, (m) => ({ kind: "object", read: { object_kind: "checkpoint_manifest", object_id: m[1] } })],
[/^checkpoints\/(ckp_[0-9a-f]{32})\.pack\.enc$/, (m) => ({ kind: "object", read: { object_kind: "checkpoint_pack", object_id: m[1] } })],
[/^checkpoints\/(ccs_[0-9a-f]{32})\.claims\.json$/, (m) => ({ kind: "object", read: { object_kind: "checkpoint_claim_set", object_id: m[1] } })],
[/^maintenance\/(msc_[0-9a-f]{32})\.stage\.json$/, (m) => ({ kind: "object", read: { object_kind: "maintenance_stage_claim_set", object_id: m[1] } })],
[/^maintenance\/(msp_[0-9a-f]{32})\.page\.json$/, (m) => ({ kind: "object", read: { object_kind: "maintenance_stage_page", object_id: m[1] } })],
[/^verifier-receipts\/(vr_[0-9a-f]{32})\.json$/, (m) => ({ kind: "object", read: { object_kind: "verifier_receipt", object_id: m[1] } })],
];
/** `null` for a path with no wire identity (e.g. a locally-held cutoff ticket). */
export function vaultWireRefForPath(path) {
for (const [re, build] of PATH_PATTERNS) {
const m = re.exec(path);
if (m)
return build(m);
}
return null;
}
/** The manifest entry for one upload — closed-key, exactly what the control plane validates. */
export function vaultManifestEntry(object) {
const ref = vaultWireRefForPath(object.path);
if (!ref || ref.kind !== "object") {
fail("VAULT_UPLOAD_SESSION_INVALID", `${object.path} is not an uploadable object path; the control plane addresses uploads by object_kind + ledger identity`, "building the vault upload manifest", { path: object.path });
}
const entry = { ...ref.read, sha256: object.sha256, size_bytes: object.size_bytes };
if (entry.object_kind === "wal_pack") {
if (object.base_generation === undefined)
fail("VAULT_UPLOAD_SESSION_INVALID", "a wal_pack upload must declare base_generation (§4.1)", "building the vault upload manifest", { path: object.path });
entry.base_generation = object.base_generation;
}
return entry;
}
// ─── Inline upload (vault-composite-state-read design D2) ─────────────────
/** Mirrors the gateway's `VAULT_INLINE_UPLOAD_MAX_OBJECT_BYTES` (`services/vault/upload-sessions.ts`). */
export const VAULT_INLINE_UPLOAD_MAX_OBJECT_BYTES = 262_144;
/** Mirrors the gateway's `VAULT_INLINE_UPLOAD_MAX_REQUEST_BYTES`. */
export const VAULT_INLINE_UPLOAD_MAX_REQUEST_BYTES = 1_048_576;
/**
* The client-side mirror of the gateway's `isInlineUploadRequest` + per-object/
* per-request cap check: every object must fit under the PER-OBJECT cap AND
* the batch's total under the PER-REQUEST cap, or the whole batch takes the
* presigned session+PUT+finalize shape — no per-object mixing, matching the
* server's `VALIDATION_FAILED` refusal on a mixed request. An empty batch is
* never "inline" (nothing to send either way; `upload()`'s own early return
* already short-circuits before this is consulted, and `putObject` always
* wraps exactly one object so it never hits this branch).
*
* Takes the narrowest shape that satisfies every call site (`VaultUploadObject[]`
* for `uploadObjects`, a single `{bytes}`-shaped array for `putObject`) so
* neither caller needs to fabricate unrelated fields just to ask the
* question.
*/
export function vaultInlineUploadEligible(objects) {
if (objects.length === 0)
return false;
let total = 0;
for (const o of objects) {
if (o.bytes.length > VAULT_INLINE_UPLOAD_MAX_OBJECT_BYTES)
return false;
total += o.bytes.length;
}
return total <= VAULT_INLINE_UPLOAD_MAX_REQUEST_BYTES;
}
/**
* The stable key both sides agree on, used to pair receipts back to
* requests — MIRRORS the gateway's `keyEnvelopeLedgerId`/`pinManifestLedgerId`
* (services/vault/epoch-rotation.ts) exactly; drift here breaks receipt
* pairing at upload finalize for a rotation-attempt envelope or a pin
* manifest.
*/
export function vaultLedgerId(read) {
if (read.object_kind === "key_envelope")
return keyEnvelopeLedgerId(read.epoch, read.recipient_fingerprint, read.rotation_id ?? null);
if (read.object_kind === "recipient_pin_manifest")
return pinManifestLedgerId(read.pin_manifest_version);
return String(read.object_id);
}
/**
* `scheme://host` for a URL string, or `null` for anything that fails to
* parse (never thrown) — the sole normalizer between a full presigned/edge
* URL (path, query, signature, everything) and the bare origin
* {@link VaultHttpTransportOptions.onObjectStoreOriginObserved} hands a
* caller. Deliberately NOT exported: this module is the only place that
* observes raw object-store URLs, so the normalizer has exactly one call
* site and needs no wider audience.
*/
function vaultObjectStoreOrigin(url) {
try {
return new URL(url).origin;
}
catch {
return null;
}
}
/** Dedup, in order, dropping anything that failed to parse. */
function vaultObjectStoreOrigins(urls) {
const out = [];
for (const u of urls) {
if (!u)
continue;
const origin = vaultObjectStoreOrigin(u);
if (origin && !out.includes(origin))
out.push(origin);
}
return out;
}
function b64(bytes) { return Buffer.from(bytes).toString("base64"); }
function b64u(bytes) { return Buffer.from(bytes).toString("base64url"); }
/**
* Independent reads/PUTs within one vault step run at this concurrency
* (design D2) — "browser-era origin etiquette", well within what S3/the
* gateway tolerate, and it keeps in-flight memory bounded by ~6×frame size.
* Chain-ordered steps (the head-chain walk, admission, readback) never call
* this — they stay strictly sequential.
*/
export const VAULT_TRANSPORT_CONCURRENCY = 6;
/**
* Run `fn` over `items` with at most `limit` in flight at once, preserving
* result order regardless of completion order. A plain worker-pool: `limit`
* workers each pull the next unclaimed index until the queue is empty.
*/
export async function mapBounded(items, limit, fn) {
const results = new Array(items.length);
let next = 0;
async function worker() {
for (;;) {
const i = next++;
if (i >= items.length)
return;
results[i] = await fn(items[i], i);
}
}
await Promise.all(Array.from({ length: Math.max(0, Math.min(limit, items.length)) }, () => worker()));
return results;
}
/**
* The `fetch`-backed transport over the SDK kernel. Presigned PUTs carry
* `If-None-Match: *` (create-only — the bucket policy demands it) and the
* FULL_OBJECT SHA-256 checksum header.
*/
export function createVaultHttpTransport(client, options = {}) {
const vid = (repoId) => encodeURIComponent((options.vaultIdFor ?? ((r) => r))(repoId));
const base = (repoId) => `/vaults/v1/${vid(repoId)}`;
/**
* Read raw bytes from a generation-addressed route (heads, admission records).
*
* Deliberately NOT `client.request` — that parses JSON, and re-serializing a
* parsed head would verify the SDK's own canonicalizer instead of the bytes
* the host actually stored. §0's client obligation is to hash what was
* served, so this fetches the response body untouched (authenticated by the
* same credential provider the kernel uses).
*/
async function getGenerationBytes(repoId, route, generation) {
const path = `${base(repoId)}/${route}/${encodeURIComponent(generation)}`;
const auth = (await client.credentials.getAuth(path, { method: `vault.read_${route}` })) ?? {};
const r = await client.fetch(`${client.apiBase}${path}`, { method: "GET", headers: { ...auth, accept: "application/json" } });
if (r.status === 404)
return null;
if (!r.ok) {
// Surface the registry code the control plane actually sent (e.g.
// `VAULT_ACCESS_DENIED`) rather than flattening every refusal into a
// generic read failure — the codes carry the caller's next action.
let code = "VAULT_OBJECT_READ_FAILED";
let message = `${route}/${generation} read failed (HTTP ${r.status})`;
try {
const envelope = (await r.json());
if (typeof envelope?.error?.code === "string")
code = envelope.error.code;
if (typeof envelope?.error?.message === "string")
message = envelope.error.message;
}
catch {
// a non-JSON body — keep the generic code
}
fail(code, message, `reading vault ${route}`, { generation, status: r.status });
}
return new Uint8Array(await r.arrayBuffer());
}
/**
* Resolve ONE `object-reads` presign target to bytes (vault-small-
* object-inline design D3): consume `inline` when present and EITHER no
* `expectedSha256` was supplied OR the decoded bytes hash to it, else
* fall through to the ordinary `url`/`edge_url` fetch exactly as before
* this change. A lying `inline` is therefore a PLAIN MISS for this one
* slot — the fetch below reproduces the URL-only result and failure
* envelope byte-for-byte, never a special error. A caller that supplies
* no expectation cannot detect a lying `inline` at all, but that is no
* new exposure: `inline` is the SAME stored bytes, to the SAME
* authorized caller, the entry's own `url` would have served — nothing
* here becomes a new source of truth (design D5).
*
* vault-object-host-predial (task 1.2): once the fetch below
* COMPLETES (any status — a thrown fetch error is NOT this), reports the
* target's origin(s) via `options.onObjectStoreOriginObserved`. Never
* fired for the `inline` short-circuit above — no URL was dialed.
*/
/**
* vault-byo-primary-bucket: a payload object of a BYO vault, named by
* the key the gateway handed back. Read from the vault's locally configured
* destination — never from run402, which holds no copy. `null` when the
* key is absent there (the caller's absence adjudication takes over,
* `VAULT_BYO_OBJECT_MISSING`); a machine with no local BYO config for
* the vault refuses by name instead of reporting a false absence.
*/
async function readByoObject(repoId, key, what) {
const backend = options.byoBackend?.(repoId) ?? null;
if (!backend) {
fail("VAULT_BYO_NOT_CONFIGURED", `vault ${repoId} keeps its payload in its own bucket and this machine has no local BYO config for it — ${what} cannot be read`, "reading vault object", { repo_id: repoId, key }, [{ action: "this vault was allocated with `run402 repos create --byo <destination> --profile <name>|--ambient`; this machine needs the same destination and a credential NAME for it in the keystore's byo/<repo_id>.json (copy that file from the allocating machine — it carries no secret material)" }]);
}
return backend.get(key);
}
async function resolveObjectReadTarget(repoId, target, expectedSha256, path) {
if (target.byo_key !== undefined)
return readByoObject(repoId, target.byo_key, path);
if (target.inline !== undefined) {
const bytes = fromBase64url(target.inline, "reads[].inline");
if (expectedSha256 === undefined || sha256Hex(bytes) === expectedSha256)
return bytes;
}
if (target.url === undefined)
fail("VAULT_OBJECT_READ_FAILED", `object-reads entry for ${path} carries neither a presign nor a byo_key`, "reading vault object", { path });
const r = await fetchVaultObjectBytes(client, { url: target.url, ...(target.edge_url ? { edge_url: target.edge_url } : {}) });
try {
options.onObjectStoreOriginObserved?.(repoId, vaultObjectStoreOrigins([target.url, target.edge_url]));
}
catch {
/* a caller's own persistence failure must never surface into this read */
}
if (r.status === 404)
return null;
if (!r.ok)
fail("VAULT_OBJECT_READ_FAILED", `object GET failed (HTTP ${r.status}) for ${path}`, "reading vault object", { path, status: r.status });
return new Uint8Array(await r.arrayBuffer());
}
/** Presign + fetch one object by its ledger identity (`POST …/object-reads`). */
async function getObjectBytes(repoId, path, expectedSha256) {
const ref = vaultWireRefForPath(path);
if (!ref)
fail("VAULT_OBJECT_READ_FAILED", `${path} has no control-plane wire identity; it is not a readable vault object`, "reading vault object", { path });
if (ref.kind === "head")
return getGenerationBytes(repoId, "heads", ref.generation);
if (ref.kind === "admission")
return getGenerationBytes(repoId, "admissions", ref.generation);
let presigned;
try {
presigned = await client.request(`${base(repoId)}/object-reads`, { method: "POST", body: { objects: [ref.read] }, context: "resolving vault object" });
}
catch (e) {
if (isRun402Error(e) && e.status === 404)
return null;
if (isRun402Error(e) && e.code === "RESOURCE_NOT_FOUND")
return null;
// The gateway accepts `{object_kind: "recipient_pin_manifest",
// pin_manifest_version}` reads. A VALIDATION_FAILED here therefore
// indicates a genuinely malformed request and propagates as-is —
// EXCEPT the epoch-shape complaint, which a current gateway can
// never emit for this kind: that signature can only mean an unfixed
// (older/staging) gateway behind RUN402_API_BASE, so name it rather
// than letting it read as a client validation bug.
if (ref.read.object_kind === "recipient_pin_manifest" && isRun402Error(e) && e.code === "VALIDATION_FAILED" && /epoch must be 16 hex/.test(e.message ?? "")) {
throw new LocalError(`this gateway predates the 2026-08-28 fix that made object-reads accept recipient_pin_manifest reads by pin_manifest_version (its null-idScalar branch rejected them with key_envelope's epoch-shape complaint) — this vault's pin manifest at ${path} cannot be read back over the network from it by a keystore that did not itself just publish the manifest`, "reading vault object", { code: "VAULT_PIN_MANIFEST_READ_UNSUPPORTED", details: { path, pin_manifest_version: ref.read.pin_manifest_version }, cause: e });
}
throw e;
}
const target = presigned.reads[0];
if (!target)
return null;
return resolveObjectReadTarget(repoId, target, expectedSha256, path);
}
/**
* Presign + fetch N independent objects (vault-client-round-trips
* design D2): ONE `object-reads` POST naming every path, then the GETs
* with bounded concurrency ({@link VAULT_TRANSPORT_CONCURRENCY}).
* Every path must be `object-reads`-addressed (a "carrier": ref_state,
* retention_roots, a WAL/checkpoint pack, …) — a generation-addressed
* head/admission path has no place in a batch built for materialize/
* restore's carrier and pack reads, so it fails closed rather than
* silently costing an extra round trip through `getGenerationBytes`.
*/
async function getObjectsBytes(repoId, paths, expected) {
if (paths.length === 0)
return [];
const targets = await presignObjectBatch(repoId, paths);
if (!targets)
return paths.map(() => null);
return mapBounded(targets, VAULT_TRANSPORT_CONCURRENCY, async (target, i) => {
if (!target)
return null;
return resolveObjectReadTarget(repoId, target, expected?.[i], paths[i]);
});
}
/** The shared presign step of the batched read: ONE `object-reads` POST; `null` means the not-found shapes `getObjects` maps to an all-absent result. */
async function presignObjectBatch(repoId, paths) {
const refs = paths.map((path) => {
const ref = vaultWireRefForPath(path);
if (!ref || ref.kind !== "object")
fail("VAULT_OBJECT_READ_FAILED", `${path} is not a batch-readable carrier object`, "reading vault objects", { path });
return ref.read;
});
let presigned;
try {
presigned = await client.request(`${base(repoId)}/object-reads`, { method: "POST", body: { objects: refs }, context: "resolving vault objects" });
}
catch (e) {
if (isRun402Error(e) && e.status === 404)
return null;
if (isRun402Error(e) && e.code === "RESOURCE_NOT_FOUND")
return null;
throw e;
}
const byLedgerId = new Map(presigned.reads.map((r) => [vaultLedgerId(r), r]));
return refs.map((r) => byLedgerId.get(vaultLedgerId(r)) ?? null);
}
/**
* Per-object settlement over the same batch (vault-pipelined-restore
* D2): identical presign + bounded GETs, but each index's promise settles
* when ITS bytes land. Failure semantics per index match `getObjects`'s
* per-element behavior (absent → null, a failed GET → the same
* VAULT_OBJECT_READ_FAILED), including `expected` (vault-small-
* object-inline design D3 — see {@link VaultTransport.getObjects}'s
* doc comment); every promise is pre-marked handled so an abandoned tail
* never becomes an unhandled rejection.
*/
async function getObjectsSettledBytes(repoId, paths, expected) {
if (paths.length === 0)
return [];
const targets = await presignObjectBatch(repoId, paths);
if (!targets)
return paths.map(() => Promise.resolve(null));
const deferreds = targets.map(() => {
let resolve;
let reject;
const promise = new Promise((res, rej) => {
resolve = res;
reject = rej;
});
void promise.catch(() => { });
return { promise, resolve, reject };
});
let next = 0;
const worker = async () => {
for (;;) {
const i = next;
next += 1;
if (i >= targets.length)
return;
try {
const target = targets[i];
if (!target) {
deferreds[i].resolve(null);
continue;
}
deferreds[i].resolve(await resolveObjectReadTarget(repoId, target, expected?.[i], paths[i]));
}
catch (e) {
deferreds[i].reject(e);
}
}
};
void Promise.all(Array.from({ length: Math.min(VAULT_TRANSPORT_CONCURRENCY, targets.length) }, () => worker()));
return deferreds.map((d) => d.promise);
}
/**
* `POST …/head-reads` — many generation-addressed heads' EXACT stored bytes
* in ONE round trip (vault-batched-head-reads task 4.1).
*
* `null` means UNSUPPORTED, and it is deliberately the answer to EVERY
* failure shape: a 404 from a gateway that predates the route, a refusal, a
* malformed body, a network fault. The caller's fallback (per-generation
* reads) reproduces the unbatched behaviour byte-for-byte including its
* failure envelopes, so there is nothing to gain from distinguishing them
* here — and one thing to lose, since a batch that reported a real absence
* as data would let a partial page reach a consumer that expects
* all-or-nothing.
*
* The unsupported verdict is REMEMBERED for this transport's lifetime, so a
* client talking to an older gateway pays the probe once rather than once
* per window. It is never remembered in the other direction: a route that
* worked is simply used again.
*/
let headReadsUnsupported = false;
async function getHeadsBytes(repoId, generations) {
if (generations.length === 0)
return [];
if (headReadsUnsupported)
return null;
try {
const res = await client.request(`${base(repoId)}/head-reads`, {
method: "POST",
body: { generations },
context: "reading vault heads in a batch",
});
const byGeneration = new Map();
for (const h of res.heads ?? []) {
if (typeof h?.generation === "string" && typeof h.stored_bytes === "string")
byGeneration.set(h.generation, h.stored_bytes);
}
const out = [];
for (const g of generations) {
const encoded = byGeneration.get(g);
// A short or reordered page is a contract violation, not a partial
// answer — fall back rather than hand the walk a hole it would read
// as "absent".
if (encoded === undefined)
return null;
out.push(fromBase64url(encoded, "heads[].stored_bytes"));
}
return out;
}
catch (e) {
// A 404 is the shape an older gateway gives for a route it has never
// heard of, so it is the one worth remembering; every other failure
// (a refusal, a transient fault) falls back for THIS call only.
if (isRun402Error(e) && e.status === 404)
headReadsUnsupported = true;
return null;
}
}
/** Resolve ONE `GET …/state` carrier arm to raw bytes — inline decode, or a plain GET on the presigned URL (preferring its `edge_url` companion, vault-read-edge-cache design D5), `null` on a 404 (mirrors {@link getObjectBytes}'s absent reading; both arms indistinguishable after this). Origin observation (vault-object-host-predial task 1.2) mirrors `resolveObjectReadTarget`'s. */
async function resolveVaultStateCarrier(repoId, carrier) {
if ("byo_key" in carrier)
return readByoObject(repoId, carrier.byo_key, "vault-state carrier");
if ("inline" in carrier)
return fromBase64url(carrier.inline, "carriers.inline");
const r = await fetchVaultObjectBytes(client, { url: carrier.presigned_url, edge_url: carrier.edge_url });
try {
options.onObjectStoreOriginObserved?.(repoId, vaultObjectStoreOrigins([carrier.presigned_url, carrier.edge_url]));
}
catch {
/* a caller's own persistence failure must never surface into this read */
}
if (r.status === 404)
return null;
if (!r.ok)
fail("VAULT_OBJECT_READ_FAILED", `vault-state carrier GET failed (HTTP ${r.status})`, "reading the vault state", { status: r.status });
return new Uint8Array(await r.arrayBuffer());
}
/**
* `GET …/state` (design D1): one JSON body carrying the vault record, the
* newest generation, its head's exact stored bytes, and both carriers —
* resolved to raw bytes here, verified nowhere here (see the interface's
* own doc comment on {@link VaultTransport.getState}).
*/
async function getVaultStateOut(repoId, since, restore) {
const qs = [];
if (since)
qs.push(`since=${encodeURIComponent(since)}`);
if (restore)
qs.push("restore=1");
const raw = await client.request(`${base(repoId)}/state${qs.length > 0 ? `?${qs.join("&")}` : ""}`, { context: "reading the vault state" });
const head = raw.head ? { stored_bytes: fromBase64url(raw.head.stored_bytes, "head.stored_bytes"), stored_bytes_sha256: raw.head.stored_bytes_sha256 } : null;
const carriers = raw.carriers
? { ref_state: await resolveVaultStateCarrier(repoId, raw.carriers.ref_state), retention_roots: await resolveVaultStateCarrier(repoId, raw.carriers.retention_roots) }
: null;
// Delta decode is deliberately forgiving: a malformed entry is dropped,
// never thrown — the delta is an accelerator and the ordinary reads own
// every failure mode (vault-delta-fetch D2/D4).
let delta = null;
if (raw.delta && Array.isArray(raw.delta.heads) && Array.isArray(raw.delta.packs)) {
try {
const heads = raw.delta.heads
.filter((h) => typeof h?.generation === "string" && typeof h.stored_bytes === "string" && typeof h.stored_bytes_sha256 === "string")
.map((h) => ({ generation: h.generation, stored_bytes: fromBase64url(h.stored_bytes, "delta.heads[].stored_bytes"), stored_bytes_sha256: h.stored_bytes_sha256 }));
const packs = raw.delta.packs
.filter((pk) => typeof pk?.object_id === "string" && typeof pk.inline === "string")
.map((pk) => ({ object_id: pk.object_id, bytes: fromBase64url(pk.inline, "delta.packs[].inline") }));
delta = { heads, packs };
}
catch {
delta = null;
}
}
// Restore-plan decode (vault-restore-recipe design D1-D5): the SAME
// forgiving posture as `delta` above — ANY structural or resolution
// anomaly drops the whole plan, never throws, so a hiccup fetching one
// above-cap pack never fails the state read itself (head/carriers/delta
// already succeeded by the time this runs). Heads and checkpoint bytes
// are a pure base64url decode (the gateway sends them inline ALWAYS —
// design D4, no network here); packs reuse `resolveObjectReadTarget`
// verbatim (db8d745c) — the SAME self-consistency-gated inline decode
// with url/edge_url fallback every `object-reads` consumer gets — under
// bounded concurrency, so an all-inline plan costs this call NOTHING
// beyond the one `GET …/state` already in flight, and an above-cap plan
// pays exactly one GET per uncapped pack (the presign already happened
// server-side assembling the plan; no client-side `object-reads` POST).
// Decoding is its own function (never throwing `Error` — the public-SDK
// plain-`Error` contract — `null`/early-return is the disqualification
// signal instead) wrapped in one try/catch for the genuine exceptions
// `fromBase64url`/`resolveObjectReadTarget` can still raise.
const decodeRestorePlan = async () => {
if (!raw.restore_plan || !Array.isArray(raw.restore_plan.heads) || !Array.isArray(raw.restore_plan.packs))
return null;
try {
const rp = raw.restore_plan;
const heads = rp.heads
.filter((h) => typeof h?.generation === "string" && typeof h.stored_bytes === "string" && typeof h.stored_bytes_sha256 === "string")
.map((h) => ({ generation: h.generation, stored_bytes: fromBase64url(h.stored_bytes, "restore_plan.heads[].stored_bytes"), stored_bytes_sha256: h.stored_bytes_sha256 }));
if (heads.length !== rp.heads.length)
return null; // a malformed head entry disqualifies the whole plan
const checkpoint = rp.checkpoint
? {
claim_set: { object_id: rp.checkpoint.claim_set.object_id, stored_bytes: fromBase64url(rp.checkpoint.claim_set.stored_bytes, "restore_plan.checkpoint.claim_set.stored_bytes") },
manifest: { object_id: rp.checkpoint.manifest.object_id, stored_bytes: fromBase64url(rp.checkpoint.manifest.stored_bytes, "restore_plan.checkpoint.manifest.stored_bytes") },
}
: null;
const packEntries = rp.packs.filter((pk) => typeof pk?.object_kind === "string" && typeof pk.object_id === "string");
if (packEntries.length !== rp.packs.length)
return null; // a malformed pack entry disqualifies the whole plan
const resolved = await mapBounded(packEntries, VAULT_TRANSPORT_CONCURRENCY, async (pk) => {
const target = { object_kind: pk.object_kind, object_id: pk.object_id, url: pk.url ?? "", edge_url: pk.edge_url, inline: pk.inline, stored_bytes_sha256: pk.stored_bytes_sha256 ?? "", size_bytes: pk.size_bytes ?? "0", expires_at: pk.expires_at ?? "" };
// Self-consistency only (the same gate `inline` gets everywhere
// else) — real verification against the head/manifest's OWN
// receipt happens later, in `Vault`, before any byte is
// trusted. A pack the gateway declined to presign (`url` absent,
// `inline` absent) is `null` here and falls back to the ordinary
// object-reads fetch by (object_kind, object_id) in the caller.
if (!target.url && target.inline === undefined)
return null;
return resolveObjectReadTarget(repoId, target, pk.stored_bytes_sha256, `restore_plan:${pk.object_kind}:${pk.object_id}`);
});
const packs = packEntries.map((pk, i) => ({ object_kind: pk.object_kind, object_id: pk.object_id, bytes: resolved[i] ?? null }));
return { boundary_generation: rp.boundary_generation, heads, checkpoint, packs };
}
catch {
return null;
}
};
const restorePlan = await decodeRestorePlan();
return { vault: raw.vault, newest_generation: raw.newest_generation, head, carriers, ...(delta ? { delta } : {}), ...(restorePlan ? { restore_plan: restorePlan } : {}) };
}
/** Pair a `finalize`-shaped response's receipts back onto `objects` by ledger id — shared by the inline and presigned upload paths so neither forks the other's receipt-compare logic. */
function receiptsFromFinalize(objects, entries, fin) {
const receipts = new Map(fin.receipts.map((r) => [vaultLedgerId(r), r]));
return objects.map((o, i) => {
const id = vaultLedgerId(entries[i]);
const r = receipts.get(id);
if (!r)
fail("VAULT_RECEIPT_MISSING", `finalize returned no receipt for ${id}`, "finalizing vault upload session", { object_id: id, path: o.path });
return { path: o.path, object_id: o.object_id, sha256: r.ciphertext_sha256 ?? r.stored_bytes_sha256 ?? "", size_bytes: r.size_bytes };
});
}
async function upload(repoId, objects, resourceBinding, byo) {
if (objects.length === 0)
return [];
// The manifest is closed-key: `path` is client-local and MUST NOT ride the
// wire — the control plane derives the bucket key itself and refuses an
// entry carrying an unexpected member.
const entries = objects.map((o) => vaultManifestEntry(o));
// vault-byo-primary-bucket task 3.2: the inline (bytes-in-body) fast
// path is NEVER used for a BYO vault, even when the payload is small
// enough to be eligible — that transport writes bytes into run402's own
// bucket, exactly what the credential model forbids (the gateway
// refuses it too, VALIDATION_FAILED, so skipping it here is purely a
// wasted-round-trip avoidance, not a correctness dependency).
if (!byo && vaultInlineUploadEligible(objects)) {
// vault-composite-state-read design D2: every object fits under the
// caps — one POST, bytes verified + written server-side, and the
// response IS the finalize response (no session, no PUTs, no separate
// finalize call). Same closed-key manifest as the presigned path, plus
// each entry's own bytes.
const fin = await client.request(`${base(repoId)}/upload-sessions`, {
method: "POST",
body: { objects: entries.map((entry, i) => ({ ...entry, bytes: b64u(objects[i].bytes) })), ...(resourceBinding ? { resource_binding: resourceBinding } : {}) },
context: "uploading vault objects inline",
});
return receiptsFromFinalize(objects, entries, fin);
}
const session = await client.request(`${base(repoId)}/upload-sessions`, {
method: "POST",
body: { objects: entries, ...(resourceBinding ? { resource_binding: resourceBinding } : {}) },
context: "opening vault upload session",
});
const issued = new Map(session.objects.map((u) => [vaultLedgerId(u), u]));
// vault-byo-primary-bucket task 3.2 — one backend, opened ONCE, for
// every BYO payload object this session names; `null` `put` never
// appears in the session response unless `byo` was supplied (the
// gateway only mints `put: null` for a vault whose storage_profile is
// "byo", which is exactly when THIS caller must have supplied `byo`
// too — see the resolution in `Vault.uploadAll`).
const byoBackend = byo ? openVaultDestinationBackend(byo.destination, byo.credential) : null;
const byoAttestations = [];
// Independent create-only PUTs within one session (design D2) — bounded
// concurrency, same limit as the batched object reads below.
await mapBounded(objects, VAULT_TRANSPORT_CONCURRENCY, async (o, i) => {
const id = vaultLedgerId(entries[i]);
const target = issued.get(id);
if (!target)
fail("VAULT_UPLOAD_SESSION_INVALID", `the session issued no upload for ${id}`, "uploading vault objects", { object_id: id, path: o.path });
if (target.put === null) {
// BYO payload object (task 2.2's VAULT_BYO_PAYLOAD_KINDS
// partition, mirrored client-side by the server's own `put: null` +
// `key` shape) — the client writes DIRECTLY to its own bucket. No
// request to run402 ever carries a customer-bucket credential; no
// request to run402 ever carries these bytes either.
if (!byoBackend || !target.key) {
fail("VAULT_BYO_BUCKET_WRITE_REFUSED", `the session named ${o.path} as a BYO-written object but no BYO write target was resolved`, "uploading vault objects", { path: o.path, object_id: id });
}
let put;
try {
put = await byoBackend.putCreateOnly(target.key, o.bytes);
}
catch (e) {
fail("VAULT_BYO_BUCKET_WRITE_REFUSED", `writing ${o.path} to the BYO destination failed: ${e instanceof Error ? e.message : String(e)}`, "uploading vault objects", { path: o.path, key: target.key });
}
if (!put.created) {
// create-only: the key already exists — legal ONLY if it is
// byte-identical (an idempotent retry of THIS SAME write; a
// permanent object id is never legitimately reused for
// different bytes — matches the managed presigned-PUT read-and-
// compare branch below, and the gateway's own attestation rule).
const existing = await byoBackend.get(target.key);
if (!existing || sha256Hex(existing) !== o.sha256) {
fail("VAULT_BYO_BUCKET_WRITE_REFUSED", `${o.path} already exists in the BYO destination with different bytes`, "uploading vault objects", { path: o.path, key: target.key });
}
}
byoAttestations.push({ key: target.key, sha256: o.sha256, size_bytes: o.size_bytes, create_only_result: "created" });
return;
}
// `If-None-Match: *` (create-only) and the FULL_OBJECT `x-amz-checksum-sha256`
// are SIGNED INTO the presigned URL, so they must go out exactly as the
// server issued them — a dropped or altered header is a signature
// mismatch, not a silently unconditional or unchecked write. The locals
// below are only a fallback for a transport that omits them; the
// server's copy always wins.
const r = await client.fetch(target.put.url, {
method: "PUT",
headers: { "If-None-Match": "*", "Content-Length": o.size_bytes, "x-amz-checksum-sha256": b64(hexToBytes(o.sha256)), ...(target.put.headers ?? {}) },
body: o.bytes,
});
if (r.status === 412 || r.status === 409) {
// create-only: the key exists — legal only if it is byte-identical (read-and-compare)
const existing = await getObjectBytes(repoId, o.path);
if (!existing || sha256Hex(existing) !== o.sha256)
fail("VAULT_OBJECT_EXISTS_DIFFERENT", `${o.path} already exists with different bytes`, "uploading vault objects", { path: o.path });
return;
}
if (!r.ok)
fail("VAULT_UPLOAD_FAILED", `presigned PUT failed (HTTP ${r.status}) for ${o.path}`, "uploading vault objects", { path: o.path, status: r.status });
});
const fin = await client.request(`${base(repoId)}/upload-sessions/${encodeURIComponent(session.upload_session_id)}/finalize`, {
method: "POST",
// Only a BYO session ever carries attestations — an ordinary managed
// session's finalize body stays `{}`, byte-identical to today.
body: byo ? { attestations: byoAttestations } : {},
context: "finalizing vault upload session",
});
return receiptsFromFinalize(objects, entries, fin);
}
async function admit(repoId, generation, bytes, hash, extra = {}) {
try {
const r = await client.request(`${base(repoId)}/admissions`, {
method: "POST",
body: { generation, stored_bytes: b64u(bytes), stored_bytes_sha256: hash, ...extra },
context: "admitting vault head",
});
return { outcome: "admitted", admission_record_sha256: r.admission_record_sha256, capture_receipt: r.capture_receipt ?? null };
}
catch (e) {
if (isRun402Error(e) && e.code === "HEAD_CAS_CONFLICT") {
const winner = (e.details?.winner) ?? null;
if (winner)
return { outcome: "conflict", winner };
}
throw e;
}
}
const transport = {
// ── creation (5.3) ──
async allocate(request) {
// The route wraps the signed allocation object under `allocation` and
// adds routing sugar (`allocation_sha256`, `deduplicated`, next_actions).
// The vault verifies the SIGNED object, so unwrap it here.
const res = await client.request("/vaults/v1", { method: "POST", body: request, context: "allocating vault" });
return res.allocation ?? res;
},
async putObject(request) {
const [r] = await upload(request.repo_id, [{ path: request.path, object_kind: "key_envelope", object_id: null, bytes: request.bytes, sha256: request.expected_sha256, size_bytes: request.expected_size_bytes }], undefined, request.byo);
return { stored_bytes_sha256: r.sha256, size_bytes: r.size_bytes };
},
getObject: ({ repo_id, path, expected_sha256 }) => getObjectBytes(repo_id, path, expected_sha256),
getObjects: ({ repo_id, paths, expected }) => getObjectsBytes(repo_id, paths, expected),
getObjectsSettled: ({ repo_id, paths, expected }) => getObjectsSettledBytes(repo_id, paths, expected),
getHeads: ({ repo_id, generations }) => getHeadsBytes(repo_id, generations),
async admitGenesis(request) {
try {
const r = await admit(request.repo_id, VAULT_GENESIS_GENERATION, request.stored_bytes, request.stored_bytes_sha256, { allocation_generation: request.allocation_generation });
if (r.outcome === "admitted")
return { outcome: "admitted", admitted_sha256: request.stored_bytes_sha256 };
return { outcome: "already_admitted", admitted_sha256: r.winner.stored_bytes_sha256 };
}
catch (e) {
if (isRun402Error(e) && e.code === "ALLOCATION_SUPERSEDED")
return { outcome: "allocation_superseded" };
throw e;
}
},
getGenesis: ({ repo_id }) => getObjectBytes(repo_id, `head/${VAULT_GENESIS_GENERATION}`),
// ── publication (5.4) ──
async listHeads(request) {
validateHeadsListingRequest(request);
const qs = new URLSearchParams({ after_generation: request.after_generation, limit: request.limit });
if (request.cursor !== undefined)
qs.set("cursor", request.cursor);
return client.request(`${base(request.repo_id)}/heads?${qs.toString()}`, { context: "listing vault heads" });
},
uploadObjects: ({ repo_id, objects, resource_binding, byo }) => upload(repo_id, objects, resource_binding, byo),
admitHead: (r) => admit(r.repo_id, r.generation, r.stored_bytes, r.stored_bytes_sha256),
// Both routes SHIPPED (`routes/vault-admission.ts`). `retention-cutoffs`
// answers `{ticket, receipt, next_actions}`, which IS
// `VaultRetentionCutoffIssued` plus routing sugar — no unwrap needed.
requestRetentionCutoff: ({ repo_id, base_head_sha256 }) => client.request(`${base(repo_id)}/retention-cutoffs`, { method: "POST", body: { base_head_sha256 }, context: "requesting retention cutoff ticket" }),
async exchangeActivationToken({ repo_id, operation_id, capture_receipt }) {
// The route wraps the SIGNED token under `activation_token` and adds
// routing sugar (`object_id`, `reissued`, next_actions) — same envelope
// shape as `allocate` above, and unwrapped for the same reason: the
// caller verifies the SIGNED object, and `checkActivationTokenBinding`
// compares nine fields that all live INSIDE it. Passing the envelope on
// as the token mismatches every one of them
// (`VAULT_TOKEN_BINDING_MISMATCH`) and the deploy never commits — even
// though the envelope's sibling `object_id` makes the shape look close
// enough to be a plausible token at a glance. Pinned by
// `vault-wire-shapes.test.ts`.
const res = await client.request(`${base(repo_id)}/activation-tokens`, { method: "POST", body: { operation_id, capture_receipt }, context: "exchanging capture receipt for activation token" });
return res.activation_token ?? res;
},
async submitOverrideCompletion({ repo_id, operation_id, capture_receipt }) {
const r = await client.request(`${base(repo_id)}/override-completions`, { method: "POST", body: { operation_id, capture_receipt }, context: "submitting override completion" });
return { cleared: r.advisory_cleared ?? r.cleared ?? false };
},
getVaultRecord: ({ repo_id }) => client.request(base(repo_id), { context: "reading the vault record" }),
getState: ({ repo_id, since, restore }) => getVaultStateOut(repo_id, since, restore),
openCompactionGrant: ({ repo_id }) => client.request(`${base(repo_id)}/compaction-grant`, { method: "POST", context: "opening the vault compaction headroom grant" }),
closeCompactionGrant: async ({ repo_id }) => {
const r = await client.request(`${base(repo_id)}/compaction-grant`, { method: "DELETE", context: "closing the vault compaction headroom grant" });
return { closed: r.closed === true };
},
findVaultByProject: ({ project_id }) => client.request(`/vaults/v1?project_id=${encodeURIComponent(project_id)}`, { context: "resolving the project's vault" }),
findVaultByRepo: ({ org_slug, repo_name }) => client.request(`/vaults/v1?repo=${encodeURIComponent(`${org_slug}/${repo_name}`)}`, { context: "resolving the vault by repo address" }),
listOrgEncryptionKeys: ({ org_id }) => client.request(`/orgs/v1/${encodeURIComponent(org_id)}/encryption-keys`, { context: "reading the org encryption-key directory" }),
listEnvelopeRecipients: ({ repo_id }) => client.request(`${base(repo_id)}/envelope-recipients`, { context: "reading the vault envelope recipients" }),
// ── epoch rotation (D193-D203, rev 42, §9.2) ──
async createRotationAttempt({ repo_id, descriptor }) {
const res = await client.request(`${base(repo_id)}/rotation-attempts`, {
method: "POST",
body: descriptor,
context: "creating a vault rotation attempt",
});
return { rotation_id: res.rotation_id, descriptor: res.descriptor, deduplicated: res.deduplicated ?? false };
},
confirmRecipient: ({ repo_id, principal_id, new_fingerprint }) => client.request(`${base(repo_id)}/recipients/${encodeURIComponent(principal_id)}/confirm`, { method: "POST", body: { new_fingerprint }, context: "confirming a vault recipient's first pin" }),
repinRecipient: ({ repo_id, principal_id, old_ek_fingerprint, new_fingerprint }) => client.request(`${base(repo_id)}/recipients/${encodeURIComponent(principal_id)}/repin`, { method: "POST", body: { old_ek_fingerprint, new_fingerprint }, context: "re-pinning a vault recipient" }),
declareRecipientKeyRevoked: ({ repo_id, principal_id }) => client.request(`${base(repo_id)}/recipients/${encodeURIComponent(principal_id)}/key-revocation`, { method: "POST", body: {}, context: "declaring a vault recipient key revoked" }),
declareEpochSecretExposed: ({ repo_id }) => client.request(`${base(repo_id)}/epoch-secret-exposure`, { method: "POST", body: {}, context: "declaring a vault epoch secret exposed" }),
declareWriterAuthorityUnavailable: ({ repo_id }) => client.request(`${base(repo_id)}/writer-authority/declare-unavailable`, { method: "POST", body: {}, context: "declaring vault writer authority unavailable" }),
async submitOpenProof({ repo_id, principal_id, ek_fingerprint, chain_verified_to_generation, decryptable_to_generation, reader_entrypoint }) {
// requestWithResponse (not request) — the gateway's own status code is
// the ONLY signal distinguishing a fresh mint (201) from an idempotent
// replay of the tuple's already-committed winner (200); the receipt
// BODY is identical either way (routes/repos.ts:
// `res.status(inserted ? 201 : 200).json(receipt)`).
const res = await client.requestWithResponse(`${base(repo_id)}/recipients/${encodeURIComponent(principal_id)}/proof-of-open`, { method: "POST", body: { ek_fingerprint, chain_verified_to_generation, decryptable_to_generation, reader_entrypoint }, context: "submitting a vault proof-of-open receipt" });
return { receipt: res.body, deduplicated: res.status === 200 };
},
acquireMaintenanceLease: ({ repo_id, base_head_sha256, current_checkpoint_hash, r1_size_bytes, r2_cap_size_bytes, p_before_c1_size_bytes, p_before_c2_size_bytes }) => client.request(`${base(repo_id)}/maintenance-leases`, {
method: "POST",
body: {
base_head_sha256,
current_checkpoint_hash: current_checkpoint_hash ?? null,
r1_size_bytes,
r2_cap_size_bytes,
p_before_c1_size_bytes: p_before_c1_size_bytes ?? "0",
p_before_c2_size_bytes: p_before_c2_size_bytes ?? "0",
},
context: "acquiring the vault maintenance lease",
}),
heartbeatMaintenanceLease: ({ repo_id, maintenance_lease_id, holder_token }) => client.request(`${base(repo_id)}/maintenance-leases/${encodeURIComponent(maintenance_lease_id)}/heartbeat`, { method: "POST", body: { holder_token }, context: "renewing the vault maintenance lease" }),
releaseMaintenanceLease: ({ repo_id, maintenance_lease_id, holder_token }) => client.request(`${base(repo_id)}/maintenance-leases/${encodeURIComponent(maintenance_lease_id)}`, { method: "DELETE", body: { holder_token }, context: "releasing the vault maintenance lease" }),
// ── prune (§7.3) ──
async submitPruneIntent({ repo_id, intent_bytes }) {
// Deliberately NOT `client.request`: that serializes a body object, and
// the gateway strict-parses + signature-verifies THESE bytes (the route
// is registered through `express.raw`). Re-serializing an equal-valued
// object would change what was signed. Same raw-fetch shape the
// generation-addressed reads use, so it inherits the same credentials.
const path = `${base(repo_id)}/prune-intents`;
const auth = (await client.credentials.getAuth(path, { method: "repos.prune" })) ?? {};
const r = await client.fetch(`${client.apiBase}${path}`, {
method: "POST",
headers: { ...auth, "content-type": "application/json", accept: "application/json" },
body: intent_bytes,
});
const text = await r.text();
let parsed = null;
try {
parsed = text.length > 0 ? JSON.parse(text) : null;
}
catch {
parsed = null;
}
if (!r.ok) {
// The gateway's error envelope is FLAT
// (docs/style.md §Errors — `buildErrorEnvelope` in the gateway) —
// `code`, `message`, `details`, `trace_id`, and `next_actions` all
// ride at the TOP level, never nested under an `error` object (that
// key is a human-readable STRING alias for `message`, kept only for
// legacy consumers). Reading `envelope.error.code` here — as if the
// envelope were `{error: {code, message, details}}` — always missed,
// so EVERY refusal (receipt-id reuse, the retention floor,
// GC_EPOCH_STALE, …) collapsed to the same opaque
// `VAULT_PRUNE_SUBMIT_FAILED {details: null}`. Read the real
// shape so the gateway's own refusal rides through verbatim; this
// wrapper only adds the HTTP status, never replaces content.
const envelope = parsed;
// Spread the gateway's own `details` verbatim (its keys survive
// untouched — `ineligible`, `expires_at`, whatever the refusal
// carries) and add `http_status`/`trace_id` alongside as this
// wrapper's own context, never replacing what the gateway sent.
const gatewayDetails = envelope?.details;
const details = gatewayDetails && typeof gatewayDetails === "object" && !Array.isArray(gatewayDetails)
? { ...gatewayDetails }
: gatewayDetails !== undefined
? { gateway_details: gatewayDetails }
: {};
details.http_status = r.status;
if (envelope?.trace_id)
details.trace_id = envelope.trace_id;
fail(envelope?.code ?? "VAULT_PRUNE_SUBMIT_FAILED", envelope?.message ?? (typeof envelope?.error === "string" ? envelope.error : undefined) ?? `prune intent submission failed (HTTP ${r.status})`, "submitting the vault prune intent", details, Array.isArray(envelope?.next_actions) ? envelope.next_actions : undefined);
}
const body = (parsed ?? {});
return { ...body, stored: body.stored === true };
},
async getPruneIntent({ repo_id, prune_intent_object_id }) {
try {
return await client.request(`${base(repo_id)}/prune-intents/${encodeURIComponent(prune_intent_object_id)}`, { context: "reading the vault prune intent" });
}
catch (e) {
// Authorize-before-reveal: a malformed, absent, or foreign id all
// return the SAME envelope, so `null` is the only honest reading.
if (isRun402Error(e) && (e.status === 404 || e.code === "RESOURCE_NOT_FOUND"))
return null;
throw e;
}
},
};
return process.env.RUN402_VAULT_TRACE === "1" ? traceVaultTransport(transport) : transport;
}
/**
* `RUN402_VAULT_TRACE=1` (design D7) — one stderr line per real
* transport operation (op kind, a path/object-count shape when the request
* carries one, byte count when the result carries one, duration) plus a
* session summary at process exit (total ops, total time spent in this
* transport, wall-clock since the transport was created). Debug-only:
* stderr only — never stdout, so it can never contaminate the
* `git-remote-run402` protocol stream, the same discipline the helper's
* own `note()` follows — and not a canonical surface: it is NOT what the
* client-surface spec's counted budgets measure (that is
* `VaultOpCounter`, a test-only instrument over the SAME operation
* shapes). The client-side env var carries no gateway env-registry policy.
*/
function traceVaultTransport(inner) {
let opCount = 0;
let totalMs = 0;
const sessionStart = Date.now();
process.on("exit", () => {
if (opCount === 0)
return;
process.stderr.write(`vault-trace: session summary — ${opCount} op(s), ${totalMs.toFixed(1)}ms in transport, ${Date.now() - sessionStart}ms wall-clock\n`);
});
const describeRequest = (arg) => {
if (!arg || typeof arg !== "object")
return "";
const a = arg;
if (typeof a.path === "string")
return ` path=${a.path}`;
if (Array.isArray(a.paths))
return ` paths=${a.paths.length}`;
if (Array.isArray(a.objects))
return ` objects=${a.objects.length}`;
if (typeof a.generation === "string")
return ` gen=${a.generation}`;
if (typeof a.repo_id === "string")
return ` repo=${a.repo_id}`;
return "";
};
const describeResult = (result) => {
if (result instanceof Uint8Array)
return ` bytes=${result.length}`;
if (Array.isArray(result)) {
const total = result.reduce((sum, v) => sum + (v instanceof Uint8Array ? v.length : 0), 0);
return total > 0 ? ` bytes=${total}` : "";
}
return "";
};
return new Proxy(inner, {
get(target, prop, receiver) {
const value = Reflect.get(target, prop, receiver);
if (typeof value !== "function" || typeof prop !== "string")
return value;
return async (...args) => {
const start = Date.now();
try {
const result = await Reflect.apply(value, target, args);
const elapsed = Date.now() - start;
opCount += 1;
totalMs += elapsed;
process.stderr.write(`vault-trace: ${prop}${describeRequest(args[0])}${describeResult(result)} ${elapsed}ms\n`);
return result;
}
catch (e) {
const elapsed = Date.now() - start;
opCount += 1;
totalMs += elapsed;
process.stderr.write(`vault-trace: ${prop}${describeRequest(args[0])} FAILED ${elapsed}ms\n`);
throw e;
}
};
},
});
}
// ─── Storage paths (§3) ──────────────────────────────────────────────────────
export const vaultPaths = {
head: (generation) => `head/${generation}`,
admission: (generation) => `admissions/${generation}`,
wal: (id) => `wal/${id}.pack.enc`,
refState: (id) => `refs/${id}.enc`,
retentionRoots: (id) => `retention/${id}.enc`,
checkpointManifest: (id) => `checkpoints/${id}.manifest.enc`,
checkpointPack: (id) => `checkpoints/${id}.pack.enc`,
claimSet: (id) => `checkpoints/${id}.claims.json`,
cutoffTicket: (id) => `retention/${id}.ticket.json`,
/** `verifier-receipts/<vr>.json` — plaintext-structured, uploaded before a prune intent may reference it (§7.3). */
verifierReceipt: (id) => `verifier-receipts/${id}.json`,
/**
* `envelopes/<epoch>/<recipient_fingerprint>` — mirrors
* `vault-creation-journal.ts`'s private `envelopePath` (the genesis
* creator's envelope); this is the same addressing for every OTHER
* recipient's `key_envelope`. `rotationId` present (D195, rev 42) widens
* this to `envelopes/<epoch>/<rotation_id>/<recipient_fingerprint>` — a
* rotation-attempt envelope's own path (protocol §1: `rotation_id`
* ABSENT, never explicit null, in the genesis/ADD-workaround case).
*/
envelope: (epoch, recipientFingerprint, rotationId) => rotationId ? `envelopes/${epoch}/${rotationId}/${recipientFingerprint}` : `envelopes/${epoch}/${recipientFingerprint}`,
/** `recipient-pins/<pin_manifest_version>.json` (D197, rev 42) — version-addressed, plaintext-structured, writer-signed. */
pinManifest: (pinManifestVersion) => `recipient-pins/${pinManifestVersion}.json`,
};
// ─── Incremental restore marker (vault-client-round-trips design D5) ──────
//
// `restored_through` — the newest generation whose WAL packs this TARGET
// DIRECTORY has fully applied — is local git state, same store as the
// id-pin (`git config --local`), scoped to the directory `restoreObjectsInto`
// actually wrote into (never `process.cwd()`, and never scoped to `repo_id`:
// a re-pointed remote's marker simply fails its `head_sha256` comparison and
// falls back to wholesale, safely, since that comparison is a content hash).
const VAULT_RESTORE_MARKER_GENERATION_KEY = "r402.restoredThrough";
const VAULT_RESTORE_MARKER_SHA256_KEY = "r402.restoredThroughSha256";
async function readLocalGitConfigValue(dir, key) {
try {
const out = (await hardenedGit(dir, ["config", "--local", "--get", key])).text().trim();
return out.length > 0 ? out : null;
}
catch {
// Absent key or not a repository at all — either way, "nothing marked" is the correct read.
return null;
}
}
/** Read this target directory's `restored_through` marker, or `null` when nothing was ever restored into it. */
export async function readVaultRestoreMarker(targetRepoDir) {
const generation = await readLocalGitConfigValue(targetRepoDir, VAULT_RESTORE_MARKER_GENERATION_KEY);
const head_sha256 = await readLocalGitConfigValue(targetRepoDir, VAULT_RESTORE_MARKER_SHA256_KEY);
if (!generation || !head_sha256)
return null;
return { generation, head_sha256 };
}
/** Advance the marker — called only after a restore's coverage verification succeeds. */
async function writeVaultRestoreMarker(targetRepoDir, generation, headSha256) {
await hardenedGit(targetRepoDir, ["config", "--local", VAULT_RESTORE_MARKER_GENERATION_KEY, generation]);
await hardenedGit(targetRepoDir, ["config", "--local", VAULT_RESTORE_MARKER_SHA256_KEY, headSha256]);
}
// ─── auto-gc cadence threshold (vault-checkpoint-cadence design D1) ───────
//
// `auto_gc_generations` rides the SAME local-git-config mechanism as the
// restore marker above — a per-CHECKOUT knob, exactly like git's own
// `gc.auto` (`git config gc.auto`), not a server-side vault policy. Read
// fresh every push (a cheap local read, never network); `0` disables;
// absent reads as the default.
const VAULT_AUTO_GC_GENERATIONS_KEY = "r402.autoGcGenerations";
/** Default `auto_gc_generations` — see the change proposal's rationale (≈2-3s over the fresh-checkpoint floor at this backlog, a busy repo compacts roughly once per few dozen pushes). */
export const VAULT_AUTO_GC_GENERATIONS_DEFAULT = 32;
/**
* Read this checkout's auto-gc threshold, or the default when unset or
* unparseable. Never throws — a corrupt local config value degrades to the
* default rather than blocking a push's own auto-gc check.
*/
export async function readVaultAutoGcThreshold(targetRepoDir) {
const raw = await readLocalGitConfigValue(targetRepoDir, VAULT_AUTO_GC_GENERATIONS_KEY);
if (raw === null)
return VAULT_AUTO_GC_GENERATIONS_DEFAULT;
const trimmed = raw.trim();
if (!/^\d+$/.test(trimmed))
return VAULT_AUTO_GC_GENERATIONS_DEFAULT;
const n = Number.parseInt(trimmed, 10);
return Number.isSafeInteger(n) && n >= 0 ? n : VAULT_AUTO_GC_GENERATIONS_DEFAULT;
}
/** Set this checkout's auto-gc threshold. `0` disables auto-gc entirely. */
export async function writeVaultAutoGcThreshold(targetRepoDir, generations) {
await hardenedGit(targetRepoDir, ["config", "--local", VAULT_AUTO_GC_GENERATIONS_KEY, String(generations)]);
}
/** A transport-agnostic view of git ops the publication needs (the local repository). */
export class Vault {
keystore;
transport;
repoId;
repoDir;
now;
budget;
/**
* vault-clone-scaling (bench P2): the CURRENT walk window's prefetched
* bytes — head bytes (bounded-concurrent direct reads) and carrier frames
* (one batched getObjects), keyed by storage path — filled by
* `verifyToNewest`'s per-page prefetch and `restoreObjectsInto`'s
* backward-window prefetch, consulted by {@link readCachedHeadBytes} /
* {@link openMaterializeCarriers} before they pay a network read. Transient (REPLACED each page, so memory is
* bounded by one listing page) and UNTRUSTED: every consumer sha-checks
* an entry against the exact value it would check network bytes against
* (the listing's `stored_bytes_sha256`, a receipt's `ciphertext_sha256`),
* the same discipline as the keystore object cache — a wrong or stale
* entry is a MISS, never a verification bypass. Deliberately NOT the
* keystore cache: that cache's eviction window is a handful of newest
* generations by design, so routing a whole page through it would evict
* the very bytes the ordered walk is about to read.
*/
walkPrefetch = null;
/**
* WAL pack bytes carried by the state read's delta (vault-delta-fetch),
* keyed by object_id — the walkPrefetch discipline exactly: UNTRUSTED
* until a consumer's own hash check passes, a mismatch is a plain miss,
* and the buffer is transient (stashed by {@link tryStateFastPath},
* consumed and cleared by the next restore).
*/
stateDeltaPacks = null;
/**
* The state read's restore plan (vault-restore-recipe design D1-D5),
* stashed RAW — never verified at stash time, unlike `stateDeltaPacks`
* (whose heads self-check before entering the shared keystore cache): a
* plan's heads/checkpoint/packs are only ever verified by {@link
* restoreObjectsInto}'s own full obligation set, and a partially-checked
* plan sitting here would be a foot-gun for a future caller that forgot
* the difference. Consumed and cleared exactly once, by the next
* `restoreObjectsInto` call (either its own materialize, or a dedicated
* plan-only read it issues when the incremental walk it started aborts).
*/
stateRestorePlan = null;
retries;
servicePublicKey;
allowUnauthenticatedSalvage;
genesisCache = null;
/**
* vault-byo-primary-bucket task 3.2 — this vault's resolved BYO write
* target, cached after the first resolution (storage_profile/
* byo_destination are immutable-at-allocation in v1, so a single
* read-once-per-instance is safe). `undefined` = not yet resolved;
* `null` = this machine has no LOCAL BYO write config for this repo
* (either an ordinary managed vault, or a BYO vault this machine has not
* been configured to write — see {@link resolveByoWriteTarget}).
*/
byoResolution = undefined;
constructor(options) {
this.keystore = options.keystore;
this.transport = options.transport;
this.repoId = options.repo_id;
this.repoDir = options.repo_dir ?? null;
this.now = options.now ?? (() => new Date());
this.budget = options.verification_budget ?? VAULT_VERIFICATION_BUDGET_HEADS;
this.retries = options.conflict_retries ?? VAULT_PUSH_CONFLICT_RETRIES;
this.servicePublicKey = options.service_public_key ?? null;
this.allowUnauthenticatedSalvage = options.allow_unauthenticated_salvage ?? false;
}
static open(options) {
const v = new Vault(options);
v.repoFile(); // KEYSTORE_MISSING / VAULT_REPO_STATE_MISSING surface here
return v;
}
repoFile() {
// Cross-profile hint: a keystore-miss on the
// ACTIVE profile is enriched, when the scan finds one, with which OTHER
// local wallet profile holds this repo's key — a purely local
// directory/filename read, appended after the existing remedy rather
// than replacing it.
if (!this.keystore.readIdentity()) {
fail("KEYSTORE_MISSING", "no vault identity in the keystore", "opening vault", undefined, [
{ action: "restore ~/.config/run402/vault from backup or accept vault loss" },
...crossProfileVaultHint(this.repoId),
]);
}
const repo = this.keystore.readRepo(this.repoId);
if (!repo) {
const hint = crossProfileVaultHint(this.repoId);
fail("VAULT_REPO_STATE_MISSING", `no keystore repo file for ${this.repoId}; restore it from the principal's own envelope (keystore.restoreRepoFromEnvelope)`, "opening vault", { repo_id: this.repoId }, hint.length > 0 ? hint : undefined);
}
return repo;
}
/**
* vault-agent-envelopes D4 — the COLD OPEN. A keystore that holds an
* identity but no repo file for this vault (a member joining from a fresh
* machine, or a creator whose repo file was lost) restores `K_repo` from
* its OWN `key_envelope` instead of dying `VAULT_REPO_STATE_MISSING`:
*
* 1. genesis (the writer-key source) is fetched and signature-verified;
* 2. its creator fingerprints are compared against the control plane's
* SIGNED allocation record — `platform_attested`, never `receipt`
* (the platform serves both sides of that comparison; a substituted
* genesis needs a substituted allocation, which the org's owners can
* see — TOFU + audit, human-envelopes D4's tier);
* 3. the envelope-recipients read says whether THIS fingerprint is
* covered — if not, `VAULT_ENVELOPE_PENDING` names the key-holders
* who can fulfil and the exact next actions (never a terminal error:
* the desired state already records this member; any key-holder's
* next vault operation wraps);
* 4. the base envelope is fetched, opened, and the repo file written
* `restored_from_envelope` with the genesis hash PINNED (a later open
* seeing a different genesis for this repo_id refuses
* `VAULT_CREATION_CONFLICT`).
*
* Rotation epochs are NOT opened here — `verifyToNewest` walks them and
* opens each rotation-scoped envelope this identity is included in, exactly
* as it does for every other reader (`openEpochRotationForRecipient`).
*
* Returns `null` when the repo file already existed (nothing restored).
*/
/**
* vault-multi-writer (rev 47) — the vault's CURRENT admitted writer set
* for a cold open, computed from the chain itself: every admitted head is
* hash-checked against the listing, signature-verified under the writer
* the chain admits at that point, and its `add_writer_key` /
* `writer_set_update` transitions applied in order — the same rules the
* full verifying walk enforces, minus decryption (a cold open holds no
* K_repo yet). Fails closed on any defect: a non-genesis wrapper is only
* trusted when the chain vouches for it.
*/
async resolveAdmittedWritersForColdOpen(genesis, genesisBytes) {
let writerState = initialWriterState(this.repoId, genesis);
let prevSha256 = sha256Hex(genesisBytes);
let prevEpoch = genesis.epoch;
let expectedGeneration = nextGeneration(VAULT_GENESIS_GENERATION);
let progress = { after_generation: VAULT_GENESIS_GENERATION, last_generation: VAULT_GENESIS_GENERATION, delivered: 0 };
let request = { after_generation: VAULT_GENESIS_GENERATION, limit: String(VAULT_MAX_HEADS_PER_LISTING_PAGE) };
for (;;) {
const page = await this.transport.listHeads({ repo_id: this.repoId, ...request });
progress = verifyHeadsListingPage(page, request, progress, this.repoId);
for (const entry of page.heads) {
const bytes = await this.readCachedHeadBytes(entry.generation, entry.stored_bytes_sha256);
if (!bytes)
fail("CHAIN_BROKEN", `listed head ${entry.generation} is absent from storage`, "resolving the vault's writer set for a cold open", { generation: entry.generation });
const head = parseVaultStrict(new TextDecoder().decode(bytes));
const isWriterAdd = head.transition !== null && head.transition.kind === "add_writer_key";
let addWriterKeyPayload = null;
let signerPubkey;
let signerKeyId;
if (isWriterAdd) {
addWriterKeyPayload = parseAddWriterKeyPayload(head);
const v = validateAddWriterKeyPayload(this.repoId, writerState, addWriterKeyPayload, head.writer_key_id);
if (!v.ok)
fail(v.code, `head ${head.generation}: ${v.detail}`, "resolving the vault's writer set for a cold open", { generation: head.generation });
if (addWriterKeyPayload.authorization.kind === "writer") {
const signer = resolveActiveWriter(writerState, head.writer_key_id);
signerPubkey = signer.signing_pubkey;
signerKeyId = signer.writer_key_id;
}
else {
signerPubkey = addWriterKeyPayload.added_writer.signing_pubkey;
signerKeyId = addWriterKeyPayload.added_writer.writer_key_id;
}
}
else {
const signer = resolveActiveWriter(writerState, head.writer_key_id);
if (!signer)
fail("VAULT_WRITER_NOT_ADMITTED", `head ${head.generation}: writer_key_id ${head.writer_key_id} is not a currently admitted writer`, "resolving the vault's writer set for a cold open", { generation: head.generation });
signerPubkey = signer.signing_pubkey;
signerKeyId = signer.writer_key_id;
}
checkChainLink({ head, stored_bytes: bytes, listed_sha256: entry.stored_bytes_sha256, expected_generation: expectedGeneration, prev_sha256: prevSha256, repo_id: this.repoId, writer_public_key: signerPubkey, writer_key_id: signerKeyId, prev_epoch: prevEpoch });
if (head.transition !== null && head.transition.kind === "rotate_epoch") {
const rotationPayload = parseRotateEpochPayload(head);
if (rotationPayload.writer_set_update) {
const wsu = rotationPayload.writer_set_update;
const declaredBlocked = new Set(wsu.removed.map((r) => r.writer_key_id));
const wv = validateWriterSetUpdate(this.repoId, writerState, wsu, head.writer_key_id, declaredBlocked, wsu.writers.length === 0);
if (!wv.ok)
fail(wv.code, `head ${head.generation}: ${wv.detail}`, "resolving the vault's writer set for a cold open", { generation: head.generation });
writerState = applyWriterSetUpdate(this.repoId, writerState, wsu.removed.map((r) => r.writer_key_id));
}
}
if (isWriterAdd && addWriterKeyPayload) {
const consumedHandoffId = addWriterKeyPayload.authorization.kind === "handoff" ? (addWriterKeyPayload.authorization.grant.handoff_id ?? null) : null;
writerState = applyAddWriterKey(this.repoId, writerState, { writer_key_id: addWriterKeyPayload.added_writer.writer_key_id, signing_pubkey: addWriterKeyPayload.added_writer.signing_pubkey }, consumedHandoffId);
}
prevSha256 = entry.stored_bytes_sha256;
prevEpoch = head.epoch;
expectedGeneration = nextGeneration(head.generation);
}
if (!page.has_more)
break;
request = { ...request, cursor: page.next_cursor };
}
return writerState;
}
async ensureRepoState() {
if (this.keystore.readRepo(this.repoId))
return null;
const identity = this.keystore.readIdentity();
if (!identity) {
fail("KEYSTORE_MISSING", "no vault identity in the keystore", "opening vault", undefined, [
{ action: "restore ~/.config/run402/vault from backup or accept vault loss" },
...crossProfileVaultHint(this.repoId),
]);
}
const ownKeypair = this.keystore.encryptionKeypair(identity);
if (!ownKeypair) {
fail("VAULT_UNRECOVERABLE", VAULT_TERMINAL_LOSS_DOCTOR_TEXT, "opening vault", { statement: VAULT_TERMINAL_LOSS_STATEMENT, repo_id: this.repoId });
}
const ownFingerprint = identity.encryption_fingerprint;
const genesisBytes = await this.transport.getGenesis({ repo_id: this.repoId });
if (!genesisBytes)
fail("CHAIN_BROKEN", "the vault has no admitted genesis", "opening vault", { repo_id: this.repoId });
const genesis = parseVaultStrict(new TextDecoder().decode(genesisBytes));
if (genesis.epoch !== VAULT_GENESIS_EPOCH) {
fail("VAULT_CREATION_CONFLICT", `the served genesis declares epoch ${genesis.epoch}, not the genesis epoch`, "opening vault", { repo_id: this.repoId, epoch: genesis.epoch });
}
if (genesis.repo_id !== this.repoId) {
fail("VAULT_CREATION_CONFLICT", `the served genesis names repo ${genesis.repo_id}, not ${this.repoId}`, "opening vault", { repo_id: this.repoId, served: genesis.repo_id });
}
// Platform-attested creator anchor. The allocation is optional on the
// wire (older gateways) — absent means the restore is unauthenticated
// salvage, and the result says so.
let allocationAttested = false;
const record = await this.transport.getVaultRecord({ repo_id: this.repoId });
const allocation = record.allocation ?? null;
if (!allocation && !this.allowUnauthenticatedSalvage) {
// Consult round 2 §5: an absent allocation on an ordinary open is not a
// downgrade to salvage — it is a refusal. A malicious or broken serving
// path that cannot forge the SIGNED allocation must not get a vault
// opened by simply omitting it.
fail("VAULT_ALLOCATION_UNATTESTED", "the control plane returned no signed allocation record for this vault — refusing an unattested cold restore (recovery flows may opt into explicit unauthenticated salvage)", "opening vault", { repo_id: this.repoId });
}
if (allocation) {
const genesisEk = ekFingerprint(fromBase64url(genesis.creator_encryption_pubkey, "genesis.creator_encryption_pubkey"));
const genesisVk = vkFingerprint(fromBase64url(genesis.creator_signing_pubkey, "genesis.creator_signing_pubkey"));
if (genesisEk !== allocation.creator_encryption_fingerprint || genesisVk !== allocation.creator_signing_fingerprint) {
fail("VAULT_CREATION_CONFLICT", "the served genesis's creator keys do not match the control plane's signed allocation record for this vault — refusing a substituted genesis", "opening vault", { repo_id: this.repoId, genesis_creator_encryption_fingerprint: genesisEk, allocation_creator_encryption_fingerprint: allocation.creator_encryption_fingerprint, genesis_creator_signing_fingerprint: genesisVk, allocation_creator_signing_fingerprint: allocation.creator_signing_fingerprint });
}
allocationAttested = true;
}
const coverage = await this.transport.listEnvelopeRecipients({ repo_id: this.repoId });
if (!coverage.recipient_fingerprints.includes(ownFingerprint)) {
// Who can fulfil: every current directory recipient that IS covered
// (they hold K_repo), named by principal id — best-effort, the read may
// 403 for a caller whose membership is still propagating.
let keyHolders = [];
try {
const directory = await this.transport.listOrgEncryptionKeys({ org_id: genesis.org_id });
keyHolders = directory.keys
.filter((k) => coverage.recipient_fingerprints.includes(k.ek_fingerprint))
.map((k) => ({ principal_id: k.principal_id, display_name: k.display_name ?? null, ek_fingerprint: k.ek_fingerprint }));
}
catch {
// best-effort — the refusal is complete without the roster
}
const desired = coverage.desired?.find((d) => d.ek_fingerprint === ownFingerprint) ?? null;
fail("VAULT_ENVELOPE_PENDING", `this keystore (fingerprint ${ownFingerprint}) is not yet a recipient on vault ${this.repoId} — no key_envelope has been wrapped for it; a key-holder's next vault operation fulfils the org's desired state`, "opening vault", {
repo_id: this.repoId,
org_id: genesis.org_id,
own_fingerprint: ownFingerprint,
desired_state: desired ? desired.status : (coverage.desired ? "not_desired" : "unknown"),
key_holders: keyHolders,
covering_recipient_count: coverage.recipient_fingerprints.length,
}, [
{ action: "run402 repos access", why: "poll — this_keystore.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; nothing else is required of them" },
]);
}
const envelopeBytes = await this.transport.getObject({ repo_id: this.repoId, path: vaultPaths.envelope(genesis.epoch, ownFingerprint) });
if (!envelopeBytes) {
fail("VAULT_EPOCH_NOT_OPENABLE", `the envelope-recipients read lists this keystore (fingerprint ${ownFingerprint}) as covered, but its base key_envelope could not be retrieved — a server-side or network problem, not a problem with your key; retry`, "opening vault", { repo_id: this.repoId, epoch: genesis.epoch, recipient_fingerprint: ownFingerprint, reason: "envelope_fetch_failed" });
}
const envelope = parseVaultStrict(new TextDecoder().decode(envelopeBytes));
if (envelope.repo_id !== this.repoId || envelope.epoch !== genesis.epoch || envelope.recipient_fingerprint !== ownFingerprint) {
fail("VAULT_ENVELOPE_ALTERED", "the retrieved base key_envelope does not address this vault, epoch, and recipient — refusing to open it", "opening vault", { repo_id: this.repoId, epoch: genesis.epoch, recipient_fingerprint: ownFingerprint, envelope: { repo_id: envelope.repo_id, epoch: envelope.epoch, recipient_fingerprint: envelope.recipient_fingerprint } });
}
// rev 47 (writers plural): an envelope wrapped by a non-genesis writer is
// verified under THAT writer's key, which only the chain can vouch for —
// walk the admitted heads (signatures + writer transitions, no
// decryption) exactly when the wrapper is not the genesis writer.
const admittedWriters = envelope.created_by === genesis.writer_key_id ? [] : (await this.resolveAdmittedWritersForColdOpen(genesis, genesisBytes)).writers;
const restored = await this.keystore.restoreRepoFromEnvelope({ genesis, envelope, allocation_attested: allocationAttested, admitted_writers: admittedWriters });
this.keystore.writeCachedGenesis(this.repoId, restored.repo.genesis_sha256, genesisBytes);
this.genesisCache = { genesis, sha256: restored.repo.genesis_sha256 };
return {
repo_id: this.repoId,
org_id: genesis.org_id,
provenance: "restored_from_envelope",
trust: restored.trust,
continuity: restored.continuity,
independently_verified: restored.independently_verified,
epoch: envelope.epoch,
recipient_fingerprint: ownFingerprint,
};
}
/** {@link open}, but a missing repo file triggers {@link ensureRepoState} first. */
static async openOrRestore(options) {
const v = new Vault(options);
const restored = await v.ensureRepoState();
v.repoFile();
return { vault: v, restored };
}
kRepo() { return hexToBytes(this.repoFile().k_repo_hex); }
epoch() { return this.repoFile().epoch; }
/**
* vault-clone-scaling (P3): staleness of the newest checkpoint coverage
* this checkout has locally learned, measured at `newestGeneration`. Reads
* the keystore AFTER the caller's own persist (a checkpoint-form push has
* already recorded its fresh coverage by the time its result is built), so
* a compacting push reports itself current. Pure + never-throwing by way of
* the helper; unknown coverage reads as `{0, advised: false}` — silent.
*/
checkpointStalenessNow(newestGeneration) {
return vaultCheckpointStaleness({ newest_generation: newestGeneration, covers_through_generation: this.repoFile().checkpoint_covers_through ?? null });
}
/**
* Fetch many generation-addressed heads' bytes ahead of the ordered walk,
* keyed by head path.
*
* BATCH-FIRST (vault-batched-head-reads task 4.2): one
* `POST …/head-reads` carries a whole page's bytes. Heads deliberately do
* NOT ride `getObjects` — the `object-reads` presign batch is CARRIER-ONLY
* by wire design (see `getObjectsBytes`'s fail-closed doc comment; the live
* probe that caught this recorded `getObjects paths=67 FAILED 1ms` followed
* by 25 serial singles), which is exactly why the batch route had to be its
* own thing.
*
* FALLBACK (the shipped vault-clone-scaling P2 shape): on ANY
* unsupported answer — an older gateway, a refusal, a fault — head bytes
* parallelize as the SAME direct GETs the ordered walk itself would issue,
* just early and overlapped at {@link VAULT_TRANSPORT_CONCURRENCY}. The
* transport remembers a route-absent verdict, so the probe is paid once per
* client, not once per window.
*
* Either way a per-head failure simply leaves that slot EMPTY — the walk's
* own read owns that failure and its envelope — and results are raw and
* UNTRUSTED: callers sha-check before use, per `walkPrefetch`'s contract.
* That is what keeps this a transport change and never a trust change.
*/
async prefetchHeadsConcurrent(generations) {
const map = new Map();
if (generations.length === 0)
return map;
// The route's grammar is strictly ascending; the backward-window call site
// asks in DESCENDING order, so sort for the wire and reassemble by
// generation rather than by position.
const ascending = [...new Set(generations)].sort();
// `getHeads` post-dates the transport interface, so a caller-supplied
// transport built against an earlier SDK simply has no such method —
// indistinguishable, here, from a gateway that lacks the route.
const batched = typeof this.transport.getHeads === "function"
? await this.transport.getHeads({ repo_id: this.repoId, generations: ascending }).catch(() => null)
: null;
if (batched && batched.length === ascending.length) {
for (let i = 0; i < ascending.length; i++) {
const b = batched[i];
if (b)
map.set(vaultPaths.head(ascending[i]), b);
}
return map;
}
const paths = generations.map((g) => vaultPaths.head(g));
const fetched = await mapBounded(paths, VAULT_TRANSPORT_CONCURRENCY, (path) => this.transport.getObject({ repo_id: this.repoId, path }).catch(() => null));
for (let i = 0; i < paths.length; i++) {
const b = fetched[i];
if (b)
map.set(paths[i], b);
}
return map;
}
git() {
if (!this.repoDir)
fail("VAULT_REPO_DIR_REQUIRED", "this operation needs the local git repository (repo_dir)", "vault publication");
return this.repoDir;
}
/** Fetch + pin-check the genesis (the writer key source). */
async genesis() {
if (this.genesisCache)
return this.genesisCache;
const repo = this.repoFile();
// Design D3: genesis is one small, immutable object per vault — cached
// forever beside the keystore's per-repo state, re-verified against the
// pinned `genesis_sha256` on every use exactly like a network read.
const cached = this.keystore.readCachedGenesis(this.repoId);
let bytes = cached && sha256Hex(cached.bytes) === repo.genesis_sha256 ? cached.bytes : null;
if (!bytes) {
bytes = await this.transport.getGenesis({ repo_id: this.repoId });
if (bytes && sha256Hex(bytes) === repo.genesis_sha256)
this.keystore.writeCachedGenesis(this.repoId, repo.genesis_sha256, bytes);
}
if (!bytes)
fail("CHAIN_BROKEN", "the vault has no admitted genesis", "reading vault genesis", { repo_id: this.repoId });
const sha256 = sha256Hex(bytes);
if (sha256 !== repo.genesis_sha256)
fail("VAULT_CREATION_CONFLICT", `the admitted genesis (${sha256}) is not the pinned one (${repo.genesis_sha256}); refusing a substituted vault`, "reading vault genesis", { admitted: sha256, pinned: repo.genesis_sha256 });
const genesis = parseVaultStrict(new TextDecoder().decode(bytes));
this.genesisCache = { genesis, sha256 };
return this.genesisCache;
}
// ── §6.3/6.4 discovery + verification ──
/**
* vault-composite-state-read design D1 — the pin-current fast path
* `verifyToNewest` tries FIRST: one `GET …/state` in place of BOTH the
* live "server still holds the pin" read {@link readHead} would otherwise
* perform AND, when eligible, the `listHeads` walk that would follow it.
*
* `null` means ineligible — the caller falls straight through to the
* UNCHANGED `readHead` + `listHeads` flow, so a `null` here never weakens
* verification, it only declines the shortcut:
* - the vault is genuinely more than one generation ahead of `pin`
* (the listing-walk shape this change does not touch, per design D1:
* "a client whose pin is >1 behind newest_generation falls back to
* the existing paginated listing + per-head walk");
* - OR (one-generation-ahead only) the D194 epoch-continuity check the
* ONE new head needs `pin`'s own `.epoch` for, and this call declines
* to fetch `pin`'s own head bytes over the network — the entire point
* of the shortcut — so it needs a LOCAL source for that epoch: either
* `pin` is genesis (a fixed, known epoch), or `pin`'s own head bytes
* are already cache-warm (from an earlier call, or from `admit()`'s
* own post-push cache write). A cold cache here is not a correctness
* problem, only a missed optimization.
*
* On a non-`null` return, `entries` is exactly what ONE real `listHeads`
* page's `heads[]` would have been for this pin (0 items when the pin is
* already current, 1 when it is exactly one generation behind — chain
* link, gaplessness, and signature all still verified by the UNCHANGED
* per-entry loop body {@link verifyToNewest} feeds them through), and
* `pinnedHead` is `lastHead`'s INITIAL value: the real, verified head at
* `pin.generation` when nothing new needs walking (so it is also the
* FINAL value — the loop never runs), or a throwaway placeholder when one
* new head is coming (the loop overwrites `lastHead` before anything else
* ever reads it again — see `verifyToNewest`'s own `prevEpoch` line,
* which is the ONLY thing that reads `lastHead`'s pre-loop value).
*
* Every byte this method reads from `getState` is cache-WARMED (head +
* both carriers, keyed exactly as {@link readCachedHeadBytes}/
* {@link openCarrier} already key their own writes) but NEVER trusted
* here — every reader downstream re-verifies a cache hit against the hash
* it would check network bytes against before using it (this file's own
* established cache discipline; see `VaultKeystore`'s class doc
* comment). A wrong or absent byte this method wrote is therefore just a
* cache MISS on the next read, never a verification bypass.
*/
async tryStateFastPath(pin, deltaSince, restore) {
// vault-delta-fetch: carry the caller's MATERIALIZED position as
// `since` — a capable gateway answers small spans with the heads +
// inline WAL packs in this same response; every other gateway/span
// simply omits `delta`. The RESTORE marker (what this git dir has
// actually applied) is the honest position when the caller supplies it:
// the chain-trust pin advances on every push from ANY checkout on this
// keystore, so pin-as-since would report "current" for a standing clone
// that is generations behind — exactly the multi-checkout shape the
// bench runs.
//
// `restore` (vault-restore-recipe design D2/D6) rides the SAME state
// read — never a second round trip — so the caller (`restoreObjectsInto`,
// via `materialize`/`verifyToNewest`) declares it only on the wholesale
// shape (see that method's own doc comment). This call declining the
// shortcut entirely (more than one generation behind `pin`) also means
// no plan is ever requested on this attempt — the listing-walk path that
// owns catch-up already has its own batched primitives.
const state = await this.transport.getState({ repo_id: this.repoId, since: deltaSince ?? pin.generation, restore });
// §6.4: the vault's newest generation may never fall below the
// authenticated pin — checked here regardless of eligibility below, so
// a regressed vault is caught exactly as loudly as it always was, even
// when the walk that would otherwise discover it is about to be skipped.
checkGenerationRegression(state.newest_generation ?? VAULT_GENESIS_GENERATION, pin.generation);
// The gateway's own admission ledger advances `newest_generation` to the
// GENESIS generation (0) the moment genesis itself is admitted — a vault
// between "genesis admitted" and "first ordinary push" reports its own
// generation as newest, `null` only for the narrower window before that
// (kept here defensively; `state.head`/`state.carriers` are non-null
// whenever `newest_generation` is non-null on the real route). EITHER
// way, "no ORDINARY head yet" is the same case this file has always
// treated specially: genesis is a DIFFERENT stored-object shape
// (`vault_genesis` — no `ref_state`/`retention_roots`, {@link
// Vault.genesis} owns verifying it via its OWN cache), so this
// path must never parse `state.head` as a {@link VaultHead} when it
// is actually genesis's bytes.
if (state.delta)
this.consumeStateDelta(state.delta);
// vault-restore-recipe: stashed RAW (never verified here — see the
// field's own doc comment); a genesis-only response (below) still runs
// this line first, but `assembleVaultRestorePlan` never fires server-side
// before an ordinary head exists, so `state.restore_plan` is simply
// absent in that window — nothing to stash either way.
if (state.restore_plan)
this.stateRestorePlan = state.restore_plan;
const noOrdinaryHeadYet = state.newest_generation === null || state.newest_generation === VAULT_GENESIS_GENERATION;
const pinBig = generationToBigInt(pin.generation);
const newestBig = noOrdinaryHeadYet ? 0n : generationToBigInt(state.newest_generation);
const diff = newestBig - pinBig; // ≥ 0n, guaranteed by the regression check above (which treats `null`/genesis identically to this)
if (diff === 0n) {
if (noOrdinaryHeadYet)
return { entries: [], pinnedHead: null, prevEpoch: VAULT_GENESIS_EPOCH }; // matches today's `lastHead = null` for a genesis-only vault
if (!state.head)
fail("CHAIN_BROKEN", "the vault state reports an admitted generation but carries no head bytes", "verifying vault chain", { generation: state.newest_generation });
const sha = sha256Hex(state.head.stored_bytes);
if (sha !== pin.head_sha256)
fail("CHAIN_BROKEN", `pinned head ${pin.generation} no longer hashes to the pin`, "reading pinned vault head", { generation: pin.generation });
this.keystore.writeCachedHead(this.repoId, pin.generation, sha, state.head.stored_bytes);
const head = parseVaultStrict(new TextDecoder().decode(state.head.stored_bytes));
if (state.carriers)
this.warmStateCarrierCache(pin.generation, head, state.carriers);
return { entries: [], pinnedHead: head, prevEpoch: head.epoch };
}
if (diff === 1n) {
let prevEpoch;
if (pin.generation === VAULT_GENESIS_GENERATION) {
prevEpoch = VAULT_GENESIS_EPOCH;
}
else {
const cached = this.keystore.readCachedHead(this.repoId, pin.generation);
if (!cached || sha256Hex(cached.bytes) !== pin.head_sha256)
return null; // cold cache — decline the shortcut, never weaken it
prevEpoch = parseVaultStrict(new TextDecoder().decode(cached.bytes)).epoch;
}
if (!state.head)
fail("CHAIN_BROKEN", "the vault state reports a newer generation but carries no head bytes", "verifying vault chain", { generation: state.newest_generation });
const newestGeneration = state.newest_generation;
const shaOfBytes = sha256Hex(state.head.stored_bytes);
if (shaOfBytes !== state.head.stored_bytes_sha256)
fail("CHAIN_BROKEN", `head ${newestGeneration}: stored bytes hash does not match its own declared hash`, "verifying head chain", { generation: newestGeneration });
this.keystore.writeCachedHead(this.repoId, newestGeneration, shaOfBytes, state.head.stored_bytes);
const head = parseVaultStrict(new TextDecoder().decode(state.head.stored_bytes));
if (state.carriers)
this.warmStateCarrierCache(newestGeneration, head, state.carriers);
// `pinnedHead` is a throwaway — see this method's own doc comment: the
// ONE loop iteration below overwrites `lastHead` before anything but
// `prevEpoch` (already resolved above) ever reads it again.
return { entries: [{ generation: newestGeneration, stored_bytes_sha256: shaOfBytes }], pinnedHead: null, prevEpoch };
}
return null; // more than one generation behind — the existing listHeads walk owns this
}
/**
* Absorb a state read's delta (vault-delta-fetch): heads blind-warm the
* SAME keystore head cache every walk already re-verifies on read (the
* established cache discipline — a wrong byte is a miss, never a bypass),
* gated only on each entry's self-consistency; packs stash into the
* transient {@link stateDeltaPacks} buffer for the next restore, which
* hash-checks each against its carrying head's receipt before use.
*/
consumeStateDelta(delta) {
for (const h of delta.heads) {
const sha = sha256Hex(h.stored_bytes);
if (sha !== h.stored_bytes_sha256)
continue; // self-inconsistent — drop, the walk's own read owns it
this.keystore.writeCachedHead(this.repoId, h.generation, sha, h.stored_bytes);
}
if (delta.packs.length > 0) {
this.stateDeltaPacks = new Map(delta.packs.map((pk) => [pk.object_id, pk.bytes]));
}
}
/**
* Warm the D3 carrier cache from a `GET …/state` response's two carriers,
* keyed by the SAME `(object_id, ciphertext_sha256)` the carrying head's
* own receipts name — a BLIND write (see {@link tryStateFastPath}'s doc
* comment: every reader re-verifies a cache hit before trusting it, so
* this is safe by construction). Skips a `null` carrier (absent stored
* bytes) entirely rather than caching an absence — the existing
* `openCarrier`/`decodeCarrierFrame` machinery already has its own
* "frame absent" handling (`CHAIN_UNUSABLE`) via a genuine cache miss.
*/
warmStateCarrierCache(generation, head, carriers) {
if (carriers.ref_state)
this.keystore.writeCachedCarrier(this.repoId, head.ref_state.object_id, generation, head.ref_state.ciphertext_sha256, carriers.ref_state);
if (carriers.retention_roots)
this.keystore.writeCachedCarrier(this.repoId, head.retention_roots.object_id, generation, head.retention_roots.ciphertext_sha256, carriers.retention_roots);
}
/**
* List from the authenticated pin and verify every link upward. Persists
* the verified prefix after each page, so a `VERIFICATION_BUDGET_EXCEEDED`
* continues rather than restarts. Returns the newest verified state.
*
* `options.persist` (default `true`, repo-surface-consolidation task 3.3 —
* `repos fsck --no-write`'s audit mode): when `false`, the chain is walked
* and verified exactly the same way, but every `keystore.updateRepo(...)`
* write below is skipped — no local trust pin advances. A
* `VERIFICATION_BUDGET_EXCEEDED` pause in this mode persists nothing, so a
* retry restarts from the ORIGINAL pin rather than resuming — the honest
* consequence of asking for a no-write audit and then walking off the end
* of one call's budget.
*
* The chain walk itself (`checkChainLink`, `assertNoTransition`, collecting
* `rotations[]`) is ALWAYS keyless — an admitted `rotate_epoch` transition
* never stops it (D193, rev 42), so `generation`/`head` here are the
* genuinely chain-verified newest, independent of whether this principal
* can decrypt anything past a rotation it cannot open.
*
* `options.decryptValidate` (default `false`, Part C — `repos fsck`'s
* decrypt-validation pass): additionally opens every `rotate_epoch`
* envelope needed and decrypts each walked generation's OWN
* `ref_state`/`retention_roots` as it goes — the "main object" restoration
* needs per generation — persisting newly-opened epoch keys via
* `keystore.recordEpochRotation` exactly like an ordinary rotation
* producer/consumer would, and counting each decrypt attempt as an EXTRA
* unit against the same `this.budget` (decryption is the expensive step).
* `options.strict` (default `true`) throws immediately, fail-closed, on the
* first `VAULT_EPOCH_NOT_OPENABLE` / AEAD failure it hits — the ordinary
* `materialize()` read path's behavior. `strict: false` (fsck's tolerant
* mode) instead records `decrypt.failure` and stops attempting further
* decrypts while the pure chain walk keeps going — this is exactly what
* makes `chain_verified_to` (this call's `generation`) and `decryptable_to`
* (`decrypt.decryptable_to_generation`) able to differ honestly.
*/
async verifyToNewest(options = {}) {
const persist = options.persist ?? true;
const decryptValidate = options.decryptValidate ?? false;
const strict = options.strict ?? true;
const { genesis, sha256: genesisSha } = await this.genesis();
// vault-multi-writer rev 47: `writerKey`/`writerKeyId` (kept for the
// rotation-envelope open below, which is signed by whoever DROVE that
// specific rotation — resolved per-rotation from `writerState`, not this
// fallback) name just the genesis creator, retained as the seed for
// `writerState` and as the fallback signer for methods that do not
// resolve a per-rotation writer.
const writerKey = genesis.creator_signing_pubkey;
const writerKeyId = genesis.writer_key_id;
const repo = this.repoFile();
let pin = repo.verified_prefix ?? repo.head_pin ?? { generation: VAULT_GENESIS_GENERATION, head_sha256: genesisSha, pinned_at: formatVaultTimestamp(this.now()) };
// vault-multi-writer rev 47 (task 5.1): the writer set as chain state.
// Trusted as the starting point at `pin`'s generation exactly the same
// way `pin` itself is trusted as the starting point for the head chain —
// `writer_set_pin` advances in LOCKSTEP with `head_pin`/`verified_prefix`
// (see `VaultRepoFile.writer_set_pin`'s own doc comment), so a repeat
// call never re-walks the writer set from genesis. `initialWriterState`
// covers the true first-ever call (no persisted pin yet).
//
// `burnedWriterKeyIds`/`consumedHandoffIds` are NOT persisted and reset
// to empty on every resume — deliberately, not an oversight. This is safe
// for THIS function's own job (verifying an ALREADY-ADMITTED chain):
// the gateway is the sole admission authority, so a burned key or a
// consumed handoff_id can never appear in a real `add_writer_key`
// transition on the actual chain — the gateway already refused it before
// it could be admitted. These two sets only matter as a CLIENT-SIDE
// pre-check before PROPOSING a new transition (tasks 5.4/5.5), where an
// incomplete local set costs at most a wasted round trip — the gateway's
// own fence-time re-check (which DOES hold the complete history) is the
// real backstop either way. If a future caller ever needs these complete
// across a resume, persist them on `writer_set_pin` too; not needed yet.
//
// `retired_writers` (the keys of writers a later `writer_set_update`
// removed) IS persisted: a head such a writer signed while active is
// re-read later by the decrypt catch-up, a restore, or a checkpoint
// walk, and only its own key verifies it. A pin written before that
// field existed cannot vouch for those heads, so the walk starts over
// from genesis once (the same walk a fresh clone runs) and re-pins with
// the complete bookkeeping.
const legacyWriterPin = repo.writer_set_pin != null && repo.writer_set_pin.retired_writers === undefined;
if (legacyWriterPin)
pin = { generation: VAULT_GENESIS_GENERATION, head_sha256: genesisSha, pinned_at: formatVaultTimestamp(this.now()) };
let writerState = repo.writer_set_pin && !legacyWriterPin
? { version: repo.writer_set_pin.version, writers: repo.writer_set_pin.writers, sha256: repo.writer_set_pin.sha256, burnedWriterKeyIds: new Set(), consumedHandoffIds: new Set(), retiredWriters: repo.writer_set_pin.retired_writers ?? [] }
: initialWriterState(this.repoId, genesis);
const persistWriterSetPin = () => {
if (!persist)
return;
this.keystore.updateRepo(this.repoId, { writer_set_pin: { version: writerState.version, sha256: writerState.sha256, writers: [...writerState.writers], retired_writers: [...(writerState.retiredWriters ?? [])], pinned_at: formatVaultTimestamp(this.now()) } });
};
// vault-composite-state-read design D1: try the pin-current fast path
// FIRST — see {@link tryStateFastPath}'s own doc comment. `null` means
// ineligible (genuinely more than one generation behind, or the pin's
// own epoch could not be resolved without the network read this path
// exists to avoid) — the caller falls straight through to the UNCHANGED
// readHead + listHeads flow below, byte-identical to before this change.
// `options.restore` (vault-restore-recipe) rides along unchanged —
// declined exactly when the fast path itself is declined, so a caller
// more than one generation behind never gets (or needs) a plan here.
const fastPath = await this.tryStateFastPath(pin, options.deltaSince, options.restore);
let lastHead = fastPath ? fastPath.pinnedHead : pin.generation === VAULT_GENESIS_GENERATION ? null : await this.readHead(pin.generation, pin.head_sha256);
const anchor = pin.generation;
let prevEpoch = fastPath ? fastPath.prevEpoch : (lastHead?.epoch ?? VAULT_GENESIS_EPOCH);
let progress = { after_generation: anchor, last_generation: anchor, delivered: 0 };
let request = { after_generation: anchor, limit: String(VAULT_MAX_HEADS_PER_LISTING_PAGE) };
let verified = 0;
const rotations = [];
// vault-clone-scaling (P3): coverage this walk LEARNS. A checkpoint
// head names it outright; a walk anchored at GENESIS that completes
// without seeing one proves coverage = genesis. A partial (non-genesis)
// walk that sees none proves nothing and persists nothing.
let walkCheckpointCoverage = null;
const walkedFromGenesis = anchor === VAULT_GENESIS_GENERATION;
// Consumed by (at most) the FIRST for(;;) iteration below — a listHeads
// page this call never had to ask the network for, because the state
// read above already proved it (0 entries: pin already current; 1 entry:
// exactly the ONE new head, chain-linked from `pin` the SAME way a real
// listHeads page's entry would be).
let syntheticEntries = fastPath ? fastPath.entries : null;
// ── decrypt-validation state (opt-in) ──
const identity = decryptValidate ? this.keystore.readIdentity() : null;
const ownKeypair = identity ? this.keystore.encryptionKeypair(identity) : null;
const epochKeys = { ...(repo.epoch_keys ?? { [repo.epoch]: repo.k_repo_hex }) };
let decryptPin = repo.materialized_pin ?? null;
let decryptedRefState = null;
let decryptedRoots = null;
let decryptFailure = null;
let decryptFailureError = null;
const persistMaterializedIfAny = () => {
if (persist && decryptValidate && decryptPin)
this.keystore.updateRepo(this.repoId, { materialized_pin: decryptPin });
};
/**
* Open (if needed) `headPin`'s own rotation envelope and decrypt its
* `ref_state`/`retention_roots`. Shared by the per-head inline call below
* AND the post-loop catch-up call: a call with NOTHING new to walk (this
* repo's chain-verified pin is already at the newest generation) never
* enters the loop body at all, so the newest head's own decrypt still
* needs to run once, using whatever `epoch_keys` the keystore already
* persisted from an EARLIER call's rotation-envelope open.
*
* NEVER throws — a decrypt failure is recorded on the enclosing
* `decryptFailure`/`decryptFailureError` and this returns `false`. This
* is deliberate: chain verification (this call's OWN `generation`/
* `head_pin`, "highest_authenticated") is independent of decrypt
* capability and must walk the FULL chain regardless — exactly the
* existing "authenticated-but-undecryptable ⇒ CHAIN_UNUSABLE, read-only
* at the materialized pin" split this file's own header already
* documents. `strict` is enforced ONCE, at the very end of
* `verifyToNewest`, after the complete chain walk has run.
*/
const tryDecrypt = async (head, headPin, pendingRotations) => {
verified += 1; // decryption is the expensive step — counted separately against the same budget
// Tracks whichever rotation is currently being opened — the failure
// report below names THAT rotation's own generation/epoch/rotation_id,
// never `head`'s (which, in the backward catch-up call, can be a much
// LATER, ordinary-push generation entirely unrelated to which epoch
// actually failed to open).
let current = { generation: head.generation, epoch: head.epoch, rotation_id: null };
try {
for (const rot of pendingRotations) {
if (epochKeys[rot.epoch])
continue;
current = { generation: rot.generation, epoch: rot.epoch, rotation_id: rot.payload.rotation_id };
if (!identity || !ownKeypair) {
fail("VAULT_EPOCH_NOT_OPENABLE", `no local vault identity — cannot open epoch ${rot.epoch} (rotation ${rot.payload.rotation_id})`, "opening an epoch-rotation key envelope", { epoch: rot.epoch, rotation_id: rot.payload.rotation_id });
}
const ownFingerprint = ekFingerprint(ownKeypair.public_key);
const kE = await openEpochRotationForRecipient({
repo_id: this.repoId,
payload: rot.payload,
own_fingerprint: ownFingerprint,
own_encryption_keypair: ownKeypair,
// vault-multi-writer rev 47: the writer who actually drove
// THIS rotation (resolved by the forward walk when it collected
// `rot` — see `VaultEncounteredRotation.signing_pubkey`'s own
// doc comment for the one narrower gap this falls back for).
writer_signing_public_key: rot.signing_pubkey ?? writerKey,
get_envelope_bytes: (path) => this.transport.getObject({ repo_id: this.repoId, path }),
envelope_path: (epoch, fp, rotationId) => vaultPaths.envelope(epoch, fp, rotationId),
});
epochKeys[rot.epoch] = bytesToHex(kE);
if (persist)
this.keystore.recordEpochRotation(this.repoId, { new_epoch: rot.epoch, new_k_repo_hex: bytesToHex(kE) });
}
current = { generation: head.generation, epoch: head.epoch, rotation_id: null };
const kRepoHex = epochKeys[head.epoch];
if (!kRepoHex) {
fail("VAULT_EPOCH_NOT_OPENABLE", `no locally known key for epoch ${head.epoch} at generation ${head.generation}`, "materializing vault head", { epoch: head.epoch, generation: head.generation });
}
const kRepo = hexToBytes(kRepoHex);
// Design D2: ref_state + retention_roots both ride `head`, so one
// batched presign (or a cache hit) serves both instead of two
// independent presign-then-GET round trips.
// vault-multi-writer rev 47: `head`'s OWN signer, resolved from
// the writer state — by the time this runs, `writerState` already
// reflects `head`'s own transition (if any), so this correctly finds
// a just-self-admitted handoff writer too, not only a pre-existing one.
// Historical read: the head is already admitted, so a writer removed
// SINCE it was signed still verifies it (retired keys are kept).
const headSigner = resolveKnownWriter(writerState, head.writer_key_id)?.signing_pubkey ?? writerKey;
const { refState, roots } = await this.openMaterializeCarriers(head.ref_state, vaultPaths.refState(head.ref_state.object_id), head.retention_roots, vaultPaths.retentionRoots(head.retention_roots.object_id), headSigner, { epoch: head.epoch, k_repo: kRepo });
if (refState.generation !== head.generation || roots.generation !== head.generation)
fail("CHAIN_UNUSABLE", "carrier generation does not match the head", "materializing vault head");
decryptedRefState = refState;
decryptedRoots = roots;
decryptPin = { ...headPin };
return true;
}
catch (e) {
const code = isRun402Error(e) && e.code ? e.code : "VAULT_EPOCH_NOT_OPENABLE";
decryptFailure = { generation: current.generation, epoch: current.epoch, rotation_id: current.rotation_id, code, message: e instanceof Error ? e.message : String(e) };
decryptFailureError = e;
return false;
}
};
for (;;) {
// The synthetic page (0 or 1 entries) is only ever valid for the FIRST
// iteration — `has_more: false` guarantees `nextListingRequest` ends
// the loop right after it is consumed, so clearing it here is purely
// defensive (a real second iteration can never see it non-null).
const page = syntheticEntries !== null
? { format: VAULT_FORMAT, repo_id: this.repoId, after_generation: request.after_generation, heads: syntheticEntries, has_more: false, next_cursor: null, total: null }
: await this.transport.listHeads({ repo_id: this.repoId, ...request });
syntheticEntries = null;
progress = verifyHeadsListingPage(page, request, progress, this.repoId);
// vault-clone-scaling (bench P2): the page just verified names every
// entry's generation + stored_bytes_sha256, and the BYTES reads are
// independent — verification (and decryption) order is a LOCAL
// obligation. Bounded by the remaining verification budget, this
// page's per-head SERIAL reads are replaced by: the cache-missing
// HEAD bytes as bounded-CONCURRENT direct reads (heads cannot ride
// the object-reads presign batch — it is carrier-only by wire design;
// see prefetchHeadsConcurrent), then — the same split one level
// deeper — ONE batched getObjects for the ref_state/retention_roots
// FRAMES each decrypt-validated head will open (their paths +
// expected ciphertext hashes parse out of the head bytes just
// fetched; parsing is local CPU). Results land in the transient
// `walkPrefetch` map (NOT the keystore cache — its eviction window is
// smaller than a page; see the field's doc). Failure fidelity: only a
// hash-matching result is kept — an absent, mismatched, or
// unparseable entry (or a prefetch that fails outright) leaves its
// slot empty, and the ordered loop's own per-head reads reproduce the
// exact unbatched envelopes. Single-miss sets skip their prefetch
// (one direct read costs the same).
{
const prefetch = new Map();
this.walkPrefetch = prefetch;
try {
const wanted = page.heads.slice(0, Math.max(0, this.budget - verified));
const cachedHeadIfMatching = (e) => {
const cached = this.keystore.readCachedHead(this.repoId, e.generation);
return cached && sha256Hex(cached.bytes) === e.stored_bytes_sha256 ? cached.bytes : null;
};
const missingHeads = wanted.filter((e) => cachedHeadIfMatching(e) === null);
if (missingHeads.length > 1) {
const fetched = await this.prefetchHeadsConcurrent(missingHeads.map((e) => e.generation));
for (const e of missingHeads) {
const path = vaultPaths.head(e.generation);
const bytes = fetched.get(path) ?? null;
if (bytes && sha256Hex(bytes) === e.stored_bytes_sha256)
prefetch.set(path, bytes);
}
}
if (decryptValidate && wanted.length > 1) {
const carriers = [];
for (const e of wanted) {
const headBytes = prefetch.get(vaultPaths.head(e.generation)) ?? cachedHeadIfMatching(e);
if (!headBytes)
continue;
let parsed;
try {
parsed = parseVaultStrict(new TextDecoder().decode(headBytes));
}
catch {
continue; // the ordered loop's checkChainLink owns rejecting it
}
for (const w of [
{ receipt: parsed.ref_state, path: vaultPaths.refState(parsed.ref_state.object_id) },
{ receipt: parsed.retention_roots, path: vaultPaths.retentionRoots(parsed.retention_roots.object_id) },
]) {
const cached = this.keystore.readCachedCarrier(this.repoId, w.receipt.object_id);
if (cached && sha256Hex(cached.bytes) === w.receipt.ciphertext_sha256)
continue;
carriers.push({ path: w.path, sha: w.receipt.ciphertext_sha256 });
}
}
if (carriers.length > 1) {
const fetched = await this.transport.getObjects({ repo_id: this.repoId, paths: carriers.map((c) => c.path), expected: carriers.map((c) => c.sha) });
for (let i = 0; i < carriers.length; i++) {
const bytes = fetched[i] ?? null;
if (bytes && sha256Hex(bytes) === carriers[i].sha)
prefetch.set(carriers[i].path, bytes);
}
}
}
}
catch {
// A batch that fails OUTRIGHT (network, not a per-slot null) must
// not introduce a failure mode the unbatched walk never had — the
// ordered loop below re-reads what it needs itself and fails (or
// succeeds, if the fault was transient) with its own envelopes.
}
}
for (const entry of page.heads) {
if (verified >= this.budget) {
if (persist)
this.keystore.updateRepo(this.repoId, { verified_prefix: pin });
persistWriterSetPin();
persistMaterializedIfAny();
fail("VERIFICATION_BUDGET_EXCEEDED", persist
? `${verified} heads verified this call; the verified prefix (generation ${pin.generation}) is persisted — call again to continue`
: `${verified} heads verified this call in no-write mode; nothing was persisted — a retry restarts from the original pin, not generation ${pin.generation}`, "verifying vault chain", { verified_through: pin.generation, persisted: persist }, [{ action: persist ? "resume verification from the persisted verified prefix" : "re-run without --no-write to persist and resume incrementally, or re-run this same audit call again from the start" }]);
}
const bytes = await this.readCachedHeadBytes(entry.generation, entry.stored_bytes_sha256);
if (!bytes)
fail("CHAIN_BROKEN", `listed head ${entry.generation} is absent from storage`, "verifying vault chain", { generation: entry.generation });
const head = parseVaultStrict(new TextDecoder().decode(bytes));
// vault-multi-writer rev 47 (task 5.1): resolve WHO must have
// signed this specific head from the writer state as of the
// PREDECESSOR generation (`writerState`, not yet advanced past this
// head) — never the single fixed genesis key. A head carrying an
// admitted `add_writer_key` transition is the one case the signer
// might NOT already be in `writerState`: the "handoff" door is
// self-signed by the key being added, which is only proven legitimate
// by `validateAddWriterKeyPayload` succeeding on THIS SAME payload —
// so that validation runs BEFORE signature verification, not after,
// and its own success is what licenses trusting `added_writer.signing_pubkey`
// as the correct verification key.
const failClosedTransition = (code, detail) => {
if (persist)
this.keystore.updateRepo(this.repoId, { head_pin: pin, verified_prefix: null });
persistWriterSetPin();
persistMaterializedIfAny();
fail(code, `head ${head.generation}: ${detail}`, "verifying vault chain", { generation: head.generation });
};
const isWriterAdd = head.transition !== null && head.transition.kind === "add_writer_key";
let addWriterKeyPayload = null;
let resolvedWriterPubkey = writerKey;
let resolvedWriterKeyId = writerKeyId;
if (isWriterAdd) {
addWriterKeyPayload = parseAddWriterKeyPayload(head); // throws CHAIN_BROKEN on any wire-shape defect
const v = validateAddWriterKeyPayload(this.repoId, writerState, addWriterKeyPayload, head.writer_key_id);
if (!v.ok)
failClosedTransition(v.code, v.detail);
if (addWriterKeyPayload.authorization.kind === "writer") {
// The head's own signer is an EXISTING writer — same resolution as the ordinary case below.
const signer = resolveActiveWriter(writerState, head.writer_key_id); // non-null: v.ok already proved it
resolvedWriterPubkey = signer.signing_pubkey;
resolvedWriterKeyId = signer.writer_key_id;
}
else {
// "handoff" door: self-signed by the key being admitted — validated above, not yet in writerState.
resolvedWriterPubkey = addWriterKeyPayload.added_writer.signing_pubkey;
resolvedWriterKeyId = addWriterKeyPayload.added_writer.writer_key_id;
}
}
else {
const signer = resolveActiveWriter(writerState, head.writer_key_id);
if (!signer)
failClosedTransition("VAULT_WRITER_NOT_ADMITTED", `writer_key_id ${head.writer_key_id} is not a currently admitted writer`);
resolvedWriterPubkey = signer.signing_pubkey;
resolvedWriterKeyId = signer.writer_key_id;
}
checkChainLink({ head, stored_bytes: bytes, listed_sha256: entry.stored_bytes_sha256, expected_generation: nextGeneration(pin.generation), prev_sha256: pin.head_sha256, repo_id: this.repoId, writer_public_key: resolvedWriterPubkey, writer_key_id: resolvedWriterKeyId, prev_epoch: prevEpoch });
try {
assertNoTransition(head);
}
catch (e) {
// fail closed: pin stays BELOW the transition head; the verified prefix is cleared (this is the final state, not a budget pause)
if (persist)
this.keystore.updateRepo(this.repoId, { head_pin: pin, verified_prefix: null });
persistWriterSetPin();
persistMaterializedIfAny();
throw e;
}
const isRotation = head.transition !== null && head.transition.kind === "rotate_epoch";
let rotationPayload = null;
if (isRotation) {
// Pure/keyless (Part A's structural half — D202's join predicate
// needs only this): parses + self-checks the payload, but never
// opens an envelope. Collected regardless of decryptValidate so a
// later `materialize()` call over an already-chain-verified prefix
// can still resolve the epoch keys it needs.
rotationPayload = parseRotateEpochPayload(head);
rotations.push({ generation: head.generation, epoch: head.epoch, payload: rotationPayload, signing_pubkey: resolvedWriterPubkey });
// vault-multi-writer rev 47 (D227): writer REMOVAL rides
// rotate_epoch as an additive `writer_set_update` field. The
// "blocked set" gate inside `validateWriterSetUpdate` is a
// gateway-only, live-state fact this reader cannot independently
// re-derive (the module's own header doc names this boundary) —
// trusting the update's OWN declared `removed[]` as the "blocked
// set" input makes that ONE check a structural no-op while every
// OTHER invariant (base/next commitments, the writers-array
// arithmetic, the signer-survives-or-explicit-terminal rule) is
// still independently re-verified in full. Honest: this reader
// trusts the GATEWAY already answered "were these the right keys
// to remove" at admission time, same as it already trusts every
// other admission decision it did not itself make.
if (rotationPayload.writer_set_update) {
const wsu = rotationPayload.writer_set_update;
const declaredBlocked = new Set(wsu.removed.map((r) => r.writer_key_id));
const allowEmpty = wsu.writers.length === 0;
const wv = validateWriterSetUpdate(this.repoId, writerState, wsu, head.writer_key_id, declaredBlocked, allowEmpty);
if (!wv.ok)
failClosedTransition(wv.code, wv.detail);
writerState = applyWriterSetUpdate(this.repoId, writerState, wsu.removed.map((r) => r.writer_key_id));
}
}
if (isWriterAdd && addWriterKeyPayload) {
const consumedHandoffId = addWriterKeyPayload.authorization.kind === "handoff" ? addWriterKeyPayload.authorization.grant.handoff_id ?? null : null;
writerState = applyAddWriterKey(this.repoId, writerState, { writer_key_id: addWriterKeyPayload.added_writer.writer_key_id, signing_pubkey: addWriterKeyPayload.added_writer.signing_pubkey }, consumedHandoffId);
}
prevEpoch = head.epoch;
pin = { generation: head.generation, head_sha256: entry.stored_bytes_sha256, pinned_at: formatVaultTimestamp(this.now()) };
lastHead = head;
if (head.checkpoint)
walkCheckpointCoverage = head.checkpoint.covers_through_generation;
verified += 1;
// The chain walk ALWAYS continues below regardless of decrypt
// outcome (`tryDecrypt` never throws) — `!decryptFailure` just stops
// WASTING further decrypt attempts once one has failed, since every
// later generation shares the same unopenable epoch until (if ever)
// a LATER rotation this principal CAN open supersedes it.
// The inline open carries the SAME resolved writer the collected entry
// above does — a rotation driven by a non-genesis writer is opened under
// that writer, never the creator fallback.
if (decryptValidate && !decryptFailure)
await tryDecrypt(head, pin, rotationPayload ? [{ generation: head.generation, epoch: head.epoch, payload: rotationPayload, signing_pubkey: resolvedWriterPubkey }] : []);
}
// verified prefix persists per page (resumable) — skipped entirely in no-write mode
if (persist)
this.keystore.updateRepo(this.repoId, { verified_prefix: pin });
persistWriterSetPin();
persistMaterializedIfAny();
const next = nextListingRequest(request, page);
if (!next)
break;
request = next;
}
// P3: reaching here means the walk COMPLETED (a budget pause throws
// above) — persist whatever coverage it proved. A genesis-anchored walk
// is authoritative for its whole history, so no checkpoint seen means
// coverage = genesis, honestly.
if (persist) {
const learned = walkCheckpointCoverage ?? (walkedFromGenesis ? VAULT_GENESIS_GENERATION : null);
if (learned !== null)
this.keystore.updateRepo(this.repoId, { checkpoint_covers_through: learned });
}
// Catch-up: a call with NOTHING new to walk (this repo's chain-verified
// pin was already at `pin`/`lastHead` — e.g. an EARLIER, decrypt-blind
// `verifyToNewest({})` call already advanced `head_pin` to the newest,
// and THIS is the first decrypt-validating call) never entered the loop
// body above at all, so any `rotate_epoch` transitions between the
// newest and the highest epoch this call's `epochKeys` seed already
// covers were NEVER collected — walk BACKWARD via `prev_sha256` (the
// same re-read `chainFrom` uses) to find every one of them, stopping the
// INSTANT a generation's own epoch is already a known key (nothing
// earlier can matter: every generation before it decrypts under a key
// already held). Never a silent gap — this is exactly the mechanism
// that makes a vault readable end to end even when chain verification
// and decrypt-validation happened in genuinely SEPARATE calls.
if (decryptValidate && !decryptFailure && lastHead && !decryptedRefState) {
const backwardRotations = [];
let cur = lastHead;
let curPin = pin;
while (cur && !epochKeys[cur.epoch]) {
if (cur.transition !== null && cur.transition.kind === "rotate_epoch") {
const payload = parseRotateEpochPayload(cur);
// vault-multi-writer rev 47: best-effort resolution against the
// FINAL (post-forward-walk) writer state — correct unless `cur`'s
// own signer was removed by a LATER rotation between `cur` and
// `lastHead`, the one documented residual gap (falls back to the
// genesis-creator key at the `openEpochRotationForRecipient`
// call site when this resolves to `undefined`).
const curSigner = resolveKnownWriter(writerState, cur.writer_key_id)?.signing_pubkey;
backwardRotations.unshift({ generation: cur.generation, epoch: cur.epoch, payload, signing_pubkey: curSigner });
if (!rotations.some((r) => r.generation === cur.generation))
rotations.push({ generation: cur.generation, epoch: cur.epoch, payload, signing_pubkey: curSigner });
}
if (cur.generation === "0000000000000001") {
cur = null;
break;
}
const prevGen = bigIntToGeneration(generationToBigInt(cur.generation) - 1n);
// Design D3: this downward walk is rooted in `lastHead`, itself just
// chain-verified above (or trusted from an earlier call's own
// verification) — the same "freshness already established" argument
// {@link readCachedHeadBytes} documents, so a cache hit is safe here too.
const bytes = await this.readCachedHeadBytes(prevGen, cur.prev_sha256);
if (!bytes || sha256Hex(bytes) !== cur.prev_sha256)
fail("CHAIN_BROKEN", `head ${prevGen} does not match the chain during epoch-key catch-up`, "materializing vault head", { generation: prevGen });
cur = parseVaultStrict(new TextDecoder().decode(bytes));
curPin = { generation: prevGen, head_sha256: bytes ? sha256Hex(bytes) : curPin.head_sha256, pinned_at: formatVaultTimestamp(this.now()) };
}
rotations.sort((a, b) => (a.generation < b.generation ? -1 : a.generation > b.generation ? 1 : 0));
// `cur` (non-null, non-lastHead) is the BOUNDARY generation whose own
// epoch this call already holds a key for — establish `decryptPin`
// THERE first (this always succeeds: its epoch is, by the loop's own
// stop condition, already in `epochKeys`), so `refs`/`head_target`
// reflect a REAL decrypted generation even when every pending rotation
// toward `lastHead` then fails. Without this, a keystore that has
// never once materialized (no `materialized_pin` yet) reports
// `decryptable_to_generation: genesis` on an epoch-open failure, even
// though generations well before the failing rotation were genuinely
// decryptable all along.
if (cur && cur.generation !== lastHead.generation)
await tryDecrypt(cur, curPin, []);
if (!decryptFailure)
await tryDecrypt(lastHead, pin, backwardRotations);
}
if (persist)
this.keystore.updateRepo(this.repoId, { head_pin: pin, verified_prefix: null });
persistWriterSetPin();
persistMaterializedIfAny();
// `strict` (materialize()'s ordinary, fail-closed read path) is enforced
// HERE, once, after the full keyless chain walk has already run to
// completion — never mid-walk (see `tryDecrypt`'s own doc comment).
if (strict && decryptValidate && decryptFailure)
throw decryptFailureError;
return {
generation: pin.generation,
head_sha256: pin.head_sha256,
head: lastHead,
genesis,
rotations,
decrypt: decryptValidate
? {
decryptable_to_generation: decryptPin?.generation ?? VAULT_GENESIS_GENERATION,
ref_state: decryptedRefState,
retention_roots: decryptedRoots,
epoch_keys_hex: epochKeys,
failure: decryptFailure,
}
: null,
};
}
/**
* Read one NEWLY-LISTED head's raw bytes, trying the local cache first
* (design D3), re-verified against `expectedSha256` — the SAME check
* network bytes get. Safe to cache-serve ONLY because a caller here
* always supplies a hash a FRESH `listHeads` call just reported as
* current — the cache never substitutes for that freshness check, it just
* avoids re-downloading bytes a live listing already vouched for. A cache
* miss or a hash mismatch (never trusted, always falls through) fetches
* from the network and, on a match, refreshes the cache entry. Returns
* whatever the network returned on a final miss too (including `null`) —
* callers keep their own existing absent/mismatch handling unchanged.
*
* Deliberately NOT used by {@link readHead}: that call verifies the
* PINNED generation is STILL held by the server, with no fresh listing
* involved — its entire purpose is detecting server-side loss/rollback,
* which a cache read can never observe. That call always goes live.
*/
async readCachedHeadBytes(generation, expectedSha256) {
const cached = this.keystore.readCachedHead(this.repoId, generation);
if (cached && sha256Hex(cached.bytes) === expectedSha256)
return cached.bytes;
// vault-clone-scaling (P2): a page-prefetched head serves exactly as
// a network fetch would — sha-checked here against the SAME expected
// value, then cache-warmed. A miss/mismatch falls through to the read.
const path = vaultPaths.head(generation);
const prefetched = this.walkPrefetch?.get(path);
if (prefetched && sha256Hex(prefetched) === expectedSha256) {
this.keystore.writeCachedHead(this.repoId, generation, expectedSha256, prefetched);
return prefetched;
}
const bytes = await this.transport.getObject({ repo_id: this.repoId, path });
if (bytes && sha256Hex(bytes) === expectedSha256)
this.keystore.writeCachedHead(this.repoId, generation, expectedSha256, bytes);
return bytes;
}
/**
* Confirm the server STILL holds the pinned generation, unchanged —
* ALWAYS a live network read (see {@link readCachedHeadBytes}'s doc
* comment for why this specific check is not cacheable: it exists to
* detect server-side loss, which a cache can never observe). A
* successful read still WARMS the cache afterward — later reads of this
* SAME generation via the chain walk or restore benefit from it; only
* THIS call's own read is exempt.
*/
async readHead(generation, expectedSha256) {
const bytes = await this.transport.getObject({ repo_id: this.repoId, path: vaultPaths.head(generation) });
// Absent ⇒ the vault no longer holds a generation this client authenticated: a ROLLBACK, not a
// broken link (chain-005). Present-but-different bytes IS a broken link (substituted object).
if (!bytes) {
fail("GENERATION_REGRESSION", `the vault no longer holds the authenticated generation ${generation}; it regressed below the pin`, "reading pinned vault head", { generation }, [
{ action: "do not publish; escalate — the vault regressed below a generation this client authenticated" },
]);
}
if (sha256Hex(bytes) !== expectedSha256)
fail("CHAIN_BROKEN", `pinned head ${generation} no longer hashes to the pin`, "reading pinned vault head", { generation });
this.keystore.writeCachedHead(this.repoId, generation, expectedSha256, bytes);
return parseVaultStrict(new TextDecoder().decode(bytes));
}
/**
* Decrypt + verify one carrier's already-fetched ciphertext frame; any
* failure is `CHAIN_UNUSABLE`. Split out of {@link openCarrier} so a
* caller that fetched the frame itself (a cache hit, or one leg of a
* batched read) can reuse the same decode + identity/signature checks.
*
* `keyOverride` supplies the exact `(epoch, k_repo)` this carrier was
* sealed under — REQUIRED for any generation that is not necessarily
* under `this.epoch()`/`this.kRepo()` (this principal's CURRENT
* pointer), which is exactly the case across an epoch rotation; omitted
* call sites (checkpoint/prune paths untouched by this fold) keep the
* prior CURRENT-pointer behavior unchanged.
*/
decodeCarrierFrame(kind, receipt, frame, writerKey, keyOverride) {
const epoch = keyOverride?.epoch ?? this.epoch();
const kRepo = keyOverride?.k_repo ?? this.kRepo();
if (!frame)
fail("CHAIN_UNUSABLE", `${kind} ${receipt.object_id} is absent from storage`, "materializing vault head", { object_id: receipt.object_id }, [{ action: "stay read-only at the materialized pin; run the repair path" }]);
let plaintext;
try {
plaintext = openFrame({ k_obj: deriveObjectKey(kRepo, this.repoId, epoch, kind, receipt.object_id), repo_id: this.repoId, object_kind: kind, object_id: receipt.object_id, epoch, frame, expected_ciphertext_sha256: receipt.ciphertext_sha256 });
}
catch (e) {
fail("CHAIN_UNUSABLE", `${kind} ${receipt.object_id} cannot be opened: ${e.message}`, "materializing vault head", { object_id: receipt.object_id }, [{ action: "stay read-only at the materialized pin; run the repair path" }]);
}
const object = parseVaultStrict(new TextDecoder().decode(plaintext));
if (object.object_kind !== kind || object.object_id !== receipt.object_id || object.repo_id !== this.repoId || !verifyVaultObject(object, writerKey)) {
fail("CHAIN_UNUSABLE", `${kind} ${receipt.object_id} plaintext fails its identity/signature checks`, "materializing vault head", { object_id: receipt.object_id });
}
return object;
}
/**
* Fetch (network) + decrypt one carrier object by its receipt; any
* failure is `CHAIN_UNUSABLE`. Design D3: `ref_state`/`retention_roots`
* ciphertext is cached beside the keystore's per-repo state, re-verified
* against `receipt.ciphertext_sha256` on every use — a hit skips the
* network read entirely. `checkpoint_manifest` is never cached (outside
* D3's table). The cache write derives its generation tag from the
* DECODED object's own `generation` field, so no caller needs to thread
* one through by hand.
*/
async openCarrier(kind, receipt, path, writerKey, keyOverride) {
const cacheable = kind === "ref_state" || kind === "retention_roots";
const cached = cacheable ? this.keystore.readCachedCarrier(this.repoId, receipt.object_id) : null;
const cacheHit = cached && sha256Hex(cached.bytes) === receipt.ciphertext_sha256;
const frame = cacheHit ? cached.bytes : await this.transport.getObject({ repo_id: this.repoId, path, expected_sha256: receipt.ciphertext_sha256 });
const object = this.decodeCarrierFrame(kind, receipt, frame, writerKey, keyOverride);
if (cacheable && !cacheHit && frame && typeof object.generation === "string") {
this.keystore.writeCachedCarrier(this.repoId, receipt.object_id, object.generation, receipt.ciphertext_sha256, frame);
}
return object;
}
/**
* `materialize`'s ref_state + retention_roots read, batched (design D2):
* cache-check both first, then ONE `getObjects` call (one presigned batch
* + concurrent GETs) for whichever missed, instead of two independent
* presign-then-GET round trips. Falls through to zero network calls when
* both are cache-warm. `keyOverride` is the epoch/k_repo the CARRYING
* HEAD sealed both carriers under (D194) — both always share one head, so
* one override serves both, unlike the WAL/checkpoint pack loops in
* {@link restoreObjectsInto} which span many heads and many epochs.
*/
async openMaterializeCarriers(refStateReceipt, refStatePath, rootsReceipt, rootsPath, writerKey, keyOverride) {
const cachedRefState = this.keystore.readCachedCarrier(this.repoId, refStateReceipt.object_id);
const cachedRoots = this.keystore.readCachedCarrier(this.repoId, rootsReceipt.object_id);
const refStateHit = Boolean(cachedRefState && sha256Hex(cachedRefState.bytes) === refStateReceipt.ciphertext_sha256);
const rootsHit = Boolean(cachedRoots && sha256Hex(cachedRoots.bytes) === rootsReceipt.ciphertext_sha256);
// vault-clone-scaling (P2): a page-prefetched frame serves exactly as
// a fetched one — sha-checked against the receipt's ciphertext hash; a
// miss/mismatch falls through to the batched network read below.
const preRefState = !refStateHit ? (this.walkPrefetch?.get(refStatePath) ?? null) : null;
const refStatePre = preRefState && sha256Hex(preRefState) === refStateReceipt.ciphertext_sha256 ? preRefState : null;
const preRoots = !rootsHit ? (this.walkPrefetch?.get(rootsPath) ?? null) : null;
const rootsPre = preRoots && sha256Hex(preRoots) === rootsReceipt.ciphertext_sha256 ? preRoots : null;
const missingPaths = [];
const missingExpected = [];
if (!refStateHit && !refStatePre) {
missingPaths.push(refStatePath);
missingExpected.push(refStateReceipt.ciphertext_sha256);
}
if (!rootsHit && !rootsPre) {
missingPaths.push(rootsPath);
missingExpected.push(rootsReceipt.ciphertext_sha256);
}
const fetched = missingPaths.length > 0 ? await this.transport.getObjects({ repo_id: this.repoId, paths: missingPaths, expected: missingExpected }) : [];
let next = 0;
const refStateFrame = refStateHit ? cachedRefState.bytes : (refStatePre ?? fetched[next++] ?? null);
const rootsFrame = rootsHit ? cachedRoots.bytes : (rootsPre ?? fetched[next++] ?? null);
const refState = this.decodeCarrierFrame("ref_state", refStateReceipt, refStateFrame, writerKey, keyOverride);
const roots = this.decodeCarrierFrame("retention_roots", rootsReceipt, rootsFrame, writerKey, keyOverride);
if (!refStateHit && refStateFrame)
this.keystore.writeCachedCarrier(this.repoId, refStateReceipt.object_id, refState.generation, refStateReceipt.ciphertext_sha256, refStateFrame);
if (!rootsHit && rootsFrame)
this.keystore.writeCachedCarrier(this.repoId, rootsReceipt.object_id, roots.generation, rootsReceipt.ciphertext_sha256, rootsFrame);
return { refState, roots };
}
/**
* Verify to newest, then decrypt + apply its carriers — advancing the
* materialized pin. `options.persist` (default `true`) is forwarded to
* {@link verifyToNewest} and gates this method's OWN `materialized_pin`
* write the same way — `repos fsck --no-write` computes and returns the
* real ref map and generation without moving either local pin.
*
* Runs `verifyToNewest({..., decryptValidate: true, strict: true})`
* internally (Part A: an ordinary read across an admitted `rotate_epoch`
* transition now opens the rotation's own envelope and decrypts under the
* NEW epoch, chaining through multiple sequential rotations; a keystore
* with no envelope for a new epoch fails CLOSED with
* `VAULT_EPOCH_NOT_OPENABLE`, never a bare `VAULT_AEAD_AUTH_FAILURE`)
* — `strict: true` means this call throws exactly where the OLD
* (pre-fix) `materialize()` silently produced a wrong `k_obj` instead.
*/
async materialize(options = {}) {
const persist = options.persist ?? true;
const state = await this.verifyToNewest({ persist, decryptValidate: true, strict: true, deltaSince: options.deltaSince, restore: options.restore });
if (!state.head) {
if (persist)
this.keystore.updateRepo(this.repoId, { materialized_pin: { generation: state.generation, head_sha256: state.head_sha256, pinned_at: formatVaultTimestamp(this.now()) } });
return { ...state, ref_state: null, retention_roots: null, refs: {}, roots: [], head_target: { kind: "symref", ref: "refs/heads/main" }, epoch_keys_hex: state.decrypt?.epoch_keys_hex ?? {} };
}
// `strict: true` guarantees `state.decrypt` is non-null with no failure
// and `decryptable_to_generation === state.generation` — it throws
// otherwise, so these are never null/mismatched here. `verifyToNewest`'s
// own `tryDecrypt` is what actually fetches these now (design D2/D3's
// batching + caching moved there with it — see its doc comment) —
// `openMaterializeCarriers` no longer has a caller from here.
const refState = state.decrypt.ref_state;
const roots = state.decrypt.retention_roots;
if (refState.generation !== state.generation || roots.generation !== state.generation)
fail("CHAIN_UNUSABLE", "carrier generation does not match the head", "materializing vault head");
return { ...state, ref_state: refState, retention_roots: roots, refs: { ...refState.refs }, roots: roots.roots.map((r) => ({ ...r })), head_target: refState.head_target, epoch_keys_hex: state.decrypt.epoch_keys_hex };
}
// ── envelope recipients (vault-human-envelopes task 4.1, the ADD-path workaround) ──
/**
* Wrap the vault's CURRENT epoch key to every org member the directory
* lists but the vault does not yet have a `key_envelope` for.
*
* **This is task 1.1's residual WORKAROUND, not the design D5 ideal.** D5
* describes a recipient-set change as an epoch rotation — "history epochs
* stay wrapped as they were; a new member reads from their first covered
* epoch forward" — which needs a protocol revision: V0 pins `epoch` to
* the single constant `VAULT_GENESIS_EPOCH` on every head, so there
* is no "forward" to speak of. What this method actually does, legally, without any
* protocol change: `key_envelope` objects are never head-referenced (not
* even genesis's own envelope is), so uploading an ADDITIONAL one at
* `envelopes/<current epoch>/<recipient fingerprint>` for a missing
* recipient is accepted by the existing generic create-only upload route
* as-is. The honest consequence: a newly-wrapped member gets the SAME
* single epoch every existing member already has, which in V0 means the
* vault's ENTIRE history — not "from here forward." True forward-only
* semantics wait on task 1's protocol revision; this method does not
* pretend otherwise.
*
* **TOFU pinning (design D4 point 3).** The first time this repo wraps a
* given `principal_id`, its CURRENT `ek_fingerprint` is pinned in the
* keystore repo file (`envelope_recipient_pins`). On a later call, a
* directory entry whose fingerprint no longer matches its pin is a
* REFUSAL for that recipient ONLY — reported under `skipped` with reason
* `pinned_key_mismatch` and both fingerprints in `details`, never wrapped
* under the new key, and never a thrown error that would abort the whole
* call (other recipients still get processed). Whether that mismatch is a
* legitimate key rotation or a substitution is a product/human decision
* this SDK does not make unattended.
*
* **The gateway directory route carries `public_key` on every row**
* (`GET /orgs/v1/:org_id/encryption-keys` — see the
* doc comment on {@link VaultOrgEncryptionKeyEntry}), so against a
* current gateway entries actually wrap. A directory entry that arrives
* WITHOUT the field (an older/rolling-deploy gateway) is still tolerated
* per-entry — reported under `skipped` with reason `missing_public_key`,
* never a thrown error that would abort the other recipients.
*
* Best-effort by design at the call site, not here: this method itself
* either completes (returning a full per-recipient breakdown) or throws
* (e.g. `VAULT_READ_ONLY` when this principal holds no signing key).
* Callers that want "never block on this" (the deploy hook) wrap the call
* themselves — see `Repos.capture`'s `#tryReconcileEnvelopeRecipients`.
*/
async reconcileEnvelopeRecipients() {
const repo = this.repoFile();
const epoch = this.epoch();
const signer = this.signingKeypair();
const [directory, coverage] = await Promise.all([
this.transport.listOrgEncryptionKeys({ org_id: repo.org_id }),
this.transport.listEnvelopeRecipients({ repo_id: this.repoId }),
]);
const covered = new Set(coverage.recipient_fingerprints);
const pins = { ...(repo.envelope_recipient_pins ?? {}) };
const wrapped = [];
const alreadyCovered = [];
const skipped = [];
let pinsChanged = false;
for (const entry of directory.keys) {
if (covered.has(entry.ek_fingerprint)) {
alreadyCovered.push(entry.ek_fingerprint);
continue;
}
const pinned = pins[entry.principal_id];
if (pinned !== undefined && pinned !== entry.ek_fingerprint) {
skipped.push({ principal_id: entry.principal_id, ek_fingerprint: entry.ek_fingerprint, reason: "pinned_key_mismatch", details: { pinned_fingerprint: pinned, directory_fingerprint: entry.ek_fingerprint } });
continue;
}
if (typeof entry.public_key !== "string" || entry.public_key.length === 0) {
skipped.push({ principal_id: entry.principal_id, ek_fingerprint: entry.ek_fingerprint, reason: "missing_public_key" });
continue;
}
let recipientPublicKey;
try {
recipientPublicKey = fromBase64url(entry.public_key, "public_key");
}
catch {
skipped.push({ principal_id: entry.principal_id, ek_fingerprint: entry.ek_fingerprint, reason: "invalid_public_key" });
continue;
}
// Defense in depth: a directory row whose printed fingerprint does not
// derive from its own public_key is corrupt data, not a valid
// recipient — fail loud here rather than let `sealKeyEnvelope` (which
// derives the fingerprint itself and never trusts a caller-supplied
// one) silently seal under a fingerprint that disagrees with the one
// the directory printed.
const derived = ekFingerprint(recipientPublicKey);
if (derived !== entry.ek_fingerprint) {
skipped.push({ principal_id: entry.principal_id, ek_fingerprint: entry.ek_fingerprint, reason: "invalid_public_key", details: { derived_fingerprint: derived } });
continue;
}
const sealed = await sealKeyEnvelope({
k_repo: this.kRepo(),
repo_id: this.repoId,
epoch,
recipient_public_key: recipientPublicKey,
signer,
created_at: formatVaultTimestamp(this.now()),
});
try {
const byo = await this.resolveByoWriteTarget();
await this.transport.putObject({
repo_id: this.repoId,
path: vaultPaths.envelope(epoch, sealed.receipt.recipient_fingerprint),
bytes: sealed.stored_bytes,
expected_sha256: sealed.stored_bytes_sha256,
expected_size_bytes: sealed.size_bytes,
...(byo ? { byo } : {}),
});
wrapped.push({ principal_id: entry.principal_id, ek_fingerprint: entry.ek_fingerprint });
}
catch (e) {
// A concurrent reconcile (another machine/session) may have wrapped
// the SAME recipient first — HPKE seal is randomized, so two valid
// wraps of the same K_repo to the same recipient produce DIFFERENT
// ciphertext bytes, and the create-only path's read-and-compare
// reports that as `VAULT_OBJECT_EXISTS_DIFFERENT` even though the
// recipient is now genuinely covered. Treat exactly that code as a
// benign race, not a failure; anything else propagates.
if (isRun402Error(e) && e.code === "VAULT_OBJECT_EXISTS_DIFFERENT") {
// vault-agent-envelopes (consult 7.6): a benign race is only
// benign once the WINNING envelope has been read back and verified
// — same repo, same epoch, this recipient, signed by the vault's
// registered writer. Anything else is recorded as skipped, never as
// coverage, and never pinned.
const winnerPath = vaultPaths.envelope(epoch, sealed.receipt.recipient_fingerprint);
const winnerBytes = await this.transport.getObject({ repo_id: this.repoId, path: winnerPath }).catch(() => null);
let winnerOk = false;
if (winnerBytes) {
try {
const winner = parseVaultStrict(new TextDecoder().decode(winnerBytes));
const g = (await this.genesis()).genesis;
winnerOk = winner.repo_id === this.repoId && winner.epoch === epoch && winner.recipient_fingerprint === entry.ek_fingerprint
&& winner.created_by === g.writer_key_id
&& verifyVaultObject(winner, g.creator_signing_pubkey);
}
catch {
winnerOk = false;
}
}
if (!winnerOk) {
// Consult round 2 §7: a conflicting IMMUTABLE object whose stored
// winner does not verify is evidence of tampering, equivocation,
// or broken object identity — categorically different from a
// stale recipient. FATAL, never an ordinary skip.
fail("VAULT_ENVELOPE_ALTERED", `a conflicting key_envelope already stored at ${winnerPath} does not verify as this vault's writer-signed envelope for this recipient — refusing to treat the conflict as a benign race`, "reconciling vault envelope recipients", { repo_id: this.repoId, path: winnerPath, epoch, recipient_fingerprint: entry.ek_fingerprint });
}
alreadyCovered.push(entry.ek_fingerprint);
}
else {
throw e;
}
}
pins[entry.principal_id] = entry.ek_fingerprint;
pinsChanged = true;
}
if (pinsChanged)
this.keystore.updateRepo(this.repoId, { envelope_recipient_pins: pins });
return { repo_id: this.repoId, org_id: repo.org_id, epoch, wrapped, already_covered: alreadyCovered, skipped };
}
// ── object building ──
/**
* `keyOverride` (D194, rev 42): a `rotate_epoch` head's OWN `ref_state`/
* `retention_roots` must be sealed under the NEWLY-sampled `K_e` at the
* NEW epoch, never under `this.kRepo()`/`this.epoch()` (the about-to-be-
* superseded current key) — every other call site keeps calling `seal`
* with no override, unaffected.
*/
seal(kind, objectId, plaintext, path, keyOverride) {
const kRepo = keyOverride?.k_repo ?? this.kRepo();
const epoch = keyOverride?.epoch ?? this.epoch();
const sealed = sealFrame({ k_obj: deriveObjectKey(kRepo, this.repoId, epoch, kind, objectId), repo_id: this.repoId, object_kind: kind, object_id: objectId, epoch, plaintext });
return { path, object_kind: kind, object_id: objectId, bytes: sealed.frame, sha256: sealed.ciphertext_sha256, size_bytes: sealed.size_bytes };
}
/**
* The owner signing seed, or `VAULT_READ_ONLY`.
*
* Public so the prune lane signs its intent core, wrapper, and verifier
* receipt through the SAME refusal path every other signed object uses — a
* second "get the seed" helper is a second place for a read-only principal to
* slip through. The vault is already open by the time this is reachable, so
* `ensureIdentity` never MINTS here (it would refuse at `repoFile()` first).
*/
signer() {
const identity = this.keystore.ensureIdentity();
const kp = this.keystore.signingKeypair(identity);
if (!kp)
fail("VAULT_READ_ONLY", "the signing key is missing from identity.json; this principal is read-only", "signing vault object", undefined, [{ action: "stay read-only at the materialized pin" }]);
return kp.seed;
}
writerKeyId() { return this.keystore.ensureIdentity().signing_fingerprint; }
/**
* vault-multi-writer (task 5.8) — the push pre-check: before signing an
* ORDINARY head (any operation that expects this session to already be an
* active writer), refuse EARLY and LOCALLY when this session's own key is
* not (or is no longer) an active writer, rather than let real
* crypto/upload work run only to be refused by the gateway with a less
* specific message once the head finally reaches it. Reads the FRESHLY-
* pinned `writer_set_pin`; every call site below calls this immediately
* after its own `materialize()`/`verifyToNewest()` (same generation the
* caller is about to build against), so this performs no verification of
* its own — it only reads what the caller already froze.
*
* `removedMidRace` (design D8/D10) distinguishes the two shapes this
* refusal takes: `false` (the default) is the ORDINARY pre-check — this
* session was never (or is not currently) an admitted writer, thrown as
* `VAULT_WRITER_NOT_ADMITTED`. `true` is the CAS-LOSER path
* specifically: a retry, after re-materializing from the winner, that
* discovers THIS session's own key was removed by whatever won the race —
* "stop if removed" in D8's loser-rule sequence (fetch winner → verify →
* apply writer transition → stop if removed → rebase → rebuild → bounded
* backoff) — thrown as the more specific, client-local
* `VAULT_WRITER_REMOVED` (D10) instead: a real prior attempt just lost
* to a removal, not a caller who was never eligible.
*
* Deliberately NOT called for a "handoff"-door `add_writer_key` head
* ({@link submitWriterActivationHead}): there the signer is BY DESIGN not
* yet a writer — becoming one is what that exact head does.
*/
assertCallerIsWriter(context, removedMidRace = false) {
const pin = this.repoFile().writer_set_pin;
const myKey = this.writerKeyId();
if (pin && pin.writers.some((w) => w.writer_key_id === myKey))
return;
const code = removedMidRace ? "VAULT_WRITER_REMOVED" : "VAULT_WRITER_NOT_ADMITTED";
fail(code, removedMidRace
? `this session's writer key (${myKey}) was removed while a conflicting head was admitted first`
: `this session's writer key (${myKey}) is not an active writer on this vault`, context, {}, [
{
type: "request_writer_sync",
why: "This session's key is not (yet, or no longer) an admitted writer on this vault. If you were just added as an org member, any current writer's next vault operation (push, deploy, or r.repos.reconcile()) admits pending writers automatically.",
},
]);
}
/** The owner's full signing keypair, or `VAULT_READ_ONLY` — same refusal path as {@link signer}, which returns only the seed; {@link sealKeyEnvelope} needs both halves. */
signingKeypair() {
const identity = this.keystore.ensureIdentity();
const kp = this.keystore.signingKeypair(identity);
if (!kp)
fail("VAULT_READ_ONLY", "the signing key is missing from identity.json; this principal is read-only", "signing vault object", undefined, [{ action: "stay read-only at the materialized pin" }]);
return kp;
}
buildRefState(generation, refs, headTarget, keyOverride) {
if (headTarget.kind === "symref" && !BRANCH_REF_RE.test(headTarget.ref))
fail("REFNAME_UNSUPPORTED", `head_target symref must name a refs/heads/* branch: ${headTarget.ref}`, "building ref_state");
if (headTarget.kind === "detached" && !VAULT_OID40_RE.test(headTarget.oid))
fail("REF_TRANSACTION_INVALID", "detached head_target needs a 40-hex oid", "building ref_state");
assertRefMapCardinality(refs);
const sorted = {};
for (const k of Object.keys(refs).sort())
sorted[k] = refs[k];
const id = newVaultId("refs");
const object = signVaultObject({ format: VAULT_FORMAT, object_kind: "ref_state", suite: VAULT_SUITE, repo_id: this.repoId, object_id: id, generation, refs: sorted, head_target: headTarget }, this.signer());
const plaintext = storedBytes(object);
if (plaintext.length > VAULT_MAX_REF_STATE_OBJECT_BYTES)
fail("REF_STATE_LIMIT_EXCEEDED", "ref_state object exceeds 32 MiB", "building ref_state");
return { object, upload: this.seal("ref_state", id, plaintext, vaultPaths.refState(id), keyOverride) };
}
buildRetentionRoots(generation, roots, cutoff, keyOverride) {
const id = newVaultId("rr");
const object = signVaultObject({ format: VAULT_FORMAT, object_kind: "retention_roots", suite: VAULT_SUITE, repo_id: this.repoId, object_id: id, generation, cutoff, roots: [...roots].sort(compareRoots) }, this.signer());
const plaintext = storedBytes(object);
if (plaintext.length > VAULT_MAX_REF_STATE_OBJECT_BYTES)
fail("REF_STATE_LIMIT_EXCEEDED", "retention_roots object exceeds 32 MiB", "building retention_roots");
return { object, upload: this.seal("retention_roots", id, plaintext, vaultPaths.retentionRoots(id), keyOverride) };
}
/** Plaintext, independently non-thin packs covering `reachable(tips) ∖ reachable(base)`, split at the multi-object target. */
async buildPacks(tips, base) {
const dir = this.git();
const baseSet = new Set(base);
// A tip this object store does not hold: on a multi-writer vault another
// writer's tip (a handoff checkpoint minted elsewhere, a retained root it
// displaced) is routinely absent here. If the vault ALREADY covers it
// (it is in `base`), it contributes nothing new to this pack — skip it,
// never `pack-objects: fatal: bad object`. A NEW tip that is absent is a
// genuine local gap and is named as such.
const uniqueTips = [];
for (const t of new Set(tips)) {
if (!VAULT_OID40_RE.test(t))
continue;
if (await hasObject(dir, t)) {
uniqueTips.push(t);
continue;
}
if (baseSet.has(t))
continue;
fail("GIT_COMMAND_FAILED", `tip ${t} is not in this checkout's object store and the vault does not cover it yet — fetch it before publishing`, "building vault packs", { oid: t });
}
if (uniqueTips.length === 0)
return [];
const presentBase = [];
for (const b of baseSet)
if (VAULT_OID40_RE.test(b) && (await hasObject(dir, b)))
presentBase.push(b);
// On the object store's own filesystem — `git pack-objects` rename(2)s
// its temp pack from `objects/pack/` onto this base (see
// `allocatePackScratchDir`); a tmpfs `/tmp` would fail that with EXDEV.
const tmp = await allocatePackScratchDir(dir);
try {
const revs = [...uniqueTips, ...presentBase.map((b) => `^${b}`)].join("\n") + "\n";
await hardenedGit(dir, ["pack-objects", "--revs", "--no-reuse-delta", "--delta-base-offset", `--max-pack-size=${VAULT_MULTI_OBJECT_PACK_TARGET_BYTES}`, "-q", join(tmp, "p")], { input: revs });
const files = readdirSync(tmp).filter((f) => f.endsWith(".pack")).sort();
return files.map((f) => new Uint8Array(readFileSync(join(tmp, f))));
}
finally {
rmSync(tmp, { recursive: true, force: true });
}
}
/** Sorted unique object ids reachable from `tips` (the `"objectset"` content). */
async objectSet(tips) {
return this.objectSetIn(this.git(), tips);
}
/**
* The same `"objectset"` content computed in an ARBITRARY repository.
*
* The prune lane's restore-and-verify pass runs against a scratch clone-back,
* not the working tree, and must recompute the digest there with the same
* canonicalization the manifest was built with — hence one implementation,
* parameterized by directory, rather than a second rev-list at the call site.
*/
async objectSetIn(dir, tips) {
const unique = [...new Set(tips)].filter((t) => VAULT_OID40_RE.test(t));
if (unique.length === 0)
return [];
const out = await hardenedGit(dir, ["rev-list", "--objects", "--no-object-names", ...unique]);
return objectsetContent(out.lines().map((l) => l.trim())).oids;
}
/**
* Decrypt one generation's `retention_roots` carrier by its head receipt.
*
* `materialize()` opens only the NEWEST carrier; the prune lane must compare
* consecutive generations to see which roots LEFT the map, so it needs any
* generation's. Same `openCarrier` path, same `CHAIN_UNUSABLE` semantics — a
* carrier that cannot be opened is never silently treated as empty.
*/
async openRetentionRootsAt(receipt) {
const { genesis } = await this.genesis();
return this.openCarrier("retention_roots", receipt, vaultPaths.retentionRoots(receipt.object_id), genesis.creator_signing_pubkey);
}
digest(label, content) {
return keyedCommitment(deriveDigestKey(this.kRepo(), this.repoId, this.epoch(), label), content);
}
/**
* The §1 keyed commitment under one of the five `K_digest` labels.
*
* Public because the prune lane needs `gcrootset` (over the GC root set's
* sorted receipts) and `rootset` (over the retention-roots carrier) and must
* compute them with the SAME key derivation the checkpoint manifest uses —
* two derivations for one commitment is how a verifier and a publisher stop
* agreeing. Keyed by design (§7.3): a server-comparable plaintext digest
* would be a confirmation oracle.
*/
keyedDigest(label, content) {
return this.digest(label, content);
}
/** Strip the single top-level signature — the commitment preimage shape carriers use. */
digestPreimage(o) {
return this.withoutSignature(o);
}
withoutSignature(o) {
const { signature: _s, ...rest } = o;
return rest;
}
/** Coverage tips (§4.7): canonical refs ∪ unexpired roots ∪ the HEAD target (detached commit; an unborn symref contributes nothing). */
static coverageTips(refs, roots, headTarget) {
const tips = new Set(Object.values(refs));
for (const r of roots)
tips.add(r.oid);
if (headTarget.kind === "detached")
tips.add(headTarget.oid);
return [...tips].sort();
}
/**
* Build a checkpoint set (§4.7): manifest + packs + the owner-signed claim
* set, with the acceptance self-check (restore into an empty scratch, every
* covered tip resolves, full connectivity, all three keyed commitments
* recomputed). Coverage above the V0 maximum → `CHECKPOINT_SET_LIMIT_EXCEEDED`.
*/
async buildCheckpoint(input) {
const tips = Vault.coverageTips(input.ref_state.refs, input.retention_roots.roots, input.ref_state.head_target);
const absent = [];
for (const t of tips)
if (!(await hasObject(this.git(), t)))
absent.push(t);
if (absent.length > 0) {
// Another writer's coverage is routinely absent from this checkout: a
// handoff's retention root, or a ref only that writer pushed, reaches a
// standing clone only when git runs the remote helper's fetch verb, and
// git runs it only for a ref in the clone's refspec that moved — a
// retention root moves none. The vault holds every covered object, so
// restore its retained set into THIS repository (the same walk the
// fetch verb runs; it writes objects and refs/r402/retain/* only, never
// a branch), then re-check. What is still absent is a real gap.
await this.restoreObjectsInto(this.git());
for (const t of absent)
if (!(await hasObject(this.git(), t)))
fail("CHECKPOINT_INCOMPLETE", `covered tip ${t} is not present locally and the vault did not supply it; the checkpoint cannot be built from this repository`, "building checkpoint set", { oid: t });
}
const packs = await this.buildPacks(tips, []);
if (packs.length > VAULT_MAX_CHECKPOINT_PACKS)
fail("CHECKPOINT_SET_LIMIT_EXCEEDED", `${packs.length} packs exceed the ${VAULT_MAX_CHECKPOINT_PACKS}-pack V0 checkpoint maximum`, "building checkpoint set", undefined, [{ action: "compact or prune before adding coverage" }]);
const entries = [];
const objects = [];
let totalPlain = 0n;
let totalStored = 0n;
for (const pack of packs) {
const id = newVaultId("ckp");
const upload = this.seal("checkpoint_pack", id, pack, vaultPaths.checkpointPack(id));
objects.push(upload);
entries.push({ object_id: id, plaintext_sha256: sha256Hex(pack), plaintext_size_bytes: String(pack.length), ciphertext_sha256: upload.sha256, size_bytes: upload.size_bytes });
totalPlain += BigInt(pack.length);
totalStored += BigInt(upload.size_bytes);
}
const objectIds = await this.objectSet(tips);
const manifestUnsigned = {
format: VAULT_FORMAT, object_kind: "checkpoint_manifest", suite: VAULT_SUITE, repo_id: this.repoId, object_id: newVaultId("chk"),
covers_through_generation: input.generation, git_object_format: "sha1", packs: entries, total_plaintext_size_bytes: String(totalPlain),
ref_state_hmac: this.digest("refmap", this.withoutSignature(input.ref_state)),
retention_roots_hmac: this.digest("rootset", this.withoutSignature(input.retention_roots)),
object_set_hmac: this.digest("objectset", { oids: objectIds }),
};
const manifest = signVaultObject(manifestUnsigned, this.signer());
const manifestUpload = this.seal("checkpoint_manifest", manifest.object_id, storedBytes(manifest), vaultPaths.checkpointManifest(manifest.object_id));
objects.unshift(manifestUpload);
totalStored += BigInt(manifestUpload.size_bytes);
if (totalStored > VAULT_MAX_CHECKPOINT_TOTAL_STORED_BYTES)
fail("CHECKPOINT_SET_LIMIT_EXCEEDED", "the checkpoint's stored bytes exceed the 800 GiB V0 maximum", "building checkpoint set", { total_stored_size_bytes: String(totalStored) }, [{ action: "compact or prune before adding coverage" }]);
const claimSet = signVaultObject({
format: VAULT_FORMAT, object_kind: "checkpoint_claim_set", suite: VAULT_SUITE, repo_id: this.repoId, object_id: newVaultId("ccs"),
manifest_receipt: { object_id: manifest.object_id, object_kind: "checkpoint_manifest", ciphertext_sha256: manifestUpload.sha256, size_bytes: manifestUpload.size_bytes },
ordered_pack_receipts: entries.map((e) => ({ object_id: e.object_id, object_kind: "checkpoint_pack", ciphertext_sha256: e.ciphertext_sha256, size_bytes: e.size_bytes })),
total_stored_size_bytes: String(totalStored), covers_through_generation: input.generation, writer_key_id: this.writerKeyId(),
}, this.signer());
const claimBytes = storedBytes(claimSet);
const claimUpload = { path: vaultPaths.claimSet(claimSet.object_id), object_kind: "checkpoint_claim_set", object_id: claimSet.object_id, bytes: claimBytes, sha256: sha256Hex(claimBytes), size_bytes: String(claimBytes.length) };
objects.push(claimUpload);
const built = { manifest, claim_set: claimSet, claim_set_receipt: { object_id: claimSet.object_id, object_kind: "checkpoint_claim_set", stored_bytes_sha256: claimUpload.sha256, size_bytes: claimUpload.size_bytes }, objects, packs, covered_tips: tips };
await this.acceptCheckpoint(built, input.ref_state, input.retention_roots);
return built;
}
/** §4.7 acceptance: restore from the set ALONE into an empty scratch; every covered ref resolves; fsck connectivity; recompute the three commitments. */
async acceptCheckpoint(built, refState, roots) {
checkClaimSetEquality(built.claim_set, built.manifest, built.claim_set.covers_through_generation);
const scratch = mkdtempSync(join(tmpdir(), "run402-vault-accept-"));
try {
await hardenedGit(scratch, ["init", "-q", "--bare", "--object-format=sha1", "."]);
for (const pack of built.packs) {
await hardenedGit(scratch, ["index-pack", "--stdin", "--strict"], { input: pack });
}
const tips = Vault.coverageTips(refState.refs, roots.roots, refState.head_target);
for (const t of tips) {
if (!(await hasObject(scratch, t)))
fail("CHECKPOINT_INCOMPLETE", `covered tip ${t} does not resolve from the restored set`, "accepting checkpoint set", { oid: t }, [{ action: "rebuild the checkpoint set; restorers fall back to WAL replay" }]);
}
const fsck = await hardenedGit(scratch, ["fsck", "--no-dangling", "--connectivity-only", ...tips], { okStatuses: [1, 2] });
if (fsck.status !== 0)
fail("CHECKPOINT_INCOMPLETE", `fsck reports missing connectivity: ${fsck.stderr.slice(0, 300)}`, "accepting checkpoint set");
const restored = tips.length === 0 ? [] : objectsetContent((await hardenedGit(scratch, ["rev-list", "--objects", "--no-object-names", ...tips])).lines().map((l) => l.trim())).oids;
if (this.digest("objectset", { oids: restored }) !== built.manifest.object_set_hmac)
fail("CHECKPOINT_INCOMPLETE", "restored object set does not match the manifest's object_set_hmac", "accepting checkpoint set");
if (this.digest("refmap", this.withoutSignature(refState)) !== built.manifest.ref_state_hmac)
fail("CHECKPOINT_INCOMPLETE", "ref_state_hmac mismatch", "accepting checkpoint set");
if (this.digest("rootset", this.withoutSignature(roots)) !== built.manifest.retention_roots_hmac)
fail("CHECKPOINT_INCOMPLETE", "retention_roots_hmac mismatch", "accepting checkpoint set");
}
finally {
rmSync(scratch, { recursive: true, force: true });
}
}
/**
* The §4.7 acceptance run against a checkpoint ALREADY IN STORAGE — the
* restore-and-verify pass a `verifier_receipt` attests (§7.3).
*
* `acceptCheckpoint` above proves a checkpoint the client just BUILT; this
* proves one the client is about to make a claim about, from the stored bytes
* alone. It reports the observed facts rather than throwing on a mismatch,
* because "the checkpoint does not verify" is exactly the finding a receipt
* must be able to carry as `false` — turning it into an exception would make
* an honest negative attestation impossible to produce.
*/
async verifyStoredCheckpoint(head, headSha256) {
const block = head.checkpoint;
if (!block)
fail("CHECKPOINT_INCOMPLETE", `head ${head.generation} carries no checkpoint to verify`, "verifying a stored checkpoint", { generation: head.generation });
const { genesis } = await this.genesis();
// vault-multi-writer rev 47: `head` may have been signed by any
// CURRENTLY-KNOWN writer, not just the genesis creator — resolved from
// the keystore's persisted `writer_set_pin` (the same substrate
// `verifyToNewest` maintains), falling back to the genesis-creator key
// for a pre-rev-47 keystore or one that hasn't run `verifyToNewest` yet
// (byte-identical to this method's own pre-rev-47 behavior in that case).
// A writer removed SINCE this checkpoint's own head was admitted is the
// same documented residual as the backward-catch-up decrypt path above —
// `writer_set_pin` only remembers currently-active writers, not burned ones.
const writerKey = this.writerKeyForHead(head, genesis.creator_signing_pubkey);
const claimBytes = await this.transport.getObject({ repo_id: this.repoId, path: vaultPaths.claimSet(block.claim_set.object_id), expected_sha256: block.claim_set.stored_bytes_sha256 });
if (!claimBytes || sha256Hex(claimBytes) !== block.claim_set.stored_bytes_sha256) {
fail("CHECKPOINT_INCOMPLETE", `checkpoint claim set ${block.claim_set.object_id} is absent or does not match the head's receipt`, "verifying a stored checkpoint", { object_id: block.claim_set.object_id });
}
const claimSet = parseVaultStrict(new TextDecoder().decode(claimBytes));
if (!verifyVaultObject(claimSet, writerKey))
fail("CHECKPOINT_INCOMPLETE", "checkpoint claim set signature fails", "verifying a stored checkpoint", { object_id: claimSet.object_id });
const manifest = await this.openCarrier("checkpoint_manifest", claimSet.manifest_receipt, vaultPaths.checkpointManifest(claimSet.manifest_receipt.object_id), writerKey);
checkClaimSetEquality(claimSet, manifest, block.covers_through_generation);
const refState = await this.openCarrier("ref_state", head.ref_state, vaultPaths.refState(head.ref_state.object_id), writerKey);
const roots = await this.openCarrier("retention_roots", head.retention_roots, vaultPaths.retentionRoots(head.retention_roots.object_id), writerKey);
const tips = Vault.coverageTips(refState.refs, roots.roots, refState.head_target);
const scratch = mkdtempSync(join(tmpdir(), "run402-vault-attest-"));
try {
await hardenedGit(scratch, ["init", "-q", "--bare", "--object-format=sha1", "."]);
for (const p of manifest.packs) {
const frame = await this.transport.getObject({ repo_id: this.repoId, path: vaultPaths.checkpointPack(p.object_id), expected_sha256: p.ciphertext_sha256 });
if (!frame)
fail("CHECKPOINT_INCOMPLETE", `checkpoint pack ${p.object_id} is absent from storage`, "verifying a stored checkpoint", { object_id: p.object_id });
const plain = openFrame({ k_obj: deriveObjectKey(this.kRepo(), this.repoId, this.epoch(), "checkpoint_pack", p.object_id), repo_id: this.repoId, object_kind: "checkpoint_pack", object_id: p.object_id, epoch: this.epoch(), frame, expected_ciphertext_sha256: p.ciphertext_sha256 });
if (sha256Hex(plain) !== p.plaintext_sha256 || String(plain.length) !== p.plaintext_size_bytes)
fail("CHECKPOINT_INCOMPLETE", `checkpoint pack ${p.object_id} plaintext mismatch`, "verifying a stored checkpoint", { object_id: p.object_id });
await hardenedGit(scratch, ["index-pack", "--stdin", "--strict"], { input: plain });
}
const missing = [];
for (const t of tips)
if (!(await hasObject(scratch, t)))
missing.push(t);
const fsck = tips.length === 0 ? { status: 0 } : await hardenedGit(scratch, ["fsck", "--no-dangling", "--connectivity-only", ...tips], { okStatuses: [1, 2] });
const restored = tips.length === 0 || missing.length > 0 ? [] : await this.objectSetIn(scratch, tips);
const restoredHmac = this.digest("objectset", { oids: restored });
// Named locals, not inline ternaries: the no-removal checkpoint form has
// no ticket at all, and both halves of that absence travel together.
const cutoffTicketSha256 = block.cutoff ? block.cutoff.ticket.stored_bytes_sha256 : null;
const cutoffAt = block.cutoff ? block.cutoff.cutoff_at : null;
return {
checkpoint_head_sha256: headSha256,
checkpoint_generation: head.generation,
claim_set_sha256: block.claim_set.stored_bytes_sha256,
cutoff_ticket_sha256: cutoffTicketSha256,
cutoff_at: cutoffAt,
covered_tips: tips,
missing_tips: missing,
restored_object_set_hmac: restoredHmac,
object_set_matches: missing.length === 0 && fsck.status === 0 && restoredHmac === manifest.object_set_hmac,
ref_state_matches: this.digest("refmap", this.withoutSignature(refState)) === manifest.ref_state_hmac,
retention_roots_matches: this.digest("rootset", this.withoutSignature(roots)) === manifest.retention_roots_hmac,
retention_state_hmac: this.digest("rootset", this.withoutSignature(roots)),
};
}
finally {
rmSync(scratch, { recursive: true, force: true });
}
}
/**
* The whole verified chain, newest-first walk returned oldest-first, each
* head paired with its checkpoint claim set (`null` when it bears none).
*
* The prune lane needs EVERY generation, not just the newest: a candidate is
* an object some head once named and no surviving head still needs, and that
* is only computable over the whole chain. Reuses {@link chainFrom}, so the
* bytes are re-read and hash-checked against the verified chain rather than
* trusted from a listing.
*/
async chainEntries() {
const newest = await this.verifyToNewest();
if (!newest.head)
return [];
const chain = await this.chainFrom("0000000000000001", newest, "activated");
const generations = [...chain.keys()].sort();
const out = [];
const { genesis } = await this.genesis();
// vault-multi-writer rev 47: `verifyToNewest()` above already
// persisted the current `writer_set_pin` (active AND retired writers) —
// same per-head resolution as `verifyStoredCheckpoint`.
for (const gen of generations) {
const entry = chain.get(gen);
let claimSet = null;
const block = entry.head.checkpoint;
if (block) {
const writerKey = this.writerKeyForHead(entry.head, genesis.creator_signing_pubkey);
// Plaintext-structured and stored-bytes-receipted: no decryption, but
// the hash and the owner signature are still checked before a single
// pack receipt inside it is believed.
const bytes = await this.transport.getObject({ repo_id: this.repoId, path: vaultPaths.claimSet(block.claim_set.object_id), expected_sha256: block.claim_set.stored_bytes_sha256 });
if (!bytes || sha256Hex(bytes) !== block.claim_set.stored_bytes_sha256) {
fail("CHECKPOINT_INCOMPLETE", `checkpoint claim set ${block.claim_set.object_id} (generation ${gen}) is absent or altered`, "walking the vault chain", { generation: gen, object_id: block.claim_set.object_id });
}
claimSet = parseVaultStrict(new TextDecoder().decode(bytes));
if (!verifyVaultObject(claimSet, writerKey)) {
fail("CHECKPOINT_INCOMPLETE", `checkpoint claim set ${claimSet.object_id} signature fails`, "walking the vault chain", { generation: gen });
}
}
out.push({ head: entry.head, head_sha256: entry.sha256, claim_set: claimSet });
}
return out;
}
// ── upload with receipt-compare ──
/**
* vault-byo-primary-bucket task 3.2 — resolve (once, cached) this
* machine's local BYO write config for THIS vault. `null` when none is
* configured locally — either because this is an ordinary managed vault
* (the common case; deliberately never confirmed with a network call, so
* a managed vault's push pays zero extra round trips), or because this
* machine has not been configured to write a BYO vault it nonetheless
* belongs to (surfaced downstream as `VAULT_BYO_BUCKET_WRITE_REFUSED`
* when the session actually names a `put: null` object with no target).
*/
async resolveByoWriteTarget() {
if (this.byoResolution !== undefined)
return this.byoResolution;
const { readByoConfig } = await import("./vault-byo-config.js");
const local = readByoConfig(this.keystore, this.repoId);
this.byoResolution = local ? { destination: local.destination, ...(local.credential ? { credential: local.credential } : {}) } : null;
return this.byoResolution;
}
async uploadAll(objects) {
const byo = await this.resolveByoWriteTarget();
const receipts = await this.transport.uploadObjects({ repo_id: this.repoId, objects, ...(byo ? { byo } : {}) });
if (receipts.length !== objects.length)
fail("VAULT_RECEIPT_MISMATCH", `${receipts.length} receipts for ${objects.length} objects`, "comparing finalization receipts");
const mismatches = objects.filter((o, i) => receipts[i].path !== o.path || receipts[i].sha256 !== o.sha256 || receipts[i].size_bytes !== o.size_bytes);
if (mismatches.length > 0) {
fail("VAULT_RECEIPT_MISMATCH", `the server's finalization receipts do not match the expected manifest for ${mismatches.map((m) => m.path).join(", ")}; refusing to sign a head over them`, "comparing finalization receipts", { paths: mismatches.map((m) => m.path) });
}
}
// ── §6.2 push ──
/**
* Build + upload + sign + admit ONE generation over `base`. Shared by the ref
* transaction path (`push`) and the checkpoint-only path (`publishCheckpoint`); the
* caller owns the conflict loop because only it knows how to re-derive the
* next state from the winner.
*/
async publishGeneration(input) {
const { base } = input;
const generation = nextGeneration(base.generation);
// The ticket is obtained BEFORE the checkpoint is built (§4.5a: expiry is evaluated against a
// server time that exists before signing, never a future storage-commit time).
const ticket = input.cutoff ? await this.issueRetentionCutoff(base.head_sha256) : null;
const roots = evolveRetentionRoots(base.roots, {
generation,
dropped: input.dropped,
...(ticket && input.cutoff ? { checkpoint_cutoff: { cutoff_at: ticket.ticket.cutoff_at, effectiveAdmittedAt: input.cutoff.effectiveAdmittedAt ?? (() => null) } } : {}),
});
const refState = this.buildRefState(generation, input.refs, input.head_target);
const rootsObj = this.buildRetentionRoots(generation, roots, ticket ? { cutoff_ticket_sha256: ticket.receipt.stored_bytes_sha256, cutoff_at: ticket.ticket.cutoff_at } : null);
const baseTips = Vault.coverageTips(base.refs, base.roots, base.head_target);
const newTips = Vault.coverageTips(input.refs, roots, input.head_target);
// Preflight the projected coverage against the V0 checkpoint maximum (client-side, plaintext projection).
const walPacks = input.force_checkpoint ? [] : await this.buildPacks(newTips, baseTips);
let form;
const walEntries = [];
let checkpoint = null;
const objects = [refState.upload, rootsObj.upload];
let rawPackBytes = 0;
if (input.force_checkpoint || walPacks.length > VAULT_MAX_WAL_RECEIPTS_PER_HEAD) {
form = "checkpoint";
const built = await this.buildCheckpoint({ generation, ref_state: refState.object, retention_roots: rootsObj.object });
objects.push(...built.objects);
checkpoint = { claim_set: built.claim_set_receipt, covers_through_generation: generation, git_object_format: "sha1", cutoff: ticket ? { ticket: ticket.receipt, cutoff_at: ticket.ticket.cutoff_at } : null };
rawPackBytes = built.packs.reduce((sum, p) => sum + p.length, 0);
}
else {
form = "wal";
for (const pack of walPacks) {
const id = newVaultId("wal");
const upload = { ...this.seal("wal_pack", id, pack, vaultPaths.wal(id)), base_generation: base.generation };
objects.push(upload);
walEntries.push({ object_id: id, object_kind: "wal_pack", ciphertext_sha256: upload.sha256, size_bytes: upload.size_bytes, base_generation: base.generation });
rawPackBytes += pack.length;
}
}
// everything above this point is REAL local work —
// real signing, real pack building, real encryption. Stopping HERE is what
// makes the dry run honest: nothing below this line has run yet, so
// nothing was uploaded and no generation was admitted.
if (input.dry_run) {
return { outcome: "dry_run", generation, form, refs: input.refs, head_target: input.head_target, objects, raw_pack_bytes: rawPackBytes };
}
await this.uploadAll(objects);
const binding = typeof input.capture_binding === "function" ? await input.capture_binding() : input.capture_binding ?? null;
const head = this.signHead({
generation, prev_sha256: base.head_sha256, wal_entries: walEntries,
ref_state: { object_id: refState.object.object_id, object_kind: "ref_state", ciphertext_sha256: refState.upload.sha256, size_bytes: refState.upload.size_bytes },
retention_roots: { object_id: rootsObj.object.object_id, object_kind: "retention_roots", ciphertext_sha256: rootsObj.upload.sha256, size_bytes: rootsObj.upload.size_bytes },
checkpoint, checkpoint_purpose: checkpoint ? "ordinary_push" : null, capture_binding: binding, repair: null,
},
// vault-multi-writer (task 5.6) — an `add_writer_key` activation
// head rides THIS same content-neutral machinery: `input.transition`
// is undefined for every pre-existing caller (push/publishCheckpoint),
// so their heads are byte-for-byte unchanged; `submitWriterActivationHead`
// below is the one caller that passes it.
input.transition !== undefined ? { transition: input.transition } : undefined);
const admitted = await this.admit(head);
if (admitted.outcome === "conflict")
return { outcome: "conflict", generation, winner: admitted.winner };
return { outcome: "admitted", generation, head, head_sha256: admitted.head_sha256, admission_record_sha256: admitted.admission_record_sha256, capture_receipt: admitted.capture_receipt, form, refs: input.refs };
}
/**
* The complete push: verify → materialize → evaluate → pack → upload → head → admit (409: re-apply to the winner, retry) → read back → advance pins.
*
* `options.base` (design D1), when supplied, is used VERBATIM for the
* first attempt instead of a fresh {@link materialize} call — a conflict
* retry always re-materializes from storage, exactly as when no base is
* supplied.
*/
async push(options) {
let conflicts = 0;
let removalRotated = false;
let base = options.base ?? (await this.materialize());
for (;;) {
// vault-multi-writer (task 5.8) — checked fresh on EVERY attempt
// (including a CAS-loser retry, design D8's "stop if removed"): a
// concurrent rotation could remove this session's own writer key
// between one attempt and the next. `conflicts > 0` is exactly the
// CAS-loser path (this iteration only runs after losing a race and
// re-materializing from the winner) — VAULT_WRITER_REMOVED there,
// the ordinary VAULT_WRITER_NOT_ADMITTED on the first attempt.
this.assertCallerIsWriter("publishing vault head", conflicts > 0);
const evaluation = await evaluateRefTransaction(base.refs, options.transaction, { isAncestor: (a, d) => isAncestor(this.git(), a, d), protocol_refs: options.protocol_refs });
let published;
try {
published = await this.publishGeneration({
base, refs: evaluation.refs, dropped: evaluation.dropped, head_target: options.head_target ?? base.head_target,
force_checkpoint: options.checkpoint === true, cutoff: options.cutoff ?? null, capture_binding: options.capture_binding,
});
}
catch (e) {
// vault-human-envelopes D5 ("Remove: next capture rotates the
// epoch key to the remaining set") / vault-multi-writer D6: an
// outstanding MEMBERSHIP REMOVAL is completed by the next push of any
// surviving writer — rotate under the writer-capable
// reason:"member_removed", re-materialize on the rotated chain, and
// retry this push once. Every OTHER cause the gate names (a
// migration bootstrap, an epoch-secret exposure) stays THROWN, with
// the enriched next_actions naming its owner + step-up remedy.
const details = isRun402Error(e) && e.code === "EPOCH_ROTATION_REQUIRED"
? (e.details ?? {})
: null;
const removalOnly = details !== null && (details.revocation_outstanding === true || details.writer_removal_outstanding === true)
&& details.migration_required !== true && details.exposure_outstanding !== true;
if (!removalOnly || removalRotated)
throw e;
removalRotated = true;
await this.rotateEpochForMemberRemoval();
base = await this.materialize();
continue;
}
if (published.outcome === "conflict") {
conflicts += 1;
if (conflicts > this.retries)
fail("HEAD_CAS_CONFLICT", `admission lost ${conflicts} races at generation ${published.generation}; giving up`, "publishing vault head", { generation: published.generation, winner: published.winner }, [{ action: "verify the attached winner from storage, rebase, retry" }]);
base = await this.materialize(); // re-verify from storage (the winner) before re-applying the transaction
continue;
}
// `push()` never sets `dry_run`, so this outcome is unreachable here —
// narrows `published` to `"admitted"` for the return below.
if (published.outcome === "dry_run")
fail("GIT_COMMAND_FAILED", "internal: push() received a dry-run result it never requested", "publishing vault head");
this.keystore.updateRepo(this.repoId, { last_ref_transaction: { generation: published.generation, transaction: options.transaction, at: formatVaultTimestamp(this.now()) } });
return { generation: published.generation, head_sha256: published.head_sha256, head: published.head, admission_record_sha256: published.admission_record_sha256, capture_receipt: published.capture_receipt, form: published.form, conflicts_retried: conflicts, refs: published.refs, checkpoint_staleness: this.checkpointStalenessNow(published.generation) };
}
}
/**
* A REAL preview of what {@link push} would publish
* — runs the SAME local pipeline `push` runs (materialize → evaluate →
* evolve retention roots → build refState/retentionRoots → build packs or a
* checkpoint set → seal/encrypt) and stops BEFORE the two network
* mutations `push` performs (`uploadAll`, `admit`). One shot, no conflict
* retry: there is nothing to retry against, since no generation is ever
* admitted. `would_admit_generation` is therefore the generation this push
* WOULD claim over the CURRENTLY OBSERVED base — a concurrent publisher can
* still take it first before a real push runs, exactly as `git push
* --dry-run` never promises a fast-forward will still hold by the time a
* real push executes.
*
* Never retries and never allocates: an unallocated vault has no `repo_id`
* (hence no encryption key) to preview a push against at all — callers
* resolve the vault READ-ONLY first (see `Repos.planCapture` in
* `../namespaces/repos.js`, which reports `allocation_needed: true` in
* that case instead of calling this method).
*/
async planPush(options) {
// Design D1: a caller-supplied base reports against the CALLER's own
// observed snapshot instead of materializing a fresh one — same
// verbatim-first-attempt contract as `push`, minus the retry loop
// (a dry run never re-materializes; there is nothing to retry against).
const base = options.base ?? (await this.materialize());
const evaluation = await evaluateRefTransaction(base.refs, options.transaction, { isAncestor: (a, d) => isAncestor(this.git(), a, d), protocol_refs: options.protocol_refs });
const headTarget = options.head_target ?? base.head_target;
const published = await this.publishGeneration({
base, refs: evaluation.refs, dropped: evaluation.dropped, head_target: headTarget,
force_checkpoint: options.checkpoint === true, cutoff: null, dry_run: true,
});
// `dry_run: true` above means `publishGeneration` returns ONLY the
// `"dry_run"` outcome — `"conflict"`/`"admitted"` are reachable only via
// `admit()`, which a dry run never calls.
if (published.outcome !== "dry_run")
fail("GIT_COMMAND_FAILED", "internal: planPush did not receive a dry-run result", "planning vault push");
const encryptedBytes = published.objects.reduce((sum, o) => sum + BigInt(o.size_bytes), 0n);
return {
base_generation: base.generation,
would_admit_generation: published.generation,
would_admit_generation_decimal: generationToBigInt(published.generation).toString(),
form: published.form,
refs: published.refs,
head_target: published.head_target,
objects: published.objects.map((o) => ({ object_kind: o.object_kind, size_bytes: o.size_bytes })),
object_count: published.objects.length,
encrypted_bytes: encryptedBytes.toString(),
raw_bytes: String(published.raw_pack_bytes),
};
}
/**
* Publish an `ordinary_push` checkpoint-bearing head that changes NO ref (the
* canonical map and HEAD target are carried forward). With a cutoff the head
* binds a fresh `retention_cutoff` ticket and roots past their ≥90-day lane
* may leave the map; without one it is the no-removal form (§4.5a) and every
* root is carried.
*
* Root expiry is PERMISSIVE: a root whose `effective_admitted_at` this client
* cannot resolve is RETAINED. That is deliberate — `effective_admitted_at =
* max(prepared_at, the admission record's storage creation time)`, and a client
* reading only object bytes cannot see the second term. Resolving it from
* `prepared_at` alone would shorten the lane, which the protocol's own
* delayed-PUT vector calls out as the naive implementation.
*
* This is NOT `run402 repos gc`: the §7.2 maintenance CYCLE (purpose
* `maintenance_cycle`, C1/C2 roles, stage claim sets, prune intents, `R2_cap`
* accounting) is a separate protocol under compact authority, and gets its own
* method when it ships.
*/
async publishCheckpoint(options = {}) {
let conflicts = 0;
for (;;) {
const base = await this.materialize();
const published = await this.publishGeneration({
base, refs: base.refs, dropped: [], head_target: base.head_target,
force_checkpoint: true, cutoff: options.cutoff === false ? null : options.cutoff ?? {}, capture_binding: undefined,
});
if (published.outcome === "conflict") {
conflicts += 1;
if (conflicts > this.retries)
fail("HEAD_CAS_CONFLICT", `the checkpoint lost ${conflicts} races at generation ${published.generation}; giving up`, "publishing vault checkpoint", { generation: published.generation, winner: published.winner }, [{ action: "verify the attached winner from storage, retry" }]);
continue;
}
// `publishCheckpoint` never sets `dry_run`, so this outcome is
// unreachable here — narrows `published` to `"admitted"` below.
if (published.outcome === "dry_run")
fail("GIT_COMMAND_FAILED", "internal: publishCheckpoint() received a dry-run result it never requested", "publishing vault checkpoint");
return { generation: published.generation, head_sha256: published.head_sha256, head: published.head, admission_record_sha256: published.admission_record_sha256, capture_receipt: published.capture_receipt, form: published.form, conflicts_retried: conflicts, refs: published.refs, checkpoint_staleness: this.checkpointStalenessNow(published.generation) };
}
}
/** Request a `retention_cutoff` ticket and check it binds THIS base head (and the service key, when one is pinned). */
async issueRetentionCutoff(baseHeadSha256) {
const issued = await this.transport.requestRetentionCutoff({ repo_id: this.repoId, base_head_sha256: baseHeadSha256 });
const t = issued.ticket;
if (t.object_kind !== "retention_cutoff" || t.repo_id !== this.repoId || t.base_head_sha256 !== baseHeadSha256) {
fail("VAULT_CUTOFF_TICKET_INVALID", `the retention cutoff ticket does not bind this vault's base head ${baseHeadSha256}`, "requesting retention cutoff ticket", { object_id: t.object_id, base_head_sha256: t.base_head_sha256 });
}
if (Date.parse(t.expires_at) <= this.now().getTime())
fail("VAULT_CUTOFF_TICKET_INVALID", `the retention cutoff ticket expired at ${t.expires_at}`, "requesting retention cutoff ticket", { object_id: t.object_id });
if (this.servicePublicKey && !verifyVaultObject(t, this.servicePublicKey)) {
fail("VAULT_CUTOFF_TICKET_INVALID", "the retention cutoff ticket is not signed by the pinned service key", "requesting retention cutoff ticket", { object_id: t.object_id, service_key_id: t.service_key_id });
}
return issued;
}
/**
* `overrides.epoch` (D194, rev 42 fix): defaults to `this.epoch()` — the
* LOCALLY KNOWN current epoch — rather than the fixed genesis constant.
* This is load-bearing, not cosmetic: once ANY rotation has landed, every
* ORDINARY (non-`rotate_epoch`) head this principal signs must still
* claim the vault's CURRENT epoch (protocol §4.3's chain-link rule —
* "every head's epoch equals its predecessor's UNLESS this head admits a
* `rotate_epoch` transition"); hard-coding the generation-1 constant here
* would make EVERY ordinary push after a vault's first rotation refuse
* `CHAIN_BROKEN` forever. `rotateEpoch` passes `overrides.epoch = new_epoch`
* explicitly for the ONE head that legitimately claims a DIFFERENT epoch
* than `this.epoch()` currently reads (the local pointer only advances
* AFTER a successful admit, via `keystore.recordEpochRotation`).
*/
signHead(fields, overrides) {
const unsigned = {
format: VAULT_FORMAT, object_kind: "head", suite: VAULT_SUITE, repo_id: this.repoId,
generation: fields.generation, prev_sha256: fields.prev_sha256, epoch: overrides?.epoch ?? this.epoch(),
wal_entries: fields.wal_entries, ref_state: fields.ref_state, retention_roots: fields.retention_roots,
checkpoint: fields.checkpoint, checkpoint_purpose: fields.checkpoint_purpose, capture_binding: fields.capture_binding,
repair: fields.repair, transition: overrides?.transition ?? null,
...(overrides?.pin_manifest ? { pin_manifest: overrides.pin_manifest } : {}),
writer_key_id: this.writerKeyId(), created_at: formatVaultTimestamp(this.now()),
};
return signVaultObject(unsigned, this.signer());
}
/** Admit a signed head; on success read it back from storage and compare BEFORE any pin advances. */
async admit(head) {
const bytes = storedBytes(head);
const hash = sha256Hex(bytes);
const result = await this.transport.admitHead({ repo_id: this.repoId, generation: head.generation, stored_bytes: bytes, stored_bytes_sha256: hash });
if (result.outcome === "conflict")
return result;
// Post-admit readback is a spec-mandated verification obligation, never
// a cache lookup — it exists to prove the SERVER stored what was sent,
// which only a genuine network read can answer.
const back = await this.transport.getObject({ repo_id: this.repoId, path: vaultPaths.head(head.generation) });
if (!back || sha256Hex(back) !== hash) {
fail("VAULT_HEAD_READBACK_MISMATCH", `the admitted head at generation ${head.generation} read back ${back ? sha256Hex(back) : "absent"} ≠ ${hash}; the push is NOT reported as landed and no pin advances`, "reading back admitted head", { generation: head.generation, expected: hash, observed: back ? sha256Hex(back) : null });
}
// Design D3: the readback just network-confirmed these exact bytes —
// warm the cache with them so the NEXT operation (this session's own
// `materialize` retry, or a later invocation) can skip re-fetching this
// generation's head entirely.
this.keystore.writeCachedHead(this.repoId, head.generation, hash, back);
const pin = { generation: head.generation, head_sha256: hash, pinned_at: formatVaultTimestamp(this.now()) };
this.keystore.updateRepo(this.repoId, {
head_pin: pin,
materialized_pin: pin,
verified_prefix: null,
// vault-clone-scaling (P3): a checkpoint-form head IS fresh
// coverage this checkout just learned first-hand.
...(head.checkpoint ? { checkpoint_covers_through: head.checkpoint.covers_through_generation } : {}),
});
return { outcome: "admitted", head_sha256: hash, admission_record_sha256: result.admission_record_sha256, capture_receipt: result.capture_receipt };
}
// ── epoch rotation (D193-D203, rev 42, change vault-human-envelopes) ──
/**
* Walk the chain BACKWARD from the current tip to find the nearest
* admitted `recipient_pin_manifest` receipt (D197: "a fresh client... reads
* the LATEST admitted manifest, walking `prev_sha256` back to the nearest
* head carrying one"). Heads are plaintext-structured/signed (never
* encrypted) so this needs no key material — only signature verification
* against the creator's own pubkey. Falls back to `vault_genesis.pin_manifest`
* (D198 N-recipient genesis — this SDK does not BUILD one, but reads one
* correctly for interop), then to the zero-value sentinel (no manifest has
* ever been admitted — every principal starts `excluded_unconfirmed`).
*
* Cost is O(distance to the nearest pin-manifest-bearing head) — for a
* vault that has never published one, that is every generation back to
* genesis. There is no index that avoids this in protocol v0 (the same
* cost class `verifyToNewest`'s own chain walk already has); a vault with
* a long, pin-manifest-free history pays it once per rotation.
*/
async loadEffectivePinManifest(newestGeneration) {
const { genesis } = await this.genesis();
let gen = generationToBigInt(newestGeneration);
while (gen >= 1n) {
const generation = gen.toString(16).padStart(16, "0");
const bytes = await this.transport.getObject({ repo_id: this.repoId, path: vaultPaths.head(generation) });
if (!bytes)
fail("CHAIN_BROKEN", `head ${generation} is missing from storage while resolving the effective pin manifest`, "resolving the effective recipient pin manifest", { generation });
const head = parseVaultStrict(new TextDecoder().decode(bytes));
if (head.pin_manifest)
return this.readPinManifestObject(head.pin_manifest);
gen -= 1n;
}
if (genesis.pin_manifest)
return this.readPinManifestObject(genesis.pin_manifest);
return { pinManifestVersion: "0".repeat(16), pinManifestSha256: VAULT_ZERO_SHA256_SENTINEL, pinnedFingerprintOf: new Map() };
}
/**
* Local-cache short-circuit (see {@link VaultRepoFile.known_pin_manifest}'s
* own doc comment for why this is safe): a manifest THIS keystore itself
* just built, signed, and admitted is resolved from the on-disk cache
* instead of a network `object-reads` round trip — same return shape,
* skipped only on an exact `(pin_manifest_version, stored_bytes_sha256)`
* match against `receipt`. A miss (cache absent, or naming a DIFFERENT
* manifest — e.g. one another principal/machine published) falls through
* to the unchanged network path below.
*
* History: this cache originally also routed around a gateway gap —
* `POST …/object-reads` rejected every `recipient_pin_manifest` read
* (its null-`idScalar` validation was hardcoded to `key_envelope`'s
* `{epoch, recipient_fingerprint}` shape, never generalized when D197
* shipped the second path-addressed kind), which 400'd the network
* fallback below. That gateway bug is fixed: the network path works for
* any keystore, including
* §4.11's fresh-client "SEEDS its local pin file from it" onboarding.
* The cache stays purely as the round-trip saver described above.
*/
async readPinManifestObject(receipt) {
const known = this.repoFile().known_pin_manifest;
if (known && known.pin_manifest_version === receipt.pin_manifest_version && known.stored_bytes_sha256 === receipt.stored_bytes_sha256) {
return { pinManifestVersion: known.pin_manifest_version, pinManifestSha256: known.stored_bytes_sha256, pinnedFingerprintOf: new Map(known.pins.map((p) => [p.principal_id, p.ek_fingerprint])) };
}
const bytes = await this.transport.getObject({ repo_id: this.repoId, path: vaultPaths.pinManifest(receipt.pin_manifest_version), expected_sha256: receipt.stored_bytes_sha256 });
if (!bytes || sha256Hex(bytes) !== receipt.stored_bytes_sha256) {
fail("VAULT_RECEIPT_MISMATCH", `recipient_pin_manifest ${receipt.pin_manifest_version} is absent or does not match its receipted hash`, "resolving the effective recipient pin manifest", { pin_manifest_version: receipt.pin_manifest_version });
}
const manifest = parseVaultStrict(new TextDecoder().decode(bytes));
const { genesis } = await this.genesis();
// vault-multi-writer rev 47: resolve the manifest's OWN declared
// signer against the persisted writer set (same pattern + same
// documented residual as `verifyStoredCheckpoint`/`chainEntries` above).
const manifestSigner = this.repoFile().writer_set_pin?.writers.find((w) => w.writer_key_id === manifest.writer_key_id)?.signing_pubkey ?? genesis.creator_signing_pubkey;
if (!verifyVaultObject(manifest, manifestSigner)) {
fail("VAULT_SIGNATURE_INVALID", `recipient_pin_manifest ${receipt.pin_manifest_version} signature does not verify`, "resolving the effective recipient pin manifest", { pin_manifest_version: receipt.pin_manifest_version });
}
const pinnedFingerprintOf = new Map(manifest.pins.map((p) => [p.principal_id, p.ek_fingerprint]));
return { pinManifestVersion: receipt.pin_manifest_version, pinManifestSha256: receipt.stored_bytes_sha256, pinnedFingerprintOf };
}
/**
* Build the successor `recipient_pin_manifest` object from the currently-
* effective PREDECESSOR manifest plus a batch of receipted updates (D197
* full-map conservation: `next_map = prior_map + receipt-authorized
* additions/replacements`). Shared by {@link publishPinManifestUpdate} (a
* single-entry ordinary-push publish) and {@link rotateEpoch}'s
* `pending_confirmations` fold (a multi-entry publish riding the SAME
* head as the rotation, D196). Validates EVERY update's receipt against
* `prior` before building — a receipt issued against a stale predecessor
* fails closed here (`VALIDATION_FAILED`), never silently overwritten,
* matching the gateway's own admission-time field-by-field check.
*
* Does NOT admit anything — the caller uploads `.upload` and attaches the
* returned `{object_kind, pin_manifest_version, stored_bytes_sha256,
* size_bytes}` receipt shape to whichever head it is publishing on.
*/
buildPinManifestUpdate(prior, updates) {
for (const u of updates) {
if (u.receipt.base_pin_manifest_sha256 !== prior.pinManifestSha256) {
fail("VALIDATION_FAILED", `the confirmation receipt for ${u.principal_id} was issued against a different predecessor manifest than the one currently effective — obtain a fresh /confirm or /repin`, "publishing a recipient_pin_manifest update", { principal_id: u.principal_id, receipt_base: u.receipt.base_pin_manifest_sha256, current_base: prior.pinManifestSha256 });
}
}
const updateIds = new Set(updates.map((u) => u.principal_id));
if (updateIds.size !== updates.length) {
fail("VALIDATION_FAILED", "duplicate principal_id across pin-manifest updates in one publish", "publishing a recipient_pin_manifest update", { principal_ids: updates.map((u) => u.principal_id) });
}
const nextVersion = (generationToBigInt(prior.pinManifestVersion) + 1n).toString(16).padStart(16, "0");
const pins = [];
for (const [principalId, ekFp] of prior.pinnedFingerprintOf) {
if (updateIds.has(principalId))
continue; // replaced below
pins.push({ principal_id: principalId, ek_fingerprint: ekFp, pinned_at: formatVaultTimestamp(this.now()), confirmed_by: "operator_confirmation", confirmation_receipt_sha256: VAULT_ZERO_SHA256_SENTINEL });
}
for (const u of updates) {
pins.push({ principal_id: u.principal_id, ek_fingerprint: u.ek_fingerprint, pinned_at: formatVaultTimestamp(this.now()), confirmed_by: "operator_confirmation", confirmation_receipt_sha256: storedBytesSha256(u.receipt) });
}
pins.sort((a, b) => (a.principal_id < b.principal_id ? -1 : a.principal_id > b.principal_id ? 1 : 0));
const unsignedManifest = { format: VAULT_FORMAT, object_kind: "recipient_pin_manifest", suite: VAULT_SUITE, repo_id: this.repoId, pin_manifest_version: nextVersion, base_pin_manifest_sha256: prior.pinManifestSha256, pins, writer_key_id: this.writerKeyId() };
const manifest = signVaultObject(unsignedManifest, this.signer());
const manifestBytes = storedBytes(manifest);
const manifestSha = sha256Hex(manifestBytes);
const upload = { path: vaultPaths.pinManifest(nextVersion), object_kind: "recipient_pin_manifest", object_id: null, bytes: manifestBytes, sha256: manifestSha, size_bytes: String(manifestBytes.length) };
return { nextVersion, manifestSha, upload, pins: pins.map((p) => ({ principal_id: p.principal_id, ek_fingerprint: p.ek_fingerprint })) };
}
/**
* Build a `recipient_pin_manifest` update (D197 full-map conservation) and
* publish it via an ORDINARY head (`gitvault.writer`-sufficient — the
* OWNER-GATED half of the ceremony already happened at `/confirm`/`/repin`,
* which is what produced `receipt`; PUBLISHING the resulting entry is
* ordinary-writer authority, same split as envelope-wrap authority
* always had). Carries the SAME refs/roots forward unchanged, at the
* CURRENT epoch (this is not a rotation — no new epoch, no new K_e).
*
* **This is an ORDINARY admission (`transition: null`) and is therefore
* itself refused `EPOCH_ROTATION_REQUIRED` while a migration/revocation/
* exposure condition is outstanding on this vault (D193) — the exact
* deadlock the incident behind {@link Vault.rotateEpoch}'s
* `pending_confirmations` parameter closes.** When this vault is in that
* state, fold the receipt into `rotateEpoch({..., pending_confirmations:
* [{principal_id, ek_fingerprint, receipt}]})` instead of calling this
* method directly — that submission carries a `rotate_epoch` transition,
* which IS the gate's own escape valve.
*/
async publishPinManifestUpdate(input) {
let conflicts = 0;
for (;;) {
const base = await this.materialize();
this.assertCallerIsWriter("publishing a recipient_pin_manifest update", conflicts > 0); // vault-multi-writer task 5.8
const prior = await this.loadEffectivePinManifest(base.generation);
const { nextVersion, manifestSha, upload: manifestUpload, pins } = this.buildPinManifestUpdate(prior, [input]);
await this.uploadAll([manifestUpload]);
const generation = nextGeneration(base.generation);
const refState = this.buildRefState(generation, base.refs, base.head_target);
const rootsObj = this.buildRetentionRoots(generation, base.roots, null);
await this.uploadAll([refState.upload, rootsObj.upload]);
const head = this.signHead({
generation, prev_sha256: base.head_sha256, wal_entries: [],
ref_state: { object_id: refState.object.object_id, object_kind: "ref_state", ciphertext_sha256: refState.upload.sha256, size_bytes: refState.upload.size_bytes },
retention_roots: { object_id: rootsObj.object.object_id, object_kind: "retention_roots", ciphertext_sha256: rootsObj.upload.sha256, size_bytes: rootsObj.upload.size_bytes },
checkpoint: null, checkpoint_purpose: null, capture_binding: null, repair: null,
}, { pin_manifest: { object_kind: "recipient_pin_manifest", pin_manifest_version: nextVersion, stored_bytes_sha256: manifestSha, size_bytes: String(manifestUpload.size_bytes) } });
const admitted = await this.admit(head);
if (admitted.outcome === "conflict") {
conflicts += 1;
if (conflicts > this.retries)
fail("HEAD_CAS_CONFLICT", `the pin-manifest update lost ${conflicts} races at generation ${generation}; giving up`, "publishing a recipient_pin_manifest update", { generation, winner: admitted.winner });
continue;
}
// Cache OUR OWN just-admitted manifest (see readPinManifestObject's
// doc comment) so a LATER call on this vault — most importantly
// rotateEpoch's own loadEffectivePinManifest, which MUST read the
// predecessor manifest back to compute confirmed() (D196) — resolves
// it locally instead of a network object-reads round trip.
this.keystore.updateRepo(this.repoId, { known_pin_manifest: { pin_manifest_version: nextVersion, stored_bytes_sha256: manifestSha, pins } });
return { generation, head_sha256: admitted.head_sha256, head, admission_record_sha256: admitted.admission_record_sha256, capture_receipt: admitted.capture_receipt, form: "wal", conflicts_retried: conflicts, refs: base.refs, checkpoint_staleness: this.checkpointStalenessNow(generation) };
}
}
/**
* Drive one epoch rotation to a committed head (D193-D203). This is the
* producer's obligations in full: sample a FRESH `K_e` independent of
* every prior epoch key this principal has locally held; compute the H
* bijection from the live desired-recipient state + the effective pin
* manifest; seal one `key_envelope` per included recipient from the SAME
* `K_e`; submit the create-only `rotation_attempt_descriptor` BEFORE any
* envelope upload; upload the envelopes; submit the `rotate_epoch` head;
* on a CAS conflict, retry from a fresh `materialize()` (the SAME
* conflict-retry shape `push()` uses); after commit, verify this
* principal's OWN envelope (when it is itself a recipient) opens to
* exactly the committed `K_e` and reproduces `epoch_key_commitment`
* (D200's narrowed per-recipient self-check — never a global proof);
* advance the local keystore's current epoch/key pointer AND retain the
* prior key in `epoch_keys`.
*
* **`recipient_state_version`/`recipient_revocation_version` are REQUIRED
* inputs, not discovered here.** The gateway exposes NO general read route
* for `internal.vault_recipient_state_counters` (D194) — the ONLY
* client-visible read of these two org-scoped counters today is the
* response of `POST …/recipients/:principal_id/key-revocation`
* ({@link VaultTransport.declareRecipientKeyRevoked}), which is why
* {@link rotateEpochForKeyRevocation} (below) is the one fully
* self-contained entry point. For `member_removed` / `elective_rekey` /
* `epoch_secret_exposed`, a caller that does not already know the current
* counter pair cannot discover it from any shipped gateway route — this
* is a confirmed gap in the live gateway (verified against
* `packages/gateway/src/services/vault/reads.ts:getVaultRecord` and
* every `routes/vault*.ts` handler, not inferred), not a client
* limitation this SDK can work around. Passing a stale/guessed pair fails
* safely: the admission fence's D194 frozen-counter comparison refuses
* `RECIPIENT_SET_MISMATCH` rather than silently canonizing against wrong
* evidence.
*
* **`options.pending_confirmations` (the manifest-publish deadlock fix).**
* `publishPinManifestUpdate` is an ORDINARY admission (`transition:
* null`) and is therefore itself refused `EPOCH_ROTATION_REQUIRED` while
* this vault has an urgent/migration condition outstanding — the exact
* state a `rotateEpoch` call is being made to clear. On an
* `epoch_secret_exposed` rekey `/confirm` mints a receipt server-side,
* but the ordinary push that would publish it never admits. Pass the
* pending receipted updates
* here instead — they ride the SAME head as this rotation's `transition`,
* which IS `EPOCH_ROTATION_REQUIRED`'s own escape valve, so the publish
* is no longer blocked. **This does NOT include these principals in
* THIS rotation's `envelopes[]`** — protocol-v0.md D196 is explicit: "a
* manifest update riding the SAME head never self-authorizes its own
* recipients"; `confirmed(h)` for THIS rotation still reads only the
* PREDECESSOR manifest, unchanged. They land in
* `excluded_unconfirmed_principal_ids` here (same as without folding) and
* become eligible starting at the NEXT rotation, once this manifest is
* the admitted predecessor. See {@link VaultRotationResult.pin_manifest_published}.
*
* **Honest residual — this does not rescue a vault with ZERO ever-
* confirmed principals.** If `included` would be empty even with the
* fold (no predecessor-confirmed principal exists at all — e.g. a
* grandfathered pre-rev-42 vault whose bare genesis never published a
* pin manifest and which was never bootstrapped before its
* `migration_rotation_required` flag was set), the gateway refuses
* `EPOCH_ROTATION_WOULD_LEAVE_VAULT_UNCOVERED` regardless of what rides
* along on `pin_manifest` — D196's same-head exclusion makes THIS
* impossible to route around from the client. That is a genuine,
* currently-open protocol gap (not something this parameter can paper
* over) and needs an operator-side decision, not a client workaround.
*/
async rotateEpoch(options) {
let conflicts = 0;
for (;;) {
const base = await this.materialize();
this.assertCallerIsWriter("driving an epoch rotation", conflicts > 0); // vault-multi-writer task 5.8
const repo = this.repoFile();
const currentEpoch = base.head?.epoch ?? this.epoch();
const newEpoch = nextEpoch(currentEpoch);
// Computed here (rather than where it was historically built, just
// before the head is signed) because D209's self_open_attestation
// needs to name THIS attempt's own admitted generation as
// decryptable_to_generation before the head itself is built.
const generation = nextGeneration(base.generation);
// D195's producer obligation: a fresh K_e, checked against EVERY prior
// epoch key this principal has ever locally held.
const kE = randomBytes(32);
const priorKnownKeys = Object.values(repo.epoch_keys ?? { [repo.epoch]: repo.k_repo_hex }).map(hexToBytes);
checkFreshEpochKeyAgainstPriorKeys(kE, priorKnownKeys);
// Live desired-recipient state (H) + the effective pin manifest.
const envelopeRecipients = await this.transport.listEnvelopeRecipients({ repo_id: this.repoId });
const desired = envelopeRecipients.desired;
if (!desired) {
fail("VAULT_DESIRED_STATE_UNAVAILABLE", "the gateway did not report desired-recipient state (desired[]) for this vault — an epoch rotation cannot compute its H-partition without it", "computing the epoch-rotation H-partition");
}
const pinManifest = await this.loadEffectivePinManifest(base.generation);
// The vault record: `writer_set` (the chain's active writers, by
// principal) and `ineligible_members` (the gateway-blocked writers this
// rotation's writer_set_update must remove). Read ONCE here; both the
// H-partition below and the writer_set_update fold-in further down use it.
const vaultRecord = await this.transport.getVaultRecord({ repo_id: this.repoId });
const ineligible = vaultRecord.ineligible_members ?? [];
// kygit-handoff's member-removal decision: a keyed desired recipient
// whose signing key is an active writer AND survives this rotation is
// included on its current directory fingerprint, pin or no pin (the
// writer set is client-signed chain state — the same authority that
// lets it sign heads). Mirrors the gateway's own rule under its fence.
const blockedWriterPrincipalIds = new Set(ineligible.map((m) => m.principal_id));
const survivingWriterPrincipalIds = new Set((vaultRecord.writer_set?.writers ?? []).map((w) => w.principal_id).filter((id) => !blockedWriterPrincipalIds.has(id)));
const directoryFingerprintOf = new Map(desired.filter((d) => d.status === "active" && d.ek_fingerprint).map((d) => [d.principal_id, d.ek_fingerprint]));
// D196's H-partition: included / excluded_keyless / excluded_unconfirmed.
// A `pending_removal` desired-recipient row is EXCLUDED from H entirely
// (the gateway's own H is `status='active'` only) — that omission is
// the entire forward-revocation point for reason:"member_removed".
const included = [];
const excludedKeyless = [];
const excludedUnconfirmed = [];
const pinBlocked = [];
for (const d of desired) {
if (d.status !== "active")
continue;
if (!d.ek_fingerprint || !d.public_key) {
excludedKeyless.push(d.principal_id);
continue;
}
const pinned = pinManifest.pinnedFingerprintOf.get(d.principal_id);
if (pinned === undefined) {
if (survivingWriterPrincipalIds.has(d.principal_id)) {
// A surviving writer without a pin: included on the directory key.
let pub;
try {
pub = fromBase64url(d.public_key, "public_key");
}
catch {
fail("VALIDATION_FAILED", `surviving writer ${d.principal_id}'s directory public key is not valid base64url`, "computing the epoch-rotation H-partition");
}
if (ekFingerprint(pub) !== d.ek_fingerprint) {
fail("VALIDATION_FAILED", `surviving writer ${d.principal_id}'s directory public key does not hash to its declared fingerprint`, "computing the epoch-rotation H-partition");
}
included.push({ principal_id: d.principal_id, ek_fingerprint: d.ek_fingerprint, public_key: pub });
continue;
}
excludedUnconfirmed.push(d.principal_id);
continue;
}
if (pinned !== d.ek_fingerprint) {
// A live pin exists but disagrees with the directory's current
// fingerprint (the principal re-enrolled a key since their last
// confirmation). Neither "included" (D196 requires the envelope
// fingerprint to equal the PINNED one) nor "excluded_unconfirmed"
// (D196/gateway validation requires pinnedFingerprintOf to LACK
// them entirely) is a legal bucket for this principal under the
// protocol as specified — the owner must run `/repin` first.
pinBlocked.push({ principal_id: d.principal_id, directory_fingerprint: d.ek_fingerprint, pinned_fingerprint: pinned });
continue;
}
let pub;
try {
pub = fromBase64url(d.public_key, "public_key");
}
catch {
pinBlocked.push({ principal_id: d.principal_id, directory_fingerprint: d.ek_fingerprint, pinned_fingerprint: pinned });
continue;
}
if (ekFingerprint(pub) !== d.ek_fingerprint) {
pinBlocked.push({ principal_id: d.principal_id, directory_fingerprint: d.ek_fingerprint, pinned_fingerprint: pinned });
continue;
}
included.push({ principal_id: d.principal_id, ek_fingerprint: d.ek_fingerprint, public_key: pub });
}
if (pinBlocked.length > 0) {
fail("VAULT_ROTATION_BLOCKED_PIN_MISMATCH", `${pinBlocked.length} principal(s) have a live pin that disagrees with their current directory key and cannot be safely included or excluded under the protocol's own H-partition rules — run the /repin ceremony for each, then retry: ${pinBlocked.map((p) => p.principal_id).join(", ")}`, "computing the epoch-rotation H-partition", { blocked: pinBlocked });
}
if (options.reason === "elective_rekey" && (excludedKeyless.length > 0 || excludedUnconfirmed.length > 0)) {
fail("EPOCH_ROTATION_INCOMPLETE_ENROLLMENT", `reason:"elective_rekey" refuses a target set that is a proper subset of the vault's current actual coverage — ${excludedKeyless.length} keyless, ${excludedUnconfirmed.length} unconfirmed`, "computing the epoch-rotation H-partition", { excluded_keyless_principal_ids: excludedKeyless, excluded_unconfirmed_principal_ids: excludedUnconfirmed });
}
if (included.length === 0) {
fail("EPOCH_ROTATION_WOULD_LEAVE_VAULT_UNCOVERED", "every eligible principal is excluded — this reason would leave the vault with no recipient able to decrypt the new epoch", "computing the epoch-rotation H-partition");
}
// Self-consistency (defense in depth — the gateway's own recomputation under its live lock is authoritative).
const desiredIds = new Set(desired.filter((d) => d.status === "active").map((d) => d.principal_id));
const keyedIds = new Set(desired.filter((d) => d.status === "active" && d.ek_fingerprint).map((d) => d.principal_id));
const hCheck = checkHPartition({ desiredPrincipalIds: desiredIds, keyedPrincipalIds: keyedIds, pinnedFingerprintOf: pinManifest.pinnedFingerprintOf, included, excludedKeylessPrincipalIds: excludedKeyless, excludedUnconfirmedPrincipalIds: excludedUnconfirmed, survivingWriterPrincipalIds, directoryFingerprintOf });
if (!hCheck.ok)
fail("VALIDATION_FAILED", `internal: the H-partition this producer built is not a valid bijection over H (${hCheck.detail}) — refusing to submit a rotation the gateway would refuse`, "computing the epoch-rotation H-partition");
const targetPartitionDigest = computeTargetPartitionDigest({
recipient_state_version: options.recipient_state_version,
recipient_revocation_version: options.recipient_revocation_version,
pin_manifest_version: pinManifest.pinManifestVersion,
pin_manifest_sha256: pinManifest.pinManifestSha256,
included: included.map((p) => ({ principal_id: p.principal_id, ek_fingerprint: p.ek_fingerprint })),
excluded_keyless_principal_ids: excludedKeyless,
excluded_unconfirmed_principal_ids: excludedUnconfirmed,
});
// vault-multi-writer (task 5.9, D6/D227) — fold in a writer_set_update
// whenever the gateway's own blocked set (`ineligible_members`, read
// fresh) is non-empty. This is what closes D6's "ordinary
// admissions... refuse EPOCH_ROTATION_REQUIRED... until a rotate_epoch
// carries writer_set_update" deadlock, automatically, on EVERY
// rotation regardless of `options.reason` — the SAME "self-healing"
// shape `pending_confirmations` already gives the recipient side.
//
// Deliberately NO caller-supplied "also remove this OTHER writer key"
// option: the gateway's own admission rule requires `removed[]` to
// equal the gateway-computed blocked set EXACTLY (D6's own words,
// confirmed against `validateWriterSetUpdate`'s identical
// RECIPIENT_SET_MISMATCH check) — a client-named superset would be
// refused, not honored. A genuinely NEW, deliberate writer-key
// revocation (the "repos access revoke-key" aspiration this task's
// own text names) needs a gateway-side "declare this writer key
// revoked" mutation FIRST (the writer-dimension analog of
// `declareRecipientKeyRevoked`) to populate `ineligible_members` in
// the first place — confirmed absent from every gateway route as of
// this task (`grep -rn "declareWriterKeyRevoked|revoke-key"
// packages/gateway/src/routes/` — zero hits). That server-side
// mutation is real, new, owner+step-up-gated authorization logic, not
// a client-side gap this task can close — filed as a residual rather
// than half-built against a route that doesn't exist. Once it ships,
// this SAME automatic fold-in picks up whatever it gateway-blocks
// with zero client changes — the fold-in doesn't care WHY a key
// is blocked, only THAT it is.
const writerPin = this.repoFile().writer_set_pin;
let writerSetUpdate = null;
if (ineligible.length > 0) {
if (!writerPin) {
fail("VAULT_WRITER_STATE_UNAVAILABLE", "no locally verified writer_set_pin for this vault — the chain must be verified (materialize/verifyToNewest) before a writer_set_update can be built against a known base writer set", "computing the writer_set_update");
}
const predecessorState = { version: writerPin.version, writers: writerPin.writers, sha256: writerPin.sha256, burnedWriterKeyIds: new Set(), consumedHandoffIds: new Set(), retiredWriters: writerPin.retired_writers ?? [] };
// A rotation's own top-level `reason:"epoch_secret_exposed"` is the
// one unambiguous signal worth carrying down to each removed entry
// (a genuinely distinct, meaningful narrative); everything else —
// including the gateway's own finer-grained `ineligible_members[].
// reason` taxonomy (membership_revoked/role_below_developer/
// encryption_key_revoked/gateway_blocked_pending_removal), which
// has no clean mapping onto this field's narrower 3-value wire
// vocabulary — collapses to the safe, generic "member_removed".
const perEntryReason = options.reason === "epoch_secret_exposed" ? "epoch_secret_exposed" : "member_removed";
const removed = ineligible.map((m) => ({ writer_key_id: m.writer_key_id, principal_id: m.principal_id, reason: perEntryReason }));
const removedIds = removed.map((r) => r.writer_key_id);
const nextState = applyWriterSetUpdate(this.repoId, predecessorState, removedIds);
const update = {
base_version: writerPin.version, base_sha256: writerPin.sha256,
next_version: nextState.version, next_sha256: nextState.sha256,
removed, writers: nextState.writers,
};
const wsuCheck = validateWriterSetUpdate(this.repoId, predecessorState, update, this.writerKeyId(), new Set(removedIds), options.force_empty_writer_set === true);
if (!wsuCheck.ok) {
fail(wsuCheck.code, `internal: the writer_set_update this producer built is not valid (${wsuCheck.detail}) — refusing to submit a rotation the gateway would refuse`, "computing the writer_set_update", { removed: removedIds });
}
writerSetUpdate = { base_version: update.base_version, base_sha256: update.base_sha256, next_version: update.next_version, next_sha256: update.next_sha256, removed, writers: [...update.writers] };
}
// D195: build + sign the rotation_attempt_descriptor, derive rotation_id, submit the create-only CAS BEFORE any envelope upload.
const clientIdempotencyKey = options.client_idempotency_key ?? newHex32();
// D204 (rev 43): `migration_bootstrap` is schema-optional but the
// gateway's fence requires the boolean on BOTH the descriptor and the
// payload, and requires the two to agree with its own locked branch
// value (the vault's migration flag AND no predecessor manifest). This
// producer never drives the bootstrap branch — that is the pre-rev-42
// repair ceremony, which co-rides a first manifest — so it declares
// `false`; omitting the field is refused RECIPIENT_SET_MISMATCH.
const migrationBootstrap = false;
const descriptorFields = {
format: VAULT_FORMAT, object_kind: "rotation_attempt_descriptor", suite: VAULT_SUITE,
repo_id: this.repoId, base_head_sha256: base.head_sha256, new_epoch: newEpoch,
recipient_state_version: options.recipient_state_version, recipient_revocation_version: options.recipient_revocation_version,
migration_bootstrap: migrationBootstrap,
pin_manifest_sha256: pinManifest.pinManifestSha256, target_partition_digest: targetPartitionDigest,
client_idempotency_key: clientIdempotencyKey, writer_key_id: this.writerKeyId(),
// D227 (rev 47): present together IFF this rotation carries a writer_set_update — frozen at THIS attempt's own admission fence, mirroring recipient_state_version/recipient_revocation_version's existing discipline.
...(writerSetUpdate ? { writer_revocation_version: vaultRecord.writer_revocation_version ?? "0", writer_set_base_sha256: writerSetUpdate.base_sha256, writer_set_next_sha256: writerSetUpdate.next_sha256 } : {}),
};
const attemptCommitment = attemptKeyCommitment(kE, this.repoId, newEpoch, descriptorFields);
const signedDescriptor = signVaultObject({ ...descriptorFields, attempt_key_commitment: attemptCommitment }, this.signer());
const rotationId = computeRotationId(signedDescriptor);
const attemptOut = await this.transport.createRotationAttempt({ repo_id: this.repoId, descriptor: signedDescriptor });
if (attemptOut.rotation_id !== rotationId) {
fail("ROTATION_ID_MISMATCH", "the gateway's own re-derived rotation_id disagrees with this client's derivation — refusing to proceed", "creating a rotation attempt", { client_derived: rotationId, gateway_derived: attemptOut.rotation_id });
}
// Seal one key_envelope per included recipient, all from the SAME kE (D200's global one-key property depends on this).
const sealedReceipts = [];
const envelopeUploads = [];
const nowIso = formatVaultTimestamp(this.now());
for (const p of included) {
const sealed = await sealKeyEnvelope({ k_repo: kE, repo_id: this.repoId, epoch: newEpoch, recipient_public_key: p.public_key, signer: this.signingKeypair(), created_at: nowIso, rotation_id: rotationId });
sealedReceipts.push({ principal_id: p.principal_id, envelope: sealed.receipt });
envelopeUploads.push({ path: vaultPaths.envelope(newEpoch, p.ek_fingerprint, rotationId), object_kind: "key_envelope", object_id: null, bytes: sealed.stored_bytes, sha256: sealed.stored_bytes_sha256, size_bytes: sealed.size_bytes });
}
if (envelopeUploads.length > 0)
await this.uploadAll(envelopeUploads);
sealedReceipts.sort((a, b) => (a.principal_id < b.principal_id ? -1 : a.principal_id > b.principal_id ? 1 : 0));
const epochKeyCommitmentValue = epochRotationKeyCommitment(kE, this.repoId, newEpoch, rotationId, included.map((p) => p.ek_fingerprint));
const payloadBase = {
new_epoch: newEpoch, rotation_id: rotationId, reason: options.reason,
recipient_state_version: options.recipient_state_version, recipient_revocation_version: options.recipient_revocation_version,
migration_bootstrap: migrationBootstrap,
pin_manifest_sha256: pinManifest.pinManifestSha256, target_partition_digest: targetPartitionDigest,
epoch_key_commitment: epochKeyCommitmentValue, excluded_keyless_principal_ids: excludedKeyless, excluded_unconfirmed_principal_ids: excludedUnconfirmed,
recipient_authority_attestation: null, envelopes: sealedReceipts,
// vault-multi-writer (task 5.9, D227): present IFF this rotation ALSO removes one or more writers.
...(writerSetUpdate ? { writer_set_update: writerSetUpdate } : {}),
};
// D209 (rev 44) — round-trip THIS principal's own new-epoch
// key_envelope through the REAL reader entry point
// (openEpochRotationForRecipient — the exact unit fsck/verifyToNewest
// use to open a rotation) BEFORE submitting, and bake the result into
// the payload as `self_open_attestation`. A rev-44 gateway refuses
// EPOCH_ROTATION_SELF_OPEN_UNPROVEN on any rotate_epoch admission
// that omits this or whose claim disagrees with its own
// server-computed writer-in-envelopes biconditional — this call site
// closes the gap where a client can WRITE a rotated vault it cannot
// READ, because the reader never implements rotation traversal while
// the write side stays green on its own tests. It replaces a bare
// post-commit self-check (calling openKeyEnvelope directly, bypassing the
// membership lookup, the envelope_path callback derivation, and the
// reader's own error framing) — moving the round-trip BEFORE
// admission means a genuine failure aborts this call before anything
// is ever submitted to the gateway, rather than leaving a broken
// rotation committed server-side that even its own writer cannot
// read back. A round-trip failure THROWS
// (openEpochRotationForRecipient's own error framing — e.g.
// VAULT_EPOCH_NOT_OPENABLE / EPOCH_KEY_COMMITMENT_MISMATCH — never
// a bare AEAD failure); this call never emits a false attestation.
const identity = this.keystore.readIdentity();
const ownKeypair = identity ? this.keystore.encryptionKeypair(identity) : null;
const ownFingerprint = ownKeypair ? ekFingerprint(ownKeypair.public_key) : null;
const ownIncluded = ownFingerprint ? included.find((p) => p.ek_fingerprint === ownFingerprint) : undefined;
let selfOpenAttestation;
let selfCheck = "not_a_recipient";
if (ownFingerprint && ownKeypair && ownIncluded) {
await openEpochRotationForRecipient({
repo_id: this.repoId,
payload: payloadBase,
own_fingerprint: ownFingerprint,
own_encryption_keypair: ownKeypair,
writer_signing_public_key: this.signingKeypair().public_key,
get_envelope_bytes: (path) => this.transport.getObject({ repo_id: this.repoId, path }),
envelope_path: (epoch, fp, rid) => vaultPaths.envelope(epoch, fp, rid),
});
// openEpochRotationForRecipient already recomputes epoch_key_commitment
// from the opened plaintext and compares it to payloadBase's own
// (HMAC-derived from kE) — an HMAC collision across different keys
// is cryptographically infeasible, so its own pass IS the proof
// that the opened secret equals kE. No separate byte comparison
// needed here (unlike the old post-commit check, which duplicated
// this logic by hand instead of trusting the real entry point).
selfOpenAttestation = {
outcome: "opened",
chain_verified_to_generation: base.generation,
decryptable_to_generation: generation,
opened_fingerprint: ownFingerprint,
reader_entrypoint: vaultReaderEntrypoint("openEpochRotationForRecipient"),
};
selfCheck = "passed";
}
else {
// The normal agent/CI-writer case (D209's "writer_not_recipient"
// branch): the admitting principal has no included pair in
// envelopes[] (no local encryption identity at all, or one whose
// fingerprint is not among `included`), so no self round-trip is
// possible. This is NOT a confidentiality gap — the writer sampled
// kE itself — and D210's recipient proof-of-open receipts are the
// closure for post-rotation readability on this branch.
selfOpenAttestation = { outcome: "writer_not_recipient", chain_verified_to_generation: base.generation };
}
const payload = { ...payloadBase, self_open_attestation: selfOpenAttestation };
const payloadBytes = jcs(payload);
const transition = { kind: "rotate_epoch", payload_format: "base64url-jcs", payload: toBase64url(payloadBytes), payload_sha256: sha256Hex(payloadBytes) };
// The manifest-publish deadlock fix: fold receipted pending
// confirmations into THIS SAME head's `pin_manifest` field (schema-
// legal, §4.3 — `transition` and `pin_manifest` are independent
// optional fields on one `head`). Built from `pinManifest`, the
// PREDECESSOR already loaded above for the H-partition — NEVER used
// to recompute `included`/`excludedUnconfirmed` above, which is what
// keeps this conformant with D196 ("a manifest update riding the SAME
// head never self-authorizes its own recipients"). This durably
// publishes the receipts (unblocking every future ordinary admission
// once this rotation clears the urgent/migration condition) without
// claiming these principals are covered by THIS rotation's envelopes.
const pendingConfirmations = options.pending_confirmations ?? [];
const pinManifestFold = pendingConfirmations.length > 0
? this.buildPinManifestUpdate(pinManifest, pendingConfirmations.map((p) => ({ principal_id: p.principal_id, ek_fingerprint: p.ek_fingerprint, confirmed_by: "operator_confirmation", receipt: p.receipt })))
: null;
// The new epoch's ref_state/retention_roots — CARRIED FORWARD unchanged, sealed under kE.
// (`generation` itself was computed earlier, at the top of this loop
// — D209's self_open_attestation needed to name it as
// decryptable_to_generation before the head was even built.)
const refState = this.buildRefState(generation, base.refs, base.head_target, { k_repo: kE, epoch: newEpoch });
const rootsObj = this.buildRetentionRoots(generation, base.roots, null, { k_repo: kE, epoch: newEpoch });
const uploads = [refState.upload, rootsObj.upload];
if (pinManifestFold)
uploads.push(pinManifestFold.upload);
await this.uploadAll(uploads);
const head = this.signHead({
generation, prev_sha256: base.head_sha256, wal_entries: [],
ref_state: { object_id: refState.object.object_id, object_kind: "ref_state", ciphertext_sha256: refState.upload.sha256, size_bytes: refState.upload.size_bytes },
retention_roots: { object_id: rootsObj.object.object_id, object_kind: "retention_roots", ciphertext_sha256: rootsObj.upload.sha256, size_bytes: rootsObj.upload.size_bytes },
checkpoint: null, checkpoint_purpose: null, capture_binding: null, repair: null,
}, {
epoch: newEpoch, transition,
...(pinManifestFold ? { pin_manifest: { object_kind: "recipient_pin_manifest", pin_manifest_version: pinManifestFold.nextVersion, stored_bytes_sha256: pinManifestFold.manifestSha, size_bytes: pinManifestFold.upload.size_bytes } } : {}),
});
const admitted = await this.admit(head);
if (admitted.outcome === "conflict") {
conflicts += 1;
if (conflicts > this.retries)
fail("HEAD_CAS_CONFLICT", `the rotation lost ${conflicts} races at generation ${generation}; giving up`, "admitting a rotate_epoch head", { generation, winner: admitted.winner });
continue;
}
// The pre-submission D209 round-trip above already confirmed (when
// applicable — `selfCheck === "passed"`) that this principal's own
// opened envelope recovers exactly the K_e it sealed and reproduces
// epoch_key_commitment, through the real reader entry point. A
// genuine self-check failure would have thrown BEFORE this head was
// ever built or submitted, so the local pointer advances
// unconditionally here.
this.keystore.recordEpochRotation(this.repoId, { new_epoch: newEpoch, new_k_repo_hex: bytesToHex(kE) });
// Same local-cache short-circuit as publishPinManifestUpdate's own
// success path (see readPinManifestObject's doc comment) — the fold
// built and admitted its OWN recipient_pin_manifest on this SAME head.
if (pinManifestFold) {
this.keystore.updateRepo(this.repoId, { known_pin_manifest: { pin_manifest_version: pinManifestFold.nextVersion, stored_bytes_sha256: pinManifestFold.manifestSha, pins: pinManifestFold.pins } });
}
// vault-multi-writer (task 5.9) — the SAME local-pin advance
// #publishAddWriterKeyTransition performs on ITS success path: this
// head just admitted a writer_set_update, so the locally-pinned
// writer set is now stale until refreshed here.
if (writerSetUpdate) {
this.keystore.updateRepo(this.repoId, { writer_set_pin: { version: writerSetUpdate.next_version, sha256: writerSetUpdate.next_sha256, writers: [...writerSetUpdate.writers], pinned_at: formatVaultTimestamp(this.now()) } });
}
return {
outcome: "admitted", generation, head_sha256: admitted.head_sha256, new_epoch: newEpoch, rotation_id: rotationId, reason: options.reason,
included: included.map((p) => ({ principal_id: p.principal_id, ek_fingerprint: p.ek_fingerprint })),
excluded_keyless_principal_ids: excludedKeyless, excluded_unconfirmed_principal_ids: excludedUnconfirmed,
admission_record_sha256: admitted.admission_record_sha256, capture_receipt: admitted.capture_receipt, self_check: selfCheck,
pin_manifest_published: pinManifestFold ? { pin_manifest_version: pinManifestFold.nextVersion, stored_bytes_sha256: pinManifestFold.manifestSha, principal_ids: pendingConfirmations.map((p) => p.principal_id) } : null,
writers_removed: writerSetUpdate ? writerSetUpdate.removed.map((r) => ({ writer_key_id: r.writer_key_id, principal_id: r.principal_id, reason: r.reason })) : [],
};
}
}
/**
* The writer-capable rotation that completes an org membership removal
* (vault-multi-writer D6: "`member_removed` keeps its automatic
* writer-capable path"). The removal itself already advanced the org's
* D194 counters and flipped the member to `pending_removal`, so there is
* nothing to declare: read the counters off the envelope-recipients read
* (the same read the H-partition uses) and rotate under
* `reason:"member_removed"`, which needs `gitvault.writer` only — any
* surviving writer can run it, no owner step-up. Refuses
* `VAULT_ROTATION_COUNTERS_UNAVAILABLE` on a gateway that does not yet
* carry the counters on that read.
*/
async rotateEpochForMemberRemoval(options = {}) {
const recipients = await this.transport.listEnvelopeRecipients({ repo_id: this.repoId });
const state = recipients.recipient_state_version;
const revocation = recipients.recipient_revocation_version;
if (typeof state !== "string" || typeof revocation !== "string") {
fail("VAULT_ROTATION_COUNTERS_UNAVAILABLE", "the gateway did not report the org's rotation counters on the envelope-recipients read — a member-removal rotation cannot be fenced without them; an owner can run `run402 repos access revoke-key <principal_id>` instead", "reading the rotation counters");
}
return this.rotateEpoch({ reason: "member_removed", recipient_state_version: state, recipient_revocation_version: revocation, client_idempotency_key: options.client_idempotency_key });
}
/**
* The ONE fully self-contained rotation entry point: declares
* `reason:"recipient_key_revoked"` (owner + step-up) for `principal_id`,
* takes the D194 counters straight off that call's OWN response, and
* drives the rotation with them — no external counter source needed.
*/
async rotateEpochForKeyRevocation(principalId, options = {}) {
const counters = await this.transport.declareRecipientKeyRevoked({ repo_id: this.repoId, principal_id: principalId });
return this.rotateEpoch({ reason: "recipient_key_revoked", recipient_state_version: counters.recipient_state_version, recipient_revocation_version: counters.recipient_revocation_version, client_idempotency_key: options.client_idempotency_key });
}
// ── writer activation (vault-multi-writer task 5.6, design D4/D5) ──────
/**
* Submit a REF-NEUTRAL `add_writer_key{authorization.kind:"handoff"}`
* activation head — no ref/checkpoint/repair changes ride it (protocol
* §4.17's own "ref-neutral head shape... for the activation head" rule),
* built by `publishGeneration` the SAME way every ordinary no-op push
* would be (`refs`/`head_target` carried forward from `base` UNCHANGED),
* just with `transition` set. Retries `HEAD_CAS_CONFLICT` like every
* other publish path here. The caller (`resume()`) has already built and
* locally sanity-checked the grant + acceptance; this method only reads
* the FRESHLY-verified `writer_set_pin` (set by the `materialize()` this
* same call performs, mirroring `push()`'s own base-then-retry shape) to
* compute `base_writer_set`/`next_writer_set`, signs the head under
* `this.signer()` (the ADDED key itself — protocol §4.17's "head signed
* by the added key" — since `signer()` reads straight from THIS
* keystore's identity, and `resume()` mints/loads that identity before
* ever reaching this call), and advances the local `writer_set_pin` on
* success so a subsequent read reflects the new writer without a second
* chain walk.
*
* Crash-resumable (task 5.6): the chain burns `handoff_id` single-use, so
* a naive resubmission after a crash between a prior attempt's successful
* admission and this checkout learning about it would be refused
* VALIDATION_FAILED. Returns `{outcome:"already_admitted", generation}`
* instead of submitting anything whenever the freshly-verified writer set
* ALREADY contains `input.addedWriterKeyId` — the caller treats both
* outcomes as success (the writer IS active either way) and proceeds.
*/
async submitWriterActivationHead(input) {
return this.#publishAddWriterKeyTransition(input.addedWriterKeyId, (pin) => buildAddWriterKeyActivationPayload(this.repoId, pin, { writer_key_id: input.addedWriterKeyId, signing_pubkey: input.addedSigningPubkeyB64u, principal_id: input.addedPrincipalId }, input.handoffId, input.grant, input.acceptance), "submitting a writer activation head",
// vault-multi-writer task 5.8: the handoff door is signed by the
// ADDED key itself, which is by design NOT YET a writer — the pre-
// check must be SKIPPED here, or this call could never succeed.
false);
}
/**
* vault-multi-writer (task 5.7) — the "writer"-door twin of {@link
* submitWriterActivationHead}: an ALREADY-ACTIVE writer (this vault
* session's own key) admits `input`, an eligible org member with a
* published signing key but no writer standing yet, via a fresh
* `add_writer_key{"writer"}` head. No grant/acceptance — the carrying
* head's own signer being an active writer at the predecessor generation
* IS the authorization (`validateAddWriterKeyPayload`'s `"writer"`
* branch), re-verified admission-side by `checkTransitionAdmissible`.
* Task 5.8 adds the SAME local pre-check every other head-signing path
* gets: if THIS session's own key is not (or is no longer) an active
* writer, refuse EARLY and LOCALLY (`VAULT_WRITER_NOT_ADMITTED` /
* `VAULT_WRITER_REMOVED`) rather than let the head reach the gateway
* only to be refused there — redundant with `reconcileWriterAdmissions`'s
* own upfront gate for that caller, but this method is public and a
* direct caller should get the same fast, specific refusal.
*/
async admitPendingWriter(input) {
return this.#publishAddWriterKeyTransition(input.addedWriterKeyId, (pin) => buildWriterDoorAddWriterKeyPayload(this.repoId, pin, { writer_key_id: input.addedWriterKeyId, signing_pubkey: input.addedSigningPubkeyB64u, principal_id: input.addedPrincipalId }), "admitting a pending writer", true);
}
/**
* Shared publish+retry+pin-update tail for BOTH doors of `add_writer_key`
* ({@link submitWriterActivationHead}'s handoff door, task 5.6; {@link
* admitPendingWriter}'s writer door, task 5.7). `buildPayload` constructs
* a fresh {@link AddWriterKeyPayload} against whatever `writer_set_pin`
* the CURRENT attempt's freshly-materialized base carries, so a
* conflict-retry rebuilds against the winner's own pin, never a stale
* one. Crash/race-resumable the same way for both doors: if
* `addedWriterKeyId` is ALREADY in the freshly-verified writer set before
* anything is built, returns `{outcome:"already_admitted"}` instead of
* attempting a resubmission the chain would refuse.
*
* `requireCallerIsWriter` (task 5.8) gates {@link assertCallerIsWriter} —
* `false` for the handoff door (the signer is deliberately not yet a
* writer), `true` for the writer door (the signer must already be one).
*/
async #publishAddWriterKeyTransition(addedWriterKeyId, buildPayload, errorContext, requireCallerIsWriter) {
let conflicts = 0;
for (;;) {
const base = await this.materialize();
if (requireCallerIsWriter)
this.assertCallerIsWriter(errorContext, conflicts > 0);
const pin = this.repoFile().writer_set_pin;
if (!pin) {
fail("VAULT_WRITER_STATE_UNAVAILABLE", "no locally verified writer_set_pin for this vault — the chain must be verified (materialize/verifyToNewest) before an activation head can be built against a known base writer set", errorContext);
}
if (pin.writers.some((w) => w.writer_key_id === addedWriterKeyId)) {
return { outcome: "already_admitted", generation: base.generation };
}
const payload = buildPayload(pin);
const nextState = payload.next_writer_set;
const payloadBytes = jcs(payload);
const transition = { kind: "add_writer_key", payload_format: "base64url-jcs", payload: toBase64url(payloadBytes), payload_sha256: sha256Hex(payloadBytes) };
const published = await this.publishGeneration({
base, refs: base.refs, dropped: [], head_target: base.head_target,
force_checkpoint: false, cutoff: null, transition,
});
if (published.outcome === "conflict") {
conflicts += 1;
if (conflicts > this.retries)
fail("HEAD_CAS_CONFLICT", `the writer activation lost ${conflicts} races at generation ${published.generation}; giving up`, errorContext, { generation: published.generation, winner: published.winner });
continue;
}
if (published.outcome === "dry_run")
fail("GIT_COMMAND_FAILED", "internal: #publishAddWriterKeyTransition() received a dry-run result it never requested", errorContext);
this.keystore.updateRepo(this.repoId, { writer_set_pin: { version: nextState.version, sha256: nextState.sha256, writers: [...nextState.writers], pinned_at: formatVaultTimestamp(this.now()) } });
return {
outcome: "activated",
result: { generation: published.generation, head_sha256: published.head_sha256, head: published.head, admission_record_sha256: published.admission_record_sha256, capture_receipt: published.capture_receipt, form: published.form, conflicts_retried: conflicts, refs: published.refs, checkpoint_staleness: this.checkpointStalenessNow(published.generation) },
};
}
}
/**
* vault-multi-writer (task 5.7) — the vault's OWN writer-admission
* reconcile: reads `pending_writers[]` off the vault record (eligible org
* members with no writer standing yet), resolves each candidate's
* published signing key off the org's encryption-key directory (the SAME
* `/orgs/v1/:org_id/encryption-keys` row {@link
* reconcileEnvelopeRecipients} reads, widened D9 to also carry the
* signing half), and admits each via {@link admitPendingWriter} — one
* `add_writer_key{"writer"}` head per candidate, sequentially: each
* admission materializes fresh against the PRIOR one's own updated writer
* set, so there is no batched/parallel form.
*
* Requires THIS session's own key to already be an active writer (the
* chain's own authorization rule for the "writer" door) — checked ONCE,
* upfront, off the locally-pinned `writer_set_pin` (no network call),
* rather than discovered N times over from N identical gateway refusals:
* a session that reached this call already pushed successfully as an
* active writer in every wired call site (push/snapshot/deploy) OR is a
* member that simply is not one yet (session-start/read) — the SAME
* "not yet admitted" case {@link Repos.capture}'s own pre-push check
* (task 5.8) names, not a new refusal shape. Returns `{eligible: false}`
* with every other field empty (never throws) for that case: every OTHER
* `pending_writers[]` entry would fail identically, so there is nothing
* this call can usefully do, and "I am not a writer" is not this vault's
* fault — but it is distinguishable from "there was nothing pending"
* (`eligible: true`, still all empty), which matters to a caller trying
* to explain an all-empty result to a human.
*/
async reconcileWriterAdmissions() {
const repo = this.repoFile();
const admitted = [];
const alreadyCovered = [];
const skipped = [];
const assembleResult = (eligible) => ({ repo_id: this.repoId, org_id: repo.org_id, eligible, admitted, already_covered: alreadyCovered, skipped });
const myWriterKeyId = this.writerKeyId();
const startingPin = repo.writer_set_pin;
if (!startingPin || !startingPin.writers.some((w) => w.writer_key_id === myWriterKeyId))
return assembleResult(false);
const record = await this.transport.getVaultRecord({ repo_id: this.repoId });
const pending = record.pending_writers ?? [];
if (pending.length === 0)
return assembleResult(true);
const directory = await this.transport.listOrgEncryptionKeys({ org_id: repo.org_id });
const byPrincipal = new Map(directory.keys.map((k) => [k.principal_id, k]));
for (const candidate of pending) {
const currentPin = this.repoFile().writer_set_pin;
if (currentPin?.writers.some((w) => w.writer_key_id === candidate.writer_key_id)) {
alreadyCovered.push(candidate.writer_key_id);
continue;
}
const entry = byPrincipal.get(candidate.principal_id);
const signingPubkey = entry?.signing_pubkey;
if (typeof signingPubkey !== "string" || signingPubkey.length === 0) {
skipped.push({ principal_id: candidate.principal_id, writer_key_id: candidate.writer_key_id, reason: "missing_signing_pubkey" });
continue;
}
if (writerKeyIdOf(signingPubkey) !== candidate.writer_key_id) {
skipped.push({ principal_id: candidate.principal_id, writer_key_id: candidate.writer_key_id, reason: "invalid_signing_pubkey" });
continue;
}
const outcome = await this.admitPendingWriter({ addedWriterKeyId: candidate.writer_key_id, addedSigningPubkeyB64u: signingPubkey, addedPrincipalId: candidate.principal_id });
if (outcome.outcome === "already_admitted") {
alreadyCovered.push(candidate.writer_key_id);
}
else {
admitted.push({ principal_id: candidate.principal_id, writer_key_id: candidate.writer_key_id, generation: outcome.result.generation });
}
}
return assembleResult(true);
}
// ── repair (owner-only; mandatory fresh checkpoint; repair resource lane server-side) ──
/**
* Publish a repair head over `base_generation` (§4.3): superseded tips that
* the repaired state no longer reaches enter the retention-root map with
* `dropped_at_generation = the repair generation`; the head carries the
* mandatory self-contained checkpoint. Coverage that cannot be built →
* `REPAIR_TARGET_UNPRESERVABLE`. A repair never crosses an admitted transition.
*/
async repair(input) {
const newest = await this.verifyToNewest();
// vault-multi-writer task 5.8 — repair requires OWNER+step-up
// server-side (`authority.repair`), but that is layered ON TOP of the
// baseline writer requirement every head needs (`authority.writer`,
// confirmed against the gateway's own admission fixture: an owner who
// is not also a chain-recognized writer is refused just the same). No
// retry loop here (a losing repair is a hard failure below, not a
// rebuild-and-retry), so there is no CAS-loser variant to distinguish.
this.assertCallerIsWriter("preparing repair");
const baseGen = generationToBigInt(input.base_generation);
const newestGen = generationToBigInt(newest.generation);
if (baseGen >= newestGen)
fail("REPAIR_TARGET_UNPRESERVABLE", `base_generation ${input.base_generation} must be below the newest ${newest.generation}`, "preparing repair");
if (baseGen === 0n)
fail("REPAIR_TARGET_UNPRESERVABLE", "a repair cannot base on the genesis", "preparing repair");
const chain = await this.chainFrom(input.base_generation, newest, "refuse");
const baseEntry = chain.get(input.base_generation);
// The base head's carriers were sealed by the writer that carried THAT
// head (rev 47: any admitted writer, not only the creator).
const writerKey = this.writerKeyForHead(baseEntry.head, newest.genesis.creator_signing_pubkey);
const baseRefState = await this.openCarrier("ref_state", baseEntry.head.ref_state, vaultPaths.refState(baseEntry.head.ref_state.object_id), writerKey);
const baseRoots = await this.openCarrier("retention_roots", baseEntry.head.retention_roots, vaultPaths.retentionRoots(baseEntry.head.retention_roots.object_id), writerKey);
const repairGen = nextGeneration(newest.generation);
const repairedRefs = baseRefState.refs;
const repairedTips = Object.values(repairedRefs);
const added = [];
for (let g = baseGen + 1n; g <= newestGen; g++) {
const entry = chain.get(bigIntToGeneration(g));
let state;
try {
state = await this.openCarrier("ref_state", entry.head.ref_state, vaultPaths.refState(entry.head.ref_state.object_id), writerKey);
}
catch {
continue; // an unusable superseded ref_state preserves nothing it cannot name (reason unusable_ref_state)
}
for (const [ref, oid] of Object.entries(state.refs)) {
let reachable = false;
for (const t of repairedTips)
if (await isAncestor(this.git(), oid, t)) {
reachable = true;
break;
}
if (!reachable) {
if (!(await hasObject(this.git(), oid)))
fail("REPAIR_TARGET_UNPRESERVABLE", `superseded tip ${ref}@${oid} (generation ${entry.head.generation}) is not present locally and cannot be preserved in the repair checkpoint`, "preparing repair", { ref, oid }, [{ action: "choose a repair base whose superseded tips can all be preserved" }]);
added.push({ ref, oid });
}
}
}
if (added.length > VAULT_MAX_REPAIR_ADDED_ROOTS)
fail("REF_STATE_LIMIT_EXCEEDED", `${added.length} repair-added roots exceed the ${VAULT_MAX_REPAIR_ADDED_ROOTS} bound`, "preparing repair");
const roots = evolveRetentionRoots(baseRoots.roots, { generation: repairGen, dropped: added });
const refState = this.buildRefState(repairGen, repairedRefs, baseRefState.head_target);
const rootsObj = this.buildRetentionRoots(repairGen, roots, null);
let built;
try {
built = await this.buildCheckpoint({ generation: repairGen, ref_state: refState.object, retention_roots: rootsObj.object });
}
catch (e) {
if (isRun402Error(e) && e.code === "CHECKPOINT_INCOMPLETE")
fail("REPAIR_TARGET_UNPRESERVABLE", `the repair checkpoint cannot be constructed: ${e.message}`, "preparing repair", undefined, [{ action: "choose a repair base whose superseded tips can all be preserved" }]);
throw e;
}
await this.uploadAll([refState.upload, rootsObj.upload, ...built.objects]);
const head = this.signHead({
generation: repairGen, prev_sha256: newest.head_sha256, wal_entries: [],
ref_state: { object_id: refState.object.object_id, object_kind: "ref_state", ciphertext_sha256: refState.upload.sha256, size_bytes: refState.upload.size_bytes },
retention_roots: { object_id: rootsObj.object.object_id, object_kind: "retention_roots", ciphertext_sha256: rootsObj.upload.sha256, size_bytes: rootsObj.upload.size_bytes },
checkpoint: { claim_set: built.claim_set_receipt, covers_through_generation: repairGen, git_object_format: "sha1", cutoff: null },
checkpoint_purpose: "repair", capture_binding: null,
repair: { base_generation: input.base_generation, base_head_sha256: baseEntry.sha256, supersedes_from: nextGeneration(input.base_generation), supersedes_through: newest.generation, reason: input.reason },
});
const admitted = await this.admit(head);
if (admitted.outcome === "conflict")
fail("HEAD_CAS_CONFLICT", "a different head was admitted while the repair was being prepared", "publishing repair head", { winner: admitted.winner }, [{ action: "verify the attached winner from storage, rebase, retry" }]);
return { generation: repairGen, head_sha256: admitted.head_sha256, head, admission_record_sha256: admitted.admission_record_sha256, capture_receipt: null, form: "checkpoint", conflicts_retried: 0, refs: repairedRefs, checkpoint_staleness: this.checkpointStalenessNow(repairGen) };
}
/** Heads `base..newest` (already chain-verified by `verifyToNewest`) re-read + hash-checked from storage. */
/**
* Re-read heads `baseGeneration..newest` and hash-check each against the
* verified chain. `transitions` says what an admitted transition head on
* the walk means: a repair walks with `"refuse"` (a repair never crosses
* one), a whole-chain read such as compaction planning walks with
* `"activated"` (`verifyToNewest` already validated every head, so the
* only rule left is {@link assertNoTransition}'s fail-closed one — a
* `rotate_epoch` or `add_writer_key` head is ordinary chain state a
* rev-47 vault with any handoff or member change carries).
*/
async chainFrom(baseGeneration, newest, transitions) {
const out = new Map();
let g = generationToBigInt(newest.generation);
let expected = newest.head_sha256;
const stop = generationToBigInt(baseGeneration);
while (g >= stop) {
const gen = bigIntToGeneration(g);
const bytes = await this.transport.getObject({ repo_id: this.repoId, path: vaultPaths.head(gen) });
if (!bytes || sha256Hex(bytes) !== expected)
fail("CHAIN_BROKEN", `head ${gen} no longer matches the verified chain`, "re-reading vault chain", { generation: gen });
const head = parseVaultStrict(new TextDecoder().decode(bytes));
if (transitions === "refuse") {
if (head.transition !== null)
fail("UPGRADE_REQUIRED", "a repair must not cross an admitted transition", "re-reading vault chain", { generation: gen });
}
else {
assertNoTransition(head);
}
out.set(gen, { head, sha256: expected });
expected = head.prev_sha256;
g -= 1n;
}
return out;
}
// ── restore (clone-back / WAL replay into a local repository) ──
/**
* Pull the newest checkpoint (if any) and every later WAL pack into
* `targetRepoDir` (an initialized repository), then verify every canonical
* ref + the HEAD target resolves. Returns the materialized refs.
*
* Design D5 (vault-client-round-trips): incremental above the local
* `restored_through` marker (this target directory's own local git
* config, read/written by {@link readVaultRestoreMarker}/{@link
* writeVaultRestoreMarker}) — replaying only the WAL packs of
* generations above it — WHEN the walk from newest back to the marker is
* plain WAL the entire way. The moment that walk crosses a
* checkpoint-bearing, repair, or transition head (checked BEFORE
* including a head, so the marker-boundary and wholesale-boundary checks
* share one pass), this falls back to the ORIGINAL wholesale walk below —
* re-fetching any head already visited during the aborted attempt is a
* cache hit (design D3), never a second network round trip. Coverage
* verification and retained-refs reconciliation run UNCHANGED on both
* paths; the marker only advances after they both succeed.
*/
async restoreObjectsInto(targetRepoDir, reuse) {
// vault-delta-fetch: the marker is read BEFORE materialize so the
// state read can carry THIS git dir's applied position as `since` —
// see tryStateFastPath's own comment on why the pin is the wrong
// position for a standing clone.
//
// vault-session-state-reuse: `reuse` lets a caller that ALREADY
// materialized state for this same target directory — using the SAME
// marker `since` this method would itself read — hand that response
// in directly, skipping this method's own state read entirely (the
// remote-helper session's `list` phase is the caller: see `runFetch`
// in `cli/lib/remote-helper-session.mjs`). The marker below is read
// regardless (a cheap local `git config` read, never a network call)
// and compared against `reuse.marker`: only an EXACT match (both
// `null`, or both present with equal `generation`/`head_sha256`) is
// trusted — any mismatch (a different push admitted in this same
// session, a stale handoff) falls through to this method's own
// `materialize()` exactly as if `reuse` had never been passed. A wrong
// reuse therefore costs one extra read, never a wrong result.
const marker = await readVaultRestoreMarker(targetRepoDir);
const markerMatches = (a, b) => a === null ? b === null : b !== null && a.generation === b.generation && a.head_sha256 === b.head_sha256;
// vault-restore-recipe design D2/D6: a marker-absent materialize is,
// by construction, the wholesale shape (a fresh target has nothing to
// replay incrementally) — declare restore intent here so a capable
// gateway's plan rides THIS read rather than a second one. Mirrors the
// remote-helper session's own `list`-phase decision (`runList` in
// `cli/lib/remote-helper-session.mjs`); this branch only fires when
// `reuse` was absent or mismatched (no prior `list` in this session, or
// a direct SDK caller), so the PRIMARY clone path never pays for this —
// it rides the reused state instead.
const newest = reuse && markerMatches(reuse.marker, marker) ? reuse.state : await this.materialize({ ...(marker ? { deltaSince: marker.generation } : { restore: true }) });
if (!newest.head) {
const retained_refs = await reconcileRetainedTipRefs(targetRepoDir, { refs: {}, roots: [], head_target: newest.head_target });
return { refs: {}, head_target: newest.head_target, generation: newest.generation, retained_refs };
}
const writerKey = newest.genesis.creator_signing_pubkey;
// Already up to date locally: no pack fetch, not even a walk — only the
// (entirely local) coverage + retained-refs bookkeeping below runs.
if (marker && marker.generation === newest.generation && marker.head_sha256 === newest.head_sha256) {
const tips = Vault.coverageTips(newest.refs, newest.roots, newest.head_target);
const present = await hasObjects(targetRepoDir, tips);
for (const t of tips) {
if (!present.has(t))
fail("CHAIN_UNUSABLE", `covered tip ${t} does not resolve after restore`, "restoring vault objects", { oid: t });
}
const retained_refs = await reconcileRetainedTipRefs(targetRepoDir, { refs: newest.refs, roots: newest.roots, head_target: newest.head_target });
return { refs: newest.refs, head_target: newest.head_target, generation: newest.generation, retained_refs };
}
// vault-restore-recipe (design D1-D6): consume a plan that already
// rode this method's OWN materialize() (the `restore: true` request
// above, or the reused list-phase read) BEFORE paying for the backward
// walk below at all. `tryConsumeRestorePlan` verifies from scratch and
// returns `null` on absence or ANY failure — the walk below is the
// unconditional fallback, byte-identical to before this change.
{
const applied = await this.tryConsumeRestorePlan(targetRepoDir, newest, writerKey);
if (applied)
return applied;
}
// Walk back from newest, trying for an INCREMENTAL boundary at the
// marker (a pure local prev_sha256 comparison — no fetch needed to
// CONFIRM the boundary itself); abandon incremental the moment a
// non-plain-WAL head would have to be included, and restart as the
// ORIGINAL wholesale walk (stopping at the newest checkpoint, or
// genesis) — every head visited in the aborted attempt is a D3 cache
// hit on this restart, so abandoning costs no extra round trip.
const heads = [];
let cur = newest.head;
let incremental = marker !== null;
// vault-clone-scaling (bench P2): the backward walk's head PATHS are
// all derivable up front (generation N−1, N−2, …) — only each head's
// expected hash arrives chain-sequentially. Same fetch-concurrent /
// verify-ordered split as the forward walk: page-sized windows of
// predecessor head bytes batch into the transient `walkPrefetch` map
// (use-time sha-checked by `readCachedHeadBytes`; a miss, mismatch, or
// failed batch falls back to that read's own single fetch), refilled as
// the walk descends past the window floor. The floor ESTIMATE — the
// marker on the incremental path, else locally learned checkpoint
// coverage, else genesis — only bounds over-fetch; the walk's own stop
// conditions are unchanged, and a wrong estimate costs at most one
// window of concurrent GETs, never correctness.
let prefetchFloor = null;
// The window GROWS geometrically (16 → 64 → 256 → page cap) because the
// walk's true stop (the newest checkpoint-bearing head) is only
// discovered by walking: when the floor estimate is genesis (coverage
// never learned), a page-sized first window over-fetches everything
// below a nearby checkpoint. Measured live before this schedule: 66
// heads fetched for a walk that stopped after 31. A known floor
// (marker/coverage) still bounds every window exactly.
let nextWindow = 16n;
const prefetchBackwardWindow = async (hi) => {
let lo = 1n;
if (incremental && marker)
lo = generationToBigInt(marker.generation) + 1n;
else {
const known = this.repoFile().checkpoint_covers_through;
if (known && /^[0-9a-f]{16}$/.test(known)) {
const k = generationToBigInt(known);
if (k > lo)
lo = k;
}
}
const capLo = hi - nextWindow + 1n;
if (capLo > lo)
lo = capLo;
if (lo < 1n)
lo = 1n;
nextWindow = nextWindow * 4n;
const pageCap = BigInt(VAULT_MAX_HEADS_PER_LISTING_PAGE);
if (nextWindow > pageCap)
nextWindow = pageCap;
prefetchFloor = lo;
if (hi - lo < 1n)
return; // 0 or 1 path — a single read costs the same
const gens = [];
for (let g = hi; g >= lo; g--)
gens.push(bigIntToGeneration(g));
try {
this.walkPrefetch = await this.prefetchHeadsConcurrent(gens);
}
catch {
// Fidelity: an outright prefetch failure leaves the map alone — the
// walk's own per-head reads take over with their own envelopes.
}
};
while (cur) {
// `cur` disqualifies incremental (checked BEFORE including it): abort
// and restart as the wholesale walk. Re-visiting heads already fetched
// this attempt costs nothing (D3 cache hits).
if (incremental && (cur.checkpoint !== null || cur.transition !== null)) {
incremental = false;
heads.length = 0;
cur = newest.head;
// vault-restore-recipe (design D1/D2, task 2.1's named fallback
// shape): the marker WAS present when this method's own materialize()
// ran, so `restore` was never declared and no plan rode that read —
// fetch one now, dedicated, before falling through to the ordinary
// wholesale walk. Best-effort: an older gateway, a network fault, a
// disqualification, or a verification failure all just continue the
// loop exactly as before this change; nothing here can make the
// restore fail that would otherwise have succeeded.
if (!this.stateRestorePlan) {
try {
const resp = await this.transport.getState({ repo_id: this.repoId, restore: true });
if (resp.restore_plan)
this.stateRestorePlan = resp.restore_plan;
}
catch {
// fall through to the wholesale walk
}
}
const applied = await this.tryConsumeRestorePlan(targetRepoDir, newest, writerKey);
if (applied)
return applied;
continue;
}
heads.unshift(cur);
if (!incremental && cur.checkpoint)
break; // wholesale stop: the newest checkpoint-bearing head
if (cur.generation === "0000000000000001")
break; // genesis — a hard stop either way
// Before fetching `cur`'s predecessor, check whether the marker
// already names it — a pure LOCAL comparison against bytes this
// client already applied last time, no network round trip.
const prevBig = generationToBigInt(cur.generation) - 1n;
const prevGen = bigIntToGeneration(prevBig);
if (incremental && cur.prev_sha256 === marker.head_sha256 && prevGen === marker.generation) {
cur = null; // the predecessor is the marker's own head — already applied; `heads` already holds everything above it
break;
}
if (prefetchFloor === null || prevBig < prefetchFloor)
await prefetchBackwardWindow(prevBig);
const bytes = await this.readCachedHeadBytes(prevGen, cur.prev_sha256);
if (!bytes || sha256Hex(bytes) !== cur.prev_sha256)
fail("CHAIN_BROKEN", `head ${prevGen} does not match the chain during restore`, "restoring vault objects");
cur = parseVaultStrict(new TextDecoder().decode(bytes));
}
return this.applyRestoreHeads(targetRepoDir, heads, incremental, newest, writerKey, null, null);
}
/**
* The shared restore tail (vault-restore-recipe): apply `heads`'
* checkpoint (if any) + WAL packs into `targetRepoDir`, verify §4.7
* coverage, reconcile retained refs, and advance the marker — IDENTICALLY
* whether `heads` came from {@link restoreObjectsInto}'s own backward walk
* or a verified restore plan ({@link tryConsumeRestorePlan}). `incremental`
* only affects the checkpoint-coverage LEARNING step (an incremental walk
* stops at the marker and learns nothing; a plan-derived call always
* passes `false` — a plan is the wholesale shape by construction).
*
* `precheckedCheckpoint`, when non-null, is a claim set + manifest the
* caller ALREADY verified (signature, cross-equality — {@link
* verifyRestorePlan}) — skips the network fetch + re-verify this method
* would otherwise run for `heads[0].checkpoint`. `planPacks`, when
* non-null, is a map of pack bytes the caller already has (untrusted —
* only used when its OWN hash matches the carrying head's/manifest's
* receipt, exactly `stateDeltaPacks`'s discipline), keyed
* `${object_kind}:${object_id}` so it covers BOTH WAL and checkpoint
* packs (`stateDeltaPacks` never carries checkpoint packs — a delta span
* never crosses a checkpoint boundary). Both are `null` on the ordinary
* walk path, in which case every line below is byte-identical to the
* pre-vault-restore-recipe shape.
*/
/**
* vault-multi-writer (rev 47): the key a head's own checkpoint claim set
* and carriers were signed under — the head's writer, resolved against the
* keystore's persisted `writer_set_pin` (maintained by the chain walk that
* always precedes a restore), falling back to `fallback` (the genesis
* creator) for a pre-rev-47 keystore or a writer the pin no longer lists.
* Before this, a restore verified every checkpoint under the creator key
* and a clone of any vault whose newest checkpoint was minted by a handoff
* recipient died CHECKPOINT_INCOMPLETE "claim set signature fails".
*/
writerKeyForHead(head, fallback) {
const pin = this.repoFile().writer_set_pin;
const known = [...(pin?.writers ?? []), ...(pin?.retired_writers ?? [])];
return known.find((w) => w.writer_key_id === head.writer_key_id)?.signing_pubkey ?? fallback;
}
async applyRestoreHeads(targetRepoDir, heads, incremental, newest, writerKey, precheckedCheckpoint, planPacks) {
// vault-clone-scaling (P3): a WHOLESALE walk just stopped at the
// newest checkpoint-bearing head, or proved there is none back to
// genesis — that is first-hand checkpoint coverage, and recording it is
// what makes the NEXT walk's backward window exact instead of a
// genesis-floored guess. Monotonic: never regress a newer coverage an
// earlier walk or admit already recorded. (The incremental path stops
// at the marker, learns nothing, and skips — `learned` stays null.)
{
const stop = heads[0];
const learned = stop.checkpoint ? stop.checkpoint.covers_through_generation : !incremental && stop.generation === "0000000000000001" ? VAULT_GENESIS_GENERATION : null;
if (learned !== null) {
const known = this.repoFile().checkpoint_covers_through ?? null;
if (known === null || !/^[0-9a-f]{16}$/.test(known) || generationToBigInt(learned) > generationToBigInt(known)) {
this.keystore.updateRepo(this.repoId, { checkpoint_covers_through: learned });
}
}
}
// Every object below is decrypted under ITS OWN carrying head's `epoch`
// (D194) — a covered span crossing a rotation mixes epochs, so this
// NEVER falls back to `this.kRepo()`/`this.epoch()` (this principal's
// CURRENT pointer, which is the NEWEST epoch, not necessarily every
// historical one a restore spans). `newest.epoch_keys_hex` is the full
// map `materialize()` just resolved (throwing `VAULT_EPOCH_NOT_OPENABLE`
// fail-closed if any needed epoch could not be opened), so every lookup
// below is guaranteed present.
const kRepoForEpoch = (epoch) => this.epochKeyFor(newest, epoch);
const first = heads[0];
// vault-pipelined-restore: per-object settlement when the transport
// offers it, the `getObjects` barrier otherwise — pipelining is a
// wall-clock property, never a correctness dependency, so a transport
// without the method reproduces today's serial-after-barrier behavior
// exactly (each per-index promise settles when the whole batch does).
//
// `expected` (vault-small-object-inline design D3) is index-aligned
// with `paths` — every caller below already knows each object's
// receipted ciphertext hash BEFORE fetching it, so it rides along here
// to let an `object-reads`-backed transport verify (and, on a lying
// reply, discard) an `inline` bytes offer per slot. Not required: an
// omitted `expected` is byte-identical to before this change.
const settled = async (paths, expected) => {
if (this.transport.getObjectsSettled)
return this.transport.getObjectsSettled({ repo_id: this.repoId, paths, expected });
const all = this.transport.getObjects({ repo_id: this.repoId, paths, expected });
const perIndex = paths.map((_, i) => all.then((frames) => frames[i] ?? null));
// Mark every derived promise handled — the consumer awaits them in
// order and stops at the first failure, abandoning the tail.
for (const p of perIndex)
void p.catch(() => { });
return perIndex;
};
// vault-pipelined-restore D3: the WAL entry list derives from the
// already-verified head walk, so its batched download is INITIATED here —
// before the checkpoint branch — and its (small, many) objects land while
// the checkpoint downloads, decrypts, and indexes. APPLICATION of WAL
// packs still begins only after the checkpoint class completes, in the
// same chain order as always. Each entry decrypts under its OWN carrying
// head's epoch (D194) — the flattened list keeps that pairing so a
// rotation-spanning restore never reuses one head's epoch for another's
// pack.
const walEntries = heads.flatMap((h) => h.wal_entries.map((w) => ({ w, epoch: h.epoch })));
// vault-delta-fetch: packs the state read already delivered inline
// skip the network ENTIRELY — but only after hashing to their carrying
// head's receipt (a lying delta is a plain miss; the ordinary fetch
// below reproduces the unbatched behavior for that slot). The buffer is
// consumed exactly once. vault-restore-recipe extends the SAME
// short-circuit to `planPacks` (a verified plan's own pack bytes) —
// checked SECOND so a delta hit never gets shadowed by a plan hit for
// the same slot (the two never coexist in practice, per `getState`'s
// mutual-exclusivity between `since` and `restore` on one call, but
// checking order stays deterministic either way).
const deltaPacks = this.stateDeltaPacks;
this.stateDeltaPacks = null;
const deltaCovered = new Map();
if (deltaPacks) {
walEntries.forEach(({ w }, i) => {
const bytes = deltaPacks.get(w.object_id);
if (bytes && sha256Hex(bytes) === w.ciphertext_sha256)
deltaCovered.set(i, bytes);
});
}
if (planPacks) {
walEntries.forEach(({ w }, i) => {
if (deltaCovered.has(i))
return;
const bytes = planPacks.get(`wal_pack:${w.object_id}`);
if (bytes && sha256Hex(bytes) === w.ciphertext_sha256)
deltaCovered.set(i, bytes);
});
}
const uncovered = walEntries.map((_, i) => i).filter((i) => !deltaCovered.has(i));
const walFramesP = (async () => {
const fetched = uncovered.length > 0 ? await settled(uncovered.map((i) => vaultPaths.wal(walEntries[i].w.object_id)), uncovered.map((i) => walEntries[i].w.ciphertext_sha256)) : [];
return walEntries.map((_, i) => (deltaCovered.has(i) ? Promise.resolve(deltaCovered.get(i)) : fetched[uncovered.indexOf(i)]));
})();
void walFramesP.catch(() => { });
if (first.checkpoint) {
let claimSet;
let manifest;
if (precheckedCheckpoint) {
// vault-restore-recipe: already fetched + verified (signature,
// cross-equality) by `verifyRestorePlan` against the plan's own
// inline bytes — no network read, no re-verify.
({ claimSet, manifest } = precheckedCheckpoint);
}
else {
const claimBytes = await this.transport.getObject({ repo_id: this.repoId, path: vaultPaths.claimSet(first.checkpoint.claim_set.object_id), expected_sha256: first.checkpoint.claim_set.stored_bytes_sha256 });
if (!claimBytes || sha256Hex(claimBytes) !== first.checkpoint.claim_set.stored_bytes_sha256)
fail("CHECKPOINT_INCOMPLETE", "claim set absent or altered", "restoring vault objects");
claimSet = parseVaultStrict(new TextDecoder().decode(claimBytes));
if (!verifyVaultObject(claimSet, this.writerKeyForHead(first, writerKey)))
fail("CHECKPOINT_INCOMPLETE", "claim set signature fails", "restoring vault objects");
manifest = await this.openCarrier("checkpoint_manifest", claimSet.manifest_receipt, vaultPaths.checkpointManifest(claimSet.manifest_receipt.object_id), this.writerKeyForHead(first, writerKey), { epoch: first.epoch, k_repo: kRepoForEpoch(first.epoch) });
checkClaimSetEquality(claimSet, manifest, first.checkpoint.covers_through_generation);
}
// Design D2 + vault-pipelined-restore D1: every checkpoint pack is
// independent — one batched presign for all of them, applied via
// index-pack strictly in manifest order, PIPELINED: apply(i) awaits
// bytes(i), so decrypt/verify/index of an early pack overlaps the
// later packs' downloads. Per-pack verification (AEAD open + plaintext
// hash) still completes before any byte reaches git. vault-restore-
// recipe: a `planPacks` hit skips the fetch for that index entirely —
// the SAME short-circuit the WAL loop above runs, extended to the
// checkpoint class (never carried by `stateDeltaPacks`).
const checkpointCovered = new Map();
if (planPacks) {
manifest.packs.forEach((p, i) => {
const bytes = planPacks.get(`checkpoint_pack:${p.object_id}`);
if (bytes && sha256Hex(bytes) === p.ciphertext_sha256)
checkpointCovered.set(i, bytes);
});
}
const uncoveredCk = manifest.packs.map((_, i) => i).filter((i) => !checkpointCovered.has(i));
const ckFrames = uncoveredCk.length > 0 ? await settled(uncoveredCk.map((i) => vaultPaths.checkpointPack(manifest.packs[i].object_id)), uncoveredCk.map((i) => manifest.packs[i].ciphertext_sha256)) : [];
for (let i = 0; i < manifest.packs.length; i++) {
const p = manifest.packs[i];
const frame = checkpointCovered.has(i) ? checkpointCovered.get(i) : ((await ckFrames[uncoveredCk.indexOf(i)]) ?? null);
if (!frame)
fail("CHECKPOINT_INCOMPLETE", `checkpoint pack ${p.object_id} absent`, "restoring vault objects");
const plain = openFrame({ k_obj: deriveObjectKey(kRepoForEpoch(first.epoch), this.repoId, first.epoch, "checkpoint_pack", p.object_id), repo_id: this.repoId, object_kind: "checkpoint_pack", object_id: p.object_id, epoch: first.epoch, frame, expected_ciphertext_sha256: p.ciphertext_sha256 });
if (sha256Hex(plain) !== p.plaintext_sha256 || String(plain.length) !== p.plaintext_size_bytes)
fail("CHECKPOINT_INCOMPLETE", `checkpoint pack ${p.object_id} plaintext mismatch`, "restoring vault objects");
await hardenedGit(targetRepoDir, ["index-pack", "--stdin", "--strict"], { input: plain });
}
}
// Design D2: every WAL pack across every head in this restore's range is
// independent — one batched presign for the whole set (initiated above,
// before the checkpoint branch), applied via index-pack in the SAME
// chain order the wholesale path always used, pipelined the same way:
// apply(i) awaits bytes(i) while later packs finish downloading.
const walFrames = await walFramesP;
for (let i = 0; i < walEntries.length; i++) {
const { w, epoch } = walEntries[i];
const frame = (await walFrames[i]) ?? null;
if (!frame)
fail("CHAIN_UNUSABLE", `WAL pack ${w.object_id} absent`, "restoring vault objects");
const plain = openFrame({ k_obj: deriveObjectKey(kRepoForEpoch(epoch), this.repoId, epoch, "wal_pack", w.object_id), repo_id: this.repoId, object_kind: "wal_pack", object_id: w.object_id, epoch, frame, expected_ciphertext_sha256: w.ciphertext_sha256 });
await hardenedGit(targetRepoDir, ["index-pack", "--stdin", "--strict"], { input: plain });
}
// the §4.7 coverage set — canonical refs ∪ unexpired roots ∪ the HEAD target — must all resolve.
// ONE batched git invocation for the whole set (vault-delta-fetch
// task 3.1) — identical every-tip-must-resolve semantics, one process
// instead of one per tip.
{
const tips = Vault.coverageTips(newest.refs, newest.roots, newest.head_target);
const present = await hasObjects(targetRepoDir, tips);
for (const t of tips) {
if (!present.has(t))
fail("CHAIN_UNUSABLE", `covered tip ${t} does not resolve after restore`, "restoring vault objects", { oid: t });
}
}
// clone-installs-retained-refs (D2): every retained tip just restored
// above is now present locally — install/reconcile its refs/r402/retain/*
// ref so `git fsck` is silent. Runs AFTER coverage verification so a
// reconcile never references an object the restore itself failed to land.
const retained_refs = await reconcileRetainedTipRefs(targetRepoDir, { refs: newest.refs, roots: newest.roots, head_target: newest.head_target });
// Design D5: the marker advances ONLY here — after coverage verification
// and the retained-refs reconcile both succeeded. An interrupted restore
// (a crash or throw anywhere above) leaves the marker unadvanced, so the
// next fetch simply repeats this same range; applying an already-applied
// WAL pack again is the existing, already-safe wholesale behavior.
await writeVaultRestoreMarker(targetRepoDir, newest.generation, newest.head_sha256);
return { refs: newest.refs, head_target: newest.head_target, generation: newest.generation, retained_refs };
}
/**
* `newest.epoch_keys_hex[epoch]`, decoded — the D194 per-carrying-head key
* lookup shared by {@link applyRestoreHeads} and {@link verifyRestorePlan}.
* `newest.epoch_keys_hex` is the FULL map `materialize()` resolved
* (throwing `VAULT_EPOCH_NOT_OPENABLE` fail-closed if any needed epoch
* could not be opened), so every lookup through this helper is guaranteed
* present.
*/
epochKeyFor(newest, epoch) {
const hex = newest.epoch_keys_hex[epoch];
if (!hex)
fail("VAULT_EPOCH_NOT_OPENABLE", `no locally known key for epoch ${epoch} while restoring objects`, "restoring vault objects", { epoch });
return hexToBytes(hex);
}
/**
* Verify a restore plan's heads (self-consistency + backward chain-link +
* cross-check against the caller's own already-verified `newest`, plus
* genesis/boundary linkage) and, when the boundary carries a checkpoint,
* its claim set + manifest (signature, cross-equality) — the SAME
* obligations {@link applyRestoreHeads}'s ordinary path runs, against
* plan-supplied bytes instead of network-fetched ones (design D5: "the
* plan is transport, never trust"). Returns `null` on ANY failure — a
* self-inconsistent head, a broken link, a wrong newest, a bad signature,
* a cross-equality mismatch, anything the reused `decodeCarrierFrame`/
* `checkClaimSetEquality` reject — rather than throwing, so the caller's
* fallback to the ordinary walk is unconditional and silent, mirroring
* `assembleVaultStateDelta`'s own disqualification posture server-side.
*
* Heads that verify their OWN chain link (Stage A) are cache-warmed into
* {@link walkPrefetch} BEFORE Stage B (the checkpoint) runs, so a
* checkpoint failure still leaves the fallback walk with every head this
* call already proved — the D3 cache-hit discipline, extended to a failed
* plan.
*/
async verifyRestorePlan(plan, newest, writerKey) {
// Every disqualification below is a plain `return null` — never `throw
// new Error(...)` (the public-SDK plain-`Error` contract) — so a manual
// `for` loop replaces `plan.heads.map(...)`: a `.map()` callback can only
// fail its OWN element, never abort the whole computation, and this
// function's contract is "any bad element disqualifies the WHOLE plan."
// The try/catch below still stands, to catch the genuine exceptions
// `parseVaultStrict`/`decodeCarrierFrame`/`checkClaimSetEquality`/
// `verifyVaultObject` can raise (a `fail()`-thrown `Run402Error`, or a
// strict-parse rejection) — those are real errors, just ones this method
// treats as "the plan didn't verify" rather than propagating.
try {
if (plan.heads.length === 0)
return null;
const parsed = [];
for (const h of plan.heads) {
if (sha256Hex(h.stored_bytes) !== h.stored_bytes_sha256)
return null; // self-hash mismatch
const head = parseVaultStrict(new TextDecoder().decode(h.stored_bytes));
if (head.generation !== h.generation)
return null; // generation mismatch
parsed.push({ generation: h.generation, bytes: h.stored_bytes, head });
}
for (let i = 1; i < parsed.length; i++) {
if (generationToBigInt(parsed[i].generation) !== generationToBigInt(parsed[i - 1].generation) + 1n)
return null; // span not contiguous
}
const last = parsed[parsed.length - 1];
if (last.generation !== newest.generation || sha256Hex(last.bytes) !== newest.head_sha256)
return null; // does not match the caller's own verified newest
// Backward chain-link, newest to boundary — the SAME hash-equality
// check `applyRestoreHeads`'s own backward walk runs (no signature
// re-check here: the chain was already fully verified FORWARD by
// `materialize()`/`verifyToNewest()` before this ever runs — see that
// method's own doc comment on why its backward walk is a hash-only
// re-confirmation, not a re-verification).
for (let i = parsed.length - 1; i >= 1; i--) {
if (parsed[i].head.prev_sha256 !== sha256Hex(parsed[i - 1].bytes))
return null; // chain link broken
}
const first = parsed[0];
if (plan.boundary_generation === VAULT_GENESIS_GENERATION) {
if (first.generation !== "0000000000000001")
return null; // genesis boundary mismatch
const { sha256: genesisSha } = await this.genesis();
if (first.head.prev_sha256 !== genesisSha)
return null; // generation 1 does not link to genesis
}
else {
if (first.generation !== plan.boundary_generation || !first.head.checkpoint)
return null; // boundary does not carry a checkpoint
}
// Stage A verified — cache-warm every plan head regardless of Stage B's
// outcome below (D3: a failed plan still costs the fallback nothing it
// already proved).
if (!this.walkPrefetch)
this.walkPrefetch = new Map();
for (const p of parsed)
this.walkPrefetch.set(vaultPaths.head(p.generation), p.bytes);
let checkpoint = null;
if (first.head.checkpoint) {
if (!plan.checkpoint)
return null; // boundary head carries a checkpoint but the plan carries none
const claimReceipt = first.head.checkpoint.claim_set;
if (plan.checkpoint.claim_set.object_id !== claimReceipt.object_id)
return null; // claim set id mismatch
if (sha256Hex(plan.checkpoint.claim_set.stored_bytes) !== claimReceipt.stored_bytes_sha256)
return null; // claim set hash mismatch
const claimSet = parseVaultStrict(new TextDecoder().decode(plan.checkpoint.claim_set.stored_bytes));
if (!verifyVaultObject(claimSet, this.writerKeyForHead(first.head, writerKey)))
return null; // claim set signature fails
if (claimSet.manifest_receipt.object_id !== plan.checkpoint.manifest.object_id)
return null; // manifest id mismatch
// Reuses `decodeCarrierFrame` verbatim — ciphertext hash, AEAD open,
// and identity/signature checks all run exactly as they would for a
// network-fetched manifest frame; the only difference is where the
// ciphertext bytes came from.
const manifest = this.decodeCarrierFrame("checkpoint_manifest", claimSet.manifest_receipt, plan.checkpoint.manifest.stored_bytes, this.writerKeyForHead(first.head, writerKey), { epoch: first.head.epoch, k_repo: this.epochKeyFor(newest, first.head.epoch) });
checkClaimSetEquality(claimSet, manifest, first.head.checkpoint.covers_through_generation);
checkpoint = { claimSet, manifest };
}
return { heads: parsed.map((p) => p.head), checkpoint };
}
catch {
return null;
}
}
/**
* Consume a stashed restore plan (vault-restore-recipe design D1-D6):
* verify it ({@link verifyRestorePlan}) and, on success, apply it via the
* SAME shared tail the backward walk uses ({@link applyRestoreHeads}) —
* `null` on absence or ANY verification failure, the caller's cue to run
* (or continue) the ordinary walk. Clears `this.stateRestorePlan`
* unconditionally: a plan is single-use whether it verified or not (a
* failed plan's USABLE heads already rode into `walkPrefetch` inside
* `verifyRestorePlan` itself).
*/
async tryConsumeRestorePlan(targetRepoDir, newest, writerKey) {
const plan = this.stateRestorePlan;
this.stateRestorePlan = null;
if (!plan)
return null;
const verified = await this.verifyRestorePlan(plan, newest, writerKey);
if (!verified)
return null;
const planPacks = new Map();
for (const p of plan.packs) {
if (p.bytes)
planPacks.set(`${p.object_kind}:${p.object_id}`, p.bytes);
}
return this.applyRestoreHeads(targetRepoDir, verified.heads, false, newest, writerKey, verified.checkpoint, planPacks);
}
// ── compaction headroom grant (vault-checkpoint-cadence design D3) ──
/** Thin passthrough to the transport — see {@link VaultTransport.openCompactionGrant}. */
async openCompactionGrant() {
return this.transport.openCompactionGrant({ repo_id: this.repoId });
}
/** Thin passthrough to the transport — see {@link VaultTransport.closeCompactionGrant}. Always safe best-effort; never throws by construction of the route (idempotent). */
async closeCompactionGrant() {
return this.transport.closeCompactionGrant({ repo_id: this.repoId });
}
}
/** §4.7 cross-field equality: covers_through agree; the claim set's ordered pack ids/hashes/sizes/total equal the manifest's (shared stored fields only). */
export function checkClaimSetEquality(claimSet, manifest, headCoversThrough) {
if (claimSet.covers_through_generation !== headCoversThrough || manifest.covers_through_generation !== headCoversThrough)
fail("CHECKPOINT_INCOMPLETE", "covers_through_generation disagrees between head, claim set, and manifest", "checking checkpoint claim set");
if (claimSet.ordered_pack_receipts.length !== manifest.packs.length)
fail("CHECKPOINT_INCOMPLETE", "claim set and manifest list different pack counts", "checking checkpoint claim set");
let total = BigInt(claimSet.manifest_receipt.size_bytes);
for (let i = 0; i < manifest.packs.length; i++) {
const c = claimSet.ordered_pack_receipts[i];
const m = manifest.packs[i];
if (c.object_id !== m.object_id || c.ciphertext_sha256 !== m.ciphertext_sha256 || c.size_bytes !== m.size_bytes)
fail("CHECKPOINT_INCOMPLETE", `pack ${i}: claim set and manifest disagree on the shared stored fields`, "checking checkpoint claim set", { index: i });
total += BigInt(c.size_bytes);
}
if (BigInt(claimSet.total_stored_size_bytes) !== total)
fail("CHECKPOINT_INCOMPLETE", "total_stored_size_bytes ≠ manifest receipt + Σ pack sizes", "checking checkpoint claim set");
if (claimSet.manifest_receipt.object_id !== manifest.object_id)
fail("CHECKPOINT_INCOMPLETE", "claim set names a different manifest", "checking checkpoint claim set");
}
/** Convenience for the deploy lane: the §6.5 capture binding. */
export function captureBinding(captureId, applyPlanSha256, snapshotOidHmac) {
if (!/^[0-9a-f]{32}$/.test(captureId))
fail("VAULT_BAD_ID", "capture_id must be 32 lowercase hex", "building capture binding");
return { capture_id: captureId, apply_plan_sha256: applyPlanSha256, snapshot_oid_hmac: snapshotOidHmac };
}
//# sourceMappingURL=vault-publication.js.map