run402 4.47.0 → 4.49.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.
@@ -559,6 +559,33 @@ export function gitvaultLedgerId(read) {
559
559
  }
560
560
  function b64(bytes) { return Buffer.from(bytes).toString("base64"); }
561
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
+ }
562
589
  /**
563
590
  * The `fetch`-backed transport over the SDK kernel. Presigned PUTs carry
564
591
  * `If-None-Match: *` (create-only — the bucket policy demands it) and the
@@ -645,6 +672,49 @@ export function createGitvaultHttpTransport(client, options = {}) {
645
672
  fail("GITVAULT_OBJECT_READ_FAILED", `object GET failed (HTTP ${r.status}) for ${path}`, "reading gitvault object", { path, status: r.status });
646
673
  return new Uint8Array(await r.arrayBuffer());
647
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
+ }
648
718
  async function upload(repoId, objects, resourceBinding) {
649
719
  if (objects.length === 0)
650
720
  return [];
@@ -658,8 +728,9 @@ export function createGitvaultHttpTransport(client, options = {}) {
658
728
  context: "opening gitvault upload session",
659
729
  });
660
730
  const issued = new Map(session.objects.map((u) => [gitvaultLedgerId(u), u]));
661
- for (let i = 0; i < objects.length; i++) {
662
- 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) => {
663
734
  const id = gitvaultLedgerId(entries[i]);
664
735
  const target = issued.get(id);
665
736
  if (!target)
@@ -680,11 +751,11 @@ export function createGitvaultHttpTransport(client, options = {}) {
680
751
  const existing = await getObjectBytes(repoId, o.path);
681
752
  if (!existing || sha256Hex(existing) !== o.sha256)
682
753
  fail("GITVAULT_OBJECT_EXISTS_DIFFERENT", `${o.path} already exists with different bytes`, "uploading gitvault objects", { path: o.path });
683
- continue;
754
+ return;
684
755
  }
685
756
  if (!r.ok)
686
757
  fail("GITVAULT_UPLOAD_FAILED", `presigned PUT failed (HTTP ${r.status}) for ${o.path}`, "uploading gitvault objects", { path: o.path, status: r.status });
687
- }
758
+ });
688
759
  const fin = await client.request(`${base(repoId)}/upload-sessions/${encodeURIComponent(session.upload_session_id)}/finalize`, { method: "POST", body: {}, context: "finalizing gitvault upload session" });
689
760
  const receipts = new Map(fin.receipts.map((r) => [gitvaultLedgerId(r), r]));
690
761
  return objects.map((o, i) => {
@@ -713,7 +784,7 @@ export function createGitvaultHttpTransport(client, options = {}) {
713
784
  throw e;
714
785
  }
715
786
  }
716
- return {
787
+ const transport = {
717
788
  // ── creation (5.3) ──
718
789
  async allocate(request) {
719
790
  // The route wraps the signed allocation object under `allocation` and
@@ -727,6 +798,7 @@ export function createGitvaultHttpTransport(client, options = {}) {
727
798
  return { stored_bytes_sha256: r.sha256, size_bytes: r.size_bytes };
728
799
  },
729
800
  getObject: ({ repo_id, path }) => getObjectBytes(repo_id, path),
801
+ getObjects: ({ repo_id, paths }) => getObjectsBytes(repo_id, paths),
730
802
  async admitGenesis(request) {
731
803
  try {
732
804
  const r = await admit(request.repo_id, GITVAULT_GENESIS_GENERATION, request.stored_bytes, request.stored_bytes_sha256, { allocation_generation: request.allocation_generation });
@@ -848,6 +920,80 @@ export function createGitvaultHttpTransport(client, options = {}) {
848
920
  }
849
921
  },
850
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
+ });
851
997
  }
852
998
  // ─── Storage paths (§3) ──────────────────────────────────────────────────────
853
999
  export const gitvaultPaths = {
@@ -875,6 +1021,39 @@ export const gitvaultPaths = {
875
1021
  /** `recipient-pins/<pin_manifest_version>.json` (D197, rev 42) — version-addressed, plaintext-structured, writer-signed. */
