run402 4.64.0 → 4.66.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 (33) hide show
  1. package/gitvault-surface.json +1 -1
  2. package/lib/gitvault-daemon.mjs +30 -1
  3. package/lib/remote-helper-session.mjs +30 -3
  4. package/package.json +1 -1
  5. package/sdk/dist/namespaces/gitvault.d.ts.map +1 -1
  6. package/sdk/dist/namespaces/gitvault.js +8 -1
  7. package/sdk/dist/namespaces/gitvault.js.map +1 -1
  8. package/sdk/dist/node/gitvault-address.d.ts +41 -10
  9. package/sdk/dist/node/gitvault-address.d.ts.map +1 -1
  10. package/sdk/dist/node/gitvault-address.js +51 -10
  11. package/sdk/dist/node/gitvault-address.js.map +1 -1
  12. package/sdk/dist/node/gitvault-creation-journal.d.ts +12 -1
  13. package/sdk/dist/node/gitvault-creation-journal.d.ts.map +1 -1
  14. package/sdk/dist/node/gitvault-creation-journal.js.map +1 -1
  15. package/sdk/dist/node/gitvault-keystore.d.ts +66 -1
  16. package/sdk/dist/node/gitvault-keystore.d.ts.map +1 -1
  17. package/sdk/dist/node/gitvault-keystore.js +100 -0
  18. package/sdk/dist/node/gitvault-keystore.js.map +1 -1
  19. package/sdk/dist/node/gitvault-mirror.d.ts.map +1 -1
  20. package/sdk/dist/node/gitvault-mirror.js +16 -1
  21. package/sdk/dist/node/gitvault-mirror.js.map +1 -1
  22. package/sdk/dist/node/gitvault-prewarm.d.ts +31 -0
  23. package/sdk/dist/node/gitvault-prewarm.d.ts.map +1 -1
  24. package/sdk/dist/node/gitvault-prewarm.js +67 -0
  25. package/sdk/dist/node/gitvault-prewarm.js.map +1 -1
  26. package/sdk/dist/node/gitvault-publication.d.ts +176 -5
  27. package/sdk/dist/node/gitvault-publication.d.ts.map +1 -1
  28. package/sdk/dist/node/gitvault-publication.js +475 -71
  29. package/sdk/dist/node/gitvault-publication.js.map +1 -1
  30. package/sdk/dist/node/index.d.ts +1 -1
  31. package/sdk/dist/node/index.d.ts.map +1 -1
  32. package/sdk/dist/node/index.js +1 -1
  33. package/sdk/dist/node/index.js.map +1 -1
@@ -634,6 +634,35 @@ export function gitvaultLedgerId(read) {
634
634
  return pinManifestLedgerId(read.pin_manifest_version);
635
635
  return String(read.object_id);
636
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
+ }
637
666
  function b64(bytes) { return Buffer.from(bytes).toString("base64"); }
638
667
  function b64u(bytes) { return Buffer.from(bytes).toString("base64url"); }
639
668
  /**
@@ -706,8 +735,45 @@ export function createGitvaultHttpTransport(client, options = {}) {
706
735
  }
707
736
  return new Uint8Array(await r.arrayBuffer());
708
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
+ }
709
775
  /** Presign + fetch one object by its ledger identity (`POST …/object-reads`). */
