run402 4.63.0 → 4.64.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/gitvault-surface.json +2 -1
  2. package/lib/doctor.mjs +28 -15
  3. package/lib/gitvault-capabilities.mjs +8 -0
  4. package/lib/remote-helper-session.mjs +166 -32
  5. package/lib/repos.mjs +122 -9
  6. package/package.json +1 -1
  7. package/sdk/dist/errors.d.ts +4 -2
  8. package/sdk/dist/errors.d.ts.map +1 -1
  9. package/sdk/dist/errors.js.map +1 -1
  10. package/sdk/dist/index.d.ts +1 -1
  11. package/sdk/dist/index.d.ts.map +1 -1
  12. package/sdk/dist/index.js +1 -1
  13. package/sdk/dist/index.js.map +1 -1
  14. package/sdk/dist/namespaces/gitvault.crypto.d.ts +80 -0
  15. package/sdk/dist/namespaces/gitvault.crypto.d.ts.map +1 -1
  16. package/sdk/dist/namespaces/gitvault.crypto.js +80 -0
  17. package/sdk/dist/namespaces/gitvault.crypto.js.map +1 -1
  18. package/sdk/dist/namespaces/gitvault.d.ts +219 -3
  19. package/sdk/dist/namespaces/gitvault.d.ts.map +1 -1
  20. package/sdk/dist/namespaces/gitvault.js +282 -5
  21. package/sdk/dist/namespaces/gitvault.js.map +1 -1
  22. package/sdk/dist/node/gitvault-byo-config.d.ts +29 -0
  23. package/sdk/dist/node/gitvault-byo-config.d.ts.map +1 -0
  24. package/sdk/dist/node/gitvault-byo-config.js +100 -0
  25. package/sdk/dist/node/gitvault-byo-config.js.map +1 -0
  26. package/sdk/dist/node/gitvault-byo-probe.d.ts +20 -0
  27. package/sdk/dist/node/gitvault-byo-probe.d.ts.map +1 -0
  28. package/sdk/dist/node/gitvault-byo-probe.js +57 -0
  29. package/sdk/dist/node/gitvault-byo-probe.js.map +1 -0
  30. package/sdk/dist/node/gitvault-creation-journal.d.ts +43 -0
  31. package/sdk/dist/node/gitvault-creation-journal.d.ts.map +1 -1
  32. package/sdk/dist/node/gitvault-creation-journal.js +34 -3
  33. package/sdk/dist/node/gitvault-creation-journal.js.map +1 -1
  34. package/sdk/dist/node/gitvault-degraded-read.d.ts +120 -0
  35. package/sdk/dist/node/gitvault-degraded-read.d.ts.map +1 -0
  36. package/sdk/dist/node/gitvault-degraded-read.js +164 -0
  37. package/sdk/dist/node/gitvault-degraded-read.js.map +1 -0
  38. package/sdk/dist/node/gitvault-mirror-backend.d.ts +73 -0
  39. package/sdk/dist/node/gitvault-mirror-backend.d.ts.map +1 -1
  40. package/sdk/dist/node/gitvault-mirror-backend.js +114 -8
  41. package/sdk/dist/node/gitvault-mirror-backend.js.map +1 -1
  42. package/sdk/dist/node/gitvault-mirror-config.d.ts +17 -1
  43. package/sdk/dist/node/gitvault-mirror-config.d.ts.map +1 -1
  44. package/sdk/dist/node/gitvault-mirror-config.js +21 -0
  45. package/sdk/dist/node/gitvault-mirror-config.js.map +1 -1
  46. package/sdk/dist/node/gitvault-mirror.d.ts +42 -0
  47. package/sdk/dist/node/gitvault-mirror.d.ts.map +1 -1
  48. package/sdk/dist/node/gitvault-mirror.js +162 -1
  49. package/sdk/dist/node/gitvault-mirror.js.map +1 -1
  50. package/sdk/dist/node/gitvault-publication.d.ts +51 -1
  51. package/sdk/dist/node/gitvault-publication.d.ts.map +1 -1
  52. package/sdk/dist/node/gitvault-publication.js +88 -6
  53. package/sdk/dist/node/gitvault-publication.js.map +1 -1
