run402 4.55.0 → 4.56.1

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.
@@ -40,6 +40,7 @@ import { LocalError, isRun402Error } from "../errors.js";
40
40
  import { fetchGitvaultObjectBytes } from "./gitvault-edge-fetch.js";
41
41
  import { GITVAULT_FORMAT, GITVAULT_GENESIS_EPOCH, GITVAULT_GENESIS_GENERATION, GITVAULT_HEX16_RE, GITVAULT_OID40_RE, GITVAULT_SUITE, attemptKeyCommitment, bytesToHex, checkFreshEpochKeyAgainstPriorKeys, checkHPartition, computeRotationId, computeTargetPartitionDigest, deriveDigestKey, deriveObjectKey, ekFingerprint, epochRotationKeyCommitment, formatGitvaultTimestamp, fromBase64url, hexToBytes, jcs, keyEnvelopeLedgerId, keyedCommitment, newGitvaultId, newHex32, nextEpoch, objectsetContent, openBindingPreimage, openEpochRotationForRecipient, openFrame, parseGitvaultStrict, parseRotateEpochPayload, pinManifestLedgerId, randomBytes, sealFrame, sealKeyEnvelope, sha256Hex, signGitvaultObject, storedBytes, storedBytesSha256, toBase64url, verifyGitvaultObject, } from "../namespaces/gitvault.crypto.js";
42
42
  import { GITVAULT_ZERO_SHA256_SENTINEL } from "../namespaces/gitvault.types.js";
43
+ import { gitvaultCheckpointStaleness } from "../namespaces/gitvault.js";
43
44
  import { crossProfileGitvaultHint } from "./gitvault-profile-scan.js";
44
45
  import { GITVAULT_DEPLOY_REF, hardenedGit, hasObject, isAncestor } from "./gitvault-snapshot.js";
45
46
  // ─── Constants (constants.json) ──────────────────────────────────────────────