876
1022
  pinManifest: (pinManifestVersion) => `recipient-pins/${pinManifestVersion}.json`,
877
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
+ }
878
1057
  /** A transport-agnostic view of git ops the publication needs (the local repository). */
879
1058
  export class GitvaultVault {
880
1059
  keystore;
@@ -932,7 +1111,16 @@ export class GitvaultVault {
932
1111
  if (this.genesisCache)
933
1112
  return this.genesisCache;
934
1113
  const repo = this.repoFile();
935
- 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
+ }
936
1124
  if (!bytes)
937
1125
  fail("CHAIN_BROKEN", "the vault has no admitted genesis", "reading gitvault genesis", { repo_id: this.repoId });
938
1126
  const sha256 = sha256Hex(bytes);
@@ -1063,8 +1251,10 @@ export class GitvaultVault {
1063
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 });
1064
1252
  }
1065
1253
  const kRepo = hexToBytes(kRepoHex);
1066
- const refState = await this.openCarrier("ref_state", head.ref_state, gitvaultPaths.refState(head.ref_state.object_id), writerKey, { epoch: head.epoch, k_repo: kRepo });
1067
- const roots = await this.openCarrier("retention_roots", head.retention_roots, gitvaultPaths.retentionRoots(head.retention_roots.object_id), writerKey, { epoch: head.epoch, k_repo: kRepo });
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 });
1068
1258
  if (refState.generation !== head.generation || roots.generation !== head.generation)
1069
1259
  fail("CHAIN_UNUSABLE", "carrier generation does not match the head", "materializing gitvault head");
1070
1260
  decryptedRefState = refState;
@@ -1091,7 +1281,7 @@ export class GitvaultVault {
1091
1281
  ? `${verified} heads verified this call; the verified prefix (generation ${pin.generation}) is persisted — call again to continue`
1092
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" }]);
1093
1283
  }
1094
- 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);
1095
1285
  if (!bytes)
1096
1286
  fail("CHAIN_BROKEN", `listed head ${entry.generation} is absent from storage`, "verifying gitvault chain", { generation: entry.generation });
1097
1287
  const head = parseGitvaultStrict(new TextDecoder().decode(bytes));
@@ -1167,7 +1357,11 @@ export class GitvaultVault {
1167
1357
  break;
1168
1358
  }
1169
1359
  const prevGen = bigIntToGeneration(generationToBigInt(cur.generation) - 1n);
1170
- const bytes = await this.transport.getObject({ repo_id: this.repoId, path: gitvaultPaths.head(prevGen) });
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);
1171
1365
  if (!bytes || sha256Hex(bytes) !== cur.prev_sha256)
1172
1366
  fail("CHAIN_BROKEN", `head ${prevGen} does not match the chain during epoch-key catch-up`, "materializing gitvault head", { generation: prevGen });
1173
1367
  cur = parseGitvaultStrict(new TextDecoder().decode(bytes));
@@ -1214,6 +1408,41 @@ export class GitvaultVault {
1214
1408
  : null,
1215
1409
  };
1216
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;
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
+ */
1217
1446
  async readHead(generation, expectedSha256) {
1218
1447
  const bytes = await this.transport.getObject({ repo_id: this.repoId, path: gitvaultPaths.head(generation) });
1219
1448
  // Absent ⇒ the vault no longer holds a generation this client authenticated: a ROLLBACK, not a
@@ -1225,21 +1454,25 @@ export class GitvaultVault {
1225
1454
  }
1226
1455
  if (sha256Hex(bytes) !== expectedSha256)
1227
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);
1228
1458
  return parseGitvaultStrict(new TextDecoder().decode(bytes));
1229
1459
  }
1230
1460
  /**
1231
- * Decrypt one encrypted carrier object by its receipt; any failure is
1232
- * `CHAIN_UNUSABLE`. `keyOverride` supplies the exact `(epoch, k_repo)` this
1233
- * carrier was sealed under REQUIRED for any generation that is not
1234
- * necessarily under `this.epoch()`/`this.kRepo()` (this principal's
1235
- * CURRENT pointer), which is exactly the case across an epoch rotation;
1236
- * omitted call sites (checkpoint/prune paths untouched by this fold) keep
1237
- * the prior CURRENT-pointer behavior unchanged.
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.
1238
1472
  */