710
- async function getObjectBytes(repoId, path) {
776
+ async function getObjectBytes(repoId, path, expectedSha256) {
711
777
  const ref = gitvaultWireRefForPath(path);
712
778
  if (!ref)
713
779
  fail("GITVAULT_OBJECT_READ_FAILED", `${path} has no control-plane wire identity; it is not a readable vault object`, "reading gitvault object", { path });
@@ -742,12 +808,7 @@ export function createGitvaultHttpTransport(client, options = {}) {
742
808
  const target = presigned.reads[0];
743
809
  if (!target)
744
810
  return null;
745
- const r = await fetchGitvaultObjectBytes(client, target);
746
- if (r.status === 404)
747
- return null;
748
- if (!r.ok)
749
- fail("GITVAULT_OBJECT_READ_FAILED", `object GET failed (HTTP ${r.status}) for ${path}`, "reading gitvault object", { path, status: r.status });
750
- return new Uint8Array(await r.arrayBuffer());
811
+ return resolveObjectReadTarget(repoId, target, expectedSha256, path);
751
812
  }
752
813
  /**
753
814
  * Presign + fetch N independent objects (gitvault-client-round-trips
@@ -759,7 +820,7 @@ export function createGitvaultHttpTransport(client, options = {}) {
759
820
  * restore's carrier and pack reads, so it fails closed rather than
760
821
  * silently costing an extra round trip through `getGenerationBytes`.
761
822
  */
762
- async function getObjectsBytes(repoId, paths) {
823
+ async function getObjectsBytes(repoId, paths, expected) {
763
824
  if (paths.length === 0)
764
825
  return [];
765
826
  const targets = await presignObjectBatch(repoId, paths);
@@ -768,12 +829,7 @@ export function createGitvaultHttpTransport(client, options = {}) {
768
829
  return mapBounded(targets, GITVAULT_TRANSPORT_CONCURRENCY, async (target, i) => {
769
830
  if (!target)
770
831
  return null;
771
- const r = await fetchGitvaultObjectBytes(client, target);
772
- if (r.status === 404)
773
- return null;
774
- if (!r.ok)
775
- fail("GITVAULT_OBJECT_READ_FAILED", `object GET failed (HTTP ${r.status}) for ${paths[i]}`, "reading gitvault object", { path: paths[i], status: r.status });
776
- return new Uint8Array(await r.arrayBuffer());
832
+ return resolveObjectReadTarget(repoId, target, expected?.[i], paths[i]);
777
833
  });
778
834
  }
779
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. */
@@ -803,10 +859,12 @@ export function createGitvaultHttpTransport(client, options = {}) {
803
859
  * D2): identical presign + bounded GETs, but each index's promise settles
804
860
  * when ITS bytes land. Failure semantics per index match `getObjects`'s
805
861
  * per-element behavior (absent → null, a failed GET → the same
806
- * GITVAULT_OBJECT_READ_FAILED); every promise is pre-marked handled so an
807
- * 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.
808
866
  */
809
- async function getObjectsSettledBytes(repoId, paths) {
867
+ async function getObjectsSettledBytes(repoId, paths, expected) {
810
868
  if (paths.length === 0)
811
869
  return [];
812
870
  const targets = await presignObjectBatch(repoId, paths);
@@ -835,14 +893,7 @@ export function createGitvaultHttpTransport(client, options = {}) {
835
893
  deferreds[i].resolve(null);
836
894
  continue;
837
895
  }
838
- const r = await fetchGitvaultObjectBytes(client, target);
839
- if (r.status === 404) {
840
- deferreds[i].resolve(null);
841
- continue;
842
- }
843
- if (!r.ok)
844
- fail("GITVAULT_OBJECT_READ_FAILED", `object GET failed (HTTP ${r.status}) for ${paths[i]}`, "reading gitvault object", { path: paths[i], status: r.status });
845
- deferreds[i].resolve(new Uint8Array(await r.arrayBuffer()));
896
+ deferreds[i].resolve(await resolveObjectReadTarget(repoId, target, expected?.[i], paths[i]));
846
897
  }
847
898
  catch (e) {
848
899
  deferreds[i].reject(e);
@@ -908,11 +959,17 @@ export function createGitvaultHttpTransport(client, options = {}) {
908
959
  return null;
909
960
  }
910
961
  }
911
- /** 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). */
912
- 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) {
913
964
  if ("inline" in carrier)
914
965
  return fromBase64url(carrier.inline, "carriers.inline");
915
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
+ }
916
973
  if (r.status === 404)
917
974
  return null;
918
975
  if (!r.ok)
@@ -925,11 +982,16 @@ export function createGitvaultHttpTransport(client, options = {}) {
925
982
  * resolved to raw bytes here, verified nowhere here (see the interface's
926
983
  * own doc comment on {@link GitvaultTransport.getState}).
927
984
  */
928
- async function getVaultStateOut(repoId, since) {
929
- 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" });
930
992
  const head = raw.head ? { stored_bytes: fromBase64url(raw.head.stored_bytes, "head.stored_bytes"), stored_bytes_sha256: raw.head.stored_bytes_sha256 } : null;
931
993
  const carriers = raw.carriers
932
- ? { 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) }
933
995
  : null;
934
996
  // Delta decode is deliberately forgiving: a malformed entry is dropped,
935
997
  // never thrown — the delta is an accelerator and the ordinary reads own
@@ -949,7 +1011,63 @@ export function createGitvaultHttpTransport(client, options = {}) {
949
1011
  delta = null;
950
1012
  }
951
1013
  }
952
- 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 } : {}) };
953
1071
  }
