run402 4.63.1 → 4.65.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 (66) 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/gitvault-daemon.mjs +30 -1
  5. package/lib/remote-helper-session.mjs +168 -33
  6. package/lib/repos.mjs +122 -9
  7. package/package.json +1 -1
  8. package/sdk/dist/errors.d.ts +4 -2
  9. package/sdk/dist/errors.d.ts.map +1 -1
  10. package/sdk/dist/errors.js.map +1 -1
  11. package/sdk/dist/index.d.ts +1 -1
  12. package/sdk/dist/index.d.ts.map +1 -1
  13. package/sdk/dist/index.js +1 -1
  14. package/sdk/dist/index.js.map +1 -1
  15. package/sdk/dist/namespaces/gitvault.crypto.d.ts +80 -0
  16. package/sdk/dist/namespaces/gitvault.crypto.d.ts.map +1 -1
  17. package/sdk/dist/namespaces/gitvault.crypto.js +80 -0
  18. package/sdk/dist/namespaces/gitvault.crypto.js.map +1 -1
  19. package/sdk/dist/namespaces/gitvault.d.ts +219 -3
  20. package/sdk/dist/namespaces/gitvault.d.ts.map +1 -1
  21. package/sdk/dist/namespaces/gitvault.js +290 -6
  22. package/sdk/dist/namespaces/gitvault.js.map +1 -1
  23. package/sdk/dist/node/gitvault-byo-config.d.ts +29 -0
  24. package/sdk/dist/node/gitvault-byo-config.d.ts.map +1 -0
  25. package/sdk/dist/node/gitvault-byo-config.js +100 -0
  26. package/sdk/dist/node/gitvault-byo-config.js.map +1 -0
  27. package/sdk/dist/node/gitvault-byo-probe.d.ts +20 -0
  28. package/sdk/dist/node/gitvault-byo-probe.d.ts.map +1 -0
  29. package/sdk/dist/node/gitvault-byo-probe.js +57 -0
  30. package/sdk/dist/node/gitvault-byo-probe.js.map +1 -0
  31. package/sdk/dist/node/gitvault-creation-journal.d.ts +55 -1
  32. package/sdk/dist/node/gitvault-creation-journal.d.ts.map +1 -1
  33. package/sdk/dist/node/gitvault-creation-journal.js +34 -3
  34. package/sdk/dist/node/gitvault-creation-journal.js.map +1 -1
  35. package/sdk/dist/node/gitvault-degraded-read.d.ts +120 -0
  36. package/sdk/dist/node/gitvault-degraded-read.d.ts.map +1 -0
  37. package/sdk/dist/node/gitvault-degraded-read.js +164 -0
  38. package/sdk/dist/node/gitvault-degraded-read.js.map +1 -0
  39. package/sdk/dist/node/gitvault-keystore.d.ts +34 -1
  40. package/sdk/dist/node/gitvault-keystore.d.ts.map +1 -1
  41. package/sdk/dist/node/gitvault-keystore.js +43 -0
  42. package/sdk/dist/node/gitvault-keystore.js.map +1 -1
  43. package/sdk/dist/node/gitvault-mirror-backend.d.ts +73 -0
  44. package/sdk/dist/node/gitvault-mirror-backend.d.ts.map +1 -1
  45. package/sdk/dist/node/gitvault-mirror-backend.js +114 -8
  46. package/sdk/dist/node/gitvault-mirror-backend.js.map +1 -1
  47. package/sdk/dist/node/gitvault-mirror-config.d.ts +17 -1
  48. package/sdk/dist/node/gitvault-mirror-config.d.ts.map +1 -1
  49. package/sdk/dist/node/gitvault-mirror-config.js +21 -0
  50. package/sdk/dist/node/gitvault-mirror-config.js.map +1 -1
  51. package/sdk/dist/node/gitvault-mirror.d.ts +42 -0
  52. package/sdk/dist/node/gitvault-mirror.d.ts.map +1 -1
  53. package/sdk/dist/node/gitvault-mirror.js +178 -2
  54. package/sdk/dist/node/gitvault-mirror.js.map +1 -1
  55. package/sdk/dist/node/gitvault-prewarm.d.ts +31 -0
  56. package/sdk/dist/node/gitvault-prewarm.d.ts.map +1 -1
  57. package/sdk/dist/node/gitvault-prewarm.js +67 -0
  58. package/sdk/dist/node/gitvault-prewarm.js.map +1 -1
  59. package/sdk/dist/node/gitvault-publication.d.ts +227 -6
  60. package/sdk/dist/node/gitvault-publication.d.ts.map +1 -1
  61. package/sdk/dist/node/gitvault-publication.js +563 -77
  62. package/sdk/dist/node/gitvault-publication.js.map +1 -1
  63. package/sdk/dist/node/index.d.ts +1 -1
  64. package/sdk/dist/node/index.d.ts.map +1 -1
  65. package/sdk/dist/node/index.js +1 -1
  66. package/sdk/dist/node/index.js.map +1 -1