1239
- async openCarrier(kind, receipt, path, writerKey, keyOverride) {
1473
+ decodeCarrierFrame(kind, receipt, frame, writerKey, keyOverride) {
1240
1474
  const epoch = keyOverride?.epoch ?? this.epoch();
1241
1475
  const kRepo = keyOverride?.k_repo ?? this.kRepo();
1242
- const frame = await this.transport.getObject({ repo_id: this.repoId, path });
1243
1476
  if (!frame)
1244
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" }]);
1245
1478
  let plaintext;
@@ -1255,6 +1488,59 @@ export class GitvaultVault {
1255
1488
  }
1256
1489
  return object;
1257
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
+ }
1258
1544
  /**
1259
1545
  * Verify to newest, then decrypt + apply its carriers — advancing the
1260
1546
  * materialized pin. `options.persist` (default `true`) is forwarded to
@@ -1281,7 +1567,10 @@ export class GitvaultVault {
1281
1567
  }
1282
1568
  // `strict: true` guarantees `state.decrypt` is non-null with no failure
1283
1569
  // and `decryptable_to_generation === state.generation` — it throws
1284
- // otherwise, so these are never null/mismatched here.
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.
1285
1574
  const refState = state.decrypt.ref_state;
1286
1575
  const roots = state.decrypt.retention_roots;
1287
1576
  if (refState.generation !== state.generation || roots.generation !== state.generation)
@@ -1838,11 +2127,18 @@ export class GitvaultVault {
1838
2127
  return { outcome: "conflict", generation, winner: admitted.winner };
1839
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 };
1840
2129
  }
1841
- /** 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
+ */
1842
2138
  async push(options) {
1843
2139
  let conflicts = 0;
2140
+ let base = options.base ?? (await this.materialize());
1844
2141
  for (;;) {
1845
- const base = await this.materialize();
1846
2142
  const evaluation = await evaluateRefTransaction(base.refs, options.transaction, { isAncestor: (a, d) => isAncestor(this.git(), a, d), protocol_refs: options.protocol_refs });
1847
2143
  const published = await this.publishGeneration({
1848
2144
  base, refs: evaluation.refs, dropped: evaluation.dropped, head_target: options.head_target ?? base.head_target,
@@ -1852,7 +2148,8 @@ export class GitvaultVault {
1852
2148
  conflicts += 1;
1853
2149
  if (conflicts > this.retries)
1854
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" }]);
1855
- 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;
1856
2153
  }
1857
2154
  // `push()` never sets `dry_run`, so this outcome is unreachable here —
1858
2155
  // narrows `published` to `"admitted"` for the return below.
@@ -1882,7 +2179,11 @@ export class GitvaultVault {
1882
2179
  * that case instead of calling this method).
1883
2180
  */
1884
2181
  async planPush(options) {
1885
- 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());
1886
2187
  const evaluation = await evaluateRefTransaction(base.refs, options.transaction, { isAncestor: (a, d) => isAncestor(this.git(), a, d), protocol_refs: options.protocol_refs });
1887
2188
  const headTarget = options.head_target ?? base.head_target;
1888
2189
  const published = await this.publishGeneration({
@@ -1995,10 +2296,18 @@ export class GitvaultVault {
1995
2296
  const result = await this.transport.admitHead({ repo_id: this.repoId, generation: head.generation, stored_bytes: bytes, stored_bytes_sha256: hash });
1996
2297
  if (result.outcome === "conflict")
1997
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.
1998
2302
  const back = await this.transport.getObject({ repo_id: this.repoId, path: gitvaultPaths.head(head.generation) });
1999
2303
  if (!back || sha256Hex(back) !== hash) {
2000
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 });
2001
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);
2002
2311
  const pin = { generation: head.generation, head_sha256: hash, pinned_at: formatGitvaultTimestamp(this.now()) };
2003
2312
  this.keystore.updateRepo(this.repoId, { head_pin: pin, materialized_pin: pin, verified_prefix: null });
2004
2313
  return { outcome: "admitted", head_sha256: hash, admission_record_sha256: result.admission_record_sha256, capture_receipt: result.capture_receipt };
@@ -2561,6 +2870,20 @@ export class GitvaultVault {
2561
2870
  * Pull the newest checkpoint (if any) and every later WAL pack into
2562
2871
  * `targetRepoDir` (an initialized repository), then verify every canonical
2563
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.
2564
2887
  */
2565
2888
  async restoreObjectsInto(targetRepoDir) {
2566
2889
  const newest = await this.materialize();
@@ -2569,17 +2892,51 @@ export class GitvaultVault {
2569
2892
  return { refs: {}, head_target: newest.head_target, generation: newest.generation, retained_refs };
2570
2893
  }
2571
2894
  const writerKey = newest.genesis.creator_signing_pubkey;
2572
- // 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.
2573
2913
  const heads = [];
2574
2914
  let cur = newest.head;
2915
+ let incremental = marker !== null;
2575
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
+ }
2576
2926
  heads.unshift(cur);
2577
- if (cur.checkpoint)
2578
- break;
2927
+ if (!incremental && cur.checkpoint)
2928
+ break; // wholesale stop: the newest checkpoint-bearing head
2579
2929
  if (cur.generation === "0000000000000001")
2580
- 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.
2581
2934
  const prevGen = bigIntToGeneration(generationToBigInt(cur.generation) - 1n);
2582
- 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);
2583
2940
  if (!bytes || sha256Hex(bytes) !== cur.prev_sha256)
2584
2941
  fail("CHAIN_BROKEN", `head ${prevGen} does not match the chain during restore`, "restoring gitvault objects");
2585
2942
  cur = parseGitvaultStrict(new TextDecoder().decode(bytes));
@@ -2608,8 +2965,14 @@ export class GitvaultVault {
2608
2965
  fail("CHECKPOINT_INCOMPLETE", "claim set signature fails", "restoring gitvault objects");
2609
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) });
2610
2967
  checkClaimSetEquality(claimSet, manifest, first.checkpoint.covers_through_generation);
2611
- for (const p of manifest.packs) {
2612
- 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;
2613
2976
  if (!frame)
2614
2977
  fail("CHECKPOINT_INCOMPLETE", `checkpoint pack ${p.object_id} absent`, "restoring gitvault objects");
2615
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 });
@@ -2618,14 +2981,24 @@ export class GitvaultVault {
2618
2981
  await hardenedGit(targetRepoDir, ["index-pack", "--stdin", "--strict"], { input: plain });
2619
2982
  }
2620
2983
  }
2621
- for (const h of heads) {
2622
- for (const w of h.wal_entries) {
2623
- const frame = await this.transport.getObject({ repo_id: this.repoId, path: gitvaultPaths.wal(w.object_id) });
2624
- if (!frame)
2625
- fail("CHAIN_UNUSABLE", `WAL pack ${w.object_id} absent`, "restoring gitvault objects");
2626
- const plain = openFrame({ k_obj: deriveObjectKey(kRepoForEpoch(h.epoch), this.repoId, h.epoch, "wal_pack", w.object_id), repo_id: this.repoId, object_kind: "wal_pack", object_id: w.object_id, epoch: h.epoch, frame, expected_ciphertext_sha256: w.ciphertext_sha256 });
2627
- await hardenedGit(targetRepoDir, ["index-pack", "--stdin", "--strict"], { input: plain });
2628
- }
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 });
2629
3002
  }
2630
3003
  // the §4.7 coverage set — canonical refs ∪ unexpired roots ∪ the HEAD target — must all resolve.
2631
3004
  for (const t of GitvaultVault.coverageTips(newest.refs, newest.roots, newest.head_target)) {
@@ -2637,6 +3010,12 @@ export class GitvaultVault {
2637
3010
  // ref so `git fsck` is silent. Runs AFTER coverage verification so a
2638
3011
  // reconcile never references an object the restore itself failed to land.
2639
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);
2640
3019
  return { refs: newest.refs, head_target: newest.head_target, generation: newest.generation, retained_refs };
2641
3020
  }
2642
3021
  }