954
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. */
955
1073
  function receiptsFromFinalize(objects, entries, fin) {
@@ -1100,9 +1218,9 @@ export function createGitvaultHttpTransport(client, options = {}) {
1100
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);
1101
1219
  return { stored_bytes_sha256: r.sha256, size_bytes: r.size_bytes };
1102
1220
  },
1103
- getObject: ({ repo_id, path }) => getObjectBytes(repo_id, path),
1104
- getObjects: ({ repo_id, paths }) => getObjectsBytes(repo_id, paths),
1105
- 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),
1106
1224
  getHeads: ({ repo_id, generations }) => getHeadsBytes(repo_id, generations),
1107
1225
  async admitGenesis(request) {
1108
1226
  try {
@@ -1151,7 +1269,7 @@ export function createGitvaultHttpTransport(client, options = {}) {
1151
1269
  return { cleared: r.advisory_cleared ?? r.cleared ?? false };
1152
1270
  },
1153
1271
  getVaultRecord: ({ repo_id }) => client.request(base(repo_id), { context: "reading the gitvault record" }),
1154
- getState: ({ repo_id, since }) => getVaultStateOut(repo_id, since),
1272
+ getState: ({ repo_id, since, restore }) => getVaultStateOut(repo_id, since, restore),
1155
1273
  openCompactionGrant: ({ repo_id }) => client.request(`${base(repo_id)}/compaction-grant`, { method: "POST", context: "opening the gitvault compaction headroom grant" }),
1156
1274
  closeCompactionGrant: async ({ repo_id }) => {
1157
1275
  const r = await client.request(`${base(repo_id)}/compaction-grant`, { method: "DELETE", context: "closing the gitvault compaction headroom grant" });
@@ -1461,6 +1579,18 @@ export class GitvaultVault {
1461
1579
  * consumed and cleared by the next restore).
1462
1580
  */
1463
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;
1464
1594
  retries;
1465
1595
  servicePublicKey;
1466
1596
  genesisCache = null;
@@ -1649,7 +1779,7 @@ export class GitvaultVault {
1649
1779
  * comment). A wrong or absent byte this method wrote is therefore just a
1650
1780
  * cache MISS on the next read, never a verification bypass.
1651
1781
  */
1652
- async tryStateFastPath(pin, deltaSince) {
1782
+ async tryStateFastPath(pin, deltaSince, restore) {
1653
1783
  // gitvault-delta-fetch: carry the caller's MATERIALIZED position as
1654
1784
  // `since` — a capable gateway answers small spans with the heads +
1655
1785
  // inline WAL packs in this same response; every other gateway/span
@@ -1659,7 +1789,15 @@ export class GitvaultVault {
1659
1789
  // keystore, so pin-as-since would report "current" for a standing clone
1660
1790
  // that is generations behind — exactly the multi-checkout shape the
1661
1791
  // bench runs.
1662
- 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 });
1663
1801
  // §6.4: the vault's newest generation may never fall below the
1664
1802
  // authenticated pin — checked here regardless of eligibility below, so
1665
1803
  // a regressed vault is caught exactly as loudly as it always was, even
@@ -1679,6 +1817,13 @@ export class GitvaultVault {
1679
1817
  // is actually genesis's bytes.
1680
1818
  if (state.delta)
1681
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;
1682
1827
  const noOrdinaryHeadYet = state.newest_generation === null || state.newest_generation === GITVAULT_GENESIS_GENERATION;
1683
1828
  const pinBig = generationToBigInt(pin.generation);
1684
1829
  const newestBig = noOrdinaryHeadYet ? 0n : generationToBigInt(state.newest_generation);
@@ -1811,7 +1956,10 @@ export class GitvaultVault {
1811
1956
  // own epoch could not be resolved without the network read this path
1812
1957
  // exists to avoid) — the caller falls straight through to the UNCHANGED
1813
1958
  // readHead + listHeads flow below, byte-identical to before this change.
1814
- 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);
1815
1963
  let lastHead = fastPath ? fastPath.pinnedHead : pin.generation === GITVAULT_GENESIS_GENERATION ? null : await this.readHead(pin.generation, pin.head_sha256);
1816
1964
  const anchor = pin.generation;
1817
1965
  let prevEpoch = fastPath ? fastPath.prevEpoch : (lastHead?.epoch ?? GITVAULT_GENESIS_EPOCH);
@@ -1989,7 +2137,7 @@ export class GitvaultVault {
1989
2137
  }
1990
2138
  }
1991
2139
  if (carriers.length > 1) {
1992
- 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) });
1993
2141
  for (let i = 0; i < carriers.length; i++) {
1994
2142
  const bytes = fetched[i] ?? null;
1995
2143
  if (bytes && sha256Hex(bytes) === carriers[i].sha)
@@ -2255,7 +2403,7 @@ export class GitvaultVault {
2255
2403
  const cacheable = kind === "ref_state" || kind === "retention_roots";
2256
2404
  const cached = cacheable ? this.keystore.readCachedCarrier(this.repoId, receipt.object_id) : null;
2257
2405
  const cacheHit = cached && sha256Hex(cached.bytes) === receipt.ciphertext_sha256;
2258
- 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 });
2259
2407
  const object = this.decodeCarrierFrame(kind, receipt, frame, writerKey, keyOverride);
2260
2408
  if (cacheable && !cacheHit && frame && typeof object.generation === "string") {
2261
2409
  this.keystore.writeCachedCarrier(this.repoId, receipt.object_id, object.generation, receipt.ciphertext_sha256, frame);
@@ -2285,11 +2433,16 @@ export class GitvaultVault {
2285
2433
  const preRoots = !rootsHit ? (this.walkPrefetch?.get(rootsPath) ?? null) : null;
2286
2434
  const rootsPre = preRoots && sha256Hex(preRoots) === rootsReceipt.ciphertext_sha256 ? preRoots : null;
2287
2435
  const missingPaths = [];
2288
- if (!refStateHit && !refStatePre)
2436
+ const missingExpected = [];
2437
+ if (!refStateHit && !refStatePre) {
2289
2438
  missingPaths.push(refStatePath);
2290
- if (!rootsHit && !rootsPre)
2439
+ missingExpected.push(refStateReceipt.ciphertext_sha256);
2440
+ }
2441
+ if (!rootsHit && !rootsPre) {
2291
2442
  missingPaths.push(rootsPath);
2292
- 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 }) : [];
2293
2446
  let next = 0;
2294
2447
  const refStateFrame = refStateHit ? cachedRefState.bytes : (refStatePre ?? fetched[next++] ?? null);
2295
2448
  const rootsFrame = rootsHit ? cachedRoots.bytes : (rootsPre ?? fetched[next++] ?? null);
@@ -2319,7 +2472,7 @@ export class GitvaultVault {
2319
2472
  */
2320
2473
  async materialize(options = {}) {
2321
2474
  const persist = options.persist ?? true;
2322
- 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 });
2323
2476
  if (!state.head) {
2324
2477
  if (persist)
2325
2478
  this.keystore.updateRepo(this.repoId, { materialized_pin: { generation: state.generation, head_sha256: state.head_sha256, pinned_at: formatGitvaultTimestamp(this.now()) } });
@@ -2720,7 +2873,7 @@ export class GitvaultVault {
2720
2873
  fail("CHECKPOINT_INCOMPLETE", `head ${head.generation} carries no checkpoint to verify`, "verifying a stored checkpoint", { generation: head.generation });
2721
2874
  const { genesis } = await this.genesis();
2722
2875
  const writerKey = genesis.creator_signing_pubkey;
2723
- 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 });
2724
2877
  if (!claimBytes || sha256Hex(claimBytes) !== block.claim_set.stored_bytes_sha256) {
2725
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 });
2726
2879
  }
@@ -2736,7 +2889,7 @@ export class GitvaultVault {
2736
2889
  try {
2737
2890
  await hardenedGit(scratch, ["init", "-q", "--bare", "--object-format=sha1", "."]);
2738
2891
  for (const p of manifest.packs) {
2739
- 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 });
2740
2893
  if (!frame)
2741
2894
  fail("CHECKPOINT_INCOMPLETE", `checkpoint pack ${p.object_id} is absent from storage`, "verifying a stored checkpoint", { object_id: p.object_id });
2742
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 });
@@ -2800,7 +2953,7 @@ export class GitvaultVault {
2800
2953
  // Plaintext-structured and stored-bytes-receipted: no decryption, but
2801
2954
  // the hash and the owner signature are still checked before a single
2802
2955
  // pack receipt inside it is believed.
2803
- 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 });
2804
2957
  if (!bytes || sha256Hex(bytes) !== block.claim_set.stored_bytes_sha256) {
2805
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 });
2806
2959
  }