@@ -1,5 +1,5 @@
1
1
  {
2
- "surface_version": "4.63.0",
2
+ "surface_version": "4.64.0",
3
3
  "verbs": [
4
4
  "repos create",
5
5
  "repos list",
@@ -68,6 +68,7 @@
68
68
  "revocation_live": true,
69
69
  "human_envelope_add_live": true,
70
70
  "mirror_live": true,
71
+ "byo_live": true,
71
72
  "recover_live": true,
72
73
  "allocation": "create_immediate",
73
74
  "snapshot_dirty_default": "refuse"
package/lib/doctor.mjs CHANGED
@@ -12,6 +12,7 @@
12
12
  */
13
13
 
14
14
  import { existsSync, statSync } from "node:fs";
15
+ import { GITVAULT_BYO_NO_PAYLOAD_COPY_STATEMENT } from "#sdk";
15
16
  import { configDir, readAllowance, loadKeyStore } from "./config.mjs";
16
17
  import { resolveGitvaultTarget } from "./gitvault-target.mjs";
17
18
  import { getSdk } from "./sdk.mjs";
@@ -665,6 +666,10 @@ export async function run(sub, args = []) {
665
666
  project_id: gv.project_id ?? projectId,
666
667
  repo_id: gv.repo_id,
667
668
  vault: gv.vault === null ? null : "allocated",
669
+ // gitvault-byo-primary-bucket task 3.5 — absent-or-"managed" is
670
+ // byte-identical to before this fold for every non-BYO vault.
671
+ storage_profile: gv.vault?.storage_profile ?? null,
672
+ byo_destination: gv.vault?.byo_destination ?? null,
668
673
  gitvault_policy: gv.gitvault_policy,
669
674
  keystore_root: gv.keystore.root,
670
675
  can_sign: gv.keystore.can_sign,
@@ -704,30 +709,34 @@ export async function run(sub, args = []) {
704
709
  // doctor-persistent `grandfathered` advisory it owns.
705
710
  for (const w of gv.warnings ?? []) gaps.push(`${w.kind}: ${w.message}`);
706
711
 
707
- // gitvault-mirror-and-recover task 4.3: mirror currency, reported
708
- // ALONGSIDE (never in place of) the deploy-related gaps above, and
709
- // never blocking `run402 deploy`'s own gate — the vault lane's
710
- // outcome is unaffected regardless of mirror state (design D6).
711
- // `mirror_currency` mirrors `mirror status`'s own tri-state:
712
- // `current` / `stale` / `unknown` (mirror unreachable or vault
713
- // unread) only STALE is actionable enough to become a warning; NO
714
- // mirror configured is a purely informational, ungated advisory
715
- // most vaults have never opted in, and that is a normal shape.
712
+ // gitvault-mirror-and-recover task 4.3 + gitvault-mirror-default:
713
+ // mirror currency, reported ALONGSIDE (never in place of) the
714
+ // deploy-related gaps above, and never blocking `run402 deploy`'s
715
+ // own gate — the vault lane's outcome is unaffected regardless of
716
+ // mirror state (design D6). `mirror_currency` mirrors `mirror
717
+ // status`'s own tri-state: `current` / `stale` / `unknown` (mirror
718
+ // unreachable or vault unread). Only STALE is actionable enough to
719
+ // become a warning gap; a vault with no successful mirror copy yet
720
+ // carries the SDK-computed `vault_unmirrored` finding named and
721
+ // standing (gitvault-mirror-default supersedes the old anonymous
722
+ // `advisory` string), echoed verbatim, and deliberately NOT pushed
723
+ // into `gaps`: informational, never blocking, computed client-side
724
+ // only, cleared by the first successful mirror write or sync.
716
725
  if (gv.vault !== null && value.repo_id) {
717
726
  try {
718
- const mirrorStatus = await getSdk().gitvault.mirrorStatus({ repo_id: value.repo_id });
727
+ const mirrorStatus = await getSdk().gitvault.mirrorStatus({ repo_id: value.repo_id, is_byo: value.storage_profile === "byo" });
719
728
  value.gitvault_mirror = {
720
729
  configured: mirrorStatus.configured,
721
730
  destination: mirrorStatus.destination,
722
731
  mirrored_generation: mirrorStatus.mirrored_generation,
723
732
  newest_generation: mirrorStatus.newest_generation,
724
733
  is_current: mirrorStatus.is_current,
734
+ last_success_at: mirrorStatus.last_success_at,
735
+ finding: mirrorStatus.finding,
725
736
  validity_not_freshness: mirrorStatus.validity_not_freshness,
726
737
  keystore_still_required: mirrorStatus.keystore_still_required,
727
738
  };
728
- if (!mirrorStatus.configured) {
729
- value.gitvault_mirror.advisory = "no ciphertext mirror is configured for this vault — the exit ramp is opt-in; 'run402 repos mirror <destination>' to configure one.";
730
- } else if (mirrorStatus.is_current === false) {
739
+ if (mirrorStatus.is_current === false) {
731
740
  gaps.push(`the ciphertext mirror at ${mirrorStatus.destination} is STALE (mirrored generation ${mirrorStatus.mirrored_generation ?? "(none)"}, vault newest ${mirrorStatus.newest_generation ?? "(none)"}) — ${mirrorStatus.closing_command}`);
732
741
  }
733
742
  } catch {
@@ -736,15 +745,19 @@ export async function run(sub, args = []) {
736
745
  }
737
746
  }
738
747
 
748
+ // gitvault-byo-primary-bucket task 3.5 — unconditional, independent
749
+ // of mirror status (D7); imported from the canonical constants, never
750
+ // paraphrased.
751
+ const byoDisclosure = value.storage_profile === "byo" ? ` Storage: byo (${value.byo_destination ?? "(unknown)"}) — ${GITVAULT_BYO_NO_PAYLOAD_COPY_STATEMENT}` : "";
739
752
  checks.push({
740
753
  name: "gitvault",
741
754
  status: gaps.length > 0 ? "warning" : "ok",
742
755
  value: gaps.length > 0 ? { ...value, gaps } : value,
743
- hint: gv.vault === null
756
+ hint: (gv.vault === null
744
757
  ? `No vault for this project (that is a normal shape). Allocate one with 'run402 repos create --project <id>'. Keystore: ${gv.keystore.root}`
745
758
  : gv.durability_statement
746
759
  ? `Back up ${gv.keystore.root} anyway — ${gv.durability_statement} (covering_recipients: ${gv.covering_recipients})`
747
- : `Back up ${gv.keystore.root} — whole-machine or whole-keystore loss is terminal for vault history.`,
760
+ : `Back up ${gv.keystore.root} — whole-machine or whole-keystore loss is terminal for vault history.`) + byoDisclosure,
748
761
  });
749
762
  } catch (err) {
750
763
  // A gateway without gitvault, an unreachable API, or a project this
@@ -28,6 +28,14 @@ export const GITVAULT_CAPABILITIES = {
28
28
  // `repos mirror` reads/writes a mirror destination beside the keystore and
29
29
  // moves real bytes into a customer-owned bucket.
30
30
  mirror_live: true,
31
+ // `repos create --byo <s3://bucket/prefix>` allocates a
32
+ // `storage_profile: "byo"` vault whose payload ciphertext is written by
33
+ // the client straight to the customer's own bucket (create-only probe
34
+ // fail-closed before allocation; chain-copy dual-write; attested finalize
35
+ // with `storage_verification: "client_attested"`; degraded reads from the
36
+ // destination; fsck absence check) — shipped end to end in the same
37
+ // change that flips this flag (gitvault-byo-primary-bucket, Phase 3).
38
+ byo_live: true,
31
39
  // `repos recover` materializes a git repository from a mirror source,
32
40
  // offline, with no server call.
33
41
  recover_live: true,
@@ -112,6 +112,23 @@
112
112
  * is refused rather than silently pointed at the current directory. Only
113
113
  * `capabilities`, `option` and `list` work without one, which is exactly
114
114
  * the set `git ls-remote <url>` outside a checkout needs.
115
+ *
116
+ * DEGRADED READ MODE (gitvault-byo-primary-bucket, design D4). `list` and
117
+ * `fetch` each wrap their own live gateway read in
118
+ * `getSdk().gitvault.withDegradedRead` — on a NETWORK-CLASS failure (never a
119
+ * 401/403/404 or any other 4xx; one bounded retry runs first so a transient
120
+ * blip does not flap between sources) with a mirror configured for this
121
+ * vault, the read is served from it instead via the SAME `r402s-recover`
122
+ * engine `run402 repos recover` uses — `git fetch`/`clone` keep working
123
+ * while run402 is down. The result is marked `degraded: true` with source
124
+ * provenance, and exactly one stderr line names the fallback and its source
125
+ * (`gitvaultDegradedReadNote`). Local trust pins never advance past what the
126
+ * mirror copy itself chain-verifies (the fallback engine never touches the
127
+ * live vault's own keystore pins at all — see `gitvault-degraded-read.ts`'s
128
+ * own doc comment). A vault with no mirror configured is BYTE-IDENTICAL to
129
+ * today: the original gateway error surfaces unchanged. `push` is UNTOUCHED
130
+ * by this — writes are never rerouted; admission always requires the
131
+ * gateway.
115
132
  */
116
133
 
117
134
  import { createInterface } from "node:readline";
@@ -127,7 +144,7 @@ const nodeModP = import("#sdk/node");
127
144
  const configModP = import("./config.mjs");
128
145
  const { getSdk } = await sdkModP;
129
146
  const { resolveWalletCore, enforceWalletExistsCore, WalletSelectionError } = await walletModP;
130
- const { gitvaultRemoteAddressForm, gitvaultSlugReleasedInfo, parseGitvaultRemoteUrl } = await isoModP;
147
+ const { gitvaultRemoteAddressForm, gitvaultSlugReleasedInfo, parseGitvaultRemoteUrl, gitvaultDegradedReadNote } = await isoModP;
131
148
  const { GITVAULT_R402_REF_NAMESPACE, hardenedGit, resolveGitInvocationRepo, readPinnedGitvaultRepo, pinGitvaultRepo, readGitvaultRestoreMarker, readGitvaultAutoGcThreshold } = await nodeModP;
132
149
  const { allowanceFile, projectCredentialsFile, profileStateFile } = await configModP;
133
150
 
@@ -408,6 +425,23 @@ export function shouldRunAutoGc(threshold, generationsSinceCheckpoint) {
408
425
  return Number.isFinite(threshold) && threshold > 0 && Number.isFinite(generationsSinceCheckpoint) && generationsSinceCheckpoint >= threshold;
409
426
  }
410
427
 
428
+ /**
429
+ * The auto-gc cycle's `compact()`/`prune()` target — exported standalone,
430
+ * same reasoning as `shouldRunAutoGc` above, because the merge itself was
431
+ * the bug: `GitvaultVaultHandleOptions.repo_dir` defaults to
432
+ * `process.cwd()` only in CALLER convention, never inside the SDK's own
433
+ * `open()` (which sets it ONLY when `options.repo_dir !== undefined`).
434
+ * Building the auto-gc target from `{ ...target }` alone — omitting
435
+ * `repo_dir` — made every real post-push auto-gc cycle fail closed with
436
+ * `GITVAULT_REPO_DIR_REQUIRED`, silently degrading to the advisory on
437
+ * EVERY push, live in production shape, until a push-latency guard bench
438
+ * finally drove the real trigger path (found 2026-08-31; `repos gc`'s CLI
439
+ * wrapper passes repo_dir explicitly and never hit this).
440
+ */
441
+ export function buildAutoGcCompactionTarget(target, repoDir) {
442
+ return { ...target, repo_dir: repoDir };
443
+ }
444
+
411
445
  async function main(argv, { onBackgroundWork } = {}) {
412
446
  // gitvault-connection-amortization (bench P5) note: the prewarm now fires
413
447
  // at the module TOP, before the SDK graph loads (gitvault-startup-
@@ -507,7 +541,7 @@ async function main(argv, { onBackgroundWork } = {}) {
507
541
  */
508
542
  const openVault = async (repoDir) => {
509
543
  const result = await getCachedSdk().gitvault.resolveOrCreateAddress({ address, allow_create: false, ...(repoDir ? { repo_dir: repoDir } : {}) });
510
- return { vault: result.handle.vault, resolution: result.resolution };
544
+ return { vault: result.handle.vault, keystore: result.handle.keystore, repo_id: result.handle.repo_id, resolution: result.resolution };
511
545
  };
512
546
 
513
547
  /**
@@ -552,7 +586,10 @@ async function main(argv, { onBackgroundWork } = {}) {
552
586
  note(`vault ${result.handle.repo_id} allocated (genesis ${result.created.genesis_sha256}) — one-shot recovery receipt, keep many copies:`);
553
587
  note(JSON.stringify(result.created.recovery_receipt));
554
588
  try {
555
- const { getGitvaultKeystoreRoot } = await import("#sdk/node");
589
+ const { getGitvaultKeystoreRoot, GITVAULT_MIRROR_SETUP_HINT } = await import("#sdk/node");
590
+ // gitvault-mirror-default: every allocation teaches the mirror door
591
+ // beside the recovery receipt, lazy push-to-create included.
592
+ note(GITVAULT_MIRROR_SETUP_HINT);
556
593
  note(`keystore: ${getGitvaultKeystoreRoot()} — back this up; whole-machine or whole-keystore loss is terminal for vault history until human envelopes ship`);
557
594
  } catch {
558
595
  // Never let a diagnostic line fail a push that already allocated successfully.
@@ -596,13 +633,38 @@ async function main(argv, { onBackgroundWork } = {}) {
596
633
  }
597
634
  }
598
635
  const materializeOpts = fetchMarker ? { deltaSince: fetchMarker.generation } : {};
599
- let vault;
600
- let state;
636
+ let opened;
601
637
  try {
602
- const opened = await openVault(repoDir ?? undefined);
603
- vault = opened.vault;
638
+ opened = await openVault(repoDir ?? undefined);
639
+ } catch (err) {
640
+ // An unallocated vault is not an error here: `list` is the read half of
641
+ // the protocol dance and must never create anything on its own (D2
642
+ // scopes lazy creation to `push`). Reporting it as an EMPTY ref set is
643
+ // exactly what a fresh repository looks like to git, and `push` still
644
+ // runs `list` first either way — this is what lets a first push land in
645
+ // one command instead of `list` failing the whole exchange before
646
+ // `push` ever gets a turn. Nothing to share with `push` either
647
+ // (design D7): an unallocated vault has no base to reuse.
648
+ if (isVaultNotFound(err)) {
649
+ endBlock();
650
+ return;
651
+ }
652
+ throw err;
653
+ }
654
+ let vault = opened.vault;
655
+
656
+ // gitvault-byo-primary-bucket (design D4, task 3.4 — mirror half): the
657
+ // live materialize (with its own stale-pin retry, unchanged from before
658
+ // this change) is the "attemptLive" `withDegradedRead` wraps — on a
659
+ // NETWORK-CLASS failure (never 4xx) with a mirror configured for this
660
+ // vault, it falls back to `r402s-recover`'s engine against it instead of
661
+ // failing the whole `list`/`clone`. `repoDir` is required for the
662
+ // fallback to materialize into (`out_dir`); a repo-free `list` (bare
663
+ // `git ls-remote`) simply has none, so the fallback never runs and the
664
+ // original error surfaces exactly as it always did.
665
+ const attemptLive = async () => {
604
666
  try {
605
- state = await vault.materialize(materializeOpts);
667
+ return await vault.materialize(materializeOpts);
606
668
  } catch (err) {
607
669
  // An OFFLINE (id-carrying pin) resolution discovers a stale pin on
608
670
  // its FIRST repo-scoped read (client-surface spec, id-pinning
@@ -617,30 +679,54 @@ async function main(argv, { onBackgroundWork } = {}) {
617
679
  if (!recovered) throw err;
618
680
  note(`pinned vault ${opened.resolution.repo_id} no longer resolves — re-resolved to ${recovered.resolution.repo_id}, retrying`);
619
681
  vault = recovered.handle.vault;
620
- state = await vault.materialize(materializeOpts);
682
+ opened = { ...opened, vault: recovered.handle.vault, keystore: recovered.handle.keystore, repo_id: recovered.handle.repo_id, resolution: recovered.resolution };
683
+ return await vault.materialize(materializeOpts);
621
684
  }
685
+ };
686
+
687
+ let outcome;
688
+ try {
689
+ outcome = await getCachedSdk().gitvault.withDegradedRead({ attemptLive, keystore: opened.keystore, repo_id: opened.repo_id, out_dir: repoDir });
622
690
  } catch (err) {
623
- // An unallocated vault is not an error here: `list` is the read half of
624
- // the protocol dance and must never create anything on its own (D2
625
- // scopes lazy creation to `push`). Reporting it as an EMPTY ref set is
626
- // exactly what a fresh repository looks like to git, and `push` still
627
- // runs `list` first either way — this is what lets a first push land in
628
- // one command instead of `list` failing the whole exchange before
629
- // `push` ever gets a turn. Nothing to share with `push` either
630
- // (design D7): an unallocated vault has no base to reuse.
691
+ // Same "an unallocated vault is not an error" reasoning as above a
692
+ // 404 discovered only once materialize actually runs takes this same
693
+ // empty-ref-set path (network-class errors never reach here at all:
694
+ // `withDegradedRead` only rethrows them after exhausting its own
695
+ // fallback, and a 404 is never network-class).
631
696
  if (isVaultNotFound(err)) {
632
697
  endBlock();
633
698
  return;
634
699
  }
635
700
  throw err;
636
701
  }
637
- // gitvault-session-state-reuse design D1: `fetchState`/`fetchMarker`
638
- // extend the SAME session-scoped handoff `push` already reuses (`base`)
639
- // — the `fetch` phase of THIS session reuses this response instead of
640
- // issuing its own state read. Session-scoped only: dropped the moment a
641
- // push admits in this same session (see `runPush`'s reset below).
642
- if (repoDir) sharedListSession = { repoDir, walletName: resolvedWallet?.name ?? null, vault, base: state, fetchMarker, fetchState: state };
643
- const refs = state.refs ?? {};
702
+
703
+ let refs;
704
+ let head;
705
+ if (outcome.degraded) {
706
+ // Exactly ONE stderr line naming the degraded read and its source
707
+ // (design D4) the validity-not-freshness limit rides inside the
708
+ // canonical statement this composes, verbatim.
709
+ note(gitvaultDegradedReadNote(outcome.result.source));
710
+ refs = outcome.result.refs ?? {};
711
+ head = outcome.result.head_target;
712
+ // No live vault instance materialized this pass — nothing safe to
713
+ // share with a following `push` (design D7's own "unallocated vault"
714
+ // reasoning applies equally to a degraded one: writes are never
715
+ // rerouted, so `push` must resolve and materialize live on its own).
716
+ } else {
717
+ const state = outcome.live;
718
+ refs = state.refs ?? {};
719
+ head = state.head_target;
720
+ // gitvault-session-state-reuse design D1: `fetchState`/`fetchMarker`
721
+ // extend the SAME session-scoped handoff `push` already reuses (`base`)
722
+ // — the `fetch` phase of THIS session reuses this response instead of
723
+ // issuing its own state read. Session-scoped only: dropped the moment a
724
+ // push admits in this same session (see `runPush`'s reset below).
725
+ // `keystore`/`repo_id` ride along so `fetch`'s own degraded-read wrap
726
+ // can fall back without re-resolving.
727
+ if (repoDir) sharedListSession = { repoDir, walletName: resolvedWallet?.name ?? null, vault, keystore: opened.keystore, repo_id: opened.repo_id, base: state, fetchMarker, fetchState: state };
728
+ }
729
+
644
730
  for (const ref of Object.keys(refs).sort()) out(`${refs[ref]} ${ref}`);
645
731
  // A snapshot-only vault holds protocol refs but no branch heads, so a
646
732
  // plain `git clone` prints "cloned an empty repository" with no hint the
@@ -650,7 +736,6 @@ async function main(argv, { onBackgroundWork } = {}) {
650
736
  note(`this vault has no branch heads yet — its history lives on ${refNames.sort()[0]}`);
651
737
  note(`fetch it with: git fetch <remote> '+${refNames.sort()[0]}:${refNames.sort()[0]}' && git checkout -b restored ${refNames.sort()[0]}`);
652
738
  }
653
- const head = state.head_target;
654
739
  // A symref is only advertised when its target is actually present:
655
740
  // pointing HEAD at a ref that does not exist is what an empty repository
656
741
  // looks like, and git reads the empty list correctly on its own.
@@ -683,15 +768,55 @@ async function main(argv, { onBackgroundWork } = {}) {
683
768
  // different repository/wallet, or a push that already admitted in this
684
769
  // same session — see `runPush`'s reset) falls back to the vault's own
685
770
  // read, unchanged.
771
+ //
772
+ // gitvault-byo-primary-bucket (design D4, task 3.4 — mirror half):
773
+ // either path's actual chain/payload read is the "attemptLive" that
774
+ // `withDegradedRead` wraps — `gitvault.restore(...)` is exactly `open()`
775
+ // followed by `handle.vault.restoreObjectsInto(target_dir)` (see that
776
+ // method's own one-line body), decomposed here so the SECOND half can be
777
+ // wrapped, with byte-identical resolution to today on the live-success
778
+ // path. On a NETWORK-CLASS failure (never 4xx) with a mirror configured
779
+ // for this vault, the fetch is served from it instead of failing the
780
+ // whole clone; the destination it materialized into is the SAME
781
+ // `repoDir` git already prepared, so `git fetch`/`clone` completes
782
+ // exactly as if the live path had run.
686
783
  const shared = sharedListSession && sharedListSession.repoDir === repoDir && sharedListSession.walletName === (resolvedWallet?.name ?? null) ? sharedListSession : null;
687
- const restored = shared
688
- ? await shared.vault.restoreObjectsInto(repoDir, { marker: shared.fetchMarker, state: shared.fetchState })
689
- : await getCachedSdk().gitvault.restore({ ...target, repo_dir: repoDir, target_dir: repoDir });
784
+ let attemptLive;
785
+ let degradedKeystore;
786
+ let degradedRepoId;
787
+ if (shared) {
788
+ attemptLive = () => shared.vault.restoreObjectsInto(repoDir, { marker: shared.fetchMarker, state: shared.fetchState });
789
+ degradedKeystore = shared.keystore;
790
+ degradedRepoId = shared.repo_id;
791
+ } else {
792
+ const handle = await getCachedSdk().gitvault.open({ ...target, repo_dir: repoDir });
793
+ attemptLive = () => handle.vault.restoreObjectsInto(repoDir);
794
+ degradedKeystore = handle.keystore;
795
+ degradedRepoId = handle.repo_id;
796
+ }
797
+ const outcome = await getCachedSdk().gitvault.withDegradedRead({
798
+ attemptLive,
799
+ keystore: degradedKeystore,
800
+ repo_id: degradedRepoId,
801
+ out_dir: repoDir,
802
+ });
803
+ let restored;
804
+ if (outcome.degraded) {
805
+ // Exactly ONE stderr line naming the degraded read and its source
806
+ // (design D4) — the validity-not-freshness limit rides inside the
807
+ // canonical statement this composes, verbatim. A later `git push`
808
+ // still requires the gateway unchanged — this function only ever
809
+ // serves reads; `runPush` below is untouched by this change.
810
+ note(gitvaultDegradedReadNote(outcome.result.source));
811
+ restored = { generation: outcome.result.generation, retained_refs: outcome.result.retained_refs };
812
+ } else {
813
+ restored = { generation: outcome.live.generation, retained_refs: outcome.live.retained_refs };
814
+ }
690
815
  if (verbosity >= 1) note(`restored generation ${restored.generation}`);
691
816
  // clone-installs-retained-refs D3: a bookkeeping failure here degrades to
692
817
  // exactly today's (pre-change) behavior — one stderr note, fetch still
693
- // completes. `restored.retained_refs` is never absent (the SDK always
694
- // returns a result, never throws for this step).
818
+ // completes. `restored.retained_refs` is never absent (both the live and
819
+ // the degraded path always return a result, never throw for this step).
695
820
  if (restored.retained_refs?.warning) note(restored.retained_refs.warning);
696
821
  else if (verbosity >= 1 && (restored.retained_refs?.written.length > 0 || restored.retained_refs?.deleted.length > 0)) {
697
822
  note(`refs/r402/retain: +${restored.retained_refs.written.length} -${restored.retained_refs.deleted.length} (${restored.retained_refs.retained_count} retained tip(s) total)`);
@@ -741,10 +866,19 @@ async function main(argv, { onBackgroundWork } = {}) {
741
866
  }
742
867
  if (!shouldRunAutoGc(threshold, generationsSinceCheckpoint)) return;
743
868
 
869
+ // `compact()`/`prune()` never default repo_dir to process.cwd() —
870
+ // GitvaultVaultHandleOptions's own doc comment describes CALLER
871
+ // convention, not an SDK fallback (`open()` only sets repo_dir when
872
+ // `options.repo_dir !== undefined`). Omitting it here made every real
873
+ // post-push auto-gc cycle fail closed with GITVAULT_REPO_DIR_REQUIRED,
874
+ // silently degrading to the advisory on every push (found live,
875
+ // 2026-08-31, via a push-latency guard bench that finally drove the
876
+ // ACTUAL trigger path instead of a synthetic `repos gc` invocation).
877
+ const compactionTarget = buildAutoGcCompactionTarget(target, repoDir);
744
878
  const runCycle = async () => {
745
879
  const sdk = getCachedSdk();
746
- const checkpoint = await sdk.gitvault.compact({ ...target });
747
- const prune = await sdk.gitvault.prune(target); // PLAN only — never `submit`; see compact()'s own grant-close doc comment for why prune needs no headroom of its own
880
+ const checkpoint = await sdk.gitvault.compact({ ...compactionTarget });
881
+ const prune = await sdk.gitvault.prune(compactionTarget); // PLAN only — never `submit`; see compact()'s own grant-close doc comment for why prune needs no headroom of its own
748
882
  return { checkpoint, prune };
749
883
  };
750
884