run402 4.63.1 → 4.65.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. package/gitvault-surface.json +2 -1
  2. package/lib/doctor.mjs +28 -15
  3. package/lib/gitvault-capabilities.mjs +8 -0
  4. package/lib/gitvault-daemon.mjs +30 -1
  5. package/lib/remote-helper-session.mjs +168 -33
  6. package/lib/repos.mjs +122 -9
  7. package/package.json +1 -1
  8. package/sdk/dist/errors.d.ts +4 -2
  9. package/sdk/dist/errors.d.ts.map +1 -1
  10. package/sdk/dist/errors.js.map +1 -1
  11. package/sdk/dist/index.d.ts +1 -1
  12. package/sdk/dist/index.d.ts.map +1 -1
  13. package/sdk/dist/index.js +1 -1
  14. package/sdk/dist/index.js.map +1 -1
  15. package/sdk/dist/namespaces/gitvault.crypto.d.ts +80 -0
  16. package/sdk/dist/namespaces/gitvault.crypto.d.ts.map +1 -1
  17. package/sdk/dist/namespaces/gitvault.crypto.js +80 -0
  18. package/sdk/dist/namespaces/gitvault.crypto.js.map +1 -1
  19. package/sdk/dist/namespaces/gitvault.d.ts +219 -3
  20. package/sdk/dist/namespaces/gitvault.d.ts.map +1 -1
  21. package/sdk/dist/namespaces/gitvault.js +290 -6
  22. package/sdk/dist/namespaces/gitvault.js.map +1 -1
  23. package/sdk/dist/node/gitvault-byo-config.d.ts +29 -0
  24. package/sdk/dist/node/gitvault-byo-config.d.ts.map +1 -0
  25. package/sdk/dist/node/gitvault-byo-config.js +100 -0
  26. package/sdk/dist/node/gitvault-byo-config.js.map +1 -0
  27. package/sdk/dist/node/gitvault-byo-probe.d.ts +20 -0
  28. package/sdk/dist/node/gitvault-byo-probe.d.ts.map +1 -0
  29. package/sdk/dist/node/gitvault-byo-probe.js +57 -0
  30. package/sdk/dist/node/gitvault-byo-probe.js.map +1 -0
  31. package/sdk/dist/node/gitvault-creation-journal.d.ts +55 -1
  32. package/sdk/dist/node/gitvault-creation-journal.d.ts.map +1 -1
  33. package/sdk/dist/node/gitvault-creation-journal.js +34 -3
  34. package/sdk/dist/node/gitvault-creation-journal.js.map +1 -1
  35. package/sdk/dist/node/gitvault-degraded-read.d.ts +120 -0
  36. package/sdk/dist/node/gitvault-degraded-read.d.ts.map +1 -0
  37. package/sdk/dist/node/gitvault-degraded-read.js +164 -0
  38. package/sdk/dist/node/gitvault-degraded-read.js.map +1 -0
  39. package/sdk/dist/node/gitvault-keystore.d.ts +34 -1
  40. package/sdk/dist/node/gitvault-keystore.d.ts.map +1 -1
  41. package/sdk/dist/node/gitvault-keystore.js +43 -0
  42. package/sdk/dist/node/gitvault-keystore.js.map +1 -1
  43. package/sdk/dist/node/gitvault-mirror-backend.d.ts +73 -0
  44. package/sdk/dist/node/gitvault-mirror-backend.d.ts.map +1 -1
  45. package/sdk/dist/node/gitvault-mirror-backend.js +114 -8
  46. package/sdk/dist/node/gitvault-mirror-backend.js.map +1 -1
  47. package/sdk/dist/node/gitvault-mirror-config.d.ts +17 -1
  48. package/sdk/dist/node/gitvault-mirror-config.d.ts.map +1 -1
  49. package/sdk/dist/node/gitvault-mirror-config.js +21 -0
  50. package/sdk/dist/node/gitvault-mirror-config.js.map +1 -1
  51. package/sdk/dist/node/gitvault-mirror.d.ts +42 -0
  52. package/sdk/dist/node/gitvault-mirror.d.ts.map +1 -1
  53. package/sdk/dist/node/gitvault-mirror.js +178 -2
  54. package/sdk/dist/node/gitvault-mirror.js.map +1 -1
  55. package/sdk/dist/node/gitvault-prewarm.d.ts +31 -0
  56. package/sdk/dist/node/gitvault-prewarm.d.ts.map +1 -1
  57. package/sdk/dist/node/gitvault-prewarm.js +67 -0
  58. package/sdk/dist/node/gitvault-prewarm.js.map +1 -1
  59. package/sdk/dist/node/gitvault-publication.d.ts +227 -6
  60. package/sdk/dist/node/gitvault-publication.d.ts.map +1 -1
  61. package/sdk/dist/node/gitvault-publication.js +563 -77
  62. package/sdk/dist/node/gitvault-publication.js.map +1 -1
  63. package/sdk/dist/node/index.d.ts +1 -1
  64. package/sdk/dist/node/index.d.ts.map +1 -1
  65. package/sdk/dist/node/index.js +1 -1
  66. package/sdk/dist/node/index.js.map +1 -1
@@ -38,6 +38,7 @@ import { tmpdir } from "node:os";
38
38
  import { join } from "node:path";
39
39
  import { LocalError, isRun402Error } from "../errors.js";
40
40
  import { fetchGitvaultObjectBytes } from "./gitvault-edge-fetch.js";
41
+ import { openGitvaultDestinationBackend } from "./gitvault-mirror-backend.js";
41
42
  import { GITVAULT_FORMAT, GITVAULT_GENESIS_EPOCH, GITVAULT_GENESIS_GENERATION, GITVAULT_HEX16_RE, GITVAULT_OID40_RE, GITVAULT_SUITE, attemptKeyCommitment, bytesToHex, checkFreshEpochKeyAgainstPriorKeys, checkHPartition, computeRotationId, computeTargetPartitionDigest, deriveDigestKey, deriveObjectKey, ekFingerprint, epochRotationKeyCommitment, formatGitvaultTimestamp, fromBase64url, hexToBytes, jcs, keyEnvelopeLedgerId, keyedCommitment, newGitvaultId, newHex32, nextEpoch, objectsetContent, openBindingPreimage, openEpochRotationForRecipient, openFrame, parseGitvaultStrict, parseRotateEpochPayload, pinManifestLedgerId, randomBytes, sealFrame, sealKeyEnvelope, sha256Hex, signGitvaultObject, storedBytes, storedBytesSha256, toBase64url, verifyGitvaultObject, } from "../namespaces/gitvault.crypto.js";
42
43
  import { GITVAULT_ZERO_SHA256_SENTINEL } from "../namespaces/gitvault.types.js";
43
44
  import { gitvaultCheckpointStaleness } from "../namespaces/gitvault.js";
@@ -633,6 +634,35 @@ export function gitvaultLedgerId(read) {
633
634
  return pinManifestLedgerId(read.pin_manifest_version);
634
635
  return String(read.object_id);
635
636
  }
637
+ /**
638
+ * `scheme://host` for a URL string, or `null` for anything that fails to
639
+ * parse (never thrown) — the sole normalizer between a full presigned/edge
640
+ * URL (path, query, signature, everything) and the bare origin
641
+ * {@link GitvaultHttpTransportOptions.onObjectStoreOriginObserved} hands a
642
+ * caller. Deliberately NOT exported: this module is the only place that
643
+ * observes raw object-store URLs, so the normalizer has exactly one call
644
+ * site and needs no wider audience.
645
+ */
646
+ function gitvaultObjectStoreOrigin(url) {
647
+ try {
648
+ return new URL(url).origin;
649
+ }
650
+ catch {
651
+ return null;
652
+ }
653
+ }
654
+ /** Dedup, in order, dropping anything that failed to parse. */
655
+ function gitvaultObjectStoreOrigins(urls) {
656
+ const out = [];
657
+ for (const u of urls) {
658
+ if (!u)
659
+ continue;
660
+ const origin = gitvaultObjectStoreOrigin(u);
661
+ if (origin && !out.includes(origin))
662
+ out.push(origin);
663
+ }
664
+ return out;
665
+ }
636
666
  function b64(bytes) { return Buffer.from(bytes).toString("base64"); }
637
667
  function b64u(bytes) { return Buffer.from(bytes).toString("base64url"); }