@@ -3160,7 +3313,7 @@ export class GitvaultVault {
3160
3313
  if (known && known.pin_manifest_version === receipt.pin_manifest_version && known.stored_bytes_sha256 === receipt.stored_bytes_sha256) {
3161
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])) };
3162
3315
  }
3163
- 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 });
3164
3317
  if (!bytes || sha256Hex(bytes) !== receipt.stored_bytes_sha256) {
3165
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 });
3166
3319
  }
@@ -3740,7 +3893,16 @@ export class GitvaultVault {
3740
3893
  // reuse therefore costs one extra read, never a wrong result.
3741
3894
  const marker = await readGitvaultRestoreMarker(targetRepoDir);
3742
3895
  const markerMatches = (a, b) => a === null ? b === null : b !== null && a.generation === b.generation && a.head_sha256 === b.head_sha256;
3743
- 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 }) });
3744
3906
  if (!newest.head) {
3745
3907
  const retained_refs = await reconcileRetainedTipRefs(targetRepoDir, { refs: {}, roots: [], head_target: newest.head_target });
3746
3908
  return { refs: {}, head_target: newest.head_target, generation: newest.generation, retained_refs };
@@ -3758,6 +3920,17 @@ export class GitvaultVault {
3758
3920
  const retained_refs = await reconcileRetainedTipRefs(targetRepoDir, { refs: newest.refs, roots: newest.roots, head_target: newest.head_target });
3759
3921
  return { refs: newest.refs, head_target: newest.head_target, generation: newest.generation, retained_refs };
3760
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
+ }
3761
3934
  // Walk back from newest, trying for an INCREMENTAL boundary at the
3762
3935
  // marker (a pure local prev_sha256 comparison — no fetch needed to
3763
3936
  // CONFIRM the boundary itself); abandon incremental the moment a
@@ -3832,6 +4005,27 @@ export class GitvaultVault {
3832
4005
  incremental = false;
3833
4006
  heads.length = 0;
3834
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;
3835
4029
  continue;
3836
4030
  }
3837
4031
  heads.unshift(cur);
@@ -3855,6 +4049,32 @@ export class GitvaultVault {
3855
4049
  fail("CHAIN_BROKEN", `head ${prevGen} does not match the chain during restore`, "restoring gitvault objects");
3856
4050
  cur = parseGitvaultStrict(new TextDecoder().decode(bytes));
3857
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) {
3858
4078
  // gitvault-clone-scaling (P3): a WHOLESALE walk just stopped at the
3859
4079
  // newest checkpoint-bearing head, or proved there is none back to
3860
4080
  // genesis — that is first-hand checkpoint coverage, and recording it is
@@ -3880,22 +4100,24 @@ export class GitvaultVault {
3880
4100
  // map `materialize()` just resolved (throwing `GITVAULT_EPOCH_NOT_OPENABLE`
3881
4101
  // fail-closed if any needed epoch could not be opened), so every lookup
3882
4102
  // below is guaranteed present.
3883
- const kRepoForEpoch = (epoch) => {
3884
- const hex = newest.epoch_keys_hex[epoch];
3885
- if (!hex)
3886
- fail("GITVAULT_EPOCH_NOT_OPENABLE", `no locally known key for epoch ${epoch} while restoring objects`, "restoring gitvault objects", { epoch });
3887
- return hexToBytes(hex);
3888
- };
4103
+ const kRepoForEpoch = (epoch) => this.epochKeyFor(newest, epoch);
3889
4104
  const first = heads[0];
3890
4105
  // gitvault-pipelined-restore: per-object settlement when the transport
3891
4106
  // offers it, the `getObjects` barrier otherwise — pipelining is a
3892
4107
  // wall-clock property, never a correctness dependency, so a transport
3893
4108
  // without the method reproduces today's serial-after-barrier behavior
3894
4109
  // exactly (each per-index promise settles when the whole batch does).
3895
- 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) => {
3896
4118
  if (this.transport.getObjectsSettled)
3897
- return this.transport.getObjectsSettled({ repo_id: this.repoId, paths });
3898
- 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 });
3899
4121
  const perIndex = paths.map((_, i) => all.then((frames) => frames[i] ?? null));
3900
4122
  // Mark every derived promise handled — the consumer awaits them in
3901
4123
  // order and stops at the first failure, abandoning the tail.
@@ -3917,7 +4139,12 @@ export class GitvaultVault {
3917
4139
  // skip the network ENTIRELY — but only after hashing to their carrying
3918
4140
  // head's receipt (a lying delta is a plain miss; the ordinary fetch
3919
4141
  // below reproduces the unbatched behavior for that slot). The buffer is
3920
- // 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).
3921
4148
  const deltaPacks = this.stateDeltaPacks;
3922
4149
  this.stateDeltaPacks = null;
3923
4150
  const deltaCovered = new Map();
@@ -3928,31 +4155,62 @@ export class GitvaultVault {
3928
4155
  deltaCovered.set(i, bytes);
3929
4156
  });
3930
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
+ }
3931
4167
  const uncovered = walEntries.map((_, i) => i).filter((i) => !deltaCovered.has(i));
