run402 4.46.0 → 4.48.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.
@@ -37,7 +37,7 @@ import { mkdtempSync, readdirSync, readFileSync, rmSync } from "node:fs";
37
37
  import { tmpdir } from "node:os";
38
38
  import { join } from "node:path";
39
39
  import { LocalError, isRun402Error } from "../errors.js";
40
- 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, openFrame, openKeyEnvelope, parseGitvaultStrict, pinManifestLedgerId, randomBytes, sealFrame, sealKeyEnvelope, sha256Hex, signGitvaultObject, storedBytes, storedBytesSha256, toBase64url, verifyGitvaultObject, } from "../namespaces/gitvault.crypto.js";
40
+ 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, openKeyEnvelope, parseGitvaultStrict, parseRotateEpochPayload, pinManifestLedgerId, randomBytes, sealFrame, sealKeyEnvelope, sha256Hex, signGitvaultObject, storedBytes, storedBytesSha256, toBase64url, verifyGitvaultObject, } from "../namespaces/gitvault.crypto.js";
41
41
  import { GITVAULT_ZERO_SHA256_SENTINEL } from "../namespaces/gitvault.types.js";
42
42
  import { crossProfileGitvaultHint } from "./gitvault-profile-scan.js";
43
43
  import { GITVAULT_DEPLOY_REF, hardenedGit, hasObject, isAncestor } from "./gitvault-snapshot.js";
