run402 4.55.0 → 4.56.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.
@@ -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,22 @@ export class GitvaultVault {
1189
1190
  repoDir;
1190
1191
  now;
1191
1192
  budget;
1193
+ /**
1194
+ * gitvault-clone-scaling (bench P2): the CURRENT listing page's batched
1195
+ * bytes — head bytes and carrier frames, keyed by storage path — filled
1196
+ * by `verifyToNewest`'s per-page prefetch and consulted by
1197
+ * {@link readCachedHeadBytes} / {@link openMaterializeCarriers} before
1198
+ * they pay a network read. Transient (REPLACED each page, so memory is
1199
+ * bounded by one listing page) and UNTRUSTED: every consumer sha-checks
1200
+ * an entry against the exact value it would check network bytes against
1201
+ * (the listing's `stored_bytes_sha256`, a receipt's `ciphertext_sha256`),
1202
+ * the same discipline as the keystore object cache — a wrong or stale
1203
+ * entry is a MISS, never a verification bypass. Deliberately NOT the
1204
+ * keystore cache: that cache's eviction window is a handful of newest
1205
+ * generations by design, so routing a whole page through it would evict
1206
+ * the very bytes the ordered walk is about to read.
1207
+ */
1208
+ walkPrefetch = null;
1192
1209
  retries;
1193
1210
  servicePublicKey;
1194
1211
  genesisCache = null;
@@ -1228,6 +1245,17 @@ export class GitvaultVault {
1228
1245
  }
1229
1246
  kRepo() { return hexToBytes(this.repoFile().k_repo_hex); }
1230
1247
  epoch() { return this.repoFile().epoch; }
1248
+ /**
1249
+ * gitvault-clone-scaling (P3): staleness of the newest checkpoint coverage
1250
+ * this checkout has locally learned, measured at `newestGeneration`. Reads
1251
+ * the keystore AFTER the caller's own persist (a checkpoint-form push has
1252
+ * already recorded its fresh coverage by the time its result is built), so
1253
+ * a compacting push reports itself current. Pure + never-throwing by way of
1254
+ * the helper; unknown coverage reads as `{0, advised: false}` — silent.
1255
+ */
1256
+ checkpointStalenessNow(newestGeneration) {
1257
+ return gitvaultCheckpointStaleness({ newest_generation: newestGeneration, covers_through_generation: this.repoFile().checkpoint_covers_through ?? null });
1258
+ }
1231
1259
  git() {
1232
1260
  if (!this.repoDir)
1233
1261
  fail("GITVAULT_REPO_DIR_REQUIRED", "this operation needs the local git repository (repo_dir)", "gitvault publication");
@@ -1441,6 +1469,12 @@ export class GitvaultVault {
1441
1469
  let request = { after_generation: anchor, limit: String(GITVAULT_MAX_HEADS_PER_LISTING_PAGE) };
1442
1470
  let verified = 0;
1443
1471
  const rotations = [];
1472
+ // gitvault-clone-scaling (P3): coverage this walk LEARNS. A checkpoint
1473
+ // head names it outright; a walk anchored at GENESIS that completes
1474
+ // without seeing one proves coverage = genesis. A partial (non-genesis)
1475
+ // walk that sees none proves nothing and persists nothing.
1476
+ let walkCheckpointCoverage = null;
1477
+ const walkedFromGenesis = anchor === GITVAULT_GENESIS_GENERATION;
1444
1478
  // Consumed by (at most) the FIRST for(;;) iteration below — a listHeads
1445
1479
  // page this call never had to ask the network for, because the state
1446
1480
  // read above already proved it (0 entries: pin already current; 1 entry:
@@ -1543,6 +1577,82 @@ export class GitvaultVault {
1543
1577
  : await this.transport.listHeads({ repo_id: this.repoId, ...request });
1544
1578
  syntheticEntries = null;
1545
1579
  progress = verifyHeadsListingPage(page, request, progress, this.repoId);
1580
+ // gitvault-clone-scaling (bench P2): the page just verified names every
1581
+ // entry's generation + stored_bytes_sha256, and the BYTES reads are
1582
+ // independent — verification (and decryption) order is a LOCAL
1583
+ // obligation. Two batched getObjects per page (the same D2 machinery
1584
+ // the restore pack fetches ride), bounded by the remaining
1585
+ // verification budget, replace this page's per-head serial reads:
1586
+ // first the cache-missing HEAD bytes, then — the same split one level
1587
+ // deeper — the ref_state/retention_roots FRAMES each decrypt-validated
1588
+ // head will open (their paths + expected ciphertext hashes parse out
1589
+ // of the head bytes just fetched; parsing is local CPU). Results land
1590
+ // in the transient `walkPrefetch` map (NOT the keystore cache — its
1591
+ // eviction window is smaller than a page; see the field's doc).
1592
+ // Failure fidelity: only a hash-matching result is kept — an absent,
1593
+ // mismatched, or unparseable entry (or a batch that fails outright)
1594
+ // leaves its slot empty, and the ordered loop's own per-head reads
1595
+ // reproduce the exact unbatched envelopes. Single-miss sets skip the
1596
+ // batch (one direct read costs the same).
1597
+ {
1598
+ const prefetch = new Map();
1599
+ this.walkPrefetch = prefetch;
1600
+ try {
1601
+ const wanted = page.heads.slice(0, Math.max(0, this.budget - verified));
1602
+ const cachedHeadIfMatching = (e) => {
1603
+ const cached = this.keystore.readCachedHead(this.repoId, e.generation);
1604
+ return cached && sha256Hex(cached.bytes) === e.stored_bytes_sha256 ? cached.bytes : null;
1605
+ };
1606
+ const missingHeads = wanted.filter((e) => cachedHeadIfMatching(e) === null);
1607
+ if (missingHeads.length > 1) {
1608
+ const fetched = await this.transport.getObjects({ repo_id: this.repoId, paths: missingHeads.map((e) => gitvaultPaths.head(e.generation)) });
1609
+ for (let i = 0; i < missingHeads.length; i++) {
1610
+ const e = missingHeads[i];
1611
+ const bytes = fetched[i] ?? null;
1612
+ if (bytes && sha256Hex(bytes) === e.stored_bytes_sha256)
1613
+ prefetch.set(gitvaultPaths.head(e.generation), bytes);
1614
+ }
1615
+ }
1616
+ if (decryptValidate && wanted.length > 1) {
1617
+ const carriers = [];
1618
+ for (const e of wanted) {
1619
+ const headBytes = prefetch.get(gitvaultPaths.head(e.generation)) ?? cachedHeadIfMatching(e);
1620
+ if (!headBytes)
1621
+ continue;
1622
+ let parsed;
1623
+ try {
1624
+ parsed = parseGitvaultStrict(new TextDecoder().decode(headBytes));
1625
+ }
1626
+ catch {
1627
+ continue; // the ordered loop's checkChainLink owns rejecting it
1628
+ }
1629
+ for (const w of [
1630
+ { receipt: parsed.ref_state, path: gitvaultPaths.refState(parsed.ref_state.object_id) },
1631
+ { receipt: parsed.retention_roots, path: gitvaultPaths.retentionRoots(parsed.retention_roots.object_id) },
1632
+ ]) {
1633
+ const cached = this.keystore.readCachedCarrier(this.repoId, w.receipt.object_id);
1634
+ if (cached && sha256Hex(cached.bytes) === w.receipt.ciphertext_sha256)
1635
+ continue;
1636
+ carriers.push({ path: w.path, sha: w.receipt.ciphertext_sha256 });
1637
+ }
1638
+ }
1639
+ if (carriers.length > 1) {
1640
+ const fetched = await this.transport.getObjects({ repo_id: this.repoId, paths: carriers.map((c) => c.path) });
1641
+ for (let i = 0; i < carriers.length; i++) {
1642
+ const bytes = fetched[i] ?? null;
1643
+ if (bytes && sha256Hex(bytes) === carriers[i].sha)
1644
+ prefetch.set(carriers[i].path, bytes);
1645
+ }
1646
+ }
1647
+ }
1648
+ }
1649
+ catch {
1650
+ // A batch that fails OUTRIGHT (network, not a per-slot null) must
1651
+ // not introduce a failure mode the unbatched walk never had — the
1652
+ // ordered loop below re-reads what it needs itself and fails (or
1653
+ // succeeds, if the fault was transient) with its own envelopes.
1654
+ }
1655
+ }
1546
1656
  for (const entry of page.heads) {
1547
1657
  if (verified >= this.budget) {
1548
1658
  if (persist)
@@ -1581,6 +1691,8 @@ export class GitvaultVault {
1581
1691
  prevEpoch = head.epoch;
1582
1692
  pin = { generation: head.generation, head_sha256: entry.stored_bytes_sha256, pinned_at: formatGitvaultTimestamp(this.now()) };
1583
1693
  lastHead = head;
1694
+ if (head.checkpoint)
1695
+ walkCheckpointCoverage = head.checkpoint.covers_through_generation;
1584
1696
  verified += 1;
1585
1697
  // The chain walk ALWAYS continues below regardless of decrypt
1586
1698
  // outcome (`tryDecrypt` never throws) — `!decryptFailure` just stops
@@ -1599,6 +1711,15 @@ export class GitvaultVault {
1599
1711
  break;
1600
1712
  request = next;
1601
1713
  }
1714
+ // P3: reaching here means the walk COMPLETED (a budget pause throws
1715
+ // above) — persist whatever coverage it proved. A genesis-anchored walk
1716
+ // is authoritative for its whole history, so no checkpoint seen means
1717
+ // coverage = genesis, honestly.
1718
+ if (persist) {
1719
+ const learned = walkCheckpointCoverage ?? (walkedFromGenesis ? GITVAULT_GENESIS_GENERATION : null);
1720
+ if (learned !== null)
1721
+ this.keystore.updateRepo(this.repoId, { checkpoint_covers_through: learned });
1722
+ }
1602
1723
  // Catch-up: a call with NOTHING new to walk (this repo's chain-verified
1603
1724
  // pin was already at `pin`/`lastHead` — e.g. an EARLIER, decrypt-blind
1604
1725
  // `verifyToNewest({})` call already advanced `head_pin` to the newest,
@@ -1700,7 +1821,16 @@ export class GitvaultVault {
1700
1821
  const cached = this.keystore.readCachedHead(this.repoId, generation);
1701
1822
  if (cached && sha256Hex(cached.bytes) === expectedSha256)
1702
1823
  return cached.bytes;
1703
- const bytes = await this.transport.getObject({ repo_id: this.repoId, path: gitvaultPaths.head(generation) });
1824
+ // gitvault-clone-scaling (P2): a page-prefetched head serves exactly as
1825
+ // a network fetch would — sha-checked here against the SAME expected
1826
+ // value, then cache-warmed. A miss/mismatch falls through to the read.
1827
+ const path = gitvaultPaths.head(generation);
1828
+ const prefetched = this.walkPrefetch?.get(path);
1829
+ if (prefetched && sha256Hex(prefetched) === expectedSha256) {
1830
+ this.keystore.writeCachedHead(this.repoId, generation, expectedSha256, prefetched);
1831
+ return prefetched;
1832
+ }
1833
+ const bytes = await this.transport.getObject({ repo_id: this.repoId, path });
1704
1834
  if (bytes && sha256Hex(bytes) === expectedSha256)
1705
1835
  this.keystore.writeCachedHead(this.repoId, generation, expectedSha256, bytes);
1706
1836
  return bytes;
@@ -1795,15 +1925,22 @@ export class GitvaultVault {
1795
1925
  const cachedRoots = this.keystore.readCachedCarrier(this.repoId, rootsReceipt.object_id);
1796
1926
  const refStateHit = Boolean(cachedRefState && sha256Hex(cachedRefState.bytes) === refStateReceipt.ciphertext_sha256);
1797
1927
  const rootsHit = Boolean(cachedRoots && sha256Hex(cachedRoots.bytes) === rootsReceipt.ciphertext_sha256);
1928
+ // gitvault-clone-scaling (P2): a page-prefetched frame serves exactly as
1929
+ // a fetched one — sha-checked against the receipt's ciphertext hash; a
1930
+ // miss/mismatch falls through to the batched network read below.
1931
+ const preRefState = !refStateHit ? (this.walkPrefetch?.get(refStatePath) ?? null) : null;
1932
+ const refStatePre = preRefState && sha256Hex(preRefState) === refStateReceipt.ciphertext_sha256 ? preRefState : null;
1933
+ const preRoots = !rootsHit ? (this.walkPrefetch?.get(rootsPath) ?? null) : null;
1934
+ const rootsPre = preRoots && sha256Hex(preRoots) === rootsReceipt.ciphertext_sha256 ? preRoots : null;
1798
1935
  const missingPaths = [];
1799
- if (!refStateHit)
1936
+ if (!refStateHit && !refStatePre)
1800
1937
  missingPaths.push(refStatePath);
1801
- if (!rootsHit)
1938
+ if (!rootsHit && !rootsPre)
1802
1939
  missingPaths.push(rootsPath);
1803
1940
  const fetched = missingPaths.length > 0 ? await this.transport.getObjects({ repo_id: this.repoId, paths: missingPaths }) : [];
1804
1941
  let next = 0;
1805
- const refStateFrame = refStateHit ? cachedRefState.bytes : (fetched[next++] ?? null);
1806
- const rootsFrame = rootsHit ? cachedRoots.bytes : (fetched[next++] ?? null);
1942
+ const refStateFrame = refStateHit ? cachedRefState.bytes : (refStatePre ?? fetched[next++] ?? null);
1943
+ const rootsFrame = rootsHit ? cachedRoots.bytes : (rootsPre ?? fetched[next++] ?? null);
1807
1944
  const refState = this.decodeCarrierFrame("ref_state", refStateReceipt, refStateFrame, writerKey, keyOverride);
1808
1945
  const roots = this.decodeCarrierFrame("retention_roots", rootsReceipt, rootsFrame, writerKey, keyOverride);
1809
1946
  if (!refStateHit && refStateFrame)
@@ -2427,7 +2564,7 @@ export class GitvaultVault {
2427
2564
  if (published.outcome === "dry_run")
2428
2565
  fail("GIT_COMMAND_FAILED", "internal: push() received a dry-run result it never requested", "publishing gitvault head");
2429
2566
  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 };
2567
+ 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
2568
  }
2432
2569
  }
2433
2570
  /**
@@ -2517,7 +2654,7 @@ export class GitvaultVault {
2517
2654
  // unreachable here — narrows `published` to `"admitted"` below.
2518
2655
  if (published.outcome === "dry_run")
2519
2656
  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 };
2657
+ 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
2658
  }
2522
2659
  }
2523
2660
  /** Request a `retention_cutoff` ticket and check it binds THIS base head (and the service key, when one is pinned). */
@@ -2580,7 +2717,14 @@ export class GitvaultVault {
2580
2717
  // generation's head entirely.
2581
2718
  this.keystore.writeCachedHead(this.repoId, head.generation, hash, back);
2582
2719
  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 });
2720
+ this.keystore.updateRepo(this.repoId, {
2721
+ head_pin: pin,
2722
+ materialized_pin: pin,
2723
+ verified_prefix: null,
2724
+ // gitvault-clone-scaling (P3): a checkpoint-form head IS fresh
2725
+ // coverage this checkout just learned first-hand.
2726
+ ...(head.checkpoint ? { checkpoint_covers_through: head.checkpoint.covers_through_generation } : {}),
2727
+ });
2584
2728
  return { outcome: "admitted", head_sha256: hash, admission_record_sha256: result.admission_record_sha256, capture_receipt: result.capture_receipt };
2585
2729
  }
2586
2730
  // ── epoch rotation (D193-D203, rev 42, change gitvault-human-envelopes) ──
@@ -2748,7 +2892,7 @@ export class GitvaultVault {
2748
2892
  // predecessor manifest back to compute confirmed() (D196) — resolves
2749
2893
  // it locally instead of a network object-reads round trip.
2750
2894
  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 };
2895
+ 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
2896
  }
2753
2897
  }
2754
2898
  /**
@@ -3160,7 +3304,7 @@ export class GitvaultVault {
3160
3304
  const admitted = await this.admit(head);
3161
3305
  if (admitted.outcome === "conflict")
3162
3306
  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 };
3307
+ 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
3308
  }
3165
3309
  /** Heads `base..newest` (already chain-verified by `verifyToNewest`) re-read + hash-checked from storage. */
3166
3310
  async chainFrom(baseGeneration, newest) {
@@ -3230,6 +3374,57 @@ export class GitvaultVault {
3230
3374
  const heads = [];
3231
3375
  let cur = newest.head;
3232
3376
  let incremental = marker !== null;
3377
+ // gitvault-clone-scaling (bench P2): the backward walk's head PATHS are
3378
+ // all derivable up front (generation N−1, N−2, …) — only each head's
3379
+ // expected hash arrives chain-sequentially. Same fetch-concurrent /
3380
+ // verify-ordered split as the forward walk: page-sized windows of
3381
+ // predecessor head bytes batch into the transient `walkPrefetch` map
3382
+ // (use-time sha-checked by `readCachedHeadBytes`; a miss, mismatch, or
3383
+ // failed batch falls back to that read's own single fetch), refilled as
3384
+ // the walk descends past the window floor. The floor ESTIMATE — the
3385
+ // marker on the incremental path, else locally learned checkpoint
3386
+ // coverage, else genesis — only bounds over-fetch; the walk's own stop
3387
+ // conditions are unchanged, and a wrong estimate costs at most one
3388
+ // window of concurrent GETs, never correctness.
3389
+ let prefetchFloor = null;
3390
+ const prefetchBackwardWindow = async (hi) => {
3391
+ let lo = 1n;
3392
+ if (incremental && marker)
3393
+ lo = generationToBigInt(marker.generation) + 1n;
3394
+ else {
3395
+ const known = this.repoFile().checkpoint_covers_through;
3396
+ if (known && /^[0-9a-f]{16}$/.test(known)) {
3397
+ const k = generationToBigInt(known);
3398
+ if (k > lo)
3399
+ lo = k;
3400
+ }
3401
+ }
3402
+ const capLo = hi - BigInt(GITVAULT_MAX_HEADS_PER_LISTING_PAGE) + 1n;
3403
+ if (capLo > lo)
3404
+ lo = capLo;
3405
+ if (lo < 1n)
3406
+ lo = 1n;
3407
+ prefetchFloor = lo;
3408
+ if (hi - lo < 1n)
3409
+ return; // 0 or 1 path — a single read costs the same
3410
+ const gens = [];
3411
+ for (let g = hi; g >= lo; g--)
3412
+ gens.push(bigIntToGeneration(g));
3413
+ try {
3414
+ const fetched = await this.transport.getObjects({ repo_id: this.repoId, paths: gens.map((g) => gitvaultPaths.head(g)) });
3415
+ const map = new Map();
3416
+ for (let i = 0; i < gens.length; i++) {
3417
+ const b = fetched[i];
3418
+ if (b)
3419
+ map.set(gitvaultPaths.head(gens[i]), b);
3420
+ }
3421
+ this.walkPrefetch = map;
3422
+ }
3423
+ catch {
3424
+ // Fidelity: an outright batch failure leaves the map alone — the
3425
+ // walk's own per-head reads take over with their own envelopes.
3426
+ }
3427
+ };
3233
3428
  while (cur) {
3234
3429
  // `cur` disqualifies incremental (checked BEFORE including it): abort
3235
3430
  // and restart as the wholesale walk. Re-visiting heads already fetched
@@ -3248,11 +3443,14 @@ export class GitvaultVault {
3248
3443
  // Before fetching `cur`'s predecessor, check whether the marker
3249
3444
  // already names it — a pure LOCAL comparison against bytes this
3250
3445
  // client already applied last time, no network round trip.
3251
- const prevGen = bigIntToGeneration(generationToBigInt(cur.generation) - 1n);
3446
+ const prevBig = generationToBigInt(cur.generation) - 1n;
3447
+ const prevGen = bigIntToGeneration(prevBig);
3252
3448
  if (incremental && cur.prev_sha256 === marker.head_sha256 && prevGen === marker.generation) {
3253
3449
  cur = null; // the predecessor is the marker's own head — already applied; `heads` already holds everything above it
3254
3450
  break;
3255
3451
  }
3452
+ if (prefetchFloor === null || prevBig < prefetchFloor)
3453
+ await prefetchBackwardWindow(prevBig);
3256
3454
  const bytes = await this.readCachedHeadBytes(prevGen, cur.prev_sha256);
3257
3455
  if (!bytes || sha256Hex(bytes) !== cur.prev_sha256)
3258
3456
  fail("CHAIN_BROKEN", `head ${prevGen} does not match the chain during restore`, "restoring gitvault objects");