3932
4168
  const walFramesP = (async () => {
3933
- 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)) : [];
3934
4170
  return walEntries.map((_, i) => (deltaCovered.has(i) ? Promise.resolve(deltaCovered.get(i)) : fetched[uncovered.indexOf(i)]));
3935
4171
  })();
3936
4172
  void walFramesP.catch(() => { });
3937
4173
  if (first.checkpoint) {
3938
- const claimBytes = await this.transport.getObject({ repo_id: this.repoId, path: gitvaultPaths.claimSet(first.checkpoint.claim_set.object_id) });
3939
- if (!claimBytes || sha256Hex(claimBytes) !== first.checkpoint.claim_set.stored_bytes_sha256)
3940
- fail("CHECKPOINT_INCOMPLETE", "claim set absent or altered", "restoring gitvault objects");
3941
- const claimSet = parseGitvaultStrict(new TextDecoder().decode(claimBytes));
3942
- if (!verifyGitvaultObject(claimSet, writerKey))
3943
- fail("CHECKPOINT_INCOMPLETE", "claim set signature fails", "restoring gitvault objects");
3944
- 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) });
3945
- 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
+ }
3946
4192
  // Design D2 + gitvault-pipelined-restore D1: every checkpoint pack is
3947
4193
  // independent — one batched presign for all of them, applied via