638
668
  /**
@@ -705,8 +735,45 @@ export function createGitvaultHttpTransport(client, options = {}) {
705
735
  }
706
736
  return new Uint8Array(await r.arrayBuffer());
707
737
  }
738
+ /**
739
+ * Resolve ONE `object-reads` presign target to bytes (gitvault-small-
740
+ * object-inline design D3): consume `inline` when present and EITHER no
741
+ * `expectedSha256` was supplied OR the decoded bytes hash to it, else
742
+ * fall through to the ordinary `url`/`edge_url` fetch exactly as before
743
+ * this change. A lying `inline` is therefore a PLAIN MISS for this one
744
+ * slot — the fetch below reproduces the URL-only result and failure
745
+ * envelope byte-for-byte, never a special error. A caller that supplies
746
+ * no expectation cannot detect a lying `inline` at all, but that is no
747
+ * new exposure: `inline` is the SAME stored bytes, to the SAME
748
+ * authorized caller, the entry's own `url` would have served — nothing
749
+ * here becomes a new source of truth (design D5).
750
+ *
751
+ * gitvault-object-host-predial (task 1.2): once the fetch below
752
+ * COMPLETES (any status — a thrown fetch error is NOT this), reports the
753
+ * target's origin(s) via `options.onObjectStoreOriginObserved`. Never
754
+ * fired for the `inline` short-circuit above — no URL was dialed.
755
+ */
756
+ async function resolveObjectReadTarget(repoId, target, expectedSha256, path) {
757
+ if (target.inline !== undefined) {
758
+ const bytes = fromBase64url(target.inline, "reads[].inline");
759
+ if (expectedSha256 === undefined || sha256Hex(bytes) === expectedSha256)
760
+ return bytes;
761
+ }
762
+ const r = await fetchGitvaultObjectBytes(client, target);
763
+ try {
764
+ options.onObjectStoreOriginObserved?.(repoId, gitvaultObjectStoreOrigins([target.url, target.edge_url]));
765
+ }
766
+ catch {
767
+ /* a caller's own persistence failure must never surface into this read */
768
+ }
769
+ if (r.status === 404)
770
+ return null;
771
+ if (!r.ok)
772
+ fail("GITVAULT_OBJECT_READ_FAILED", `object GET failed (HTTP ${r.status}) for ${path}`, "reading gitvault object", { path, status: r.status });
773
+ return new Uint8Array(await r.arrayBuffer());
774
+ }
708
775
  /** Presign + fetch one object by its ledger identity (`POST …/object-reads`). */