@@ -1189,6 +1190,23 @@ export class GitvaultVault {
1189
1190
  repoDir;
1190
1191
  now;
1191
1192
  budget;
1193
+ /**
1194
+ * gitvault-clone-scaling (bench P2): the CURRENT walk window's prefetched
1195
+ * bytes — head bytes (bounded-concurrent direct reads) and carrier frames
1196
+ * (one batched getObjects), keyed by storage path — filled by
1197
+ * `verifyToNewest`'s per-page prefetch and `restoreObjectsInto`'s
1198
+ * backward-window prefetch, consulted by {@link readCachedHeadBytes} /
1199
+ * {@link openMaterializeCarriers} before they pay a network read. Transient (REPLACED each page, so memory is
1200
+ * bounded by one listing page) and UNTRUSTED: every consumer sha-checks
1201
+ * an entry against the exact value it would check network bytes against
1202
+ * (the listing's `stored_bytes_sha256`, a receipt's `ciphertext_sha256`),
1203
+ * the same discipline as the keystore object cache — a wrong or stale
1204
+ * entry is a MISS, never a verification bypass. Deliberately NOT the
1205
+ * keystore cache: that cache's eviction window is a handful of newest
1206
+ * generations by design, so routing a whole page through it would evict
1207
+ * the very bytes the ordered walk is about to read.
1208
+ */
1209
+ walkPrefetch = null;
1192
1210
  retries;
1193
1211
  servicePublicKey;
1194
1212
  genesisCache = null;
@@ -1228,6 +1246,39 @@ export class GitvaultVault {
1228
1246
  }
1229
1247
  kRepo() { return hexToBytes(this.repoFile().k_repo_hex); }
1230
1248
  epoch() { return this.repoFile().epoch; }
1249
+ /**
1250
+ * gitvault-clone-scaling (P3): staleness of the newest checkpoint coverage
1251
+ * this checkout has locally learned, measured at `newestGeneration`. Reads
1252
+ * the keystore AFTER the caller's own persist (a checkpoint-form push has
1253
+ * already recorded its fresh coverage by the time its result is built), so
1254
+ * a compacting push reports itself current. Pure + never-throwing by way of
1255
+ * the helper; unknown coverage reads as `{0, advised: false}` — silent.
1256
+ */
1257
+ checkpointStalenessNow(newestGeneration) {
1258
+ return gitvaultCheckpointStaleness({ newest_generation: newestGeneration, covers_through_generation: this.repoFile().checkpoint_covers_through ?? null });
1259
+ }
1260
+ /**
1261
+ * gitvault-clone-scaling (bench P2): bounded-concurrent direct reads of
1262
+ * many generation-addressed head paths. Heads deliberately do NOT ride
1263
+ * `getObjects` — the `object-reads` presign batch is CARRIER-ONLY by wire
1264
+ * design (see `getObjectsBytes`'s fail-closed doc comment; the live probe
1265
+ * that caught this recorded `getObjects paths=67 FAILED 1ms` followed by
1266
+ * 25 serial singles) — so head bytes parallelize as the SAME direct GETs
1267
+ * the ordered walk itself would issue, just early and overlapped. A
1268
+ * per-path failure resolves `null` (the walk's own read owns that
1269
+ * failure and its envelope); results are raw and UNTRUSTED — callers
1270
+ * sha-check before use, per `walkPrefetch`'s contract.
1271
+ */
1272
+ async prefetchHeadsConcurrent(paths) {
1273
+ const map = new Map();
1274
+ const fetched = await mapBounded(paths, GITVAULT_TRANSPORT_CONCURRENCY, (path) => this.transport.getObject({ repo_id: this.repoId, path }).catch(() => null));
1275
+ for (let i = 0; i < paths.length; i++) {
1276
+ const b = fetched[i];
1277
+ if (b)
1278
+ map.set(paths[i], b);
1279
+ }
1280
+ return map;
1281
+ }
1231
1282
  git() {
1232
1283
  if (!this.repoDir)
1233
1284
  fail("GITVAULT_REPO_DIR_REQUIRED", "this operation needs the local git repository (repo_dir)", "gitvault publication");
@@ -1441,6 +1492,12 @@ export class GitvaultVault {
1441
1492
  let request = { after_generation: anchor, limit: String(GITVAULT_MAX_HEADS_PER_LISTING_PAGE) };
1442
1493
  let verified = 0;
1443
1494
  const rotations = [];
1495
+ // gitvault-clone-scaling (P3): coverage this walk LEARNS. A checkpoint
1496
+ // head names it outright; a walk anchored at GENESIS that completes
1497
+ // without seeing one proves coverage = genesis. A partial (non-genesis)
1498
+ // walk that sees none proves nothing and persists nothing.
1499
+ let walkCheckpointCoverage = null;
1500
+ const walkedFromGenesis = anchor === GITVAULT_GENESIS_GENERATION;
1444
1501
  // Consumed by (at most) the FIRST for(;;) iteration below — a listHeads
1445
1502
  // page this call never had to ask the network for, because the state
1446
1503
  // read above already proved it (0 entries: pin already current; 1 entry:
@@ -1543,6 +1600,84 @@ export class GitvaultVault {
1543
1600
  : await this.transport.listHeads({ repo_id: this.repoId, ...request });
1544
1601
  syntheticEntries = null;
1545
1602
  progress = verifyHeadsListingPage(page, request, progress, this.repoId);
1603
+ // gitvault-clone-scaling (bench P2): the page just verified names every
1604
+ // entry's generation + stored_bytes_sha256, and the BYTES reads are
1605
+ // independent — verification (and decryption) order is a LOCAL
1606
+ // obligation. Bounded by the remaining verification budget, this
1607
+ // page's per-head SERIAL reads are replaced by: the cache-missing
1608
+ // HEAD bytes as bounded-CONCURRENT direct reads (heads cannot ride
1609
+ // the object-reads presign batch — it is carrier-only by wire design;
1610
+ // see prefetchHeadsConcurrent), then — the same split one level
1611
+ // deeper — ONE batched getObjects for the ref_state/retention_roots
1612
+ // FRAMES each decrypt-validated head will open (their paths +
1613
+ // expected ciphertext hashes parse out of the head bytes just
1614
+ // fetched; parsing is local CPU). Results land in the transient
1615
+ // `walkPrefetch` map (NOT the keystore cache — its eviction window is
1616
+ // smaller than a page; see the field's doc). Failure fidelity: only a
1617
+ // hash-matching result is kept — an absent, mismatched, or
1618
+ // unparseable entry (or a prefetch that fails outright) leaves its
1619
+ // slot empty, and the ordered loop's own per-head reads reproduce the
1620
+ // exact unbatched envelopes. Single-miss sets skip their prefetch
1621
+ // (one direct read costs the same).
1622
+ {
1623
+ const prefetch = new Map();
1624
+ this.walkPrefetch = prefetch;
1625
+ try {
1626
+ const wanted = page.heads.slice(0, Math.max(0, this.budget - verified));
1627
+ const cachedHeadIfMatching = (e) => {
1628
+ const cached = this.keystore.readCachedHead(this.repoId, e.generation);
1629
+ return cached && sha256Hex(cached.bytes) === e.stored_bytes_sha256 ? cached.bytes : null;
1630
+ };
1631
+ const missingHeads = wanted.filter((e) => cachedHeadIfMatching(e) === null);
1632
+ if (missingHeads.length > 1) {
1633
+ const fetched = await this.prefetchHeadsConcurrent(missingHeads.map((e) => gitvaultPaths.head(e.generation)));
1634
+ for (const e of missingHeads) {
1635
+ const path = gitvaultPaths.head(e.generation);
1636
+ const bytes = fetched.get(path) ?? null;
1637
+ if (bytes && sha256Hex(bytes) === e.stored_bytes_sha256)
1638
+ prefetch.set(path, bytes);
1639
+ }
1640
+ }
1641
+ if (decryptValidate && wanted.length > 1) {
1642
+ const carriers = [];
1643
+ for (const e of wanted) {
1644
+ const headBytes = prefetch.get(gitvaultPaths.head(e.generation)) ?? cachedHeadIfMatching(e);
1645
+ if (!headBytes)
1646
+ continue;
1647
+ let parsed;
1648
+ try {
1649
+ parsed = parseGitvaultStrict(new TextDecoder().decode(headBytes));
1650
+ }
1651
+ catch {
1652
+ continue; // the ordered loop's checkChainLink owns rejecting it
1653
+ }
1654
+ for (const w of [
1655
+ { receipt: parsed.ref_state, path: gitvaultPaths.refState(parsed.ref_state.object_id) },
1656
+ { receipt: parsed.retention_roots, path: gitvaultPaths.retentionRoots(parsed.retention_roots.object_id) },
1657
+ ]) {
1658
+ const cached = this.keystore.readCachedCarrier(this.repoId, w.receipt.object_id);
1659
+ if (cached && sha256Hex(cached.bytes) === w.receipt.ciphertext_sha256)
1660
+ continue;
1661
+ carriers.push({ path: w.path, sha: w.receipt.ciphertext_sha256 });
1662
+ }
1663
+ }
1664
+ if (carriers.length > 1) {
1665
+ const fetched = await this.transport.getObjects({ repo_id: this.repoId, paths: carriers.map((c) => c.path) });
1666
+ for (let i = 0; i < carriers.length; i++) {
1667
+ const bytes = fetched[i] ?? null;
1668
+ if (bytes && sha256Hex(bytes) === carriers[i].sha)
1669
+ prefetch.set(carriers[i].path, bytes);
1670
+ }
1671
+ }
1672
+ }
1673
+ }
1674
+ catch {
1675
+ // A batch that fails OUTRIGHT (network, not a per-slot null) must
1676
+ // not introduce a failure mode the unbatched walk never had — the
1677
+ // ordered loop below re-reads what it needs itself and fails (or
1678
+ // succeeds, if the fault was transient) with its own envelopes.
1679
+ }
1680
+ }
1546
1681
  for (const entry of page.heads) {
1547
1682
  if (verified >= this.budget) {
1548
1683
  if (persist)
@@ -1581,6 +1716,8 @@ export class GitvaultVault {
1581
1716
  prevEpoch = head.epoch;
1582
1717
  pin = { generation: head.generation, head_sha256: entry.stored_bytes_sha256, pinned_at: formatGitvaultTimestamp(this.now()) };
1583
1718
  lastHead = head;
1719
+ if (head.checkpoint)
1720
+ walkCheckpointCoverage = head.checkpoint.covers_through_generation;
1584
1721
  verified += 1;
1585
1722
  // The chain walk ALWAYS continues below regardless of decrypt
1586
1723
  // outcome (`tryDecrypt` never throws) — `!decryptFailure` just stops
@@ -1599,6 +1736,15 @@ export class GitvaultVault {
1599
1736
  break;
1600
1737
  request = next;
1601
1738
  }
1739
+ // P3: reaching here means the walk COMPLETED (a budget pause throws
1740
+ // above) — persist whatever coverage it proved. A genesis-anchored walk
1741
+ // is authoritative for its whole history, so no checkpoint seen means
1742
+ // coverage = genesis, honestly.
1743
+ if (persist) {
1744
+ const learned = walkCheckpointCoverage ?? (walkedFromGenesis ? GITVAULT_GENESIS_GENERATION : null);
1745
+ if (learned !== null)
1746
+ this.keystore.updateRepo(this.repoId, { checkpoint_covers_through: learned });
1747
+ }
1602
1748
  // Catch-up: a call with NOTHING new to walk (this repo's chain-verified
1603
1749
  // pin was already at `pin`/`lastHead` — e.g. an EARLIER, decrypt-blind
1604
1750
  // `verifyToNewest({})` call already advanced `head_pin` to the newest,
@@ -1700,7 +1846,16 @@ export class GitvaultVault {
1700
1846
  const cached = this.keystore.readCachedHead(this.repoId, generation);
1701
1847
  if (cached && sha256Hex(cached.bytes) === expectedSha256)
1702
1848
  return cached.bytes;
1703
- const bytes = await this.transport.getObject({ repo_id: this.repoId, path: gitvaultPaths.head(generation) });
1849
+ // gitvault-clone-scaling (P2): a page-prefetched head serves exactly as
1850
+ // a network fetch would — sha-checked here against the SAME expected
1851
+ // value, then cache-warmed. A miss/mismatch falls through to the read.
1852
+ const path = gitvaultPaths.head(generation);
1853
+ const prefetched = this.walkPrefetch?.get(path);
1854
+ if (prefetched && sha256Hex(prefetched) === expectedSha256) {
1855
+ this.keystore.writeCachedHead(this.repoId, generation, expectedSha256, prefetched);
1856
+ return prefetched;
1857
+ }
1858
+ const bytes = await this.transport.getObject({ repo_id: this.repoId, path });
1704
1859
  if (bytes && sha256Hex(bytes) === expectedSha256)
1705
1860
  this.keystore.writeCachedHead(this.repoId, generation, expectedSha256, bytes);
1706
1861
  return bytes;
@@ -1795,15 +1950,22 @@ export class GitvaultVault {
1795
1950
  const cachedRoots = this.keystore.readCachedCarrier(this.repoId, rootsReceipt.object_id);
1796
1951
  const refStateHit = Boolean(cachedRefState && sha256Hex(cachedRefState.bytes) === refStateReceipt.ciphertext_sha256);
1797
1952
  const rootsHit = Boolean(cachedRoots && sha256Hex(cachedRoots.bytes) === rootsReceipt.ciphertext_sha256);
1953
+ // gitvault-clone-scaling (P2): a page-prefetched frame serves exactly as
1954
+ // a fetched one — sha-checked against the receipt's ciphertext hash; a
1955
+ // miss/mismatch falls through to the batched network read below.
1956
+ const preRefState = !refStateHit ? (this.walkPrefetch?.get(refStatePath) ?? null) : null;
1957
+ const refStatePre = preRefState && sha256Hex(preRefState) === refStateReceipt.ciphertext_sha256 ? preRefState : null;
1958
+ const preRoots = !rootsHit ? (this.walkPrefetch?.get(rootsPath) ?? null) : null;
1959
+ const rootsPre = preRoots && sha256Hex(preRoots) === rootsReceipt.ciphertext_sha256 ? preRoots : null;
1798
1960
  const missingPaths = [];
1799
- if (!refStateHit)
1961
+ if (!refStateHit && !refStatePre)
1800
1962
  missingPaths.push(refStatePath);
1801
- if (!rootsHit)
1963
+ if (!rootsHit && !rootsPre)
1802
1964
  missingPaths.push(rootsPath);
1803
1965
  const fetched = missingPaths.length > 0 ? await this.transport.getObjects({ repo_id: this.repoId, paths: missingPaths }) : [];
1804
1966
  let next = 0;
1805
- const refStateFrame = refStateHit ? cachedRefState.bytes : (fetched[next++] ?? null);
1806
- const rootsFrame = rootsHit ? cachedRoots.bytes : (fetched[next++] ?? null);
1967
+ const refStateFrame = refStateHit ? cachedRefState.bytes : (refStatePre ?? fetched[next++] ?? null);
1968
+ const rootsFrame = rootsHit ? cachedRoots.bytes : (rootsPre ?? fetched[next++] ?? null);
1807
1969
  const refState = this.decodeCarrierFrame("ref_state", refStateReceipt, refStateFrame, writerKey, keyOverride);
1808
1970
  const roots = this.decodeCarrierFrame("retention_roots", rootsReceipt, rootsFrame, writerKey, keyOverride);
1809
1971
  if (!refStateHit && refStateFrame)
@@ -2427,7 +2589,7 @@ export class GitvaultVault {
2427
2589
  if (published.outcome === "dry_run")
2428
2590
  fail("GIT_COMMAND_FAILED", "internal: push() received a dry-run result it never requested", "publishing gitvault head");
2429
2591
  this.keystore.updateRepo(this.repoId, { last_ref_transaction: { generation: published.generation, transaction: options.transaction, at: formatGitvaultTimestamp(this.now()) } });
2430
- return { generation: published.generation, head_sha256: published.head_sha256, head: published.head, admission_record_sha256: published.admission_record_sha256, capture_receipt: published.capture_receipt, form: published.form, conflicts_retried: conflicts, refs: published.refs };
2592
+ return { generation: published.generation, head_sha256: published.head_sha256, head: published.head, admission_record_sha256: published.admission_record_sha256, capture_receipt: published.capture_receipt, form: published.form, conflicts_retried: conflicts, refs: published.refs, checkpoint_staleness: this.checkpointStalenessNow(published.generation) };
2431
2593
  }
2432
2594
  }
2433
2595
  /**
@@ -2517,7 +2679,7 @@ export class GitvaultVault {
2517
2679
  // unreachable here — narrows `published` to `"admitted"` below.
2518
2680
  if (published.outcome === "dry_run")
2519
2681
  fail("GIT_COMMAND_FAILED", "internal: publishCheckpoint() received a dry-run result it never requested", "publishing gitvault checkpoint");
2520
- return { generation: published.generation, head_sha256: published.head_sha256, head: published.head, admission_record_sha256: published.admission_record_sha256, capture_receipt: published.capture_receipt, form: published.form, conflicts_retried: conflicts, refs: published.refs };
2682
+ return { generation: published.generation, head_sha256: published.head_sha256, head: published.head, admission_record_sha256: published.admission_record_sha256, capture_receipt: published.capture_receipt, form: published.form, conflicts_retried: conflicts, refs: published.refs, checkpoint_staleness: this.checkpointStalenessNow(published.generation) };
2521
2683
  }
2522
2684
  }
2523
2685
  /** Request a `retention_cutoff` ticket and check it binds THIS base head (and the service key, when one is pinned). */
@@ -2580,7 +2742,14 @@ export class GitvaultVault {
2580
2742
  // generation's head entirely.
2581
2743
  this.keystore.writeCachedHead(this.repoId, head.generation, hash, back);
2582
2744
  const pin = { generation: head.generation, head_sha256: hash, pinned_at: formatGitvaultTimestamp(this.now()) };
2583
- this.keystore.updateRepo(this.repoId, { head_pin: pin, materialized_pin: pin, verified_prefix: null });
2745
+ this.keystore.updateRepo(this.repoId, {
2746
+ head_pin: pin,
2747
+ materialized_pin: pin,
2748
+ verified_prefix: null,
2749
+ // gitvault-clone-scaling (P3): a checkpoint-form head IS fresh
2750
+ // coverage this checkout just learned first-hand.
2751
+ ...(head.checkpoint ? { checkpoint_covers_through: head.checkpoint.covers_through_generation } : {}),
2752
+ });
2584
2753
  return { outcome: "admitted", head_sha256: hash, admission_record_sha256: result.admission_record_sha256, capture_receipt: result.capture_receipt };
2585
2754
  }
2586
2755
  // ── epoch rotation (D193-D203, rev 42, change gitvault-human-envelopes) ──
@@ -2748,7 +2917,7 @@ export class GitvaultVault {
2748
2917
  // predecessor manifest back to compute confirmed() (D196) — resolves
2749
2918
  // it locally instead of a network object-reads round trip.
2750
2919
  this.keystore.updateRepo(this.repoId, { known_pin_manifest: { pin_manifest_version: nextVersion, stored_bytes_sha256: manifestSha, pins } });
2751
- return { generation, head_sha256: admitted.head_sha256, head, admission_record_sha256: admitted.admission_record_sha256, capture_receipt: admitted.capture_receipt, form: "wal", conflicts_retried: conflicts, refs: base.refs };
2920
+ return { generation, head_sha256: admitted.head_sha256, head, admission_record_sha256: admitted.admission_record_sha256, capture_receipt: admitted.capture_receipt, form: "wal", conflicts_retried: conflicts, refs: base.refs, checkpoint_staleness: this.checkpointStalenessNow(generation) };
2752
2921
  }
2753
2922
  }
2754
2923
  /**
@@ -3160,7 +3329,7 @@ export class GitvaultVault {
3160
3329
  const admitted = await this.admit(head);
3161
3330
  if (admitted.outcome === "conflict")
3162
3331
  fail("HEAD_CAS_CONFLICT", "a different head was admitted while the repair was being prepared", "publishing repair head", { winner: admitted.winner }, [{ action: "verify the attached winner from storage, rebase, retry" }]);
3163
- return { generation: repairGen, head_sha256: admitted.head_sha256, head, admission_record_sha256: admitted.admission_record_sha256, capture_receipt: null, form: "checkpoint", conflicts_retried: 0, refs: repairedRefs };
3332
+ return { generation: repairGen, head_sha256: admitted.head_sha256, head, admission_record_sha256: admitted.admission_record_sha256, capture_receipt: null, form: "checkpoint", conflicts_retried: 0, refs: repairedRefs, checkpoint_staleness: this.checkpointStalenessNow(repairGen) };
3164
3333
  }
3165
3334
  /** Heads `base..newest` (already chain-verified by `verifyToNewest`) re-read + hash-checked from storage. */
3166
3335
  async chainFrom(baseGeneration, newest) {
@@ -3230,6 +3399,50 @@ export class GitvaultVault {
3230
3399
  const heads = [];
3231
3400
  let cur = newest.head;
3232
3401
  let incremental = marker !== null;
3402
+ // gitvault-clone-scaling (bench P2): the backward walk's head PATHS are
3403
+ // all derivable up front (generation N−1, N−2, …) — only each head's
3404
+ // expected hash arrives chain-sequentially. Same fetch-concurrent /
3405
+ // verify-ordered split as the forward walk: page-sized windows of
3406
+ // predecessor head bytes batch into the transient `walkPrefetch` map
3407
+ // (use-time sha-checked by `readCachedHeadBytes`; a miss, mismatch, or
3408
+ // failed batch falls back to that read's own single fetch), refilled as
3409
+ // the walk descends past the window floor. The floor ESTIMATE — the
3410
+ // marker on the incremental path, else locally learned checkpoint
3411
+ // coverage, else genesis — only bounds over-fetch; the walk's own stop
3412
+ // conditions are unchanged, and a wrong estimate costs at most one
3413
+ // window of concurrent GETs, never correctness.
3414
+ let prefetchFloor = null;
3415
+ const prefetchBackwardWindow = async (hi) => {
3416
+ let lo = 1n;
3417
+ if (incremental && marker)
3418
+ lo = generationToBigInt(marker.generation) + 1n;
3419
+ else {
3420
+ const known = this.repoFile().checkpoint_covers_through;
3421
+ if (known && /^[0-9a-f]{16}$/.test(known)) {
3422
+ const k = generationToBigInt(known);
3423
+ if (k > lo)
3424
+ lo = k;
3425
+ }
3426
+ }
3427
+ const capLo = hi - BigInt(GITVAULT_MAX_HEADS_PER_LISTING_PAGE) + 1n;
3428
+ if (capLo > lo)
3429
+ lo = capLo;
3430
+ if (lo < 1n)
3431
+ lo = 1n;
3432
+ prefetchFloor = lo;
3433
+ if (hi - lo < 1n)
3434
+ return; // 0 or 1 path — a single read costs the same
3435
+ const gens = [];
3436
+ for (let g = hi; g >= lo; g--)
3437
+ gens.push(bigIntToGeneration(g));
3438
+ try {
3439
+ this.walkPrefetch = await this.prefetchHeadsConcurrent(gens.map((g) => gitvaultPaths.head(g)));
3440
+ }
3441
+ catch {
3442
+ // Fidelity: an outright prefetch failure leaves the map alone — the
3443
+ // walk's own per-head reads take over with their own envelopes.
3444
+ }
3445
+ };
3233
3446
  while (cur) {
3234
3447
  // `cur` disqualifies incremental (checked BEFORE including it): abort
3235
3448
  // and restart as the wholesale walk. Re-visiting heads already fetched
@@ -3248,11 +3461,14 @@ export class GitvaultVault {
3248
3461
  // Before fetching `cur`'s predecessor, check whether the marker
3249
3462
  // already names it — a pure LOCAL comparison against bytes this
3250
3463
  // client already applied last time, no network round trip.
3251
- const prevGen = bigIntToGeneration(generationToBigInt(cur.generation) - 1n);
3464
+ const prevBig = generationToBigInt(cur.generation) - 1n;
3465
+ const prevGen = bigIntToGeneration(prevBig);
3252
3466
  if (incremental && cur.prev_sha256 === marker.head_sha256 && prevGen === marker.generation) {
3253
3467
  cur = null; // the predecessor is the marker's own head — already applied; `heads` already holds everything above it
3254
3468
  break;
3255
3469
  }
3470
+ if (prefetchFloor === null || prevBig < prefetchFloor)
3471
+ await prefetchBackwardWindow(prevBig);
3256
3472
  const bytes = await this.readCachedHeadBytes(prevGen, cur.prev_sha256);
3257
3473
  if (!bytes || sha256Hex(bytes) !== cur.prev_sha256)
3258
3474
  fail("CHAIN_BROKEN", `head ${prevGen} does not match the chain during restore`, "restoring gitvault objects");