@@ -452,8 +452,18 @@ export function checkChainLink(input) {
452
452
  fail("CHAIN_BROKEN", `head generation ${h.generation} ≠ expected ${input.expected_generation} (generation must equal newest+1)`, "verifying head chain", { got: h.generation, expected: input.expected_generation });
453
453
  if (h.prev_sha256 !== input.prev_sha256)
454
454
  fail("CHAIN_BROKEN", `head ${h.generation}: prev_sha256 does not name the predecessor's stored bytes`, "verifying head chain", { prev_sha256: h.prev_sha256, expected: input.prev_sha256 });
455
- if (h.epoch !== GITVAULT_GENESIS_EPOCH)
456
- fail("CHAIN_BROKEN", `head ${h.generation}: epoch ${h.epoch} breaks the V0 pin`, "verifying head chain");
455
+ // D194, rev 42 (relaxed by D193): epoch continuity — a head's `epoch`
456
+ // equals its predecessor's UNLESS THIS head admits a `rotate_epoch`
457
+ // transition, in which case it equals `nextEpoch(predecessor.epoch)`
458
+ // exactly (increment-by-one, no skip). Pure and keyless: this checks only
459
+ // the head's own signed `epoch` FIELD against the predecessor's, never
460
+ // opens any envelope — decrypting under the resulting epoch is a separate,
461
+ // keyed step (`GitvaultVault`'s rotation-envelope open, `openEpochRotationForRecipient`).
462
+ const isRotation = h.transition !== null && h.transition.kind === "rotate_epoch";
463
+ const permittedEpoch = isRotation ? nextEpoch(input.prev_epoch) : input.prev_epoch;
464
+ if (h.epoch !== permittedEpoch) {
465
+ fail("CHAIN_BROKEN", `head ${h.generation}: epoch ${h.epoch} does not match the expected ${permittedEpoch} (${isRotation ? "post-rotation increment of the predecessor's epoch" : "predecessor continuity — only an admitted rotate_epoch transition may advance the epoch"})`, "verifying head chain", { generation: h.generation, got: h.epoch, expected: permittedEpoch, prev_epoch: input.prev_epoch, is_rotation: isRotation });
466
+ }
457
467
  if (h.writer_key_id !== input.writer_key_id)
458
468
  fail("CHAIN_BROKEN", `head ${h.generation}: writer_key_id ${h.writer_key_id} is not the registered writer`, "verifying head chain");
459
469
  if (!verifyGitvaultObject(h, input.writer_public_key))
@@ -469,12 +479,23 @@ export function checkChainLink(input) {
469
479
  }
470
480
  /**
471
481
  * The transition fail-closed rule: a V0 client that encounters an ADMITTED
472
- * non-null transition stops advancing — read-only at the materialized pin,
473
- * no publish past it, `UPGRADE_REQUIRED`. Unknown kinds are a parse reject.
482
+ * transition kind it cannot validate stops advancing — read-only at the
483
+ * materialized pin, no publish past it, `UPGRADE_REQUIRED`. Unknown kinds
484
+ * are a parse reject.
485
+ *
486
+ * `rotate_epoch` is EXEMPT from this rule as of rev 42 (D193: "epoch
487
+ * rotation is ACTIVATED") — `checkChainLink`'s own D194 epoch-continuity
488
+ * check already validates its structural admissibility, and
489
+ * {@link parseRotateEpochPayload} / the caller's own envelope-open step
490
+ * (`GitvaultVault.verifyToNewest`) handle it fully. The other three kinds
491
+ * (`add_envelope`, `add_writer_key`, `transfer_binding`) remain genuinely
492
+ * unactivated and stay fail-closed exactly as before.
474
493
  */
475
494
  export function assertNoTransition(head) {
476
495
  if (head.transition === null)
477
496
  return;
497
+ if (head.transition.kind === "rotate_epoch")
498
+ return;
478
499
  const kinds = ["add_envelope", "rotate_epoch", "add_writer_key", "transfer_binding"];
479
500
  if (!kinds.includes(head.transition.kind))
480
501
  fail("CHAIN_BROKEN", `head ${head.generation}: unknown transition kind ${String(head.transition.kind)} (closed enum)`, "verifying head chain");
@@ -538,6 +559,33 @@ export function gitvaultLedgerId(read) {
538
559
  }
539
560
  function b64(bytes) { return Buffer.from(bytes).toString("base64"); }
540
561
  function b64u(bytes) { return Buffer.from(bytes).toString("base64url"); }
562
+ /**
563
+ * Independent reads/PUTs within one gitvault step run at this concurrency
564
+ * (design D2) — "browser-era origin etiquette", well within what S3/the
565
+ * gateway tolerate, and it keeps in-flight memory bounded by ~6×frame size.
566
+ * Chain-ordered steps (the head-chain walk, admission, readback) never call
567
+ * this — they stay strictly sequential.
568
+ */
569
+ export const GITVAULT_TRANSPORT_CONCURRENCY = 6;
570
+ /**
571
+ * Run `fn` over `items` with at most `limit` in flight at once, preserving
572
+ * result order regardless of completion order. A plain worker-pool: `limit`
573
+ * workers each pull the next unclaimed index until the queue is empty.
574
+ */
575
+ export async function mapBounded(items, limit, fn) {
576
+ const results = new Array(items.length);
577
+ let next = 0;
578
+ async function worker() {
579
+ for (;;) {
580
+ const i = next++;
581
+ if (i >= items.length)
582
+ return;
583
+ results[i] = await fn(items[i], i);
584
+ }
585
+ }
586
+ await Promise.all(Array.from({ length: Math.max(0, Math.min(limit, items.length)) }, () => worker()));
587
+ return results;
588
+ }
541
589
  /**
542
590
  * The `fetch`-backed transport over the SDK kernel. Presigned PUTs carry
543
591
  * `If-None-Match: *` (create-only — the bucket policy demands it) and the
@@ -599,19 +647,18 @@ export function createGitvaultHttpTransport(client, options = {}) {
599
647
  return null;
600
648
  if (isRun402Error(e) && e.code === "RESOURCE_NOT_FOUND")
601
649
  return null;
602
- // A known live gateway gap (confirmed 2026-08-27 against
603
- // src_c78d2f710a8f49d22f9c66faf2a915cd): `POST …/object-reads`
604
- // validates every null-idScalar (path-addressed) object_kind against
605
- // key_envelope's `{epoch, recipient_fingerprint}` shape, never having
606
- // been generalized for the SECOND path-addressed kind D197 shipped
607
- // `recipient_pin_manifest`'s real `{pin_manifest_version}` fields
608
- // so a genuine read always 400s `"epoch must be 16 hex"`. This is a
609
- // read-back of a manifest neither uploaded nor cached by THIS
610
- // keystore (a cache hit in `readPinManifestObject` never reaches
611
- // here at all) re-thrown with an attribution that names the gap
612
- // instead of leaving a caller to read this as a client validation bug.
613
- if (ref.read.object_kind === "recipient_pin_manifest" && isRun402Error(e) && e.code === "VALIDATION_FAILED") {
614
- throw new LocalError(`the gateway's object-reads route does not yet accept recipient_pin_manifest reads by pin_manifest_version (its null-idScalar branch is still hardcoded to key_envelope's epoch/recipient_fingerprint shape) — this vault's pin manifest at ${path} cannot be read back over the network by a keystore that did not itself just publish it`, "reading gitvault object", { code: "GITVAULT_PIN_MANIFEST_READ_UNSUPPORTED", details: { path, pin_manifest_version: ref.read.pin_manifest_version }, cause: e });
650
+ // The gateway accepts `{object_kind: "recipient_pin_manifest",
651
+ // pin_manifest_version}` reads (fixed 2026-08-28; before that its
652
+ // null-idScalar validation was hardcoded to key_envelope's
653
+ // `{epoch, recipient_fingerprint}` shape and 400'd every such read
654
+ // with "epoch must be 16 hex"). A VALIDATION_FAILED here therefore
655
+ // indicates a genuinely malformed request and propagates as-is
656
+ // EXCEPT the pre-fix epoch-shape complaint, which a fixed gateway can
657
+ // never emit for this kind: that signature can only mean an unfixed
658
+ // (older/staging) gateway behind RUN402_API_BASE, so name it rather
659
+ // than letting it read as a client validation bug.
660
+ if (ref.read.object_kind === "recipient_pin_manifest" && isRun402Error(e) && e.code === "VALIDATION_FAILED" && /epoch must be 16 hex/.test(e.message ?? "")) {
661
+ throw new LocalError(`this gateway predates the 2026-08-28 fix that made object-reads accept recipient_pin_manifest reads by pin_manifest_version (its null-idScalar branch rejected them with key_envelope's epoch-shape complaint) this vault's pin manifest at ${path} cannot be read back over the network from it by a keystore that did not itself just publish the manifest`, "reading gitvault object", { code: "GITVAULT_PIN_MANIFEST_READ_UNSUPPORTED", details: { path, pin_manifest_version: ref.read.pin_manifest_version }, cause: e });
615
662
  }
616
663
  throw e;
617
664
  }
@@ -625,6 +672,49 @@ export function createGitvaultHttpTransport(client, options = {}) {
625
672
  fail("GITVAULT_OBJECT_READ_FAILED", `object GET failed (HTTP ${r.status}) for ${path}`, "reading gitvault object", { path, status: r.status });
626
673
  return new Uint8Array(await r.arrayBuffer());
627
674
  }
675
+ /**
676
+ * Presign + fetch N independent objects (gitvault-client-round-trips
677
+ * design D2): ONE `object-reads` POST naming every path, then the GETs
678
+ * with bounded concurrency ({@link GITVAULT_TRANSPORT_CONCURRENCY}).
679
+ * Every path must be `object-reads`-addressed (a "carrier": ref_state,
680
+ * retention_roots, a WAL/checkpoint pack, …) — a generation-addressed
681
+ * head/admission path has no place in a batch built for materialize/
682
+ * restore's carrier and pack reads, so it fails closed rather than
683
+ * silently costing an extra round trip through `getGenerationBytes`.
684
+ */
685
+ async function getObjectsBytes(repoId, paths) {
686
+ if (paths.length === 0)
687
+ return [];
688
+ const refs = paths.map((path) => {
689
+ const ref = gitvaultWireRefForPath(path);
690
+ if (!ref || ref.kind !== "object")
691
+ fail("GITVAULT_OBJECT_READ_FAILED", `${path} is not a batch-readable carrier object`, "reading gitvault objects", { path });
692
+ return ref.read;
693
+ });
694
+ let presigned;
695
+ try {
696
+ presigned = await client.request(`${base(repoId)}/object-reads`, { method: "POST", body: { objects: refs }, context: "resolving gitvault objects" });
697
+ }
698
+ catch (e) {
699
+ if (isRun402Error(e) && e.status === 404)
700
+ return paths.map(() => null);
701
+ if (isRun402Error(e) && e.code === "RESOURCE_NOT_FOUND")
702
+ return paths.map(() => null);
703
+ throw e;
704
+ }
705
+ const byLedgerId = new Map(presigned.reads.map((r) => [gitvaultLedgerId(r), r]));
706
+ const targets = refs.map((r) => byLedgerId.get(gitvaultLedgerId(r)) ?? null);
707
+ return mapBounded(targets, GITVAULT_TRANSPORT_CONCURRENCY, async (target, i) => {
708
+ if (!target)
709
+ return null;
710
+ const r = await client.fetch(target.url, { method: "GET" });
711
+ if (r.status === 404)
712
+ return null;
713
+ if (!r.ok)
714
+ fail("GITVAULT_OBJECT_READ_FAILED", `object GET failed (HTTP ${r.status}) for ${paths[i]}`, "reading gitvault object", { path: paths[i], status: r.status });
715
+ return new Uint8Array(await r.arrayBuffer());
716
+ });
717
+ }
628
718
  async function upload(repoId, objects, resourceBinding) {
629
719
  if (objects.length === 0)
630
720
  return [];
@@ -638,8 +728,9 @@ export function createGitvaultHttpTransport(client, options = {}) {
638
728
  context: "opening gitvault upload session",
639
729
  });
640
730
  const issued = new Map(session.objects.map((u) => [gitvaultLedgerId(u), u]));
641
- for (let i = 0; i < objects.length; i++) {
642
- const o = objects[i];
731
+ // Independent create-only PUTs within one session (design D2) — bounded
732
+ // concurrency, same limit as the batched object reads below.
733
+ await mapBounded(objects, GITVAULT_TRANSPORT_CONCURRENCY, async (o, i) => {
643
734
  const id = gitvaultLedgerId(entries[i]);
644
735
  const target = issued.get(id);
645
736
  if (!target)
@@ -660,11 +751,11 @@ export function createGitvaultHttpTransport(client, options = {}) {
660
751
  const existing = await getObjectBytes(repoId, o.path);
661
752
  if (!existing || sha256Hex(existing) !== o.sha256)
662
753
  fail("GITVAULT_OBJECT_EXISTS_DIFFERENT", `${o.path} already exists with different bytes`, "uploading gitvault objects", { path: o.path });
663
- continue;
754
+ return;
664
755
  }
665
756
  if (!r.ok)
666
757
  fail("GITVAULT_UPLOAD_FAILED", `presigned PUT failed (HTTP ${r.status}) for ${o.path}`, "uploading gitvault objects", { path: o.path, status: r.status });
667
- }
758
+ });
668
759
  const fin = await client.request(`${base(repoId)}/upload-sessions/${encodeURIComponent(session.upload_session_id)}/finalize`, { method: "POST", body: {}, context: "finalizing gitvault upload session" });
669
760
  const receipts = new Map(fin.receipts.map((r) => [gitvaultLedgerId(r), r]));
670
761
  return objects.map((o, i) => {
@@ -693,7 +784,7 @@ export function createGitvaultHttpTransport(client, options = {}) {
693
784
  throw e;
694
785
  }
695
786
  }
696
- return {
787
+ const transport = {
697
788
  // ── creation (5.3) ──
698
789
  async allocate(request) {
699
790
  // The route wraps the signed allocation object under `allocation` and
@@ -707,6 +798,7 @@ export function createGitvaultHttpTransport(client, options = {}) {
707
798
  return { stored_bytes_sha256: r.sha256, size_bytes: r.size_bytes };
708
799
  },
709
800
  getObject: ({ repo_id, path }) => getObjectBytes(repo_id, path),
801
+ getObjects: ({ repo_id, paths }) => getObjectsBytes(repo_id, paths),
710
802
  async admitGenesis(request) {
711
803
  try {
712
804
  const r = await admit(request.repo_id, GITVAULT_GENESIS_GENERATION, request.stored_bytes, request.stored_bytes_sha256, { allocation_generation: request.allocation_generation });
@@ -828,6 +920,80 @@ export function createGitvaultHttpTransport(client, options = {}) {
828
920
  }
829
921
  },
830
922
  };
923
+ return process.env.RUN402_GITVAULT_TRACE === "1" ? traceGitvaultTransport(transport) : transport;
924
+ }
925
+ /**
926
+ * `RUN402_GITVAULT_TRACE=1` (design D7) — one stderr line per real
927
+ * transport operation (op kind, a path/object-count shape when the request
928
+ * carries one, byte count when the result carries one, duration) plus a
929
+ * session summary at process exit (total ops, total time spent in this
930
+ * transport, wall-clock since the transport was created). Debug-only:
931
+ * stderr only — never stdout, so it can never contaminate the
932
+ * `git-remote-run402` protocol stream, the same discipline the helper's
933
+ * own `note()` follows — and not a canonical surface: it is NOT what the
934
+ * client-surface spec's counted budgets measure (that is
935
+ * `GitvaultOpCounter`, a test-only instrument over the SAME operation
936
+ * shapes). The client-side env var carries no gateway env-registry policy.
937
+ */
938
+ function traceGitvaultTransport(inner) {
939
+ let opCount = 0;
940
+ let totalMs = 0;
941
+ const sessionStart = Date.now();
942
+ process.on("exit", () => {
943
+ if (opCount === 0)
944
+ return;
945
+ process.stderr.write(`gitvault-trace: session summary — ${opCount} op(s), ${totalMs.toFixed(1)}ms in transport, ${Date.now() - sessionStart}ms wall-clock\n`);
946
+ });
947
+ const describeRequest = (arg) => {
948
+ if (!arg || typeof arg !== "object")
949
+ return "";
950
+ const a = arg;
951
+ if (typeof a.path === "string")
952
+ return ` path=${a.path}`;
953
+ if (Array.isArray(a.paths))
954
+ return ` paths=${a.paths.length}`;
955
+ if (Array.isArray(a.objects))
956
+ return ` objects=${a.objects.length}`;
957
+ if (typeof a.generation === "string")
958
+ return ` gen=${a.generation}`;
959
+ if (typeof a.repo_id === "string")
960
+ return ` repo=${a.repo_id}`;
961
+ return "";
962
+ };
963
+ const describeResult = (result) => {
964
+ if (result instanceof Uint8Array)
965
+ return ` bytes=${result.length}`;
966
+ if (Array.isArray(result)) {
967
+ const total = result.reduce((sum, v) => sum + (v instanceof Uint8Array ? v.length : 0), 0);
968
+ return total > 0 ? ` bytes=${total}` : "";
969
+ }
970
+ return "";
971
+ };
972
+ return new Proxy(inner, {
973
+ get(target, prop, receiver) {
974
+ const value = Reflect.get(target, prop, receiver);
975
+ if (typeof value !== "function" || typeof prop !== "string")
976
+ return value;
977
+ return async (...args) => {
978
+ const start = Date.now();
979
+ try {
980
+ const result = await Reflect.apply(value, target, args);
981
+ const elapsed = Date.now() - start;
982
+ opCount += 1;
983
+ totalMs += elapsed;
984
+ process.stderr.write(`gitvault-trace: ${prop}${describeRequest(args[0])}${describeResult(result)} ${elapsed}ms\n`);
985
+ return result;
986
+ }
987
+ catch (e) {
988
+ const elapsed = Date.now() - start;
989
+ opCount += 1;
990
+ totalMs += elapsed;
991
+ process.stderr.write(`gitvault-trace: ${prop}${describeRequest(args[0])} FAILED ${elapsed}ms\n`);
992
+ throw e;
993
+ }
994
+ };
995
+ },
996
+ });
831
997
  }
832
998
  // ─── Storage paths (§3) ──────────────────────────────────────────────────────
833
999
  export const gitvaultPaths = {
@@ -855,6 +1021,39 @@ export const gitvaultPaths = {
855
1021
  /** `recipient-pins/<pin_manifest_version>.json` (D197, rev 42) — version-addressed, plaintext-structured, writer-signed. */
856
1022
  pinManifest: (pinManifestVersion) => `recipient-pins/${pinManifestVersion}.json`,
857
1023
  };
1024
+ // ─── Incremental restore marker (gitvault-client-round-trips design D5) ──────
1025
+ //
1026
+ // `restored_through` — the newest generation whose WAL packs this TARGET
1027
+ // DIRECTORY has fully applied — is local git state, same store as the
1028
+ // id-pin (`git config --local`), scoped to the directory `restoreObjectsInto`
1029
+ // actually wrote into (never `process.cwd()`, and never scoped to `repo_id`:
1030
+ // a re-pointed remote's marker simply fails its `head_sha256` comparison and
1031
+ // falls back to wholesale, safely, since that comparison is a content hash).
1032
+ const GITVAULT_RESTORE_MARKER_GENERATION_KEY = "r402.restoredThrough";
1033
+ const GITVAULT_RESTORE_MARKER_SHA256_KEY = "r402.restoredThroughSha256";
1034
+ async function readLocalGitConfigValue(dir, key) {
1035
+ try {
1036
+ const out = (await hardenedGit(dir, ["config", "--local", "--get", key])).text().trim();
1037
+ return out.length > 0 ? out : null;
1038
+ }
1039
+ catch {
1040
+ // Absent key or not a repository at all — either way, "nothing marked" is the correct read.
1041
+ return null;
1042
+ }
1043
+ }
1044
+ /** Read this target directory's `restored_through` marker, or `null` when nothing was ever restored into it. */
1045
+ export async function readGitvaultRestoreMarker(targetRepoDir) {
1046
+ const generation = await readLocalGitConfigValue(targetRepoDir, GITVAULT_RESTORE_MARKER_GENERATION_KEY);
1047
+ const head_sha256 = await readLocalGitConfigValue(targetRepoDir, GITVAULT_RESTORE_MARKER_SHA256_KEY);
1048
+ if (!generation || !head_sha256)
1049
+ return null;
1050
+ return { generation, head_sha256 };
1051
+ }
1052
+ /** Advance the marker — called only after a restore's coverage verification succeeds. */
1053
+ async function writeGitvaultRestoreMarker(targetRepoDir, generation, headSha256) {
1054
+ await hardenedGit(targetRepoDir, ["config", "--local", GITVAULT_RESTORE_MARKER_GENERATION_KEY, generation]);
1055
+ await hardenedGit(targetRepoDir, ["config", "--local", GITVAULT_RESTORE_MARKER_SHA256_KEY, headSha256]);
1056
+ }
858
1057
  /** A transport-agnostic view of git ops the publication needs (the local repository). */
859
1058
  export class GitvaultVault {
860
1059
  keystore;
@@ -912,7 +1111,16 @@ export class GitvaultVault {
912
1111
  if (this.genesisCache)
913
1112
  return this.genesisCache;
914
1113
  const repo = this.repoFile();
915
- const bytes = await this.transport.getGenesis({ repo_id: this.repoId });
1114
+ // Design D3: genesis is one small, immutable object per vault — cached
1115
+ // forever beside the keystore's per-repo state, re-verified against the
1116
+ // pinned `genesis_sha256` on every use exactly like a network read.
1117
+ const cached = this.keystore.readCachedGenesis(this.repoId);
1118
+ let bytes = cached && sha256Hex(cached.bytes) === repo.genesis_sha256 ? cached.bytes : null;
1119
+ if (!bytes) {
1120
+ bytes = await this.transport.getGenesis({ repo_id: this.repoId });
1121
+ if (bytes && sha256Hex(bytes) === repo.genesis_sha256)
1122
+ this.keystore.writeCachedGenesis(this.repoId, repo.genesis_sha256, bytes);
1123
+ }
916
1124
  if (!bytes)
917
1125
  fail("CHAIN_BROKEN", "the vault has no admitted genesis", "reading gitvault genesis", { repo_id: this.repoId });
918
1126
  const sha256 = sha256Hex(bytes);
@@ -936,9 +1144,33 @@ export class GitvaultVault {
936
1144
  * retry restarts from the ORIGINAL pin rather than resuming — the honest
937
1145
  * consequence of asking for a no-write audit and then walking off the end
938
1146
  * of one call's budget.
1147
+ *
1148
+ * The chain walk itself (`checkChainLink`, `assertNoTransition`, collecting
1149
+ * `rotations[]`) is ALWAYS keyless — an admitted `rotate_epoch` transition
1150
+ * never stops it (D193, rev 42), so `generation`/`head` here are the
1151
+ * genuinely chain-verified newest, independent of whether this principal
1152
+ * can decrypt anything past a rotation it cannot open.
1153
+ *
1154
+ * `options.decryptValidate` (default `false`, Part C — `repos fsck`'s
1155
+ * decrypt-validation pass): additionally opens every `rotate_epoch`
1156
+ * envelope needed and decrypts each walked generation's OWN
1157
+ * `ref_state`/`retention_roots` as it goes — the "main object" restoration
1158
+ * needs per generation — persisting newly-opened epoch keys via
1159
+ * `keystore.recordEpochRotation` exactly like an ordinary rotation
1160
+ * producer/consumer would, and counting each decrypt attempt as an EXTRA
1161
+ * unit against the same `this.budget` (decryption is the expensive step).
1162
+ * `options.strict` (default `true`) throws immediately, fail-closed, on the
1163
+ * first `GITVAULT_EPOCH_NOT_OPENABLE` / AEAD failure it hits — the ordinary
1164
+ * `materialize()` read path's behavior. `strict: false` (fsck's tolerant
1165
+ * mode) instead records `decrypt.failure` and stops attempting further
1166
+ * decrypts while the pure chain walk keeps going — this is exactly what
1167
+ * makes `chain_verified_to` (this call's `generation`) and `decryptable_to`
1168
+ * (`decrypt.decryptable_to_generation`) able to differ honestly.
939
1169
  */
940
1170
  async verifyToNewest(options = {}) {
941
1171
  const persist = options.persist ?? true;
1172
+ const decryptValidate = options.decryptValidate ?? false;
1173
+ const strict = options.strict ?? true;
942
1174
  const { genesis, sha256: genesisSha } = await this.genesis();
943
1175
  const writerKey = genesis.creator_signing_pubkey;
944
1176
  const writerKeyId = genesis.writer_key_id;
@@ -946,9 +1178,97 @@ export class GitvaultVault {
946
1178
  let pin = repo.verified_prefix ?? repo.head_pin ?? { generation: GITVAULT_GENESIS_GENERATION, head_sha256: genesisSha, pinned_at: formatGitvaultTimestamp(this.now()) };
947
1179
  let lastHead = pin.generation === GITVAULT_GENESIS_GENERATION ? null : await this.readHead(pin.generation, pin.head_sha256);
948
1180
  const anchor = pin.generation;
1181
+ let prevEpoch = lastHead?.epoch ?? GITVAULT_GENESIS_EPOCH;
949
1182
  let progress = { after_generation: anchor, last_generation: anchor, delivered: 0 };
950
1183
  let request = { after_generation: anchor, limit: String(GITVAULT_MAX_HEADS_PER_LISTING_PAGE) };
951
1184
  let verified = 0;
1185
+ const rotations = [];
1186
+ // ── decrypt-validation state (opt-in) ──
1187
+ const identity = decryptValidate ? this.keystore.readIdentity() : null;
1188
+ const ownKeypair = identity ? this.keystore.encryptionKeypair(identity) : null;
1189
+ const epochKeys = { ...(repo.epoch_keys ?? { [repo.epoch]: repo.k_repo_hex }) };
1190
+ let decryptPin = repo.materialized_pin ?? null;
1191
+ let decryptedRefState = null;
1192
+ let decryptedRoots = null;
1193
+ let decryptFailure = null;
1194
+ let decryptFailureError = null;
1195
+ const persistMaterializedIfAny = () => {
1196
+ if (persist && decryptValidate && decryptPin)
1197
+ this.keystore.updateRepo(this.repoId, { materialized_pin: decryptPin });
1198
+ };
1199
+ /**
1200
+ * Open (if needed) `headPin`'s own rotation envelope and decrypt its
1201
+ * `ref_state`/`retention_roots`. Shared by the per-head inline call below
1202
+ * AND the post-loop catch-up call: a call with NOTHING new to walk (this
1203
+ * repo's chain-verified pin is already at the newest generation) never
1204
+ * enters the loop body at all, so the newest head's own decrypt still
1205
+ * needs to run once, using whatever `epoch_keys` the keystore already
1206
+ * persisted from an EARLIER call's rotation-envelope open.
1207
+ *
1208
+ * NEVER throws — a decrypt failure is recorded on the enclosing
1209
+ * `decryptFailure`/`decryptFailureError` and this returns `false`. This
1210
+ * is deliberate: chain verification (this call's OWN `generation`/
1211
+ * `head_pin`, "highest_authenticated") is independent of decrypt
1212
+ * capability and must walk the FULL chain regardless — exactly the
1213
+ * existing "authenticated-but-undecryptable ⇒ CHAIN_UNUSABLE, read-only
1214
+ * at the materialized pin" split this file's own header already
1215
+ * documents. `strict` is enforced ONCE, at the very end of
1216
+ * `verifyToNewest`, after the complete chain walk has run.
1217
+ */
1218
+ const tryDecrypt = async (head, headPin, pendingRotations) => {
1219
+ verified += 1; // decryption is the expensive step — counted separately against the same budget
1220
+ // Tracks whichever rotation is currently being opened — the failure
1221
+ // report below names THAT rotation's own generation/epoch/rotation_id,
1222
+ // never `head`'s (which, in the backward catch-up call, can be a much
1223
+ // LATER, ordinary-push generation entirely unrelated to which epoch
1224
+ // actually failed to open).
1225
+ let current = { generation: head.generation, epoch: head.epoch, rotation_id: null };
1226
+ try {
1227
+ for (const rot of pendingRotations) {
1228
+ if (epochKeys[rot.epoch])
1229
+ continue;
1230
+ current = { generation: rot.generation, epoch: rot.epoch, rotation_id: rot.payload.rotation_id };
1231
+ if (!identity || !ownKeypair) {
1232
+ fail("GITVAULT_EPOCH_NOT_OPENABLE", `no local gitvault identity — cannot open epoch ${rot.epoch} (rotation ${rot.payload.rotation_id})`, "opening an epoch-rotation key envelope", { epoch: rot.epoch, rotation_id: rot.payload.rotation_id });
1233
+ }
1234
+ const ownFingerprint = ekFingerprint(ownKeypair.public_key);
1235
+ const kE = await openEpochRotationForRecipient({
1236
+ repo_id: this.repoId,
1237
+ payload: rot.payload,
1238
+ own_fingerprint: ownFingerprint,
1239
+ own_encryption_keypair: ownKeypair,
1240
+ writer_signing_public_key: writerKey,
1241
+ get_envelope_bytes: (path) => this.transport.getObject({ repo_id: this.repoId, path }),
1242
+ envelope_path: (epoch, fp, rotationId) => gitvaultPaths.envelope(epoch, fp, rotationId),
1243
+ });
1244
+ epochKeys[rot.epoch] = bytesToHex(kE);
1245
+ if (persist)
1246
+ this.keystore.recordEpochRotation(this.repoId, { new_epoch: rot.epoch, new_k_repo_hex: bytesToHex(kE) });
1247
+ }
1248
+ current = { generation: head.generation, epoch: head.epoch, rotation_id: null };
1249
+ const kRepoHex = epochKeys[head.epoch];
1250
+ if (!kRepoHex) {
1251
+ fail("GITVAULT_EPOCH_NOT_OPENABLE", `no locally known key for epoch ${head.epoch} at generation ${head.generation}`, "materializing gitvault head", { epoch: head.epoch, generation: head.generation });
1252
+ }
1253
+ const kRepo = hexToBytes(kRepoHex);
1254
+ // Design D2: ref_state + retention_roots both ride `head`, so one
1255
+ // batched presign (or a cache hit) serves both instead of two
1256
+ // independent presign-then-GET round trips.
1257
+ const { refState, roots } = await this.openMaterializeCarriers(head.ref_state, gitvaultPaths.refState(head.ref_state.object_id), head.retention_roots, gitvaultPaths.retentionRoots(head.retention_roots.object_id), writerKey, { epoch: head.epoch, k_repo: kRepo });
1258
+ if (refState.generation !== head.generation || roots.generation !== head.generation)
1259
+ fail("CHAIN_UNUSABLE", "carrier generation does not match the head", "materializing gitvault head");
1260
+ decryptedRefState = refState;
1261
+ decryptedRoots = roots;
1262
+ decryptPin = { ...headPin };
1263
+ return true;
1264
+ }
1265
+ catch (e) {
1266
+ const code = isRun402Error(e) && e.code ? e.code : "GITVAULT_EPOCH_NOT_OPENABLE";
1267
+ decryptFailure = { generation: current.generation, epoch: current.epoch, rotation_id: current.rotation_id, code, message: e instanceof Error ? e.message : String(e) };
1268
+ decryptFailureError = e;
1269
+ return false;
1270
+ }
1271
+ };
952
1272
  for (;;) {
953
1273
  const page = await this.transport.listHeads({ repo_id: this.repoId, ...request });
954
1274
  progress = verifyHeadsListingPage(page, request, progress, this.repoId);
@@ -956,15 +1276,16 @@ export class GitvaultVault {
956
1276
  if (verified >= this.budget) {
957
1277
  if (persist)
958
1278
  this.keystore.updateRepo(this.repoId, { verified_prefix: pin });
1279
+ persistMaterializedIfAny();
959
1280
  fail("VERIFICATION_BUDGET_EXCEEDED", persist
960
1281
  ? `${verified} heads verified this call; the verified prefix (generation ${pin.generation}) is persisted — call again to continue`
961
1282
  : `${verified} heads verified this call in no-write mode; nothing was persisted — a retry restarts from the original pin, not generation ${pin.generation}`, "verifying gitvault chain", { verified_through: pin.generation, persisted: persist }, [{ action: persist ? "resume verification from the persisted verified prefix" : "re-run without --no-write to persist and resume incrementally, or re-run this same audit call again from the start" }]);
962
1283
  }
963
- const bytes = await this.transport.getObject({ repo_id: this.repoId, path: gitvaultPaths.head(entry.generation) });
1284
+ const bytes = await this.readCachedHeadBytes(entry.generation, entry.stored_bytes_sha256);
964
1285
  if (!bytes)
965
1286
  fail("CHAIN_BROKEN", `listed head ${entry.generation} is absent from storage`, "verifying gitvault chain", { generation: entry.generation });
966
1287
  const head = parseGitvaultStrict(new TextDecoder().decode(bytes));
967
- checkChainLink({ head, stored_bytes: bytes, listed_sha256: entry.stored_bytes_sha256, expected_generation: nextGeneration(pin.generation), prev_sha256: pin.head_sha256, repo_id: this.repoId, writer_public_key: writerKey, writer_key_id: writerKeyId });
1288
+ checkChainLink({ head, stored_bytes: bytes, listed_sha256: entry.stored_bytes_sha256, expected_generation: nextGeneration(pin.generation), prev_sha256: pin.head_sha256, repo_id: this.repoId, writer_public_key: writerKey, writer_key_id: writerKeyId, prev_epoch: prevEpoch });
968
1289
  try {
969
1290
  assertNoTransition(head);
970
1291
  }
@@ -972,24 +1293,156 @@ export class GitvaultVault {
972
1293
  // fail closed: pin stays BELOW the transition head; the verified prefix is cleared (this is the final state, not a budget pause)
973
1294
  if (persist)
974
1295
  this.keystore.updateRepo(this.repoId, { head_pin: pin, verified_prefix: null });
1296
+ persistMaterializedIfAny();
975
1297
  throw e;
976
1298
  }
1299
+ const isRotation = head.transition !== null && head.transition.kind === "rotate_epoch";
1300
+ let rotationPayload = null;
1301
+ if (isRotation) {
1302
+ // Pure/keyless (Part A's structural half — D202's join predicate
1303
+ // needs only this): parses + self-checks the payload, but never
1304
+ // opens an envelope. Collected regardless of decryptValidate so a
1305
+ // later `materialize()` call over an already-chain-verified prefix
1306
+ // can still resolve the epoch keys it needs.
1307
+ rotationPayload = parseRotateEpochPayload(head);
1308
+ rotations.push({ generation: head.generation, epoch: head.epoch, payload: rotationPayload });
1309
+ }
1310
+ prevEpoch = head.epoch;
977
1311
  pin = { generation: head.generation, head_sha256: entry.stored_bytes_sha256, pinned_at: formatGitvaultTimestamp(this.now()) };
978
1312
  lastHead = head;
979
1313
  verified += 1;
1314
+ // The chain walk ALWAYS continues below regardless of decrypt
1315
+ // outcome (`tryDecrypt` never throws) — `!decryptFailure` just stops
1316
+ // WASTING further decrypt attempts once one has failed, since every
1317
+ // later generation shares the same unopenable epoch until (if ever)
1318
+ // a LATER rotation this principal CAN open supersedes it.
1319
+ if (decryptValidate && !decryptFailure)
1320
+ await tryDecrypt(head, pin, rotationPayload ? [{ generation: head.generation, epoch: head.epoch, payload: rotationPayload }] : []);
980
1321
  }
981
1322
  // verified prefix persists per page (resumable) — skipped entirely in no-write mode
982
1323
  if (persist)
983
1324
  this.keystore.updateRepo(this.repoId, { verified_prefix: pin });
1325
+ persistMaterializedIfAny();
984
1326
  const next = nextListingRequest(request, page);
985
1327
  if (!next)
986
1328
  break;
987
1329
  request = next;
988
1330
  }
1331
+ // Catch-up: a call with NOTHING new to walk (this repo's chain-verified
1332
+ // pin was already at `pin`/`lastHead` — e.g. an EARLIER, decrypt-blind
1333
+ // `verifyToNewest({})` call already advanced `head_pin` to the newest,
1334
+ // and THIS is the first decrypt-validating call) never entered the loop
1335
+ // body above at all, so any `rotate_epoch` transitions between the
1336
+ // newest and the highest epoch this call's `epochKeys` seed already
1337
+ // covers were NEVER collected — walk BACKWARD via `prev_sha256` (the
1338
+ // same re-read `chainFrom` uses) to find every one of them, stopping the
1339
+ // INSTANT a generation's own epoch is already a known key (nothing
1340
+ // earlier can matter: every generation before it decrypts under a key
1341
+ // already held). Never a silent gap — this is exactly the mechanism
1342
+ // that makes a vault readable end to end even when chain verification
1343
+ // and decrypt-validation happened in genuinely SEPARATE calls.
1344
+ if (decryptValidate && !decryptFailure && lastHead && !decryptedRefState) {
1345
+ const backwardRotations = [];
1346
+ let cur = lastHead;
1347
+ let curPin = pin;
1348
+ while (cur && !epochKeys[cur.epoch]) {
1349
+ if (cur.transition !== null && cur.transition.kind === "rotate_epoch") {
1350
+ const payload = parseRotateEpochPayload(cur);
1351
+ backwardRotations.unshift({ generation: cur.generation, epoch: cur.epoch, payload });
1352
+ if (!rotations.some((r) => r.generation === cur.generation))
1353
+ rotations.push({ generation: cur.generation, epoch: cur.epoch, payload });
1354
+ }
1355
+ if (cur.generation === "0000000000000001") {
1356
+ cur = null;
1357
+ break;
1358
+ }
1359
+ const prevGen = bigIntToGeneration(generationToBigInt(cur.generation) - 1n);
1360
+ // Design D3: this downward walk is rooted in `lastHead`, itself just
1361
+ // chain-verified above (or trusted from an earlier call's own
1362
+ // verification) — the same "freshness already established" argument
1363
+ // {@link readCachedHeadBytes} documents, so a cache hit is safe here too.
1364
+ const bytes = await this.readCachedHeadBytes(prevGen, cur.prev_sha256);
1365
+ if (!bytes || sha256Hex(bytes) !== cur.prev_sha256)
1366
+ fail("CHAIN_BROKEN", `head ${prevGen} does not match the chain during epoch-key catch-up`, "materializing gitvault head", { generation: prevGen });
1367
+ cur = parseGitvaultStrict(new TextDecoder().decode(bytes));
1368
+ curPin = { generation: prevGen, head_sha256: bytes ? sha256Hex(bytes) : curPin.head_sha256, pinned_at: formatGitvaultTimestamp(this.now()) };
1369
+ }
1370
+ rotations.sort((a, b) => (a.generation < b.generation ? -1 : a.generation > b.generation ? 1 : 0));
1371
+ // `cur` (non-null, non-lastHead) is the BOUNDARY generation whose own
1372
+ // epoch this call already holds a key for — establish `decryptPin`
1373
+ // THERE first (this always succeeds: its epoch is, by the loop's own
1374
+ // stop condition, already in `epochKeys`), so `refs`/`head_target`
1375
+ // reflect a REAL decrypted generation even when every pending rotation
1376
+ // toward `lastHead` then fails. Without this, a keystore that has
1377
+ // never once materialized (no `materialized_pin` yet) reports
1378
+ // `decryptable_to_generation: genesis` on an epoch-open failure, even
1379
+ // though generations well before the failing rotation were genuinely
1380
+ // decryptable all along.
1381
+ if (cur && cur.generation !== lastHead.generation)
1382
+ await tryDecrypt(cur, curPin, []);
1383
+ if (!decryptFailure)
1384
+ await tryDecrypt(lastHead, pin, backwardRotations);
1385
+ }
989
1386
  if (persist)
990
1387
  this.keystore.updateRepo(this.repoId, { head_pin: pin, verified_prefix: null });
991
- return { generation: pin.generation, head_sha256: pin.head_sha256, head: lastHead, genesis };
1388
+ persistMaterializedIfAny();
1389
+ // `strict` (materialize()'s ordinary, fail-closed read path) is enforced
1390
+ // HERE, once, after the full keyless chain walk has already run to
1391
+ // completion — never mid-walk (see `tryDecrypt`'s own doc comment).
1392
+ if (strict && decryptValidate && decryptFailure)
1393
+ throw decryptFailureError;
1394
+ return {
1395
+ generation: pin.generation,
1396
+ head_sha256: pin.head_sha256,
1397
+ head: lastHead,
1398
+ genesis,
1399
+ rotations,
1400
+ decrypt: decryptValidate
1401
+ ? {
1402
+ decryptable_to_generation: decryptPin?.generation ?? GITVAULT_GENESIS_GENERATION,
1403
+ ref_state: decryptedRefState,
1404
+ retention_roots: decryptedRoots,
1405
+ epoch_keys_hex: epochKeys,
1406
+ failure: decryptFailure,
1407
+ }
1408
+ : null,
1409
+ };
1410
+ }
1411
+ /**
1412
+ * Read one NEWLY-LISTED head's raw bytes, trying the local cache first
1413
+ * (design D3), re-verified against `expectedSha256` — the SAME check
1414
+ * network bytes get. Safe to cache-serve ONLY because a caller here
1415
+ * always supplies a hash a FRESH `listHeads` call just reported as
1416
+ * current — the cache never substitutes for that freshness check, it just
1417
+ * avoids re-downloading bytes a live listing already vouched for. A cache
1418
+ * miss or a hash mismatch (never trusted, always falls through) fetches
1419
+ * from the network and, on a match, refreshes the cache entry. Returns
1420
+ * whatever the network returned on a final miss too (including `null`) —
1421
+ * callers keep their own existing absent/mismatch handling unchanged.
1422
+ *
1423
+ * Deliberately NOT used by {@link readHead}: that call verifies the
1424
+ * PINNED generation is STILL held by the server, with no fresh listing
1425
+ * involved — its entire purpose is detecting server-side loss/rollback,
1426
+ * which a cache read can never observe. That call always goes live.
1427
+ */
1428
+ async readCachedHeadBytes(generation, expectedSha256) {
1429
+ const cached = this.keystore.readCachedHead(this.repoId, generation);
1430
+ if (cached && sha256Hex(cached.bytes) === expectedSha256)
1431
+ return cached.bytes;
1432
+ const bytes = await this.transport.getObject({ repo_id: this.repoId, path: gitvaultPaths.head(generation) });
1433
+ if (bytes && sha256Hex(bytes) === expectedSha256)
1434
+ this.keystore.writeCachedHead(this.repoId, generation, expectedSha256, bytes);
1435
+ return bytes;
992
1436
  }
1437
+ /**
1438
+ * Confirm the server STILL holds the pinned generation, unchanged —
1439
+ * ALWAYS a live network read (see {@link readCachedHeadBytes}'s doc
1440
+ * comment for why this specific check is not cacheable: it exists to
1441
+ * detect server-side loss, which a cache can never observe). A
1442
+ * successful read still WARMS the cache afterward — later reads of this
1443
+ * SAME generation via the chain walk or restore benefit from it; only
1444
+ * THIS call's own read is exempt.
1445
+ */
993
1446
  async readHead(generation, expectedSha256) {
994
1447
  const bytes = await this.transport.getObject({ repo_id: this.repoId, path: gitvaultPaths.head(generation) });
995
1448
  // Absent ⇒ the vault no longer holds a generation this client authenticated: a ROLLBACK, not a
@@ -1001,16 +1454,30 @@ export class GitvaultVault {
1001
1454
  }
1002
1455
  if (sha256Hex(bytes) !== expectedSha256)
1003
1456
  fail("CHAIN_BROKEN", `pinned head ${generation} no longer hashes to the pin`, "reading pinned gitvault head", { generation });
1457
+ this.keystore.writeCachedHead(this.repoId, generation, expectedSha256, bytes);
1004
1458
  return parseGitvaultStrict(new TextDecoder().decode(bytes));
1005
1459
  }
1006
- /** Decrypt one encrypted carrier object by its receipt; any failure is `CHAIN_UNUSABLE`. */
1007
- async openCarrier(kind, receipt, path, writerKey) {
1008
- const frame = await this.transport.getObject({ repo_id: this.repoId, path });
1460
+ /**
1461
+ * Decrypt + verify one carrier's already-fetched ciphertext frame; any
1462
+ * failure is `CHAIN_UNUSABLE`. Split out of {@link openCarrier} so a
1463
+ * caller that fetched the frame itself (a cache hit, or one leg of a
1464
+ * batched read) can reuse the same decode + identity/signature checks.
1465
+ *
1466
+ * `keyOverride` supplies the exact `(epoch, k_repo)` this carrier was
1467
+ * sealed under — REQUIRED for any generation that is not necessarily
1468
+ * under `this.epoch()`/`this.kRepo()` (this principal's CURRENT
1469
+ * pointer), which is exactly the case across an epoch rotation; omitted
1470
+ * call sites (checkpoint/prune paths untouched by this fold) keep the
1471
+ * prior CURRENT-pointer behavior unchanged.
1472
+ */
1473
+ decodeCarrierFrame(kind, receipt, frame, writerKey, keyOverride) {
1474
+ const epoch = keyOverride?.epoch ?? this.epoch();
1475
+ const kRepo = keyOverride?.k_repo ?? this.kRepo();
1009
1476
  if (!frame)
1010
1477
  fail("CHAIN_UNUSABLE", `${kind} ${receipt.object_id} is absent from storage`, "materializing gitvault head", { object_id: receipt.object_id }, [{ action: "stay read-only at the materialized pin; run the repair path" }]);
1011
1478
  let plaintext;
1012
1479
  try {
1013
- plaintext = openFrame({ k_obj: deriveObjectKey(this.kRepo(), this.repoId, this.epoch(), kind, receipt.object_id), repo_id: this.repoId, object_kind: kind, object_id: receipt.object_id, epoch: this.epoch(), frame, expected_ciphertext_sha256: receipt.ciphertext_sha256 });
1480
+ plaintext = openFrame({ k_obj: deriveObjectKey(kRepo, this.repoId, epoch, kind, receipt.object_id), repo_id: this.repoId, object_kind: kind, object_id: receipt.object_id, epoch, frame, expected_ciphertext_sha256: receipt.ciphertext_sha256 });
1014
1481
  }
1015
1482
  catch (e) {
1016
1483
  fail("CHAIN_UNUSABLE", `${kind} ${receipt.object_id} cannot be opened: ${e.message}`, "materializing gitvault head", { object_id: receipt.object_id }, [{ action: "stay read-only at the materialized pin; run the repair path" }]);
@@ -1021,29 +1488,94 @@ export class GitvaultVault {
1021
1488
  }
1022
1489
  return object;
1023
1490
  }
1491
+ /**
1492
+ * Fetch (network) + decrypt one carrier object by its receipt; any
1493
+ * failure is `CHAIN_UNUSABLE`. Design D3: `ref_state`/`retention_roots`
1494
+ * ciphertext is cached beside the keystore's per-repo state, re-verified
1495
+ * against `receipt.ciphertext_sha256` on every use — a hit skips the
1496
+ * network read entirely. `checkpoint_manifest` is never cached (outside
1497
+ * D3's table). The cache write derives its generation tag from the
1498
+ * DECODED object's own `generation` field, so no caller needs to thread
1499
+ * one through by hand.
1500
+ */
1501
+ async openCarrier(kind, receipt, path, writerKey, keyOverride) {
1502
+ const cacheable = kind === "ref_state" || kind === "retention_roots";
1503
+ const cached = cacheable ? this.keystore.readCachedCarrier(this.repoId, receipt.object_id) : null;
1504
+ const cacheHit = cached && sha256Hex(cached.bytes) === receipt.ciphertext_sha256;
1505
+ const frame = cacheHit ? cached.bytes : await this.transport.getObject({ repo_id: this.repoId, path });
1506
+ const object = this.decodeCarrierFrame(kind, receipt, frame, writerKey, keyOverride);
1507
+ if (cacheable && !cacheHit && frame && typeof object.generation === "string") {
1508
+ this.keystore.writeCachedCarrier(this.repoId, receipt.object_id, object.generation, receipt.ciphertext_sha256, frame);
1509
+ }
1510
+ return object;
1511
+ }
1512
+ /**
1513
+ * `materialize`'s ref_state + retention_roots read, batched (design D2):
1514
+ * cache-check both first, then ONE `getObjects` call (one presigned batch
1515
+ * + concurrent GETs) for whichever missed, instead of two independent
1516
+ * presign-then-GET round trips. Falls through to zero network calls when
1517
+ * both are cache-warm. `keyOverride` is the epoch/k_repo the CARRYING
1518
+ * HEAD sealed both carriers under (D194) — both always share one head, so
1519
+ * one override serves both, unlike the WAL/checkpoint pack loops in
1520
+ * {@link restoreObjectsInto} which span many heads and many epochs.
1521
+ */
1522
+ async openMaterializeCarriers(refStateReceipt, refStatePath, rootsReceipt, rootsPath, writerKey, keyOverride) {
1523
+ const cachedRefState = this.keystore.readCachedCarrier(this.repoId, refStateReceipt.object_id);
1524
+ const cachedRoots = this.keystore.readCachedCarrier(this.repoId, rootsReceipt.object_id);
1525
+ const refStateHit = Boolean(cachedRefState && sha256Hex(cachedRefState.bytes) === refStateReceipt.ciphertext_sha256);
1526
+ const rootsHit = Boolean(cachedRoots && sha256Hex(cachedRoots.bytes) === rootsReceipt.ciphertext_sha256);
1527
+ const missingPaths = [];
1528
+ if (!refStateHit)
1529
+ missingPaths.push(refStatePath);
1530
+ if (!rootsHit)
1531
+ missingPaths.push(rootsPath);
1532
+ const fetched = missingPaths.length > 0 ? await this.transport.getObjects({ repo_id: this.repoId, paths: missingPaths }) : [];
1533
+ let next = 0;
1534
+ const refStateFrame = refStateHit ? cachedRefState.bytes : (fetched[next++] ?? null);
1535
+ const rootsFrame = rootsHit ? cachedRoots.bytes : (fetched[next++] ?? null);
1536
+ const refState = this.decodeCarrierFrame("ref_state", refStateReceipt, refStateFrame, writerKey, keyOverride);
1537
+ const roots = this.decodeCarrierFrame("retention_roots", rootsReceipt, rootsFrame, writerKey, keyOverride);
1538
+ if (!refStateHit && refStateFrame)
1539
+ this.keystore.writeCachedCarrier(this.repoId, refStateReceipt.object_id, refState.generation, refStateReceipt.ciphertext_sha256, refStateFrame);
1540
+ if (!rootsHit && rootsFrame)
1541
+ this.keystore.writeCachedCarrier(this.repoId, rootsReceipt.object_id, roots.generation, rootsReceipt.ciphertext_sha256, rootsFrame);
1542
+ return { refState, roots };
1543
+ }
1024
1544
  /**
1025
1545
  * Verify to newest, then decrypt + apply its carriers — advancing the
1026
1546
  * materialized pin. `options.persist` (default `true`) is forwarded to
1027
1547
  * {@link verifyToNewest} and gates this method's OWN `materialized_pin`
1028
1548
  * write the same way — `repos fsck --no-write` computes and returns the
1029
1549
  * real ref map and generation without moving either local pin.
1550
+ *
1551
+ * Runs `verifyToNewest({..., decryptValidate: true, strict: true})`
1552
+ * internally (Part A: an ordinary read across an admitted `rotate_epoch`
1553
+ * transition now opens the rotation's own envelope and decrypts under the
1554
+ * NEW epoch, chaining through multiple sequential rotations; a keystore
1555
+ * with no envelope for a new epoch fails CLOSED with
1556
+ * `GITVAULT_EPOCH_NOT_OPENABLE`, never a bare `GITVAULT_AEAD_AUTH_FAILURE`)
1557
+ * — `strict: true` means this call throws exactly where the OLD
1558
+ * (pre-fix) `materialize()` silently produced a wrong `k_obj` instead.
1030
1559
  */
1031
1560
  async materialize(options = {}) {
1032
1561
  const persist = options.persist ?? true;
1033
- const state = await this.verifyToNewest({ persist });
1034
- const writerKey = state.genesis.creator_signing_pubkey;
1562
+ const state = await this.verifyToNewest({ persist, decryptValidate: true, strict: true });
1035
1563
  if (!state.head) {
1036
1564
  if (persist)
1037
1565
  this.keystore.updateRepo(this.repoId, { materialized_pin: { generation: state.generation, head_sha256: state.head_sha256, pinned_at: formatGitvaultTimestamp(this.now()) } });
1038
- return { ...state, ref_state: null, retention_roots: null, refs: {}, roots: [], head_target: { kind: "symref", ref: "refs/heads/main" } };
1566
+ return { ...state, ref_state: null, retention_roots: null, refs: {}, roots: [], head_target: { kind: "symref", ref: "refs/heads/main" }, epoch_keys_hex: state.decrypt?.epoch_keys_hex ?? {} };
1039
1567
  }
1040
- const refState = await this.openCarrier("ref_state", state.head.ref_state, gitvaultPaths.refState(state.head.ref_state.object_id), writerKey);
1041
- const roots = await this.openCarrier("retention_roots", state.head.retention_roots, gitvaultPaths.retentionRoots(state.head.retention_roots.object_id), writerKey);
1568
+ // `strict: true` guarantees `state.decrypt` is non-null with no failure
1569
+ // and `decryptable_to_generation === state.generation` it throws
1570
+ // otherwise, so these are never null/mismatched here. `verifyToNewest`'s
1571
+ // own `tryDecrypt` is what actually fetches these now (design D2/D3's
1572
+ // batching + caching moved there with it — see its doc comment) —
1573
+ // `openMaterializeCarriers` no longer has a caller from here.
1574
+ const refState = state.decrypt.ref_state;
1575
+ const roots = state.decrypt.retention_roots;
1042
1576
  if (refState.generation !== state.generation || roots.generation !== state.generation)
1043
1577
  fail("CHAIN_UNUSABLE", "carrier generation does not match the head", "materializing gitvault head");
1044
- if (persist)
1045
- this.keystore.updateRepo(this.repoId, { materialized_pin: { generation: state.generation, head_sha256: state.head_sha256, pinned_at: formatGitvaultTimestamp(this.now()) } });
1046
- return { ...state, ref_state: refState, retention_roots: roots, refs: { ...refState.refs }, roots: roots.roots.map((r) => ({ ...r })), head_target: refState.head_target };
1578
+ return { ...state, ref_state: refState, retention_roots: roots, refs: { ...refState.refs }, roots: roots.roots.map((r) => ({ ...r })), head_target: refState.head_target, epoch_keys_hex: state.decrypt.epoch_keys_hex };
1047
1579
  }
1048
1580
  // ── envelope recipients (gitvault-human-envelopes task 4.1, the ADD-path workaround) ──
1049
1581
  /**
@@ -1595,11 +2127,18 @@ export class GitvaultVault {
1595
2127
  return { outcome: "conflict", generation, winner: admitted.winner };
1596
2128
  return { outcome: "admitted", generation, head, head_sha256: admitted.head_sha256, admission_record_sha256: admitted.admission_record_sha256, capture_receipt: admitted.capture_receipt, form, refs: input.refs };
1597
2129
  }
1598
- /** The complete push: verify → materialize → evaluate → pack → upload → head → admit (409: re-apply to the winner, retry) → read back → advance pins. */
2130
+ /**
2131
+ * The complete push: verify → materialize → evaluate → pack → upload → head → admit (409: re-apply to the winner, retry) → read back → advance pins.
2132
+ *
2133
+ * `options.base` (design D1), when supplied, is used VERBATIM for the
2134
+ * first attempt instead of a fresh {@link materialize} call — a conflict
2135
+ * retry always re-materializes from storage, exactly as when no base is
2136
+ * supplied.
2137
+ */
1599
2138
  async push(options) {
1600
2139
  let conflicts = 0;
2140
+ let base = options.base ?? (await this.materialize());
1601
2141
  for (;;) {
1602
- const base = await this.materialize();
1603
2142
  const evaluation = await evaluateRefTransaction(base.refs, options.transaction, { isAncestor: (a, d) => isAncestor(this.git(), a, d), protocol_refs: options.protocol_refs });
1604
2143
  const published = await this.publishGeneration({
1605
2144
  base, refs: evaluation.refs, dropped: evaluation.dropped, head_target: options.head_target ?? base.head_target,
@@ -1609,7 +2148,8 @@ export class GitvaultVault {
1609
2148
  conflicts += 1;
1610
2149
  if (conflicts > this.retries)
1611
2150
  fail("HEAD_CAS_CONFLICT", `admission lost ${conflicts} races at generation ${published.generation}; giving up`, "publishing gitvault head", { generation: published.generation, winner: published.winner }, [{ action: "verify the attached winner from storage, rebase, retry" }]);
1612
- continue; // the loop re-verifies from storage (the winner), re-applies the transaction to the winner's map, retries
2151
+ base = await this.materialize(); // re-verify from storage (the winner) before re-applying the transaction
2152
+ continue;
1613
2153
  }
1614
2154
  // `push()` never sets `dry_run`, so this outcome is unreachable here —
1615
2155
  // narrows `published` to `"admitted"` for the return below.
@@ -1639,7 +2179,11 @@ export class GitvaultVault {
1639
2179
  * that case instead of calling this method).
1640
2180
  */
1641
2181
  async planPush(options) {
1642
- const base = await this.materialize();
2182
+ // Design D1: a caller-supplied base reports against the CALLER's own
2183
+ // observed snapshot instead of materializing a fresh one — same
2184
+ // verbatim-first-attempt contract as `push`, minus the retry loop
2185
+ // (a dry run never re-materializes; there is nothing to retry against).
2186
+ const base = options.base ?? (await this.materialize());
1643
2187
  const evaluation = await evaluateRefTransaction(base.refs, options.transaction, { isAncestor: (a, d) => isAncestor(this.git(), a, d), protocol_refs: options.protocol_refs });
1644
2188
  const headTarget = options.head_target ?? base.head_target;
1645
2189
  const published = await this.publishGeneration({
@@ -1752,10 +2296,18 @@ export class GitvaultVault {
1752
2296
  const result = await this.transport.admitHead({ repo_id: this.repoId, generation: head.generation, stored_bytes: bytes, stored_bytes_sha256: hash });
1753
2297
  if (result.outcome === "conflict")
1754
2298
  return result;
2299
+ // Post-admit readback is a spec-mandated verification obligation, never
2300
+ // a cache lookup — it exists to prove the SERVER stored what was sent,
2301
+ // which only a genuine network read can answer.
1755
2302
  const back = await this.transport.getObject({ repo_id: this.repoId, path: gitvaultPaths.head(head.generation) });
1756
2303
  if (!back || sha256Hex(back) !== hash) {
1757
2304
  fail("GITVAULT_HEAD_READBACK_MISMATCH", `the admitted head at generation ${head.generation} read back ${back ? sha256Hex(back) : "absent"} ≠ ${hash}; the push is NOT reported as landed and no pin advances`, "reading back admitted head", { generation: head.generation, expected: hash, observed: back ? sha256Hex(back) : null });
1758
2305
  }
2306
+ // Design D3: the readback just network-confirmed these exact bytes —
2307
+ // warm the cache with them so the NEXT operation (this session's own
2308
+ // `materialize` retry, or a later invocation) can skip re-fetching this
2309
+ // generation's head entirely.
2310
+ this.keystore.writeCachedHead(this.repoId, head.generation, hash, back);
1759
2311
  const pin = { generation: head.generation, head_sha256: hash, pinned_at: formatGitvaultTimestamp(this.now()) };
1760
2312
  this.keystore.updateRepo(this.repoId, { head_pin: pin, materialized_pin: pin, verified_prefix: null });
1761
2313
  return { outcome: "admitted", head_sha256: hash, admission_record_sha256: result.admission_record_sha256, capture_receipt: result.capture_receipt };
@@ -1805,20 +2357,15 @@ export class GitvaultVault {
1805
2357
  * manifest — e.g. one another principal/machine published) falls through
1806
2358
  * to the unchanged network path below.
1807
2359
  *
1808
- * **Known gap this happens to route around, not fix:** the gateway's
1809
- * `POST …/object-reads` route does not yet recognize `recipient_pin_manifest`
1810
- * as a readable `object_kind` at all (`services/gitvault/reads.ts`
1811
- * `validateReadRequest`'s null-`idScalar` branch is still hardcoded to
1812
- * `key_envelope`'s `{epoch, recipient_fingerprint}` shape the ONE other
1813
- * path-addressed kind, `recipient_pin_manifest`, was never folded in when
1814
- * D197 shipped it with its own `{pin_manifest_version}` `pathFields`).
1815
- * Confirmed live 2026-08-27 against `src_c78d2f710a8f49d22f9c66faf2a915cd`:
1816
- * every read attempt fails `400 VALIDATION_FAILED "objects[0]: epoch must
1817
- * be 16 hex"`. This cache unblocks the SAME keystore re-reading its OWN
1818
- * just-published manifest (this ceremony's actual need) but does nothing
1819
- * for a genuinely fresh keystore/machine reading an EXISTING manifest for
1820
- * the first time (§4.11's "fresh client... SEEDS its local pin file from
1821
- * it" onboarding path) — that still needs the gateway fix.
2360
+ * History: this cache originally also routed around a gateway gap
2361
+ * `POST …/object-reads` rejected every `recipient_pin_manifest` read
2362
+ * (its null-`idScalar` validation was hardcoded to `key_envelope`'s
2363
+ * `{epoch, recipient_fingerprint}` shape, never generalized when D197
2364
+ * shipped the second path-addressed kind), so the network fallback below
2365
+ * always 400'd. That gateway bug is fixed (deployed and live-verified
2366
+ * 2026-08-28): the network path works for any keystore, including
2367
+ * §4.11's fresh-client "SEEDS its local pin file from it" onboarding.
2368
+ * The cache stays purely as the round-trip saver described above.
1822
2369
  */
1823
2370
  async readPinManifestObject(receipt) {
1824
2371
  const known = this.repoFile().known_pin_manifest;
@@ -2323,6 +2870,20 @@ export class GitvaultVault {
2323
2870
  * Pull the newest checkpoint (if any) and every later WAL pack into
2324
2871
  * `targetRepoDir` (an initialized repository), then verify every canonical
2325
2872
  * ref + the HEAD target resolves. Returns the materialized refs.
2873
+ *
2874
+ * Design D5 (gitvault-client-round-trips): incremental above the local
2875
+ * `restored_through` marker (this target directory's own local git
2876
+ * config, read/written by {@link readGitvaultRestoreMarker}/{@link
2877
+ * writeGitvaultRestoreMarker}) — replaying only the WAL packs of
2878
+ * generations above it — WHEN the walk from newest back to the marker is
2879
+ * plain WAL the entire way. The moment that walk crosses a
2880
+ * checkpoint-bearing, repair, or transition head (checked BEFORE
2881
+ * including a head, so the marker-boundary and wholesale-boundary checks
2882
+ * share one pass), this falls back to the ORIGINAL wholesale walk below —
2883
+ * re-fetching any head already visited during the aborted attempt is a
2884
+ * cache hit (design D3), never a second network round trip. Coverage
2885
+ * verification and retained-refs reconciliation run UNCHANGED on both
2886
+ * paths; the marker only advances after they both succeed.
2326
2887
  */
2327
2888
  async restoreObjectsInto(targetRepoDir) {
2328
2889
  const newest = await this.materialize();
@@ -2331,21 +2892,69 @@ export class GitvaultVault {
2331
2892
  return { refs: {}, head_target: newest.head_target, generation: newest.generation, retained_refs };
2332
2893
  }
2333
2894
  const writerKey = newest.genesis.creator_signing_pubkey;
2334
- // walk back to the newest checkpoint-bearing head
2895
+ const marker = await readGitvaultRestoreMarker(targetRepoDir);
2896
+ // Already up to date locally: no pack fetch, not even a walk — only the
2897
+ // (entirely local) coverage + retained-refs bookkeeping below runs.
2898
+ if (marker && marker.generation === newest.generation && marker.head_sha256 === newest.head_sha256) {
2899
+ for (const t of GitvaultVault.coverageTips(newest.refs, newest.roots, newest.head_target)) {
2900
+ if (!(await hasObject(targetRepoDir, t)))
2901
+ fail("CHAIN_UNUSABLE", `covered tip ${t} does not resolve after restore`, "restoring gitvault objects", { oid: t });
2902
+ }
2903
+ const retained_refs = await reconcileRetainedTipRefs(targetRepoDir, { refs: newest.refs, roots: newest.roots, head_target: newest.head_target });
2904
+ return { refs: newest.refs, head_target: newest.head_target, generation: newest.generation, retained_refs };
2905
+ }
2906
+ // Walk back from newest, trying for an INCREMENTAL boundary at the
2907
+ // marker (a pure local prev_sha256 comparison — no fetch needed to
2908
+ // CONFIRM the boundary itself); abandon incremental the moment a
2909
+ // non-plain-WAL head would have to be included, and restart as the
2910
+ // ORIGINAL wholesale walk (stopping at the newest checkpoint, or
2911
+ // genesis) — every head visited in the aborted attempt is a D3 cache
2912
+ // hit on this restart, so abandoning costs no extra round trip.
2335
2913
  const heads = [];
2336
2914
  let cur = newest.head;
2915
+ let incremental = marker !== null;
2337
2916
  while (cur) {
2917
+ // `cur` disqualifies incremental (checked BEFORE including it): abort
2918
+ // and restart as the wholesale walk. Re-visiting heads already fetched
2919
+ // this attempt costs nothing (D3 cache hits).
2920
+ if (incremental && (cur.checkpoint !== null || cur.transition !== null)) {
2921
+ incremental = false;
2922
+ heads.length = 0;
2923
+ cur = newest.head;
2924
+ continue;
2925
+ }
2338
2926
  heads.unshift(cur);
2339
- if (cur.checkpoint)
2340
- break;
2927
+ if (!incremental && cur.checkpoint)
2928
+ break; // wholesale stop: the newest checkpoint-bearing head
2341
2929
  if (cur.generation === "0000000000000001")
2342
- break;
2930
+ break; // genesis — a hard stop either way
2931
+ // Before fetching `cur`'s predecessor, check whether the marker
2932
+ // already names it — a pure LOCAL comparison against bytes this
2933
+ // client already applied last time, no network round trip.
2343
2934
  const prevGen = bigIntToGeneration(generationToBigInt(cur.generation) - 1n);
2344
- const bytes = await this.transport.getObject({ repo_id: this.repoId, path: gitvaultPaths.head(prevGen) });
2935
+ if (incremental && cur.prev_sha256 === marker.head_sha256 && prevGen === marker.generation) {
2936
+ cur = null; // the predecessor is the marker's own head — already applied; `heads` already holds everything above it
2937
+ break;
2938
+ }
2939
+ const bytes = await this.readCachedHeadBytes(prevGen, cur.prev_sha256);
2345
2940
  if (!bytes || sha256Hex(bytes) !== cur.prev_sha256)
2346
2941
  fail("CHAIN_BROKEN", `head ${prevGen} does not match the chain during restore`, "restoring gitvault objects");
2347
2942
  cur = parseGitvaultStrict(new TextDecoder().decode(bytes));
2348
2943
  }
2944
+ // Every object below is decrypted under ITS OWN carrying head's `epoch`
2945
+ // (D194) — a covered span crossing a rotation mixes epochs, so this
2946
+ // NEVER falls back to `this.kRepo()`/`this.epoch()` (this principal's
2947
+ // CURRENT pointer, which is the NEWEST epoch, not necessarily every
2948
+ // historical one a restore spans). `newest.epoch_keys_hex` is the full
2949
+ // map `materialize()` just resolved (throwing `GITVAULT_EPOCH_NOT_OPENABLE`
2950
+ // fail-closed if any needed epoch could not be opened), so every lookup
2951
+ // below is guaranteed present.
2952
+ const kRepoForEpoch = (epoch) => {
2953
+ const hex = newest.epoch_keys_hex[epoch];
2954
+ if (!hex)
2955
+ fail("GITVAULT_EPOCH_NOT_OPENABLE", `no locally known key for epoch ${epoch} while restoring objects`, "restoring gitvault objects", { epoch });
2956
+ return hexToBytes(hex);
2957
+ };
2349
2958
  const first = heads[0];
2350
2959
  if (first.checkpoint) {
2351
2960
  const claimBytes = await this.transport.getObject({ repo_id: this.repoId, path: gitvaultPaths.claimSet(first.checkpoint.claim_set.object_id) });
@@ -2354,26 +2963,42 @@ export class GitvaultVault {
2354
2963
  const claimSet = parseGitvaultStrict(new TextDecoder().decode(claimBytes));
2355
2964
  if (!verifyGitvaultObject(claimSet, writerKey))
2356
2965
  fail("CHECKPOINT_INCOMPLETE", "claim set signature fails", "restoring gitvault objects");
2357
- const manifest = await this.openCarrier("checkpoint_manifest", claimSet.manifest_receipt, gitvaultPaths.checkpointManifest(claimSet.manifest_receipt.object_id), writerKey);
2966
+ 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) });
2358
2967
  checkClaimSetEquality(claimSet, manifest, first.checkpoint.covers_through_generation);
2359
- for (const p of manifest.packs) {
2360
- const frame = await this.transport.getObject({ repo_id: this.repoId, path: gitvaultPaths.checkpointPack(p.object_id) });
2968
+ // Design D2: every checkpoint pack is independent — one batched
2969
+ // presign for all of them, THEN applied via index-pack strictly in
2970
+ // manifest order (the fetch is concurrent; the local git write is
2971
+ // not, and does not need to be).
2972
+ const frames = await this.transport.getObjects({ repo_id: this.repoId, paths: manifest.packs.map((p) => gitvaultPaths.checkpointPack(p.object_id)) });
2973
+ for (let i = 0; i < manifest.packs.length; i++) {
2974
+ const p = manifest.packs[i];
2975
+ const frame = frames[i] ?? null;
2361
2976
  if (!frame)
2362
2977
  fail("CHECKPOINT_INCOMPLETE", `checkpoint pack ${p.object_id} absent`, "restoring gitvault objects");
2363
- 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 });
2978
+ 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 });
2364
2979
  if (sha256Hex(plain) !== p.plaintext_sha256 || String(plain.length) !== p.plaintext_size_bytes)
2365
2980
  fail("CHECKPOINT_INCOMPLETE", `checkpoint pack ${p.object_id} plaintext mismatch`, "restoring gitvault objects");
2366
2981
  await hardenedGit(targetRepoDir, ["index-pack", "--stdin", "--strict"], { input: plain });
2367
2982
  }
2368
2983
  }
2369
- for (const h of heads) {
2370
- for (const w of h.wal_entries) {
2371
- const frame = await this.transport.getObject({ repo_id: this.repoId, path: gitvaultPaths.wal(w.object_id) });
2372
- if (!frame)
2373
- fail("CHAIN_UNUSABLE", `WAL pack ${w.object_id} absent`, "restoring gitvault objects");
2374
- const plain = openFrame({ k_obj: deriveObjectKey(this.kRepo(), this.repoId, this.epoch(), "wal_pack", w.object_id), repo_id: this.repoId, object_kind: "wal_pack", object_id: w.object_id, epoch: this.epoch(), frame, expected_ciphertext_sha256: w.ciphertext_sha256 });
2375
- await hardenedGit(targetRepoDir, ["index-pack", "--stdin", "--strict"], { input: plain });
2376
- }
2984
+ // Design D2: every WAL pack across every head in this restore's range is
2985
+ // independent — one batched presign for the whole set (this is the
2986
+ // "restore pack set" the design's own D2 prose names alongside
2987
+ // materialize's carriers), fetched concurrently, then applied via
2988
+ // index-pack in the SAME chain order the wholesale path always used
2989
+ // (git's pack application is sequential; the network fetch need not be).
2990
+ // Each entry decrypts under its OWN carrying head's epoch (D194) — the
2991
+ // flattened list keeps that pairing so a rotation-spanning restore never
2992
+ // reuses one head's epoch for another's pack.
2993
+ const walEntries = heads.flatMap((h) => h.wal_entries.map((w) => ({ w, epoch: h.epoch })));
2994
+ const walFrames = await this.transport.getObjects({ repo_id: this.repoId, paths: walEntries.map(({ w }) => gitvaultPaths.wal(w.object_id)) });
2995
+ for (let i = 0; i < walEntries.length; i++) {
2996
+ const { w, epoch } = walEntries[i];
2997
+ const frame = walFrames[i] ?? null;
2998
+ if (!frame)
2999
+ fail("CHAIN_UNUSABLE", `WAL pack ${w.object_id} absent`, "restoring gitvault objects");
3000
+ const plain = openFrame({ k_obj: deriveObjectKey(kRepoForEpoch(epoch), this.repoId, epoch, "wal_pack", w.object_id), repo_id: this.repoId, object_kind: "wal_pack", object_id: w.object_id, epoch, frame, expected_ciphertext_sha256: w.ciphertext_sha256 });
3001
+ await hardenedGit(targetRepoDir, ["index-pack", "--stdin", "--strict"], { input: plain });
2377
3002
  }
2378
3003
  // the §4.7 coverage set — canonical refs ∪ unexpired roots ∪ the HEAD target — must all resolve.
2379
3004
  for (const t of GitvaultVault.coverageTips(newest.refs, newest.roots, newest.head_target)) {
@@ -2385,6 +3010,12 @@ export class GitvaultVault {
2385
3010
  // ref so `git fsck` is silent. Runs AFTER coverage verification so a
2386
3011
  // reconcile never references an object the restore itself failed to land.
2387
3012
  const retained_refs = await reconcileRetainedTipRefs(targetRepoDir, { refs: newest.refs, roots: newest.roots, head_target: newest.head_target });
3013
+ // Design D5: the marker advances ONLY here — after coverage verification
3014
+ // and the retained-refs reconcile both succeeded. An interrupted restore
3015
+ // (a crash or throw anywhere above) leaves the marker unadvanced, so the
3016
+ // next fetch simply repeats this same range; applying an already-applied
3017
+ // WAL pack again is the existing, already-safe wholesale behavior.
3018
+ await writeGitvaultRestoreMarker(targetRepoDir, newest.generation, newest.head_sha256);
2388
3019
  return { refs: newest.refs, head_target: newest.head_target, generation: newest.generation, retained_refs };
2389
3020
  }
2390
3021
  }