709
- async function getObjectBytes(repoId, path) {
776
+ async function getObjectBytes(repoId, path, expectedSha256) {
710
777
  const ref = gitvaultWireRefForPath(path);
711
778
  if (!ref)
712
779
  fail("GITVAULT_OBJECT_READ_FAILED", `${path} has no control-plane wire identity; it is not a readable vault object`, "reading gitvault object", { path });
@@ -741,12 +808,7 @@ export function createGitvaultHttpTransport(client, options = {}) {
741
808
  const target = presigned.reads[0];
742
809
  if (!target)
743
810
  return null;
744
- const r = await fetchGitvaultObjectBytes(client, target);
745
- if (r.status === 404)
746
- return null;
747
- if (!r.ok)
748
- fail("GITVAULT_OBJECT_READ_FAILED", `object GET failed (HTTP ${r.status}) for ${path}`, "reading gitvault object", { path, status: r.status });
749
- return new Uint8Array(await r.arrayBuffer());
811
+ return resolveObjectReadTarget(repoId, target, expectedSha256, path);
750
812
  }
751
813
  /**
752
814
  * Presign + fetch N independent objects (gitvault-client-round-trips
@@ -758,7 +820,7 @@ export function createGitvaultHttpTransport(client, options = {}) {
758
820
  * restore's carrier and pack reads, so it fails closed rather than
759
821
  * silently costing an extra round trip through `getGenerationBytes`.
760
822
  */
761
- async function getObjectsBytes(repoId, paths) {
823
+ async function getObjectsBytes(repoId, paths, expected) {
762
824
  if (paths.length === 0)
763
825
  return [];
764
826
  const targets = await presignObjectBatch(repoId, paths);
@@ -767,12 +829,7 @@ export function createGitvaultHttpTransport(client, options = {}) {
767
829
  return mapBounded(targets, GITVAULT_TRANSPORT_CONCURRENCY, async (target, i) => {
768
830
  if (!target)
769
831
  return null;
770
- const r = await fetchGitvaultObjectBytes(client, target);
771
- if (r.status === 404)
772
- return null;
773
- if (!r.ok)
774
- fail("GITVAULT_OBJECT_READ_FAILED", `object GET failed (HTTP ${r.status}) for ${paths[i]}`, "reading gitvault object", { path: paths[i], status: r.status });
775
- return new Uint8Array(await r.arrayBuffer());
832
+ return resolveObjectReadTarget(repoId, target, expected?.[i], paths[i]);
776
833
  });
777
834
  }
778
835
  /** 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. */
@@ -802,10 +859,12 @@ export function createGitvaultHttpTransport(client, options = {}) {
802
859
  * D2): identical presign + bounded GETs, but each index's promise settles
803
860
  * when ITS bytes land. Failure semantics per index match `getObjects`'s
804
861
  * per-element behavior (absent → null, a failed GET → the same
805
- * GITVAULT_OBJECT_READ_FAILED); every promise is pre-marked handled so an
806
- * abandoned tail never becomes an unhandled rejection.
862
+ * GITVAULT_OBJECT_READ_FAILED), including `expected` (gitvault-small-
863
+ * object-inline design D3 see {@link GitvaultTransport.getObjects}'s
864
+ * doc comment); every promise is pre-marked handled so an abandoned tail
865
+ * never becomes an unhandled rejection.
807
866
  */
808
- async function getObjectsSettledBytes(repoId, paths) {
867
+ async function getObjectsSettledBytes(repoId, paths, expected) {
809
868
  if (paths.length === 0)
810
869
  return [];
811
870
  const targets = await presignObjectBatch(repoId, paths);
@@ -834,14 +893,7 @@ export function createGitvaultHttpTransport(client, options = {}) {
834
893
  deferreds[i].resolve(null);
835
894
  continue;
836
895
  }
837
- const r = await fetchGitvaultObjectBytes(client, target);
838
- if (r.status === 404) {
839
- deferreds[i].resolve(null);
840
- continue;
841
- }
842
- if (!r.ok)
843
- fail("GITVAULT_OBJECT_READ_FAILED", `object GET failed (HTTP ${r.status}) for ${paths[i]}`, "reading gitvault object", { path: paths[i], status: r.status });
844
- deferreds[i].resolve(new Uint8Array(await r.arrayBuffer()));
896
+ deferreds[i].resolve(await resolveObjectReadTarget(repoId, target, expected?.[i], paths[i]));
845
897
  }
846
898
  catch (e) {
847
899
  deferreds[i].reject(e);
@@ -907,11 +959,17 @@ export function createGitvaultHttpTransport(client, options = {}) {
907
959
  return null;
908
960
  }
909
961
  }
910
- /** Resolve ONE `GET …/state` carrier arm to raw bytes — inline decode, or a plain GET on the presigned URL (preferring its `edge_url` companion, gitvault-read-edge-cache design D5), `null` on a 404 (mirrors {@link getObjectBytes}'s absent reading; both arms indistinguishable after this). */
911
- async function resolveVaultStateCarrier(carrier) {
962
+ /** Resolve ONE `GET …/state` carrier arm to raw bytes — inline decode, or a plain GET on the presigned URL (preferring its `edge_url` companion, gitvault-read-edge-cache design D5), `null` on a 404 (mirrors {@link getObjectBytes}'s absent reading; both arms indistinguishable after this). Origin observation (gitvault-object-host-predial task 1.2) mirrors `resolveObjectReadTarget`'s. */
963
+ async function resolveVaultStateCarrier(repoId, carrier) {
912
964
  if ("inline" in carrier)
913
965
  return fromBase64url(carrier.inline, "carriers.inline");
914
966
  const r = await fetchGitvaultObjectBytes(client, { url: carrier.presigned_url, edge_url: carrier.edge_url });
967
+ try {
968
+ options.onObjectStoreOriginObserved?.(repoId, gitvaultObjectStoreOrigins([carrier.presigned_url, carrier.edge_url]));
969
+ }
970
+ catch {
971
+ /* a caller's own persistence failure must never surface into this read */
972
+ }
915
973
  if (r.status === 404)
916
974
  return null;
917
975
  if (!r.ok)
@@ -924,11 +982,16 @@ export function createGitvaultHttpTransport(client, options = {}) {
924
982
  * resolved to raw bytes here, verified nowhere here (see the interface's
925
983
  * own doc comment on {@link GitvaultTransport.getState}).
926
984
  */
927
- async function getVaultStateOut(repoId, since) {
928
- const raw = await client.request(`${base(repoId)}/state${since ? `?since=${encodeURIComponent(since)}` : ""}`, { context: "reading the gitvault vault state" });
985
+ async function getVaultStateOut(repoId, since, restore) {
986
+ const qs = [];
987
+ if (since)
988
+ qs.push(`since=${encodeURIComponent(since)}`);
989
+ if (restore)
990
+ qs.push("restore=1");
991
+ const raw = await client.request(`${base(repoId)}/state${qs.length > 0 ? `?${qs.join("&")}` : ""}`, { context: "reading the gitvault vault state" });
929
992
  const head = raw.head ? { stored_bytes: fromBase64url(raw.head.stored_bytes, "head.stored_bytes"), stored_bytes_sha256: raw.head.stored_bytes_sha256 } : null;
930
993
  const carriers = raw.carriers
931
- ? { ref_state: await resolveVaultStateCarrier(raw.carriers.ref_state), retention_roots: await resolveVaultStateCarrier(raw.carriers.retention_roots) }
994
+ ? { ref_state: await resolveVaultStateCarrier(repoId, raw.carriers.ref_state), retention_roots: await resolveVaultStateCarrier(repoId, raw.carriers.retention_roots) }
932
995
  : null;
933
996
  // Delta decode is deliberately forgiving: a malformed entry is dropped,
934
997
  // never thrown — the delta is an accelerator and the ordinary reads own
@@ -948,7 +1011,63 @@ export function createGitvaultHttpTransport(client, options = {}) {
948
1011
  delta = null;
949
1012
  }
950
1013
  }
951
- return { vault: raw.vault, newest_generation: raw.newest_generation, head, carriers, ...(delta ? { delta } : {}) };
1014
+ // Restore-plan decode (gitvault-restore-recipe design D1-D5): the SAME
1015
+ // forgiving posture as `delta` above — ANY structural or resolution
1016
+ // anomaly drops the whole plan, never throws, so a hiccup fetching one
1017
+ // above-cap pack never fails the state read itself (head/carriers/delta
1018
+ // already succeeded by the time this runs). Heads and checkpoint bytes
1019
+ // are a pure base64url decode (the gateway sends them inline ALWAYS —
1020
+ // design D4, no network here); packs reuse `resolveObjectReadTarget`
1021
+ // verbatim (db8d745c) — the SAME self-consistency-gated inline decode
1022
+ // with url/edge_url fallback every `object-reads` consumer gets — under
1023
+ // bounded concurrency, so an all-inline plan costs this call NOTHING
1024
+ // beyond the one `GET …/state` already in flight, and an above-cap plan
1025
+ // pays exactly one GET per uncapped pack (the presign already happened
1026
+ // server-side assembling the plan; no client-side `object-reads` POST).
1027
+ // Decoding is its own function (never throwing `Error` — the public-SDK
1028
+ // plain-`Error` contract — `null`/early-return is the disqualification
1029
+ // signal instead) wrapped in one try/catch for the genuine exceptions
1030
+ // `fromBase64url`/`resolveObjectReadTarget` can still raise.
1031
+ const decodeRestorePlan = async () => {
1032
+ if (!raw.restore_plan || !Array.isArray(raw.restore_plan.heads) || !Array.isArray(raw.restore_plan.packs))
1033
+ return null;
1034
+ try {
1035
+ const rp = raw.restore_plan;
1036
+ const heads = rp.heads
1037
+ .filter((h) => typeof h?.generation === "string" && typeof h.stored_bytes === "string" && typeof h.stored_bytes_sha256 === "string")
1038
+ .map((h) => ({ generation: h.generation, stored_bytes: fromBase64url(h.stored_bytes, "restore_plan.heads[].stored_bytes"), stored_bytes_sha256: h.stored_bytes_sha256 }));
1039
+ if (heads.length !== rp.heads.length)
1040
+ return null; // a malformed head entry disqualifies the whole plan
1041
+ const checkpoint = rp.checkpoint
1042
+ ? {
1043
+ 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") },
1044
+ manifest: { object_id: rp.checkpoint.manifest.object_id, stored_bytes: fromBase64url(rp.checkpoint.manifest.stored_bytes, "restore_plan.checkpoint.manifest.stored_bytes") },
1045
+ }
1046
+ : null;
1047
+ const packEntries = rp.packs.filter((pk) => typeof pk?.object_kind === "string" && typeof pk.object_id === "string");
1048
+ if (packEntries.length !== rp.packs.length)
1049
+ return null; // a malformed pack entry disqualifies the whole plan
1050
+ const resolved = await mapBounded(packEntries, GITVAULT_TRANSPORT_CONCURRENCY, async (pk) => {
1051
+ 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 ?? "" };
1052
+ // Self-consistency only (the same gate `inline` gets everywhere
1053
+ // else) — real verification against the head/manifest's OWN
1054
+ // receipt happens later, in `GitvaultVault`, before any byte is
1055
+ // trusted. A pack the gateway declined to presign (`url` absent,
1056
+ // `inline` absent) is `null` here and falls back to the ordinary
1057
+ // object-reads fetch by (object_kind, object_id) in the caller.
1058
+ if (!target.url && target.inline === undefined)
1059
+ return null;
1060
+ return resolveObjectReadTarget(repoId, target, pk.stored_bytes_sha256, `restore_plan:${pk.object_kind}:${pk.object_id}`);
1061
+ });
1062
+ const packs = packEntries.map((pk, i) => ({ object_kind: pk.object_kind, object_id: pk.object_id, bytes: resolved[i] ?? null }));
1063
+ return { boundary_generation: rp.boundary_generation, heads, checkpoint, packs };
1064
+ }
1065
+ catch {
1066
+ return null;
1067
+ }
1068
+ };
1069
+ const restorePlan = await decodeRestorePlan();
1070
+ return { vault: raw.vault, newest_generation: raw.newest_generation, head, carriers, ...(delta ? { delta } : {}), ...(restorePlan ? { restore_plan: restorePlan } : {}) };
952
1071
  }
953
1072
  /** 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. */
954
1073
  function receiptsFromFinalize(objects, entries, fin) {
@@ -961,14 +1080,20 @@ export function createGitvaultHttpTransport(client, options = {}) {
961
1080
  return { path: o.path, object_id: o.object_id, sha256: r.ciphertext_sha256 ?? r.stored_bytes_sha256 ?? "", size_bytes: r.size_bytes };
962
1081
  });
963
1082
  }
964
- async function upload(repoId, objects, resourceBinding) {
1083
+ async function upload(repoId, objects, resourceBinding, byo) {
965
1084
  if (objects.length === 0)
966
1085
  return [];
967
1086
  // The manifest is closed-key: `path` is client-local and MUST NOT ride the
968
1087
  // wire — the control plane derives the bucket key itself and refuses an
969
1088
  // entry carrying an unexpected member.
970
1089
  const entries = objects.map((o) => gitvaultManifestEntry(o));
971
- if (gitvaultInlineUploadEligible(objects)) {
1090
+ // gitvault-byo-primary-bucket task 3.2: the inline (bytes-in-body) fast
1091
+ // path is NEVER used for a BYO vault, even when the payload is small
1092
+ // enough to be eligible — that transport writes bytes into run402's own
1093
+ // bucket, exactly what the credential model forbids (the gateway
1094
+ // refuses it too, VALIDATION_FAILED, so skipping it here is purely a
1095
+ // wasted-round-trip avoidance, not a correctness dependency).
1096
+ if (!byo && gitvaultInlineUploadEligible(objects)) {
972
1097
  // gitvault-composite-state-read design D2: every object fits under the
973
1098
  // caps — one POST, bytes verified + written server-side, and the
974
1099
  // response IS the finalize response (no session, no PUTs, no separate
@@ -987,6 +1112,14 @@ export function createGitvaultHttpTransport(client, options = {}) {
987
1112
  context: "opening gitvault upload session",
988
1113
  });
989
1114
  const issued = new Map(session.objects.map((u) => [gitvaultLedgerId(u), u]));
1115
+ // gitvault-byo-primary-bucket task 3.2 — one backend, opened ONCE, for
1116
+ // every BYO payload object this session names; `null` `put` never
1117
+ // appears in the session response unless `byo` was supplied (the
1118
+ // gateway only mints `put: null` for a vault whose storage_profile is
1119
+ // "byo", which is exactly when THIS caller must have supplied `byo`
1120
+ // too — see the resolution in `GitvaultVault.uploadAll`).
1121
+ const byoBackend = byo ? openGitvaultDestinationBackend(byo.destination, byo.credential) : null;
1122
+ const byoAttestations = [];
990
1123
  // Independent create-only PUTs within one session (design D2) — bounded
991
1124
  // concurrency, same limit as the batched object reads below.
992
1125
  await mapBounded(objects, GITVAULT_TRANSPORT_CONCURRENCY, async (o, i) => {
@@ -994,6 +1127,36 @@ export function createGitvaultHttpTransport(client, options = {}) {
994
1127
  const target = issued.get(id);
995
1128
  if (!target)
996
1129
  fail("GITVAULT_UPLOAD_SESSION_INVALID", `the session issued no upload for ${id}`, "uploading gitvault objects", { object_id: id, path: o.path });
1130
+ if (target.put === null) {
1131
+ // BYO payload object (task 2.2's GITVAULT_BYO_PAYLOAD_KINDS
1132
+ // partition, mirrored client-side by the server's own `put: null` +
1133
+ // `key` shape) — the client writes DIRECTLY to its own bucket. No
1134
+ // request to run402 ever carries a customer-bucket credential; no
1135
+ // request to run402 ever carries these bytes either.
1136
+ if (!byoBackend || !target.key) {
1137
+ fail("GITVAULT_BYO_BUCKET_WRITE_REFUSED", `the session named ${o.path} as a BYO-written object but no BYO write target was resolved`, "uploading gitvault objects", { path: o.path, object_id: id });
1138
+ }
1139
+ let put;
1140
+ try {
1141
+ put = await byoBackend.putCreateOnly(target.key, o.bytes);
1142
+ }
1143
+ catch (e) {
1144
+ fail("GITVAULT_BYO_BUCKET_WRITE_REFUSED", `writing ${o.path} to the BYO destination failed: ${e instanceof Error ? e.message : String(e)}`, "uploading gitvault objects", { path: o.path, key: target.key });
1145
+ }
1146
+ if (!put.created) {
1147
+ // create-only: the key already exists — legal ONLY if it is
1148
+ // byte-identical (an idempotent retry of THIS SAME write; a
1149
+ // permanent object id is never legitimately reused for
1150
+ // different bytes — matches the managed presigned-PUT read-and-
1151
+ // compare branch below, and the gateway's own attestation rule).
1152
+ const existing = await byoBackend.get(target.key);
1153
+ if (!existing || sha256Hex(existing) !== o.sha256) {
1154
+ fail("GITVAULT_BYO_BUCKET_WRITE_REFUSED", `${o.path} already exists in the BYO destination with different bytes`, "uploading gitvault objects", { path: o.path, key: target.key });
1155
+ }
1156
+ }
1157
+ byoAttestations.push({ key: target.key, sha256: o.sha256, size_bytes: o.size_bytes, create_only_result: "created" });
1158
+ return;
1159
+ }
997
1160
  // `If-None-Match: *` (create-only) and the FULL_OBJECT `x-amz-checksum-sha256`
998
1161
  // are SIGNED INTO the presigned URL, so they must go out exactly as the
999
1162
  // server issued them — a dropped or altered header is a signature
@@ -1015,7 +1178,13 @@ export function createGitvaultHttpTransport(client, options = {}) {
1015
1178
  if (!r.ok)
1016
1179
  fail("GITVAULT_UPLOAD_FAILED", `presigned PUT failed (HTTP ${r.status}) for ${o.path}`, "uploading gitvault objects", { path: o.path, status: r.status });
1017
1180
  });
1018
- const fin = await client.request(`${base(repoId)}/upload-sessions/${encodeURIComponent(session.upload_session_id)}/finalize`, { method: "POST", body: {}, context: "finalizing gitvault upload session" });
1181
+ const fin = await client.request(`${base(repoId)}/upload-sessions/${encodeURIComponent(session.upload_session_id)}/finalize`, {
1182
+ method: "POST",
1183
+ // Only a BYO session ever carries attestations — an ordinary managed
1184
+ // session's finalize body stays `{}`, byte-identical to today.
1185
+ body: byo ? { attestations: byoAttestations } : {},
1186
+ context: "finalizing gitvault upload session",
1187
+ });
1019
1188
  return receiptsFromFinalize(objects, entries, fin);
1020
1189
  }
1021
1190
  async function admit(repoId, generation, bytes, hash, extra = {}) {
@@ -1046,12 +1215,12 @@ export function createGitvaultHttpTransport(client, options = {}) {
1046
1215
  return res.allocation ?? res;
1047
1216
  },
1048
1217
  async putObject(request) {
1049
- 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 }]);
1218
+ 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);
1050
1219
  return { stored_bytes_sha256: r.sha256, size_bytes: r.size_bytes };
1051
1220
  },
1052
- getObject: ({ repo_id, path }) => getObjectBytes(repo_id, path),
1053
- getObjects: ({ repo_id, paths }) => getObjectsBytes(repo_id, paths),
1054
- getObjectsSettled: ({ repo_id, paths }) => getObjectsSettledBytes(repo_id, paths),
1221
+ getObject: ({ repo_id, path, expected_sha256 }) => getObjectBytes(repo_id, path, expected_sha256),
1222
+ getObjects: ({ repo_id, paths, expected }) => getObjectsBytes(repo_id, paths, expected),
1223
+ getObjectsSettled: ({ repo_id, paths, expected }) => getObjectsSettledBytes(repo_id, paths, expected),
1055
1224
  getHeads: ({ repo_id, generations }) => getHeadsBytes(repo_id, generations),
1056
1225
  async admitGenesis(request) {
1057
1226
  try {
@@ -1075,7 +1244,7 @@ export function createGitvaultHttpTransport(client, options = {}) {
1075
1244
  qs.set("cursor", request.cursor);
1076
1245
  return client.request(`${base(request.repo_id)}/heads?${qs.toString()}`, { context: "listing gitvault heads" });
1077
1246
  },
1078
- uploadObjects: ({ repo_id, objects, resource_binding }) => upload(repo_id, objects, resource_binding),
1247
+ uploadObjects: ({ repo_id, objects, resource_binding, byo }) => upload(repo_id, objects, resource_binding, byo),
1079
1248
  admitHead: (r) => admit(r.repo_id, r.generation, r.stored_bytes, r.stored_bytes_sha256),
1080
1249
  // Both routes SHIPPED (`routes/gitvault-admission.ts`). `retention-cutoffs`
1081
1250
  // answers `{ticket, receipt, next_actions}`, which IS
@@ -1100,7 +1269,7 @@ export function createGitvaultHttpTransport(client, options = {}) {
1100
1269
  return { cleared: r.advisory_cleared ?? r.cleared ?? false };
1101
1270
  },
1102
1271
  getVaultRecord: ({ repo_id }) => client.request(base(repo_id), { context: "reading the gitvault record" }),
1103
- getState: ({ repo_id, since }) => getVaultStateOut(repo_id, since),
1272
+ getState: ({ repo_id, since, restore }) => getVaultStateOut(repo_id, since, restore),
1104
1273
  openCompactionGrant: ({ repo_id }) => client.request(`${base(repo_id)}/compaction-grant`, { method: "POST", context: "opening the gitvault compaction headroom grant" }),
1105
1274
  closeCompactionGrant: async ({ repo_id }) => {
1106
1275
  const r = await client.request(`${base(repo_id)}/compaction-grant`, { method: "DELETE", context: "closing the gitvault compaction headroom grant" });
@@ -1410,9 +1579,31 @@ export class GitvaultVault {
1410
1579
  * consumed and cleared by the next restore).
1411
1580
  */
1412
1581
  stateDeltaPacks = null;
1582
+ /**
1583
+ * The state read's restore plan (gitvault-restore-recipe design D1-D5),
1584
+ * stashed RAW — never verified at stash time, unlike `stateDeltaPacks`
1585
+ * (whose heads self-check before entering the shared keystore cache): a
1586
+ * plan's heads/checkpoint/packs are only ever verified by {@link
1587
+ * restoreObjectsInto}'s own full obligation set, and a partially-checked
1588
+ * plan sitting here would be a foot-gun for a future caller that forgot
1589
+ * the difference. Consumed and cleared exactly once, by the next
1590
+ * `restoreObjectsInto` call (either its own materialize, or a dedicated
1591
+ * plan-only read it issues when the incremental walk it started aborts).
1592
+ */
1593
+ stateRestorePlan = null;
1413
1594
  retries;
1414
1595
  servicePublicKey;
1415
1596
  genesisCache = null;
1597
+ /**
1598
+ * gitvault-byo-primary-bucket task 3.2 — this vault's resolved BYO write
1599
+ * target, cached after the first resolution (storage_profile/
1600
+ * byo_destination are immutable-at-allocation in v1, so a single
1601
+ * read-once-per-instance is safe). `undefined` = not yet resolved;
1602
+ * `null` = this machine has no LOCAL BYO write config for this repo
1603
+ * (either an ordinary managed vault, or a BYO vault this machine has not
1604
+ * been configured to write — see {@link resolveByoWriteTarget}).
1605
+ */
1606
+ byoResolution = undefined;
1416
1607
  constructor(options) {
1417
1608
  this.keystore = options.keystore;
1418
1609
  this.transport = options.transport;
@@ -1588,7 +1779,7 @@ export class GitvaultVault {
1588
1779
  * comment). A wrong or absent byte this method wrote is therefore just a
1589
1780
  * cache MISS on the next read, never a verification bypass.
1590
1781
  */
1591
- async tryStateFastPath(pin, deltaSince) {
1782
+ async tryStateFastPath(pin, deltaSince, restore) {
1592
1783
  // gitvault-delta-fetch: carry the caller's MATERIALIZED position as
1593
1784
  // `since` — a capable gateway answers small spans with the heads +
1594
1785
  // inline WAL packs in this same response; every other gateway/span
@@ -1598,7 +1789,15 @@ export class GitvaultVault {
1598
1789
  // keystore, so pin-as-since would report "current" for a standing clone
1599
1790
  // that is generations behind — exactly the multi-checkout shape the
1600
1791
  // bench runs.
1601
- const state = await this.transport.getState({ repo_id: this.repoId, since: deltaSince ?? pin.generation });
1792
+ //
1793
+ // `restore` (gitvault-restore-recipe design D2/D6) rides the SAME state
1794
+ // read — never a second round trip — so the caller (`restoreObjectsInto`,
1795
+ // via `materialize`/`verifyToNewest`) declares it only on the wholesale
1796
+ // shape (see that method's own doc comment). This call declining the
1797
+ // shortcut entirely (more than one generation behind `pin`) also means
1798
+ // no plan is ever requested on this attempt — the listing-walk path that
1799
+ // owns catch-up already has its own batched primitives.
1800
+ const state = await this.transport.getState({ repo_id: this.repoId, since: deltaSince ?? pin.generation, restore });
1602
1801
  // §6.4: the vault's newest generation may never fall below the
1603
1802
  // authenticated pin — checked here regardless of eligibility below, so
1604
1803
  // a regressed vault is caught exactly as loudly as it always was, even
@@ -1618,6 +1817,13 @@ export class GitvaultVault {
1618
1817
  // is actually genesis's bytes.
1619
1818
  if (state.delta)
1620
1819
  this.consumeStateDelta(state.delta);
1820
+ // gitvault-restore-recipe: stashed RAW (never verified here — see the
1821
+ // field's own doc comment); a genesis-only response (below) still runs
1822
+ // this line first, but `assembleVaultRestorePlan` never fires server-side
1823
+ // before an ordinary head exists, so `state.restore_plan` is simply
1824
+ // absent in that window — nothing to stash either way.
1825
+ if (state.restore_plan)
1826
+ this.stateRestorePlan = state.restore_plan;
1621
1827
  const noOrdinaryHeadYet = state.newest_generation === null || state.newest_generation === GITVAULT_GENESIS_GENERATION;
1622
1828
  const pinBig = generationToBigInt(pin.generation);
1623
1829
  const newestBig = noOrdinaryHeadYet ? 0n : generationToBigInt(state.newest_generation);
@@ -1750,7 +1956,10 @@ export class GitvaultVault {
1750
1956
  // own epoch could not be resolved without the network read this path
1751
1957
  // exists to avoid) — the caller falls straight through to the UNCHANGED
1752
1958
  // readHead + listHeads flow below, byte-identical to before this change.
1753
- const fastPath = await this.tryStateFastPath(pin, options.deltaSince);
1959
+ // `options.restore` (gitvault-restore-recipe) rides along unchanged —
1960
+ // declined exactly when the fast path itself is declined, so a caller
1961
+ // more than one generation behind never gets (or needs) a plan here.
1962
+ const fastPath = await this.tryStateFastPath(pin, options.deltaSince, options.restore);
1754
1963
  let lastHead = fastPath ? fastPath.pinnedHead : pin.generation === GITVAULT_GENESIS_GENERATION ? null : await this.readHead(pin.generation, pin.head_sha256);
1755
1964
  const anchor = pin.generation;
1756
1965
  let prevEpoch = fastPath ? fastPath.prevEpoch : (lastHead?.epoch ?? GITVAULT_GENESIS_EPOCH);
@@ -1928,7 +2137,7 @@ export class GitvaultVault {
1928
2137
  }
1929
2138
  }
1930
2139
  if (carriers.length > 1) {
1931
- const fetched = await this.transport.getObjects({ repo_id: this.repoId, paths: carriers.map((c) => c.path) });
2140
+ const fetched = await this.transport.getObjects({ repo_id: this.repoId, paths: carriers.map((c) => c.path), expected: carriers.map((c) => c.sha) });
1932
2141
  for (let i = 0; i < carriers.length; i++) {
1933
2142
  const bytes = fetched[i] ?? null;
1934
2143
  if (bytes && sha256Hex(bytes) === carriers[i].sha)
@@ -2194,7 +2403,7 @@ export class GitvaultVault {
2194
2403
  const cacheable = kind === "ref_state" || kind === "retention_roots";
2195
2404
  const cached = cacheable ? this.keystore.readCachedCarrier(this.repoId, receipt.object_id) : null;
2196
2405
  const cacheHit = cached && sha256Hex(cached.bytes) === receipt.ciphertext_sha256;
2197
- const frame = cacheHit ? cached.bytes : await this.transport.getObject({ repo_id: this.repoId, path });
2406
+ const frame = cacheHit ? cached.bytes : await this.transport.getObject({ repo_id: this.repoId, path, expected_sha256: receipt.ciphertext_sha256 });
2198
2407
  const object = this.decodeCarrierFrame(kind, receipt, frame, writerKey, keyOverride);
2199
2408
  if (cacheable && !cacheHit && frame && typeof object.generation === "string") {
2200
2409
  this.keystore.writeCachedCarrier(this.repoId, receipt.object_id, object.generation, receipt.ciphertext_sha256, frame);
@@ -2224,11 +2433,16 @@ export class GitvaultVault {
2224
2433
  const preRoots = !rootsHit ? (this.walkPrefetch?.get(rootsPath) ?? null) : null;
2225
2434
  const rootsPre = preRoots && sha256Hex(preRoots) === rootsReceipt.ciphertext_sha256 ? preRoots : null;
2226
2435
  const missingPaths = [];
2227
- if (!refStateHit && !refStatePre)
2436
+ const missingExpected = [];
2437
+ if (!refStateHit && !refStatePre) {
2228
2438
  missingPaths.push(refStatePath);
2229
- if (!rootsHit && !rootsPre)
2439
+ missingExpected.push(refStateReceipt.ciphertext_sha256);
2440
+ }
2441
+ if (!rootsHit && !rootsPre) {
2230
2442
  missingPaths.push(rootsPath);
2231
- const fetched = missingPaths.length > 0 ? await this.transport.getObjects({ repo_id: this.repoId, paths: missingPaths }) : [];
2443
+ missingExpected.push(rootsReceipt.ciphertext_sha256);
2444
+ }
2445
+ const fetched = missingPaths.length > 0 ? await this.transport.getObjects({ repo_id: this.repoId, paths: missingPaths, expected: missingExpected }) : [];
2232
2446
  let next = 0;
2233
2447
  const refStateFrame = refStateHit ? cachedRefState.bytes : (refStatePre ?? fetched[next++] ?? null);
2234
2448
  const rootsFrame = rootsHit ? cachedRoots.bytes : (rootsPre ?? fetched[next++] ?? null);
@@ -2258,7 +2472,7 @@ export class GitvaultVault {
2258
2472
  */
2259
2473
  async materialize(options = {}) {
2260
2474
  const persist = options.persist ?? true;
2261
- const state = await this.verifyToNewest({ persist, decryptValidate: true, strict: true, deltaSince: options.deltaSince });
2475
+ const state = await this.verifyToNewest({ persist, decryptValidate: true, strict: true, deltaSince: options.deltaSince, restore: options.restore });
2262
2476
  if (!state.head) {
2263
2477
  if (persist)
2264
2478
  this.keystore.updateRepo(this.repoId, { materialized_pin: { generation: state.generation, head_sha256: state.head_sha256, pinned_at: formatGitvaultTimestamp(this.now()) } });
@@ -2379,12 +2593,14 @@ export class GitvaultVault {
2379
2593
  created_at: formatGitvaultTimestamp(this.now()),
2380
2594
  });
2381
2595
  try {
2596
+ const byo = await this.resolveByoWriteTarget();
2382
2597
  await this.transport.putObject({
2383
2598
  repo_id: this.repoId,
2384
2599
  path: gitvaultPaths.envelope(epoch, sealed.receipt.recipient_fingerprint),
2385
2600
  bytes: sealed.stored_bytes,
2386
2601
  expected_sha256: sealed.stored_bytes_sha256,
2387
2602
  expected_size_bytes: sealed.size_bytes,
2603
+ ...(byo ? { byo } : {}),
2388
2604
  });
2389
2605
  wrapped.push({ principal_id: entry.principal_id, ek_fingerprint: entry.ek_fingerprint });
2390
2606
  }
@@ -2657,7 +2873,7 @@ export class GitvaultVault {
2657
2873
  fail("CHECKPOINT_INCOMPLETE", `head ${head.generation} carries no checkpoint to verify`, "verifying a stored checkpoint", { generation: head.generation });
2658
2874
  const { genesis } = await this.genesis();
2659
2875
  const writerKey = genesis.creator_signing_pubkey;
2660
- const claimBytes = await this.transport.getObject({ repo_id: this.repoId, path: gitvaultPaths.claimSet(block.claim_set.object_id) });
2876
+ const claimBytes = await this.transport.getObject({ repo_id: this.repoId, path: gitvaultPaths.claimSet(block.claim_set.object_id), expected_sha256: block.claim_set.stored_bytes_sha256 });
2661
2877
  if (!claimBytes || sha256Hex(claimBytes) !== block.claim_set.stored_bytes_sha256) {
2662
2878
  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 });
2663
2879
  }
@@ -2673,7 +2889,7 @@ export class GitvaultVault {
2673
2889
  try {
2674
2890
  await hardenedGit(scratch, ["init", "-q", "--bare", "--object-format=sha1", "."]);
2675
2891
  for (const p of manifest.packs) {
2676
- const frame = await this.transport.getObject({ repo_id: this.repoId, path: gitvaultPaths.checkpointPack(p.object_id) });
2892
+ const frame = await this.transport.getObject({ repo_id: this.repoId, path: gitvaultPaths.checkpointPack(p.object_id), expected_sha256: p.ciphertext_sha256 });
2677
2893
  if (!frame)
2678
2894
  fail("CHECKPOINT_INCOMPLETE", `checkpoint pack ${p.object_id} is absent from storage`, "verifying a stored checkpoint", { object_id: p.object_id });
2679
2895
  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 });
@@ -2737,7 +2953,7 @@ export class GitvaultVault {
2737
2953
  // Plaintext-structured and stored-bytes-receipted: no decryption, but
2738
2954
  // the hash and the owner signature are still checked before a single
2739
2955
  // pack receipt inside it is believed.
2740
- const bytes = await this.transport.getObject({ repo_id: this.repoId, path: gitvaultPaths.claimSet(block.claim_set.object_id) });
2956
+ const bytes = await this.transport.getObject({ repo_id: this.repoId, path: gitvaultPaths.claimSet(block.claim_set.object_id), expected_sha256: block.claim_set.stored_bytes_sha256 });
2741
2957
  if (!bytes || sha256Hex(bytes) !== block.claim_set.stored_bytes_sha256) {
2742
2958
  fail("CHECKPOINT_INCOMPLETE", `checkpoint claim set ${block.claim_set.object_id} (generation ${gen}) is absent or altered`, "walking the gitvault chain", { generation: gen, object_id: block.claim_set.object_id });
2743
2959
  }
@@ -2751,8 +2967,27 @@ export class GitvaultVault {
2751
2967
  return out;
2752
2968
  }
2753
2969
  // ── upload with receipt-compare ──
2970
+ /**
2971
+ * gitvault-byo-primary-bucket task 3.2 — resolve (once, cached) this
2972
+ * machine's local BYO write config for THIS vault. `null` when none is
2973
+ * configured locally — either because this is an ordinary managed vault
2974
+ * (the common case; deliberately never confirmed with a network call, so
2975
+ * a managed vault's push pays zero extra round trips), or because this
2976
+ * machine has not been configured to write a BYO vault it nonetheless
2977
+ * belongs to (surfaced downstream as `GITVAULT_BYO_BUCKET_WRITE_REFUSED`
2978
+ * when the session actually names a `put: null` object with no target).
2979
+ */
2980
+ async resolveByoWriteTarget() {
2981
+ if (this.byoResolution !== undefined)
2982
+ return this.byoResolution;
2983
+ const { readByoConfig } = await import("./gitvault-byo-config.js");
2984
+ const local = readByoConfig(this.keystore, this.repoId);
2985
+ this.byoResolution = local ? { destination: local.destination, ...(local.credential ? { credential: local.credential } : {}) } : null;
2986
+ return this.byoResolution;
2987
+ }
2754
2988
  async uploadAll(objects) {
2755
- const receipts = await this.transport.uploadObjects({ repo_id: this.repoId, objects });
2989
+ const byo = await this.resolveByoWriteTarget();
2990
+ const receipts = await this.transport.uploadObjects({ repo_id: this.repoId, objects, ...(byo ? { byo } : {}) });
2756
2991
  if (receipts.length !== objects.length)
2757
2992
  fail("GITVAULT_RECEIPT_MISMATCH", `${receipts.length} receipts for ${objects.length} objects`, "comparing finalization receipts");
2758
2993
  const mismatches = objects.filter((o, i) => receipts[i].path !== o.path || receipts[i].sha256 !== o.sha256 || receipts[i].size_bytes !== o.size_bytes);
@@ -3078,7 +3313,7 @@ export class GitvaultVault {
3078
3313
  if (known && known.pin_manifest_version === receipt.pin_manifest_version && known.stored_bytes_sha256 === receipt.stored_bytes_sha256) {
3079
3314
  return { pinManifestVersion: known.pin_manifest_version, pinManifestSha256: known.stored_bytes_sha256, pinnedFingerprintOf: new Map(known.pins.map((p) => [p.principal_id, p.ek_fingerprint])) };
3080
3315
  }
3081
- const bytes = await this.transport.getObject({ repo_id: this.repoId, path: gitvaultPaths.pinManifest(receipt.pin_manifest_version) });
3316
+ const bytes = await this.transport.getObject({ repo_id: this.repoId, path: gitvaultPaths.pinManifest(receipt.pin_manifest_version), expected_sha256: receipt.stored_bytes_sha256 });
3082
3317
  if (!bytes || sha256Hex(bytes) !== receipt.stored_bytes_sha256) {
3083
3318
  fail("GITVAULT_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 });
3084
3319
  }
@@ -3658,7 +3893,16 @@ export class GitvaultVault {
3658
3893
  // reuse therefore costs one extra read, never a wrong result.
3659
3894
  const marker = await readGitvaultRestoreMarker(targetRepoDir);
3660
3895
  const markerMatches = (a, b) => a === null ? b === null : b !== null && a.generation === b.generation && a.head_sha256 === b.head_sha256;
3661
- const newest = reuse && markerMatches(reuse.marker, marker) ? reuse.state : await this.materialize({ ...(marker ? { deltaSince: marker.generation } : {}) });
3896
+ // gitvault-restore-recipe design D2/D6: a marker-absent materialize is,
3897
+ // by construction, the wholesale shape (a fresh target has nothing to
3898
+ // replay incrementally) — declare restore intent here so a capable
3899
+ // gateway's plan rides THIS read rather than a second one. Mirrors the
3900
+ // remote-helper session's own `list`-phase decision (`runList` in
3901
+ // `cli/lib/remote-helper-session.mjs`); this branch only fires when
3902
+ // `reuse` was absent or mismatched (no prior `list` in this session, or
3903
+ // a direct SDK caller), so the PRIMARY clone path never pays for this —
3904
+ // it rides the reused state instead.
3905
+ const newest = reuse && markerMatches(reuse.marker, marker) ? reuse.state : await this.materialize({ ...(marker ? { deltaSince: marker.generation } : { restore: true }) });
3662
3906
  if (!newest.head) {
3663
3907
  const retained_refs = await reconcileRetainedTipRefs(targetRepoDir, { refs: {}, roots: [], head_target: newest.head_target });
3664
3908
  return { refs: {}, head_target: newest.head_target, generation: newest.generation, retained_refs };
@@ -3676,6 +3920,17 @@ export class GitvaultVault {
3676
3920
  const retained_refs = await reconcileRetainedTipRefs(targetRepoDir, { refs: newest.refs, roots: newest.roots, head_target: newest.head_target });
3677
3921
  return { refs: newest.refs, head_target: newest.head_target, generation: newest.generation, retained_refs };
3678
3922
  }
3923
+ // gitvault-restore-recipe (design D1-D6): consume a plan that already
3924
+ // rode this method's OWN materialize() (the `restore: true` request
3925
+ // above, or the reused list-phase read) BEFORE paying for the backward
3926
+ // walk below at all. `tryConsumeRestorePlan` verifies from scratch and
3927
+ // returns `null` on absence or ANY failure — the walk below is the
3928
+ // unconditional fallback, byte-identical to before this change.
3929
+ {
3930
+ const applied = await this.tryConsumeRestorePlan(targetRepoDir, newest, writerKey);
3931
+ if (applied)
3932
+ return applied;
3933
+ }
3679
3934
  // Walk back from newest, trying for an INCREMENTAL boundary at the
3680
3935
  // marker (a pure local prev_sha256 comparison — no fetch needed to
3681
3936
  // CONFIRM the boundary itself); abandon incremental the moment a
@@ -3750,6 +4005,27 @@ export class GitvaultVault {
3750
4005
  incremental = false;
3751
4006
  heads.length = 0;
3752
4007
  cur = newest.head;
4008
+ // gitvault-restore-recipe (design D1/D2, task 2.1's named fallback
4009
+ // shape): the marker WAS present when this method's own materialize()
4010
+ // ran, so `restore` was never declared and no plan rode that read —
4011
+ // fetch one now, dedicated, before falling through to the ordinary
4012
+ // wholesale walk. Best-effort: an older gateway, a network fault, a
4013
+ // disqualification, or a verification failure all just continue the
4014
+ // loop exactly as before this change; nothing here can make the
4015
+ // restore fail that would otherwise have succeeded.
4016
+ if (!this.stateRestorePlan) {
4017
+ try {
4018
+ const resp = await this.transport.getState({ repo_id: this.repoId, restore: true });
4019
+ if (resp.restore_plan)
4020
+ this.stateRestorePlan = resp.restore_plan;
4021
+ }
4022
+ catch {
4023
+ // fall through to the wholesale walk
4024
+ }
4025
+ }
4026
+ const applied = await this.tryConsumeRestorePlan(targetRepoDir, newest, writerKey);
4027
+ if (applied)
4028
+ return applied;
3753
4029
  continue;
3754
4030
  }
3755
4031
  heads.unshift(cur);
@@ -3773,6 +4049,32 @@ export class GitvaultVault {
3773
4049
  fail("CHAIN_BROKEN", `head ${prevGen} does not match the chain during restore`, "restoring gitvault objects");
3774
4050
  cur = parseGitvaultStrict(new TextDecoder().decode(bytes));
3775
4051
  }
4052
+ return this.applyRestoreHeads(targetRepoDir, heads, incremental, newest, writerKey, null, null);
4053
+ }
4054
+ /**
4055
+ * The shared restore tail (gitvault-restore-recipe): apply `heads`'
4056
+ * checkpoint (if any) + WAL packs into `targetRepoDir`, verify §4.7
4057
+ * coverage, reconcile retained refs, and advance the marker — IDENTICALLY
4058
+ * whether `heads` came from {@link restoreObjectsInto}'s own backward walk
4059
+ * or a verified restore plan ({@link tryConsumeRestorePlan}). `incremental`
4060
+ * only affects the checkpoint-coverage LEARNING step (an incremental walk
4061
+ * stops at the marker and learns nothing; a plan-derived call always
4062
+ * passes `false` — a plan is the wholesale shape by construction).
4063
+ *
4064
+ * `precheckedCheckpoint`, when non-null, is a claim set + manifest the
4065
+ * caller ALREADY verified (signature, cross-equality — {@link
4066
+ * verifyRestorePlan}) — skips the network fetch + re-verify this method
4067
+ * would otherwise run for `heads[0].checkpoint`. `planPacks`, when
4068
+ * non-null, is a map of pack bytes the caller already has (untrusted —
4069
+ * only used when its OWN hash matches the carrying head's/manifest's
4070
+ * receipt, exactly `stateDeltaPacks`'s discipline), keyed
4071
+ * `${object_kind}:${object_id}` so it covers BOTH WAL and checkpoint
4072
+ * packs (`stateDeltaPacks` never carries checkpoint packs — a delta span
4073
+ * never crosses a checkpoint boundary). Both are `null` on the ordinary
4074
+ * walk path, in which case every line below is byte-identical to the
4075
+ * pre-gitvault-restore-recipe shape.
4076
+ */
4077
+ async applyRestoreHeads(targetRepoDir, heads, incremental, newest, writerKey, precheckedCheckpoint, planPacks) {
3776
4078
  // gitvault-clone-scaling (P3): a WHOLESALE walk just stopped at the
3777
4079
  // newest checkpoint-bearing head, or proved there is none back to
3778
4080
  // genesis — that is first-hand checkpoint coverage, and recording it is
@@ -3798,22 +4100,24 @@ export class GitvaultVault {
3798
4100
  // map `materialize()` just resolved (throwing `GITVAULT_EPOCH_NOT_OPENABLE`
3799
4101
  // fail-closed if any needed epoch could not be opened), so every lookup
3800
4102
  // below is guaranteed present.
3801
- const kRepoForEpoch = (epoch) => {
3802
- const hex = newest.epoch_keys_hex[epoch];
3803
- if (!hex)
3804
- fail("GITVAULT_EPOCH_NOT_OPENABLE", `no locally known key for epoch ${epoch} while restoring objects`, "restoring gitvault objects", { epoch });
3805
- return hexToBytes(hex);
3806
- };
4103
+ const kRepoForEpoch = (epoch) => this.epochKeyFor(newest, epoch);
3807
4104
  const first = heads[0];
3808
4105
  // gitvault-pipelined-restore: per-object settlement when the transport
3809
4106
  // offers it, the `getObjects` barrier otherwise — pipelining is a
3810
4107
  // wall-clock property, never a correctness dependency, so a transport
3811
4108
  // without the method reproduces today's serial-after-barrier behavior
3812
4109
  // exactly (each per-index promise settles when the whole batch does).
3813
- const settled = async (paths) => {
4110
+ //
4111
+ // `expected` (gitvault-small-object-inline design D3) is index-aligned
4112
+ // with `paths` — every caller below already knows each object's
4113
+ // receipted ciphertext hash BEFORE fetching it, so it rides along here
4114
+ // to let an `object-reads`-backed transport verify (and, on a lying
4115
+ // reply, discard) an `inline` bytes offer per slot. Not required: an
4116
+ // omitted `expected` is byte-identical to before this change.
4117
+ const settled = async (paths, expected) => {
3814
4118
  if (this.transport.getObjectsSettled)
3815
- return this.transport.getObjectsSettled({ repo_id: this.repoId, paths });
3816
- const all = this.transport.getObjects({ repo_id: this.repoId, paths });
4119
+ return this.transport.getObjectsSettled({ repo_id: this.repoId, paths, expected });
4120
+ const all = this.transport.getObjects({ repo_id: this.repoId, paths, expected });
3817
4121
  const perIndex = paths.map((_, i) => all.then((frames) => frames[i] ?? null));
3818
4122
  // Mark every derived promise handled — the consumer awaits them in
3819
4123
  // order and stops at the first failure, abandoning the tail.
@@ -3835,7 +4139,12 @@ export class GitvaultVault {
3835
4139
  // skip the network ENTIRELY — but only after hashing to their carrying
3836
4140
  // head's receipt (a lying delta is a plain miss; the ordinary fetch
3837
4141
  // below reproduces the unbatched behavior for that slot). The buffer is
3838
- // consumed exactly once.
4142
+ // consumed exactly once. gitvault-restore-recipe extends the SAME
4143
+ // short-circuit to `planPacks` (a verified plan's own pack bytes) —
4144
+ // checked SECOND so a delta hit never gets shadowed by a plan hit for
4145
+ // the same slot (the two never coexist in practice, per `getState`'s
4146
+ // mutual-exclusivity between `since` and `restore` on one call, but
4147
+ // checking order stays deterministic either way).
3839
4148
  const deltaPacks = this.stateDeltaPacks;
3840
4149
  this.stateDeltaPacks = null;
3841
4150
  const deltaCovered = new Map();
@@ -3846,31 +4155,62 @@ export class GitvaultVault {
3846
4155
  deltaCovered.set(i, bytes);
3847
4156
  });
3848
4157
  }
4158
+ if (planPacks) {
4159
+ walEntries.forEach(({ w }, i) => {
4160
+ if (deltaCovered.has(i))
4161
+ return;
4162
+ const bytes = planPacks.get(`wal_pack:${w.object_id}`);
4163
+ if (bytes && sha256Hex(bytes) === w.ciphertext_sha256)
4164
+ deltaCovered.set(i, bytes);
4165
+ });
4166
+ }
3849
4167
  const uncovered = walEntries.map((_, i) => i).filter((i) => !deltaCovered.has(i));
3850
4168
  const walFramesP = (async () => {
3851
- const fetched = uncovered.length > 0 ? await settled(uncovered.map((i) => gitvaultPaths.wal(walEntries[i].w.object_id))) : [];
4169
+ const fetched = uncovered.length > 0 ? await settled(uncovered.map((i) => gitvaultPaths.wal(walEntries[i].w.object_id)), uncovered.map((i) => walEntries[i].w.ciphertext_sha256)) : [];
3852
4170
  return walEntries.map((_, i) => (deltaCovered.has(i) ? Promise.resolve(deltaCovered.get(i)) : fetched[uncovered.indexOf(i)]));
3853
4171
  })();
3854
4172
  void walFramesP.catch(() => { });
3855
4173
  if (first.checkpoint) {
3856
- const claimBytes = await this.transport.getObject({ repo_id: this.repoId, path: gitvaultPaths.claimSet(first.checkpoint.claim_set.object_id) });
3857
- if (!claimBytes || sha256Hex(claimBytes) !== first.checkpoint.claim_set.stored_bytes_sha256)
3858
- fail("CHECKPOINT_INCOMPLETE", "claim set absent or altered", "restoring gitvault objects");
3859
- const claimSet = parseGitvaultStrict(new TextDecoder().decode(claimBytes));
3860
- if (!verifyGitvaultObject(claimSet, writerKey))
3861
- fail("CHECKPOINT_INCOMPLETE", "claim set signature fails", "restoring gitvault objects");
3862
- const manifest = await this.openCarrier("checkpoint_manifest", claimSet.manifest_receipt, gitvaultPaths.checkpointManifest(claimSet.manifest_receipt.object_id), writerKey, { epoch: first.epoch, k_repo: kRepoForEpoch(first.epoch) });
3863
- checkClaimSetEquality(claimSet, manifest, first.checkpoint.covers_through_generation);
4174
+ let claimSet;
4175
+ let manifest;
4176
+ if (precheckedCheckpoint) {
4177
+ // gitvault-restore-recipe: already fetched + verified (signature,
4178
+ // cross-equality) by `verifyRestorePlan` against the plan's own
4179
+ // inline bytes no network read, no re-verify.
4180
+ ({ claimSet, manifest } = precheckedCheckpoint);
4181
+ }
4182
+ else {
4183
+ const claimBytes = await this.transport.getObject({ repo_id: this.repoId, path: gitvaultPaths.claimSet(first.checkpoint.claim_set.object_id), expected_sha256: first.checkpoint.claim_set.stored_bytes_sha256 });
4184
+ if (!claimBytes || sha256Hex(claimBytes) !== first.checkpoint.claim_set.stored_bytes_sha256)
4185
+ fail("CHECKPOINT_INCOMPLETE", "claim set absent or altered", "restoring gitvault objects");
4186
+ claimSet = parseGitvaultStrict(new TextDecoder().decode(claimBytes));
4187
+ if (!verifyGitvaultObject(claimSet, writerKey))
4188
+ fail("CHECKPOINT_INCOMPLETE", "claim set signature fails", "restoring gitvault objects");
4189
+ manifest = await this.openCarrier("checkpoint_manifest", claimSet.manifest_receipt, gitvaultPaths.checkpointManifest(claimSet.manifest_receipt.object_id), writerKey, { epoch: first.epoch, k_repo: kRepoForEpoch(first.epoch) });
4190
+ checkClaimSetEquality(claimSet, manifest, first.checkpoint.covers_through_generation);
4191
+ }
3864
4192
  // Design D2 + gitvault-pipelined-restore D1: every checkpoint pack is
3865
4193
  // independent — one batched presign for all of them, applied via
3866
4194
  // index-pack strictly in manifest order, PIPELINED: apply(i) awaits
3867
4195
  // bytes(i), so decrypt/verify/index of an early pack overlaps the
3868
4196
  // later packs' downloads. Per-pack verification (AEAD open + plaintext
3869
- // hash) still completes before any byte reaches git.
3870
- const frames = await settled(manifest.packs.map((p) => gitvaultPaths.checkpointPack(p.object_id)));
4197
+ // hash) still completes before any byte reaches git. gitvault-restore-
4198
+ // recipe: a `planPacks` hit skips the fetch for that index entirely —
4199
+ // the SAME short-circuit the WAL loop above runs, extended to the
4200
+ // checkpoint class (never carried by `stateDeltaPacks`).
4201
+ const checkpointCovered = new Map();
4202
+ if (planPacks) {
4203
+ manifest.packs.forEach((p, i) => {
4204
+ const bytes = planPacks.get(`checkpoint_pack:${p.object_id}`);
4205
+ if (bytes && sha256Hex(bytes) === p.ciphertext_sha256)
4206
+ checkpointCovered.set(i, bytes);
4207
+ });
4208
+ }
4209
+ const uncoveredCk = manifest.packs.map((_, i) => i).filter((i) => !checkpointCovered.has(i));
4210
+ const ckFrames = uncoveredCk.length > 0 ? await settled(uncoveredCk.map((i) => gitvaultPaths.checkpointPack(manifest.packs[i].object_id)), uncoveredCk.map((i) => manifest.packs[i].ciphertext_sha256)) : [];
3871
4211
  for (let i = 0; i < manifest.packs.length; i++) {
3872
4212
  const p = manifest.packs[i];
3873
- const frame = (await frames[i]) ?? null;
4213
+ const frame = checkpointCovered.has(i) ? checkpointCovered.get(i) : ((await ckFrames[uncoveredCk.indexOf(i)]) ?? null);
3874
4214
  if (!frame)
3875
4215
  fail("CHECKPOINT_INCOMPLETE", `checkpoint pack ${p.object_id} absent`, "restoring gitvault objects");
3876
4216
  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 });
@@ -3918,6 +4258,152 @@ export class GitvaultVault {
3918
4258
  await writeGitvaultRestoreMarker(targetRepoDir, newest.generation, newest.head_sha256);
3919
4259
  return { refs: newest.refs, head_target: newest.head_target, generation: newest.generation, retained_refs };
3920
4260
  }
4261
+ /**
4262
+ * `newest.epoch_keys_hex[epoch]`, decoded — the D194 per-carrying-head key
4263
+ * lookup shared by {@link applyRestoreHeads} and {@link verifyRestorePlan}.
4264
+ * `newest.epoch_keys_hex` is the FULL map `materialize()` resolved
4265
+ * (throwing `GITVAULT_EPOCH_NOT_OPENABLE` fail-closed if any needed epoch
4266
+ * could not be opened), so every lookup through this helper is guaranteed
4267
+ * present.
4268
+ */
4269
+ epochKeyFor(newest, epoch) {
4270
+ const hex = newest.epoch_keys_hex[epoch];
4271
+ if (!hex)
4272
+ fail("GITVAULT_EPOCH_NOT_OPENABLE", `no locally known key for epoch ${epoch} while restoring objects`, "restoring gitvault objects", { epoch });
4273
+ return hexToBytes(hex);
4274
+ }
4275
+ /**
4276
+ * Verify a restore plan's heads (self-consistency + backward chain-link +
4277
+ * cross-check against the caller's own already-verified `newest`, plus
4278
+ * genesis/boundary linkage) and, when the boundary carries a checkpoint,
4279
+ * its claim set + manifest (signature, cross-equality) — the SAME
4280
+ * obligations {@link applyRestoreHeads}'s ordinary path runs, against
4281
+ * plan-supplied bytes instead of network-fetched ones (design D5: "the
4282
+ * plan is transport, never trust"). Returns `null` on ANY failure — a
4283
+ * self-inconsistent head, a broken link, a wrong newest, a bad signature,
4284
+ * a cross-equality mismatch, anything the reused `decodeCarrierFrame`/
4285
+ * `checkClaimSetEquality` reject — rather than throwing, so the caller's
4286
+ * fallback to the ordinary walk is unconditional and silent, mirroring
4287
+ * `assembleVaultStateDelta`'s own disqualification posture server-side.
4288
+ *
4289
+ * Heads that verify their OWN chain link (Stage A) are cache-warmed into
4290
+ * {@link walkPrefetch} BEFORE Stage B (the checkpoint) runs, so a
4291
+ * checkpoint failure still leaves the fallback walk with every head this
4292
+ * call already proved — the D3 cache-hit discipline, extended to a failed
4293
+ * plan.
4294
+ */
4295
+ async verifyRestorePlan(plan, newest, writerKey) {
4296
+ // Every disqualification below is a plain `return null` — never `throw
4297
+ // new Error(...)` (the public-SDK plain-`Error` contract) — so a manual
4298
+ // `for` loop replaces `plan.heads.map(...)`: a `.map()` callback can only
4299
+ // fail its OWN element, never abort the whole computation, and this
4300
+ // function's contract is "any bad element disqualifies the WHOLE plan."
4301
+ // The try/catch below still stands, to catch the genuine exceptions
4302
+ // `parseGitvaultStrict`/`decodeCarrierFrame`/`checkClaimSetEquality`/
4303
+ // `verifyGitvaultObject` can raise (a `fail()`-thrown `Run402Error`, or a
4304
+ // strict-parse rejection) — those are real errors, just ones this method
4305
+ // treats as "the plan didn't verify" rather than propagating.
4306
+ try {
4307
+ if (plan.heads.length === 0)
4308
+ return null;
4309
+ const parsed = [];
4310
+ for (const h of plan.heads) {
4311
+ if (sha256Hex(h.stored_bytes) !== h.stored_bytes_sha256)
4312
+ return null; // self-hash mismatch
4313
+ const head = parseGitvaultStrict(new TextDecoder().decode(h.stored_bytes));
4314
+ if (head.generation !== h.generation)
4315
+ return null; // generation mismatch
4316
+ parsed.push({ generation: h.generation, bytes: h.stored_bytes, head });
4317
+ }
4318
+ for (let i = 1; i < parsed.length; i++) {
4319
+ if (generationToBigInt(parsed[i].generation) !== generationToBigInt(parsed[i - 1].generation) + 1n)
4320
+ return null; // span not contiguous
4321
+ }
4322
+ const last = parsed[parsed.length - 1];
4323
+ if (last.generation !== newest.generation || sha256Hex(last.bytes) !== newest.head_sha256)
4324
+ return null; // does not match the caller's own verified newest
4325
+ // Backward chain-link, newest to boundary — the SAME hash-equality
4326
+ // check `applyRestoreHeads`'s own backward walk runs (no signature
4327
+ // re-check here: the chain was already fully verified FORWARD by
4328
+ // `materialize()`/`verifyToNewest()` before this ever runs — see that
4329
+ // method's own doc comment on why its backward walk is a hash-only
4330
+ // re-confirmation, not a re-verification).
4331
+ for (let i = parsed.length - 1; i >= 1; i--) {
4332
+ if (parsed[i].head.prev_sha256 !== sha256Hex(parsed[i - 1].bytes))
4333
+ return null; // chain link broken
4334
+ }
4335
+ const first = parsed[0];
4336
+ if (plan.boundary_generation === GITVAULT_GENESIS_GENERATION) {
4337
+ if (first.generation !== "0000000000000001")
4338
+ return null; // genesis boundary mismatch
4339
+ const { sha256: genesisSha } = await this.genesis();
4340
+ if (first.head.prev_sha256 !== genesisSha)
4341
+ return null; // generation 1 does not link to genesis
4342
+ }
4343
+ else {
4344
+ if (first.generation !== plan.boundary_generation || !first.head.checkpoint)
4345
+ return null; // boundary does not carry a checkpoint
4346
+ }
4347
+ // Stage A verified — cache-warm every plan head regardless of Stage B's
4348
+ // outcome below (D3: a failed plan still costs the fallback nothing it
4349
+ // already proved).
4350
+ if (!this.walkPrefetch)
4351
+ this.walkPrefetch = new Map();
4352
+ for (const p of parsed)
4353
+ this.walkPrefetch.set(gitvaultPaths.head(p.generation), p.bytes);
4354
+ let checkpoint = null;
4355
+ if (first.head.checkpoint) {
4356
+ if (!plan.checkpoint)
4357
+ return null; // boundary head carries a checkpoint but the plan carries none
4358
+ const claimReceipt = first.head.checkpoint.claim_set;
4359
+ if (plan.checkpoint.claim_set.object_id !== claimReceipt.object_id)
4360
+ return null; // claim set id mismatch
4361
+ if (sha256Hex(plan.checkpoint.claim_set.stored_bytes) !== claimReceipt.stored_bytes_sha256)
4362
+ return null; // claim set hash mismatch
4363
+ const claimSet = parseGitvaultStrict(new TextDecoder().decode(plan.checkpoint.claim_set.stored_bytes));
4364
+ if (!verifyGitvaultObject(claimSet, writerKey))
4365
+ return null; // claim set signature fails
4366
+ if (claimSet.manifest_receipt.object_id !== plan.checkpoint.manifest.object_id)
4367
+ return null; // manifest id mismatch
4368
+ // Reuses `decodeCarrierFrame` verbatim — ciphertext hash, AEAD open,
4369
+ // and identity/signature checks all run exactly as they would for a
4370
+ // network-fetched manifest frame; the only difference is where the
4371
+ // ciphertext bytes came from.
4372
+ const manifest = this.decodeCarrierFrame("checkpoint_manifest", claimSet.manifest_receipt, plan.checkpoint.manifest.stored_bytes, writerKey, { epoch: first.head.epoch, k_repo: this.epochKeyFor(newest, first.head.epoch) });
4373
+ checkClaimSetEquality(claimSet, manifest, first.head.checkpoint.covers_through_generation);
4374
+ checkpoint = { claimSet, manifest };
4375
+ }
4376
+ return { heads: parsed.map((p) => p.head), checkpoint };
4377
+ }
4378
+ catch {
4379
+ return null;
4380
+ }
4381
+ }
4382
+ /**
4383
+ * Consume a stashed restore plan (gitvault-restore-recipe design D1-D6):
4384
+ * verify it ({@link verifyRestorePlan}) and, on success, apply it via the
4385
+ * SAME shared tail the backward walk uses ({@link applyRestoreHeads}) —
4386
+ * `null` on absence or ANY verification failure, the caller's cue to run
4387
+ * (or continue) the ordinary walk. Clears `this.stateRestorePlan`
4388
+ * unconditionally: a plan is single-use whether it verified or not (a
4389
+ * failed plan's USABLE heads already rode into `walkPrefetch` inside
4390
+ * `verifyRestorePlan` itself).
4391
+ */
4392
+ async tryConsumeRestorePlan(targetRepoDir, newest, writerKey) {
4393
+ const plan = this.stateRestorePlan;
4394
+ this.stateRestorePlan = null;
4395
+ if (!plan)
4396
+ return null;
4397
+ const verified = await this.verifyRestorePlan(plan, newest, writerKey);
4398
+ if (!verified)
4399
+ return null;
4400
+ const planPacks = new Map();
4401
+ for (const p of plan.packs) {
4402
+ if (p.bytes)
4403
+ planPacks.set(`${p.object_kind}:${p.object_id}`, p.bytes);
4404
+ }
4405
+ return this.applyRestoreHeads(targetRepoDir, verified.heads, false, newest, writerKey, verified.checkpoint, planPacks);
4406
+ }
3921
4407
  // ── compaction headroom grant (gitvault-checkpoint-cadence design D3) ──
3922
4408
  /** Thin passthrough to the transport — see {@link GitvaultTransport.openCompactionGrant}. */
3923
4409
  async openCompactionGrant() {