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.
410 lines • 21.8 kB
JavaScript
/**
* vault — prune (protocol rev 40 §7.3; task 5.12a).
*
* The two-phase, ACYCLIC prune the client half owns:
*
* 1. plan — walk the verified chain, compute the GC ROOT SET (what must
* never be deleted) and the pruneable universe, and subtract.
* 2. attest — TWO `verifier_receipt`s over the SAME `intent_core_sha256`,
* one per CLOSED implementation identity
* (`run402-cli` | `r402s-verify`). This SDK is `run402-cli`
* and produces exactly ONE of them; the other MUST come from
* `r402s-verify`, which by spec shares no implementation code
* with the SDK — differential verification is the whole point,
* so this module will never synthesize the second receipt.
* 3. submit — sign the core, wrap it, sign the wrapper, and POST the
* EXACT BYTES. The gateway reads this route through
* `express.raw` and verifies the owner signature over the
* bytes as sent, so a re-serialized object is a different
* object. {@link pruneIntentBytes} is the one serializer.
* 4. confirm — poll the intent until its control-plane-signed
* `prune_completion` appears, then believe ONLY that: per-object
* outcomes are THREE-VALUED (`deleted` | `present_not_attempted`
* | `present_after_attempt`) and only `deleted` means the bytes
* are gone. `present_after_attempt` is NOT a deletion.
*
* What this module deliberately does NOT do:
* - decide retention eligibility. The ≥90-day schedule is measured against a
* control-plane-signed `retention_cutoff` ticket and the server's own
* admission times; a client cannot prove either. The plan is a PROPOSAL and
* the gateway is the authority — it refuses any candidate that is not
* retention-eligible against the bound ticket.
* - attest anything it did not check. A receipt this module builds carries
* `result: "failed"` (and both booleans as observed) whenever the chain
* cannot prove the property, rather than a convenient `true`.
*/
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { LocalError } from "../errors.js";
import { VAULT_FORMAT, VAULT_SUITE, newVaultId, newHex32, signVaultObject, storedBytes, storedBytesSha256, } from "../namespaces/vault.crypto.js";
function fail(code, message, context, details, nextActions) {
throw new LocalError(message, context, { code, details, ...(nextActions ? { next_actions: nextActions } : {}) });
}
/** §7.3: at most 10 000 candidates per intent; larger prunes chunk into successive intents. */
export const VAULT_MAX_PRUNE_CANDIDATES = 10_000;
/** The CLOSED V0 verifier identity set (`common.json#/$defs/implementation_id`). */
export const VAULT_VERIFIER_IMPLEMENTATIONS = ["run402-cli", "r402s-verify"];
/** This SDK's identity when it attests. The other half of the pair is never ours to produce. */
export const VAULT_SDK_VERIFIER_IMPLEMENTATION = "run402-cli";
/**
* What this lineage calls itself in a receipt's `implementation_version`.
*
* The LINEAGE and the PROTOCOL REVISION, not the npm version: a receipt records
* which verifier produced it against which frozen wire, and the npm number
* moves for reasons that have nothing to do with either. Bump this when the
* verification logic or the revision it verifies changes.
*/
export const VAULT_SDK_VERIFIER_VERSION = "run402-sdk/r402s-v0-rev40";
// ─── The GC root set + the pruneable universe (§7.3 step 1) ──────────────────
function receiptsOfHead(head) {
return [...head.wal_entries, head.ref_state, head.retention_roots];
}
/**
* A checkpoint's FULL RECOVERY BUNDLE: the claim set, the manifest, every pack,
* AND its `covers_through` head's `ref_state` + `retention_roots` carriers.
* A bundle missing its carriers is not a recovery path, so the carriers are
* part of the protection, not adjacent to it.
*/
export function checkpointRecoveryBundle(entry) {
const block = entry.head.checkpoint;
if (!block)
return [];
const out = [block.claim_set, entry.head.ref_state, entry.head.retention_roots];
if (entry.claim_set)
out.push(entry.claim_set.manifest_receipt, ...entry.claim_set.ordered_pack_receipts);
return out;
}
/** Every pruneable object the chain has ever named, sorted by `object_id`, deduplicated. */
export function collectPruneUniverse(entries) {
const seen = new Map();
for (const e of entries) {
for (const r of receiptsOfHead(e.head))
seen.set(r.object_id, r);
for (const r of checkpointRecoveryBundle(e))
seen.set(r.object_id, r);
}
return sortPruneReceipts([...seen.values()]);
}
/** Pairwise-distinct, sorted by `object_id` — the schema's canonical order, not an incidental one. */
export function sortPruneReceipts(receipts) {
return [...receipts].sort((a, b) => (a.object_id < b.object_id ? -1 : a.object_id > b.object_id ? 1 : 0));
}
/**
* The GC root set: the latest checkpoint's recovery bundle + its WAL suffix +
* the immediately-prior checkpoint's recovery bundle + every carrier at or
* after the prior checkpoint.
*
* DELIBERATELY conservative in two places, because under-protecting deletes
* history and over-protecting only costs storage:
*
* - with FEWER THAN TWO checkpoints nothing is prunable at all. The
* protection rule exists so a bad latest checkpoint still has a second
* recovery path; with a single checkpoint that second path IS the
* pre-checkpoint WAL chain, so pruning it would leave exactly one.
* - every `ref_state` / `retention_roots` carrier at or after the PRIOR
* checkpoint is protected, not just the unexpired-root carriers. Root
* expiry is measured against a server-signed ticket this client cannot
* evaluate, so it never guesses a carrier is dead.
*/
export function computeGcRootSet(entries) {
const ordered = [...entries].sort((a, b) => (a.head.generation < b.head.generation ? -1 : a.head.generation > b.head.generation ? 1 : 0));
const checkpoints = ordered.filter((e) => e.head.checkpoint !== null);
const latest = checkpoints.length > 0 ? checkpoints[checkpoints.length - 1] : null;
const prior = checkpoints.length > 1 ? checkpoints[checkpoints.length - 2] : null;
const seen = new Map();
const protect = (rs) => {
for (const r of rs)
seen.set(r.object_id, r);
};
if (!latest || !prior) {
// Everything is a root. Protect the whole universe so the commitment is
// still computable and honest, and say why.
protect(collectPruneUniverse(ordered));
return {
receipts: sortPruneReceipts([...seen.values()]),
latest_checkpoint: latest,
prior_checkpoint: prior,
blocked_reason: checkpoints.length === 0
? "the chain carries no checkpoint, so the WAL history is the only recovery path — compact first"
: "the chain carries only one checkpoint; the pre-checkpoint WAL chain is the second recovery path and stays protected until a second compaction exists",
};
}
protect(checkpointRecoveryBundle(latest));
protect(checkpointRecoveryBundle(prior));
for (const e of ordered) {
if (e.head.generation >= prior.head.generation)
protect(receiptsOfHead(e.head));
}
return { receipts: sortPruneReceipts([...seen.values()]), latest_checkpoint: latest, prior_checkpoint: prior, blocked_reason: null };
}
/** universe − GC root set, canonical order, capped. Never mutates its inputs. */
export function planPruneCandidates(entries) {
const rootSet = computeGcRootSet(entries);
const protectedIds = new Set(rootSet.receipts.map((r) => r.object_id));
const universe = collectPruneUniverse(entries);
const eligible = universe.filter((r) => !protectedIds.has(r.object_id));
return {
candidates: eligible.slice(0, VAULT_MAX_PRUNE_CANDIDATES),
deferred_count: Math.max(0, eligible.length - VAULT_MAX_PRUNE_CANDIDATES),
root_set: rootSet,
universe_count: universe.length,
};
}
/**
* Verify that every retention root which LEFT the map left legally: only at a
* checkpoint-bearing generation carrying a cutoff ticket, and only when its
* `effective_admitted_at + retentionDays < cutoff_at` strictly.
*
* `effectiveAdmittedAt` returning `null` is not a failure of the vault — it is
* a failure of THIS CLIENT to prove the departure, and the check reports `ok:
* false` so the receipt attests `false` rather than a convenient `true`.
*/
export function checkRetentionEvolution(entries, roots, effectiveAdmittedAt, isEligible) {
const ordered = [...entries].sort((a, b) => (a.head.generation < b.head.generation ? -1 : a.head.generation > b.head.generation ? 1 : 0));
const unproven = [];
for (let i = 1; i < ordered.length; i++) {
const before = ordered[i - 1];
const after = ordered[i];
const key = (r) => `${r.ref} ${r.oid}`;
const stillThere = new Set(roots(after).map(key));
for (const root of roots(before)) {
if (stillThere.has(key(root)))
continue;
const block = after.head.checkpoint;
if (!block) {
unproven.push({ generation: after.head.generation, ref: root.ref, oid: root.oid, reason: "a root left the map at a generation that carries no checkpoint" });
continue;
}
if (!block.cutoff) {
unproven.push({ generation: after.head.generation, ref: root.ref, oid: root.oid, reason: "a root left the map at a checkpoint bound to no retention_cutoff ticket" });
continue;
}
const admitted = effectiveAdmittedAt(root.dropped_at_generation);
if (admitted === null) {
unproven.push({ generation: after.head.generation, ref: root.ref, oid: root.oid, reason: "this client cannot resolve the drop's effective_admitted_at, so the departure is unproven" });
continue;
}
if (!isEligible(admitted, block.cutoff.cutoff_at)) {
unproven.push({ generation: after.head.generation, ref: root.ref, oid: root.oid, reason: "the root left the map before its retention window closed against the bound cutoff" });
}
}
}
return { ok: unproven.length === 0, unproven };
}
/**
* Build + sign `prune_intent_core`. The core is signed on its own AND embedded
* in a signed wrapper (the double signature of §7.3) — `intent_core_sha256` is
* the stored-bytes hash of the COMPLETE signed core, which is why the core must
* be finished before either verifier can attest anything about it.
*/
export function buildPruneIntentCore(input, signingSeed) {
const deleteSet = sortPruneReceipts(input.delete_set);
if (deleteSet.length === 0)
fail("UPGRADE_REQUIRED", "a prune intent must name at least one candidate", "building the vault prune intent");
if (deleteSet.length > VAULT_MAX_PRUNE_CANDIDATES) {
fail("UPGRADE_REQUIRED", `${deleteSet.length} candidates exceed the ${VAULT_MAX_PRUNE_CANDIDATES} per-intent maximum; chunk into successive intents`, "building the vault prune intent", { candidates: deleteSet.length });
}
for (let i = 1; i < deleteSet.length; i++) {
if (!(deleteSet[i - 1].object_id < deleteSet[i].object_id)) {
fail("UPGRADE_REQUIRED", `delete_set is not sorted and pairwise-distinct at index ${i}`, "building the vault prune intent", { object_id: deleteSet[i].object_id });
}
}
const cycle = input.cycle ?? null;
const unsigned = {
format: VAULT_FORMAT,
object_kind: "prune_intent_core",
suite: VAULT_SUITE,
repo_id: input.repo_id,
object_id: input.object_id ?? newVaultId("pi"),
gc_epoch: input.gc_epoch,
maintenance_cycle_id: cycle ? cycle.maintenance_cycle_id : null,
maintenance_prune_role: cycle ? cycle.maintenance_prune_role : null,
stage_claim_set_sha256: cycle ? cycle.stage_claim_set_sha256 : null,
batch_index: cycle ? cycle.batch_index : null,
batch_count: cycle ? cycle.batch_count : null,
authorizing_head_sha256: input.authorizing_head_sha256,
checkpoint_claim_set_sha256: input.checkpoint_claim_set_sha256,
gc_root_set_hmac: input.gc_root_set_hmac,
retention_state_hmac: input.retention_state_hmac,
delete_set: deleteSet,
nonce: input.nonce ?? newHex32(),
};
return signVaultObject(unsigned, signingSeed);
}
/** The stored-bytes hash of the complete signed core — what BOTH verifier receipts sign. */
export function pruneIntentCoreSha256(core) {
return storedBytesSha256(core);
}
/**
* Build + sign a `verifier_receipt`.
*
* `result` is DERIVED, never passed in: a receipt is `restored_and_verified`
* exactly when both attestation booleans are true, and `failed` otherwise. A
* caller cannot hand this function `failed` booleans and a passing result.
*/
export function buildVerifierReceipt(input, signingSeed) {
const unsigned = {
format: VAULT_FORMAT,
object_kind: "verifier_receipt",
suite: VAULT_SUITE,
repo_id: input.repo_id,
object_id: input.object_id ?? newVaultId("vr"),
intent_core_sha256: input.intent_core_sha256,
checkpoint_head_sha256: input.checkpoint_head_sha256,
cutoff_ticket_sha256: input.cutoff_ticket_sha256,
restored_object_set_hmac: input.restored_object_set_hmac,
retention_evolution_ok: input.retention_evolution_ok,
candidates_outside_roots_ok: input.candidates_outside_roots_ok,
implementation_id: input.implementation_id,
implementation_version: input.implementation_version,
result: (input.retention_evolution_ok && input.candidates_outside_roots_ok ? "restored_and_verified" : "failed"),
};
return signVaultObject(unsigned, signingSeed);
}
/** The `verifier_receipt_ref` an intent carries for an uploaded receipt. */
export function verifierReceiptRef(receipt) {
const bytes = storedBytes(receipt);
return {
object_id: receipt.object_id,
object_kind: "verifier_receipt",
stored_bytes_sha256: storedBytesSha256(receipt),
size_bytes: String(bytes.length),
implementation_id: receipt.implementation_id,
};
}
/**
* Wrap + sign the intent.
*
* Every gate the gateway applies to the pair is applied here first, so a
* malformed pair never reaches the wire as a wasted round trip: exactly two
* refs, one per CLOSED implementation identity, distinct ids AND distinct
* hashes, each attesting `restored_and_verified` with both booleans true, and
* each signing THIS core.
*/
export function buildPruneIntent(core, receipts, signingSeed) {
const coreSha = pruneIntentCoreSha256(core);
if (receipts.length !== 2) {
fail("UPGRADE_REQUIRED", `a prune intent carries exactly two verifier receipts, one per implementation (${VAULT_VERIFIER_IMPLEMENTATIONS.join(", ")}); got ${receipts.length}`, "building the vault prune intent", { supplied: receipts.map((r) => r.implementation_id) }, [{ action: "run r402s-verify against this intent core and pass its receipt alongside the SDK's own" }]);
}
for (const id of VAULT_VERIFIER_IMPLEMENTATIONS) {
const matching = receipts.filter((r) => r.implementation_id === id);
if (matching.length !== 1) {
fail("UPGRADE_REQUIRED", `exactly one verifier receipt per implementation is required; ${id} has ${matching.length}`, "building the vault prune intent", { implementation_id: id });
}
}
const [a, b] = receipts;
const refs = receipts.map(verifierReceiptRef);
if (a.object_id === b.object_id || refs[0].stored_bytes_sha256 === refs[1].stored_bytes_sha256) {
fail("UPGRADE_REQUIRED", "the two verifier receipts must be distinct objects with distinct hashes", "building the vault prune intent");
}
for (const r of receipts) {
if (r.repo_id !== core.repo_id)
fail("VAULT_ACCESS_DENIED", `verifier receipt ${r.object_id} attests a different vault`, "building the vault prune intent", { repo_id: r.repo_id });
if (r.intent_core_sha256 !== coreSha) {
fail("UPGRADE_REQUIRED", `verifier receipt ${r.object_id} signs a different intent core (${r.intent_core_sha256} ≠ ${coreSha})`, "building the vault prune intent", { object_id: r.object_id });
}
if (r.result !== "restored_and_verified" || r.retention_evolution_ok !== true || r.candidates_outside_roots_ok !== true) {
fail("UPGRADE_REQUIRED", `verifier receipt ${r.object_id} does not attest restored_and_verified with both attestations true`, "building the vault prune intent", {
object_id: r.object_id,
result: r.result,
retention_evolution_ok: r.retention_evolution_ok,
candidates_outside_roots_ok: r.candidates_outside_roots_ok,
});
}
}
// The two refs ride in the receipts' given order; the schema's `uniqueItems`
// is satisfied by the distinctness gate above, not by sorting.
const unsigned = {
format: VAULT_FORMAT,
object_kind: "prune_intent",
suite: VAULT_SUITE,
repo_id: core.repo_id,
object_id: core.object_id,
core,
intent_core_sha256: coreSha,
verifier_receipts: refs,
};
return signVaultObject(unsigned, signingSeed);
}
/**
* Refuse a supplied core that the chain has moved past — BEFORE the receipts
* are uploaded.
*
* A prune is planned, verified out-of-band by a second implementation, and only
* then submitted, so there is a real window in which a push or a compaction
* lands underneath it. The gateway would refuse such an intent at its fence,
* but by then both receipts are stored objects the fence CLAIMS, so a stale
* submission burns evidence for nothing. Same registry codes the gateway uses,
* raised one step earlier.
*/
export function assertPruneCoreStillCurrent(core, current) {
if (core.repo_id !== current.repo_id) {
fail("VAULT_ACCESS_DENIED", `the supplied intent core belongs to ${core.repo_id}, not ${current.repo_id}`, "submitting the vault prune intent", { repo_id: core.repo_id });
}
if (core.gc_epoch !== current.gc_epoch) {
fail("GC_EPOCH_STALE", `the supplied intent core was planned at gc_epoch ${core.gc_epoch}; the vault is at ${current.gc_epoch}`, "submitting the vault prune intent", { planned: core.gc_epoch, current: current.gc_epoch }, [{ action: "refetch the vault state and rebuild the intent at the current epoch" }]);
}
if (core.checkpoint_claim_set_sha256 !== current.checkpoint_claim_set_sha256) {
fail("GC_EPOCH_STALE", "the supplied intent core names a checkpoint that is no longer the latest; a fresh checkpoint landed after the plan", "submitting the vault prune intent", { planned: core.checkpoint_claim_set_sha256, current: current.checkpoint_claim_set_sha256 }, [{ action: "refetch the vault state and rebuild the intent at the current epoch" }]);
}
}
/**
* THE serializer for the submit route.
*
* `POST …/prune-intents` is parsed with `express.raw`: the service strict-parses
* these bytes and verifies the owner signature over them. Anything that
* re-serializes the object between here and the socket — a JSON body helper, a
* proxy that reformats — changes what was signed. Send exactly this.
*/
export function pruneIntentBytes(intent) {
return storedBytes(intent);
}
/**
* Read the completion's three-valued vector against the intent's canonical
* candidate order.
*
* The one rule this function exists to enforce: **only `deleted` means gone.**
* `present_after_attempt` looks like a deletion in the logs and is not one —
* treating it as one is how a client reports history removed that is still
* there. A candidate with no entry is `unadjudicated`, never inferred.
*/
export function summarizePruneCompletion(candidateIds, record) {
const completion = record?.completion ?? null;
const perObject = completion?.per_object ?? null;
if (!completion || !perObject)
return { outcome: null, deleted: [], present: [], unadjudicated: [...candidateIds] };
const byId = new Map(perObject.map((o) => [o.object_id, o]));
const deleted = [];
const present = [];
const unadjudicated = [];
for (const id of candidateIds) {
const o = byId.get(id);
if (!o) {
unadjudicated.push(id);
continue;
}
if (o.result === "deleted")
deleted.push(id);
else
present.push(o);
}
const outcome = deleted.length === candidateIds.length && unadjudicated.length === 0
? "completed"
: present.some((o) => o.result === "present_after_attempt") || deleted.length > 0
? "superseded_partial_delete"
: "superseded_no_delete";
return { outcome, deleted, present, unadjudicated };
}
// ─── A scratch directory for the restore-and-verify pass ─────────────────────
/** Run `fn` in a fresh temp directory and always remove it, even on refusal. */
export async function withScratchDir(prefix, fn) {
const dir = mkdtempSync(join(tmpdir(), prefix));
try {
return await fn(dir);
}
finally {
rmSync(dir, { recursive: true, force: true });
}
}
//# sourceMappingURL=vault-prune.js.map