@@ -1,5 +1,5 @@
1
1
  {
2
- "surface_version": "4.63.1",
2
+ "surface_version": "4.65.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,
@@ -53,9 +53,16 @@ export async function runDaemon() {
53
53
  // Load the heavy module ONCE — this is the entire point of residency.
54
54
  // Its top-level await pulls the SDK graph; the prewarm dials the API
55
55
  // origin so the first forwarded session rides a warm h2 connection.
56
- const { prewarmGitvaultConnection, kickGitvaultConnection } = await import("../sdk/dist/node/gitvault-prewarm.js");
56
+ const { prewarmGitvaultConnection, kickGitvaultConnection, predialGitvaultObjectStore } = await import("../sdk/dist/node/gitvault-prewarm.js");
57
57
  prewarmGitvaultConnection();
58
58
  const { runHelperSession } = await import("./remote-helper-session.mjs");
59
+ // gitvault-object-host-predial task 2.2 — narrow, session-scoped imports
60
+ // (not the whole `#sdk/node` barrel) for the OFFLINE repo resolution the
61
+ // session-accept predial needs: a local git subprocess (`rev-parse
62
+ // --absolute-git-dir`) plus a local git-config read, never a network
63
+ // call (design D6).
64
+ const { resolveGitInvocationRepo } = await import("../sdk/dist/node/gitvault-snapshot.js");
65
+ const { readPinnedGitvaultRepo } = await import("../sdk/dist/node/gitvault-address.js");
59
66
 
60
67
  const socketPath = daemonSocketPath();
61
68
  if (process.platform !== "win32") {
@@ -184,6 +191,28 @@ export async function runDaemon() {
184
191
  // (never throws, never awaited, never delays a session).
185
192
  const acceptedAt = Date.now();
186
193
  kickGitvaultConnection();
194
+ // gitvault-object-host-predial task 2.2 — the addressed repo's
195
+ // persisted object-store origin(s), predialed on the SAME
196
+ // pre-connect timing as the API-origin kick above: resolved from
197
+ // THIS session's OWN `msg.cwd`/`msg.env` (not `process.cwd()`/
198
+ // `process.env`, which still name the daemon's PRIOR session
199
+ // until the swap below runs) via the same offline, no-network
200
+ // mechanism the session module itself uses once it takes over —
201
+ // `resolveGitInvocationRepo` (a local `git rev-parse
202
+ // --absolute-git-dir`) then `readPinnedGitvaultRepo` (a local
203
+ // `git config --local` read). Any resolution failure (no
204
+ // `GIT_DIR`, not a repository, no pin yet) predials nothing —
205
+ // never a network read to learn where to predial (design D6),
206
+ // and never allowed to delay or fail this session's accept.
207
+ void (async () => {
208
+ try {
209
+ const { repo_dir } = await resolveGitInvocationRepo(msg.env ?? {}, msg.cwd);
210
+ const pinned = await readPinnedGitvaultRepo(repo_dir);
211
+ if (pinned) predialGitvaultObjectStore(pinned.repo_id);
212
+ } catch {
213
+ /* best-effort: must never surface from session accept */
214
+ }
215
+ })();
187
216
  busy = true;
188
217
  sessionsServed += 1;
189
218
  const stdin = new PassThrough();
@@ -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,8 +144,8 @@ 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;
131
- const { GITVAULT_R402_REF_NAMESPACE, hardenedGit, resolveGitInvocationRepo, readPinnedGitvaultRepo, pinGitvaultRepo, readGitvaultRestoreMarker, readGitvaultAutoGcThreshold } = await nodeModP;
147
+ const { gitvaultRemoteAddressForm, gitvaultSlugReleasedInfo, parseGitvaultRemoteUrl, gitvaultDegradedReadNote } = await isoModP;
148
+ const { GITVAULT_R402_REF_NAMESPACE, hardenedGit, resolveGitInvocationRepo, readPinnedGitvaultRepo, pinGitvaultRepo, readGitvaultRestoreMarker, readGitvaultAutoGcThreshold, predialGitvaultObjectStore } = await nodeModP;
132
149
  const { allowanceFile, projectCredentialsFile, profileStateFile } = await configModP;
133
150
 
134
151
  /**
@@ -461,7 +478,27 @@ async function main(argv, { onBackgroundWork } = {}) {
461
478
  */
462
479
  let resolvedRepo = null;
463
480
  async function requireRepo() {
464
- if (!resolvedRepo) resolvedRepo = await resolveGitInvocationRepo(process.env, process.cwd());
481
+ if (!resolvedRepo) {
482
+ resolvedRepo = await resolveGitInvocationRepo(process.env, process.cwd());
483
+ // gitvault-object-host-predial task 2.1 (trigger (a), the in-process
484
+ // fallback path — this module IS that fallback; a daemon-forwarded
485
+ // session reaches the same code and gets the same predial for free).
486
+ // The repository is now known OFFLINE (no network read, matching
487
+ // design D6) — fire-and-forget dial the addressed repo's persisted
488
+ // object-store origin(s), if the local keystore has learned any, so
489
+ // it overlaps the rest of this session's local work exactly like the
490
+ // API-origin prewarm that already raced module load above. A repo
491
+ // with no local pin (never opened here before) or nothing persisted
492
+ // yet predials nothing — never a source of a new failure mode.
493
+ void (async () => {
494
+ try {
495
+ const pinned = await readPinnedGitvaultRepo(resolvedRepo.repo_dir);
496
+ if (pinned) predialGitvaultObjectStore(pinned.repo_id);
497
+ } catch {
498
+ /* best-effort: must never surface from repository resolution */
499
+ }
500
+ })();
501
+ }
465
502
  return resolvedRepo.repo_dir;
466
503
  }
467
504
 
@@ -524,7 +561,7 @@ async function main(argv, { onBackgroundWork } = {}) {
524
561
  */
525
562
  const openVault = async (repoDir) => {
526
563
  const result = await getCachedSdk().gitvault.resolveOrCreateAddress({ address, allow_create: false, ...(repoDir ? { repo_dir: repoDir } : {}) });
527
- return { vault: result.handle.vault, resolution: result.resolution };
564
+ return { vault: result.handle.vault, keystore: result.handle.keystore, repo_id: result.handle.repo_id, resolution: result.resolution };
528
565
  };
529
566
 
530
567
  /**
@@ -569,7 +606,10 @@ async function main(argv, { onBackgroundWork } = {}) {
569
606
  note(`vault ${result.handle.repo_id} allocated (genesis ${result.created.genesis_sha256}) — one-shot recovery receipt, keep many copies:`);
570
607
  note(JSON.stringify(result.created.recovery_receipt));
571
608
  try {
572
- const { getGitvaultKeystoreRoot } = await import("#sdk/node");
609
+ const { getGitvaultKeystoreRoot, GITVAULT_MIRROR_SETUP_HINT } = await import("#sdk/node");
610
+ // gitvault-mirror-default: every allocation teaches the mirror door
611
+ // beside the recovery receipt, lazy push-to-create included.
612
+ note(GITVAULT_MIRROR_SETUP_HINT);
573
613
  note(`keystore: ${getGitvaultKeystoreRoot()} — back this up; whole-machine or whole-keystore loss is terminal for vault history until human envelopes ship`);
574
614
  } catch {
575
615
  // Never let a diagnostic line fail a push that already allocated successfully.
@@ -612,14 +652,46 @@ async function main(argv, { onBackgroundWork } = {}) {
612
652
  fetchMarker = null;
613
653
  }
614
654
  }
615
- const materializeOpts = fetchMarker ? { deltaSince: fetchMarker.generation } : {};
616
- let vault;
617
- let state;
655
+ // gitvault-restore-recipe design D2/D6: a marker-absent fetch target is,
656
+ // by construction, the WHOLESALE restore shape (a fresh `git clone`, or
657
+ // a target that never completed a restore) — declare restore intent
658
+ // here so a capable gateway's plan rides THIS state read, reused by
659
+ // `fetch` below via `sharedListSession` (never a second round trip). A
660
+ // repo-free `list` (bare `git ls-remote`) has no target to restore into
661
+ // at all, so neither `since` nor `restore` is sent — same gate as today.
662
+ const materializeOpts = fetchMarker ? { deltaSince: fetchMarker.generation } : repoDir ? { restore: true } : {};
663
+ let opened;
618
664
  try {
619
- const opened = await openVault(repoDir ?? undefined);
620
- vault = opened.vault;
665
+ opened = await openVault(repoDir ?? undefined);
666
+ } catch (err) {
667
+ // An unallocated vault is not an error here: `list` is the read half of
668
+ // the protocol dance and must never create anything on its own (D2
669
+ // scopes lazy creation to `push`). Reporting it as an EMPTY ref set is
670
+ // exactly what a fresh repository looks like to git, and `push` still
671
+ // runs `list` first either way — this is what lets a first push land in
672
+ // one command instead of `list` failing the whole exchange before
673
+ // `push` ever gets a turn. Nothing to share with `push` either
674
+ // (design D7): an unallocated vault has no base to reuse.
675
+ if (isVaultNotFound(err)) {
676
+ endBlock();
677
+ return;
678
+ }
679
+ throw err;
680
+ }
681
+ let vault = opened.vault;
682
+
683
+ // gitvault-byo-primary-bucket (design D4, task 3.4 — mirror half): the
684
+ // live materialize (with its own stale-pin retry, unchanged from before
685
+ // this change) is the "attemptLive" `withDegradedRead` wraps — on a
686
+ // NETWORK-CLASS failure (never 4xx) with a mirror configured for this
687
+ // vault, it falls back to `r402s-recover`'s engine against it instead of
688
+ // failing the whole `list`/`clone`. `repoDir` is required for the
689
+ // fallback to materialize into (`out_dir`); a repo-free `list` (bare
690
+ // `git ls-remote`) simply has none, so the fallback never runs and the
691
+ // original error surfaces exactly as it always did.
692
+ const attemptLive = async () => {
621
693
  try {
622
- state = await vault.materialize(materializeOpts);
694
+ return await vault.materialize(materializeOpts);
623
695
  } catch (err) {
624
696
  // An OFFLINE (id-carrying pin) resolution discovers a stale pin on
625
697
  // its FIRST repo-scoped read (client-surface spec, id-pinning
@@ -634,30 +706,54 @@ async function main(argv, { onBackgroundWork } = {}) {
634
706
  if (!recovered) throw err;
635
707
  note(`pinned vault ${opened.resolution.repo_id} no longer resolves — re-resolved to ${recovered.resolution.repo_id}, retrying`);
636
708
  vault = recovered.handle.vault;
637
- state = await vault.materialize(materializeOpts);
709
+ opened = { ...opened, vault: recovered.handle.vault, keystore: recovered.handle.keystore, repo_id: recovered.handle.repo_id, resolution: recovered.resolution };
710
+ return await vault.materialize(materializeOpts);
638
711
  }
712
+ };
713
+
714
+ let outcome;
715
+ try {
716
+ outcome = await getCachedSdk().gitvault.withDegradedRead({ attemptLive, keystore: opened.keystore, repo_id: opened.repo_id, out_dir: repoDir });
639
717
  } 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.
718
+ // Same "an unallocated vault is not an error" reasoning as above a
719
+ // 404 discovered only once materialize actually runs takes this same
720
+ // empty-ref-set path (network-class errors never reach here at all:
721
+ // `withDegradedRead` only rethrows them after exhausting its own
722
+ // fallback, and a 404 is never network-class).
648
723
  if (isVaultNotFound(err)) {
649
724
  endBlock();
650
725
  return;
651
726
  }
652
727
  throw err;
653
728
  }
654
- // gitvault-session-state-reuse design D1: `fetchState`/`fetchMarker`
655
- // extend the SAME session-scoped handoff `push` already reuses (`base`)
656
- // — the `fetch` phase of THIS session reuses this response instead of
657
- // issuing its own state read. Session-scoped only: dropped the moment a
658
- // push admits in this same session (see `runPush`'s reset below).
659
- if (repoDir) sharedListSession = { repoDir, walletName: resolvedWallet?.name ?? null, vault, base: state, fetchMarker, fetchState: state };
660
- const refs = state.refs ?? {};
729
+
730
+ let refs;
731
+ let head;
732
+ if (outcome.degraded) {
733
+ // Exactly ONE stderr line naming the degraded read and its source
734
+ // (design D4) the validity-not-freshness limit rides inside the
735
+ // canonical statement this composes, verbatim.
736
+ note(gitvaultDegradedReadNote(outcome.result.source));
737
+ refs = outcome.result.refs ?? {};
738
+ head = outcome.result.head_target;
739
+ // No live vault instance materialized this pass — nothing safe to
740
+ // share with a following `push` (design D7's own "unallocated vault"
741
+ // reasoning applies equally to a degraded one: writes are never
742
+ // rerouted, so `push` must resolve and materialize live on its own).
743
+ } else {
744
+ const state = outcome.live;
745
+ refs = state.refs ?? {};
746
+ head = state.head_target;
747
+ // gitvault-session-state-reuse design D1: `fetchState`/`fetchMarker`
748
+ // extend the SAME session-scoped handoff `push` already reuses (`base`)
749
+ // — the `fetch` phase of THIS session reuses this response instead of
750
+ // issuing its own state read. Session-scoped only: dropped the moment a
751
+ // push admits in this same session (see `runPush`'s reset below).
752
+ // `keystore`/`repo_id` ride along so `fetch`'s own degraded-read wrap
753
+ // can fall back without re-resolving.
754
+ if (repoDir) sharedListSession = { repoDir, walletName: resolvedWallet?.name ?? null, vault, keystore: opened.keystore, repo_id: opened.repo_id, base: state, fetchMarker, fetchState: state };
755
+ }
756
+
661
757
  for (const ref of Object.keys(refs).sort()) out(`${refs[ref]} ${ref}`);
662
758
  // A snapshot-only vault holds protocol refs but no branch heads, so a
663
759
  // plain `git clone` prints "cloned an empty repository" with no hint the
@@ -667,7 +763,6 @@ async function main(argv, { onBackgroundWork } = {}) {
667
763
  note(`this vault has no branch heads yet — its history lives on ${refNames.sort()[0]}`);
668
764
  note(`fetch it with: git fetch <remote> '+${refNames.sort()[0]}:${refNames.sort()[0]}' && git checkout -b restored ${refNames.sort()[0]}`);
669
765
  }
670
- const head = state.head_target;
671
766
  // A symref is only advertised when its target is actually present:
672
767
  // pointing HEAD at a ref that does not exist is what an empty repository
673
768
  // looks like, and git reads the empty list correctly on its own.
@@ -700,15 +795,55 @@ async function main(argv, { onBackgroundWork } = {}) {
700
795
  // different repository/wallet, or a push that already admitted in this
701
796
  // same session — see `runPush`'s reset) falls back to the vault's own
702
797
  // read, unchanged.
798
+ //
799
+ // gitvault-byo-primary-bucket (design D4, task 3.4 — mirror half):
800
+ // either path's actual chain/payload read is the "attemptLive" that
801
+ // `withDegradedRead` wraps — `gitvault.restore(...)` is exactly `open()`
802
+ // followed by `handle.vault.restoreObjectsInto(target_dir)` (see that
803
+ // method's own one-line body), decomposed here so the SECOND half can be
804
+ // wrapped, with byte-identical resolution to today on the live-success
805
+ // path. On a NETWORK-CLASS failure (never 4xx) with a mirror configured
806
+ // for this vault, the fetch is served from it instead of failing the
807
+ // whole clone; the destination it materialized into is the SAME
808
+ // `repoDir` git already prepared, so `git fetch`/`clone` completes
809
+ // exactly as if the live path had run.
703
810
  const shared = sharedListSession && sharedListSession.repoDir === repoDir && sharedListSession.walletName === (resolvedWallet?.name ?? null) ? sharedListSession : null;
704
- const restored = shared
705
- ? await shared.vault.restoreObjectsInto(repoDir, { marker: shared.fetchMarker, state: shared.fetchState })
706
- : await getCachedSdk().gitvault.restore({ ...target, repo_dir: repoDir, target_dir: repoDir });
811
+ let attemptLive;
812
+ let degradedKeystore;
813
+ let degradedRepoId;
814
+ if (shared) {
815
+ attemptLive = () => shared.vault.restoreObjectsInto(repoDir, { marker: shared.fetchMarker, state: shared.fetchState });
816
+ degradedKeystore = shared.keystore;
817
+ degradedRepoId = shared.repo_id;
818
+ } else {
819
+ const handle = await getCachedSdk().gitvault.open({ ...target, repo_dir: repoDir });
820
+ attemptLive = () => handle.vault.restoreObjectsInto(repoDir);
821
+ degradedKeystore = handle.keystore;
822
+ degradedRepoId = handle.repo_id;
823
+ }
824
+ const outcome = await getCachedSdk().gitvault.withDegradedRead({
825
+ attemptLive,
826
+ keystore: degradedKeystore,
827
+ repo_id: degradedRepoId,
828
+ out_dir: repoDir,
829
+ });
830
+ let restored;
831
+ if (outcome.degraded) {
832
+ // Exactly ONE stderr line naming the degraded read and its source
833
+ // (design D4) — the validity-not-freshness limit rides inside the
834
+ // canonical statement this composes, verbatim. A later `git push`
835
+ // still requires the gateway unchanged — this function only ever
836
+ // serves reads; `runPush` below is untouched by this change.
837
+ note(gitvaultDegradedReadNote(outcome.result.source));
838
+ restored = { generation: outcome.result.generation, retained_refs: outcome.result.retained_refs };
839
+ } else {
840
+ restored = { generation: outcome.live.generation, retained_refs: outcome.live.retained_refs };
841
+ }
707
842
  if (verbosity >= 1) note(`restored generation ${restored.generation}`);
708
843
  // clone-installs-retained-refs D3: a bookkeeping failure here degrades to
709
844
  // exactly today's (pre-change) behavior — one stderr note, fetch still
710
- // completes. `restored.retained_refs` is never absent (the SDK always
711
- // returns a result, never throws for this step).
845
+ // completes. `restored.retained_refs` is never absent (both the live and
846
+ // the degraded path always return a result, never throw for this step).
712
847
  if (restored.retained_refs?.warning) note(restored.retained_refs.warning);
713
848
  else if (verbosity >= 1 && (restored.retained_refs?.written.length > 0 || restored.retained_refs?.deleted.length > 0)) {
714
849
  note(`refs/r402/retain: +${restored.retained_refs.written.length} -${restored.retained_refs.deleted.length} (${restored.retained_refs.retained_count} retained tip(s) total)`);