3948
4194
  // index-pack strictly in manifest order, PIPELINED: apply(i) awaits
3949
4195
  // bytes(i), so decrypt/verify/index of an early pack overlaps the
3950
4196
  // later packs' downloads. Per-pack verification (AEAD open + plaintext
3951
- // hash) still completes before any byte reaches git.
3952
- 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)) : [];
3953
4211
  for (let i = 0; i < manifest.packs.length; i++) {
3954
4212
  const p = manifest.packs[i];
3955
- const frame = (await frames[i]) ?? null;
4213
+ const frame = checkpointCovered.has(i) ? checkpointCovered.get(i) : ((await ckFrames[uncoveredCk.indexOf(i)]) ?? null);
3956
4214
  if (!frame)
3957
4215
  fail("CHECKPOINT_INCOMPLETE", `checkpoint pack ${p.object_id} absent`, "restoring gitvault objects");
3958
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 });
@@ -4000,6 +4258,152 @@ export class GitvaultVault {
4000
4258
  await writeGitvaultRestoreMarker(targetRepoDir, newest.generation, newest.head_sha256);
4001
4259
  return { refs: newest.refs, head_target: newest.head_target, generation: newest.generation, retained_refs };
4002
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
+ }
4003
4407
  // ── compaction headroom grant (gitvault-checkpoint-cadence design D3) ──
4004
4408
  /** Thin passthrough to the transport — see {@link GitvaultTransport.openCompactionGrant}. */
4005
4409
  async openCompactionGrant() {