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
@@ -29,7 +29,7 @@
29
29
  * never persisted into an agent-surface result store, and never logged.
30
30
  */
31
31
  import { LocalError, isRun402Error } from "../errors.js";
32
- import { GITVAULT_DURABILITY_STATEMENT, GITVAULT_MIRROR_KEYSTORE_STILL_REQUIRED_STATEMENT, GITVAULT_MIRROR_VALIDITY_NOT_FRESHNESS_STATEMENT, GITVAULT_TERMINAL_LOSS_DOCTOR_TEXT, GITVAULT_TERMINAL_LOSS_STATEMENT, } from "./gitvault.crypto.js";
32
+ import { GITVAULT_BYO_UNMIRRORED_REMEDY_STATEMENT, GITVAULT_DEGRADED_READ_STATEMENT, GITVAULT_DURABILITY_STATEMENT, GITVAULT_MIRROR_KEYSTORE_STILL_REQUIRED_STATEMENT, GITVAULT_MIRROR_VALIDITY_NOT_FRESHNESS_STATEMENT, GITVAULT_TERMINAL_LOSS_DOCTOR_TEXT, GITVAULT_TERMINAL_LOSS_STATEMENT, GITVAULT_UNMIRRORED_FINDING_STATEMENT, } from "./gitvault.crypto.js";
33
33
  /** A keystore path, or `null` when there is no id to derive it from (or it is malformed). */
34
34
  function safePath(derive, repoId) {
35
35
  if (!repoId)
@@ -53,6 +53,17 @@ async function nodeOnly(load, verb) {
53
53
  function mib(bytes) {
54
54
  return `${(bytes / (1024 * 1024)).toFixed(1)} MiB`;
55
55
  }
56
+ /**
57
+ * gitvault-byo-primary-bucket task 3.3 — the number of `{key, object_kind}`
58
+ * entries `GITVAULT_BYO_OBJECT_MISSING`'s `details.missing` lists before
59
+ * truncating (`details.missing_count` always carries the true total, and
60
+ * `details.missing_truncated` says whether the list above was cut). A
61
+ * chain-referenced object list can be arbitrarily large; an error envelope
62
+ * is not the place to reproduce it whole. Matches the platform's existing
63
+ * `sample_keys`-class cap (asset-sync's plan response, `PLATFORM_INCIDENT_
64
+ * FANOUT_CAP`) rather than inventing a new number.
65
+ */
66
+ export const GITVAULT_BYO_OBJECT_MISSING_LIST_CAP = 50;
56
67
  /**
57
68
  * D210 (rev 44) — the decision function behind `fsck`'s best-effort
58
69
  * proof-of-open submission, factored out of {@link Gitvault}'s private
@@ -117,6 +128,81 @@ export async function computeOpenProofOutcome(input) {
117
128
  };
118
129
  }
119
130
  }
131
+ /**
132
+ * gitvault-byo-primary-bucket task 3.3 (design D6) — the decision function
133
+ * behind `fsck`'s BYO presence check, factored out of {@link Gitvault}'s
134
+ * private `#checkByoPresence` the same way {@link computeOpenProofOutcome}
135
+ * is factored out of `#submitFsckOpenProof`, so it is directly unit-testable
136
+ * with an injected (fake) `hasLocalConfig`/`verifyPresence`, independent of
137
+ * a real keystore or HTTP client. `#checkByoPresence` supplies the REAL
138
+ * dependencies (`readByoConfig` against the real keystore,
139
+ * `verifyByoObjectsPresent` against the real client) — this function
140
+ * contains no filesystem/HTTP code of its own.
141
+ *
142
+ * Runs AUTOMATICALLY for a `storage_profile: "byo"` vault — never behind a
143
+ * new flag, so a returning agent's ordinary `fsck` catches a
144
+ * silently-emptied bucket without having to know to ask for it. Returns
145
+ * `undefined` for a managed vault (`vault?.storage_profile !== "byo"` is
146
+ * the FIRST check, before `hasLocalConfig`/`verifyPresence` are ever
147
+ * called) — a managed vault pays zero extra network/filesystem work and
148
+ * this task changes nothing about it (design D6/D9).
149
+ *
150
+ * NO local credentials configured (`hasLocalConfig()` returns `false`) is
151
+ * reported, never thrown — a BYO vault this machine cannot reach is an
152
+ * honest "not checked", not a failure that should break `fsck` for a
153
+ * credential-less returning agent (see {@link GitvaultFsckByoPresence}'s
154
+ * own doc comment). A confirmed absence is the opposite: it THROWS
155
+ * `GITVAULT_BYO_OBJECT_MISSING` — the same severity class as {@link
156
+ * Gitvault.mirrorVerify}'s own `GITVAULT_MIRROR_NOT_CONFIGURED` throw when
157
+ * `--mirror` is requested against an unconfigured vault — this is exactly
158
+ * the honesty D6 names: "we can tell the customer what SHOULD exist and
159
+ * does not", stated as a real refusal rather than a silently-embedded
160
+ * finding. The listed entries are capped at
161
+ * {@link GITVAULT_BYO_OBJECT_MISSING_LIST_CAP} (a chain-referenced object
162
+ * list can be arbitrarily large and this is an error envelope, not a
163
+ * report); `missing_count` always carries the true total regardless of how
164
+ * many are listed.
165
+ *
166
+ * `verifyPresence()` is a pure read (HEAD checks only, via
167
+ * `verifyByoObjectsPresent`) — this function persists nothing itself, so
168
+ * it behaves identically whether `fsck` was called with `write: true` or
169
+ * `write: false`.
170
+ */
171
+ export async function computeByoPresenceOutcome(input) {
172
+ if (input.vault?.storage_profile !== "byo")
173
+ return undefined;
174
+ const destination = input.vault.byo_destination ?? null;
175
+ if (!input.hasLocalConfig()) {
176
+ return {
177
+ verified: false,
178
+ destination,
179
+ checked_count: 0,
180
+ not_checked_reason: "no local BYO destination credentials are configured for this vault on this machine — presence could not be verified; configure the same destination (`run402 repos create --byo <destination>` again, or the equivalent local BYO config) to enable it",
181
+ };
182
+ }
183
+ const report = await input.verifyPresence();
184
+ if (report.missing.length > 0) {
185
+ const missing = report.missing.slice(0, GITVAULT_BYO_OBJECT_MISSING_LIST_CAP);
186
+ throw new LocalError(`the BYO destination for ${input.repoId} (${report.destination}) is missing ${report.missing.length} object(s) run402's own signed chain says should exist`, "running gitvault fsck", {
187
+ code: "GITVAULT_BYO_OBJECT_MISSING",
188
+ details: {
189
+ repo_id: input.repoId,
190
+ destination: report.destination,
191
+ checked_count: report.checked,
192
+ missing_count: report.missing.length,
193
+ missing,
194
+ missing_truncated: report.missing.length > missing.length,
195
+ },
196
+ next_actions: [
197
+ {
198
+ action: "restore the listed object(s) to the destination bucket from your own backup, or run `run402 repos mirror <destination>` to add a second customer-held copy",
199
+ why: "run402 holds no payload copy for a BYO vault — it can only tell you what SHOULD exist from the signed chain, never restore it",
200
+ },
201
+ ],
202
+ });
203
+ }
204
+ return { verified: true, destination: report.destination, checked_count: report.checked, not_checked_reason: null };
205
+ }
120
206
  /**
121
207
  * {@link Gitvault.deploy}'s post-push glue: attach the best-effort
122
208
  * mirror/reconcile hooks to a deploy result, gated on whether this deploy
@@ -526,14 +612,28 @@ export class Gitvault {
526
612
  */
527
613
  async init(options) {
528
614
  const [{ createGitvaultHttpTransport }, { GitvaultKeystore }, { createGitvault }] = await Promise.all([this.#publication(), this.#keystore(), this.#creation()]);
615
+ const { parseMirrorDestinationUrl, formatMirrorDestination } = await this.#mirrorConfig();
529
616
  const keystore = new GitvaultKeystore(options.keystore_root !== undefined ? { rootDir: options.keystore_root } : {});
617
+ const transport = createGitvaultHttpTransport(this.#client);
618
+ let byoWriteTarget;
619
+ let byoDestinationAddress;
620
+ if (options.byo) {
621
+ const destination = parseMirrorDestinationUrl(options.byo.destination_url, { region: options.byo.region, endpoint: options.byo.endpoint });
622
+ byoWriteTarget = { destination, ...(options.byo.credential ? { credential: options.byo.credential } : {}) };
623
+ byoDestinationAddress = formatMirrorDestination(destination);
624
+ // gitvault-byo-primary-bucket task 3.1 (design D6) — probe BEFORE any
625
+ // allocation request; a failed probe throws and nothing is created.
626
+ const { probeGitvaultByoDestination } = await this.#byoProbe();
627
+ await probeGitvaultByoDestination(destination, options.byo.credential);
628
+ }
530
629
  const created = await createGitvault({
531
630
  keystore,
532
- transport: createGitvaultHttpTransport(this.#client),
631
+ transport,
533
632
  org_id: options.org_id,
534
633
  project_id: options.project_id,
535
634
  ...(options.client_creation_id !== undefined ? { client_creation_id: options.client_creation_id } : {}),
536
635
  ...(options.service_public_key !== undefined ? { service_public_key: options.service_public_key } : {}),
636
+ ...(byoWriteTarget ? { storage_profile: "byo", byo_destination: byoDestinationAddress, byo_write_target: byoWriteTarget } : {}),
537
637
  });
538
638
  let remote = null;
539
639
  if (options.scaffold_git !== false && options.repo_dir) {
@@ -545,6 +645,31 @@ export class Gitvault {
545
645
  ...(options.remote_url !== undefined ? { remote_url: options.remote_url } : {}),
546
646
  });
547
647
  }
648
+ // gitvault-byo-primary-bucket task 3.1/3.5 — read back the AUTHORITATIVE
649
+ // storage_profile from the vault record rather than trusting what THIS
650
+ // call requested: an idempotent replay against a pre-existing vault
651
+ // (a different client_creation_id, a different machine) can legitimately
652
+ // resolve to a vault whose real profile disagrees with what was just
653
+ // asked for (`allocateVault`'s own doc comment: "a replay reads the
654
+ // EXISTING row's profile, never re-derives it"). Only when the SERVER
655
+ // confirms "byo" is the local write-credential config actually saved —
656
+ // never speculatively, and never when it disagrees.
657
+ let storageProfile = "managed";
658
+ let byoDestination = null;
659
+ if (byoWriteTarget) {
660
+ const record = await transport.getVaultRecord({ repo_id: created.repo_id });
661
+ storageProfile = record.storage_profile ?? "managed";
662
+ byoDestination = record.byo_destination ?? null;
663
+ if (storageProfile === "byo") {
664
+ const { saveByoConfig } = await this.#byoConfig();
665
+ saveByoConfig(keystore, { repo_id: created.repo_id, destination: byoWriteTarget.destination, ...(byoWriteTarget.credential ? { credential: byoWriteTarget.credential } : {}) });
666
+ }
667
+ // storageProfile !== "byo" here means this project's vault already
668
+ // existed as a MANAGED vault under a different creation attempt —
669
+ // nothing is saved locally, and the result below reports the TRUE
670
+ // profile so the caller can tell the user honestly rather than
671
+ // silently writing a bogus BYO config for a managed vault.
672
+ }
548
673
  return {
549
674
  repo_id: created.repo_id,
550
675
  project_id: options.project_id,
@@ -553,6 +678,8 @@ export class Gitvault {
553
678
  remote,
554
679
  deduplicated: created.how === "reconciled",
555
680
  terminal_loss_statement: GITVAULT_TERMINAL_LOSS_STATEMENT,
681
+ storage_profile: storageProfile,
682
+ byo_destination: byoDestination,
556
683
  };
557
684
  }
558
685
  /**
@@ -966,12 +1093,16 @@ export class Gitvault {
966
1093
  // above (already returned/committed) — a mirror failure is a named
967
1094
  // pending finding reported BESIDE the vault result, on its own field.
968
1095
  const mirrorPush = await this.#tryMirrorPush(handle.repo_id, handle.keystore);
1096
+ // gitvault-byo-primary-bucket task 3.3 — the SAME non-blocking contract,
1097
+ // fired right beside the mirror hook (skipped_no_mirror with no network
1098
+ // call for any managed vault or any machine with no local BYO config).
1099
+ const byoChainCopy = await this.#tryByoChainCopyPush(handle.repo_id, handle.keystore);
969
1100
  // Deploy-time reconcile hook: fires on every successful push,
970
1101
  // best-effort — a reconcile failure (including a read-only principal
971
1102
  // with no signing key) is reported BESIDE the vault result, never a
972
1103
  // `push()` throw, same non-blocking contract as the mirror hook above.
973
1104
  const reconcileRecipients = await this.#tryReconcileEnvelopeRecipients(handle.vault);
974
- return { ...result, snapshot, gitvault_commit: snapshot.oid, gitvault_commit_line: line, mirror_push: mirrorPush, reconcile_recipients: reconcileRecipients };
1105
+ return { ...result, snapshot, gitvault_commit: snapshot.oid, gitvault_commit_line: line, mirror_push: mirrorPush, byo_chain_copy: byoChainCopy, reconcile_recipients: reconcileRecipients };
975
1106
  }
976
1107
  /** Best-effort dual-push: catches EVERYTHING, including the lazy module import itself, so a mirror problem can never surface as a `push()` throw. */
977
1108
  /**
@@ -1020,6 +1151,23 @@ export class Gitvault {
1020
1151
  return { attempted: false, outcome: "skipped_no_mirror", error: e instanceof Error ? e.message : String(e) };
1021
1152
  }
1022
1153
  }
1154
+ /**
1155
+ * gitvault-byo-primary-bucket task 3.3 — the chain's every-push dual-write
1156
+ * into a BYO vault's own destination, mirroring {@link #tryMirrorPush}'s
1157
+ * exact contract byte-for-byte (best-effort, catches EVERYTHING, never
1158
+ * throws, never alters the vault outcome already committed above — a
1159
+ * chain-copy failure is a named pending finding reported BESIDE the vault
1160
+ * result on its own field, never a `push()`/`deploy()` throw).
1161
+ */
1162
+ async #tryByoChainCopyPush(repoId, keystore) {
1163
+ try {
1164
+ const { byoChainCopyPushForGeneration } = await this.#mirror();
1165
+ return await byoChainCopyPushForGeneration(this.#client, repoId, { keystore });
1166
+ }
1167
+ catch (e) {
1168
+ return { attempted: false, outcome: "skipped_no_mirror", error: e instanceof Error ? e.message : String(e) };
1169
+ }
1170
+ }
1023
1171
  /** Best-effort envelope-recipient reconcile: catches EVERYTHING so a reconcile problem can never surface as a `push()` throw (mirrors {@link #tryMirrorPush}'s contract exactly). */
1024
1172
  async #tryReconcileEnvelopeRecipients(vault) {
1025
1173
  try {
@@ -1657,6 +1805,14 @@ export class Gitvault {
1657
1805
  * to 7, `GITVAULT_EPOCH_NOT_OPENABLE` at epoch 2/rotation …") is exactly
1658
1806
  * what this split makes representable. `refs`/`head_target`/
1659
1807
  * `pin_after.highest_materialized` all reflect `decryptable_to_generation`.
1808
+ *
1809
+ * **gitvault-byo-primary-bucket task 3.3 (design D6).** For a
1810
+ * `storage_profile: "byo"` vault, `fsck` ALSO adjudicates the customer's
1811
+ * own bucket against run402's signed chain — see {@link
1812
+ * #checkByoPresence} for the full contract (automatic, never a flag;
1813
+ * fails soft with no local credentials; throws
1814
+ * `GITVAULT_BYO_OBJECT_MISSING` on a confirmed absence). A managed vault
1815
+ * is byte-identical to before this task.
1660
1816
  */
1661
1817
  async fsck(options = {}) {
1662
1818
  const before = await this.status(options);
@@ -1701,6 +1857,14 @@ export class Gitvault {
1701
1857
  chain_verified_to_generation: state.generation,
1702
1858
  decryptable_to_generation: decryptableToGeneration,
1703
1859
  });
1860
+ // gitvault-byo-primary-bucket task 3.3 (design D6) — LAST, deliberately:
1861
+ // everything above (chain verify + its pin persist, retained-refs
1862
+ // reconcile, open-proof submission) is real, valid work regardless of
1863
+ // what the customer's own bucket holds — the signed chain is run402-
1864
+ // authoritative either way (design D9) — so a missing-object refusal
1865
+ // below must never cost any of it. See `#checkByoPresence`'s own doc
1866
+ // comment for the throw/no-throw split.
1867
+ const byoPresence = await this.#checkByoPresence(before.vault, repoId, handle.keystore);
1704
1868
  return {
1705
1869
  repo_id: repoId,
1706
1870
  write,
@@ -1717,6 +1881,7 @@ export class Gitvault {
1717
1881
  mirror,
1718
1882
  retained_refs: retainedRefs,
1719
1883
  open_proof: openProof,
1884
+ ...(byoPresence !== undefined ? { byo_presence: byoPresence } : {}),
1720
1885
  };
1721
1886
  }
1722
1887
  /**
@@ -1749,6 +1914,28 @@ export class Gitvault {
1749
1914
  }),
1750
1915
  });
1751
1916
  }
1917
+ /**
1918
+ * gitvault-byo-primary-bucket task 3.3 (design D6) — `repos fsck`'s
1919
+ * wiring of the shipped read-half primitive
1920
+ * {@link import("../node/gitvault-mirror.js").verifyByoObjectsPresent}.
1921
+ * Thin wrapper over {@link computeByoPresenceOutcome} (the testable
1922
+ * decision function, factored out the same way {@link
1923
+ * computeOpenProofOutcome} is factored out of `#submitFsckOpenProof`)
1924
+ * supplying THIS instance's real `hasLocalConfig`/`verifyPresence`
1925
+ * dependencies (`readByoConfig` against the real keystore,
1926
+ * `verifyByoObjectsPresent` against the real HTTP client). See that
1927
+ * function's own doc comment for the full gating/throw contract.
1928
+ */
1929
+ async #checkByoPresence(vault, repoId, keystore) {
1930
+ const { readByoConfig } = await this.#byoConfig();
1931
+ const { verifyByoObjectsPresent } = await this.#mirror();
1932
+ return computeByoPresenceOutcome({
1933
+ repoId,
1934
+ vault,
1935
+ hasLocalConfig: () => readByoConfig(keystore, repoId) !== null,
1936
+ verifyPresence: () => verifyByoObjectsPresent(this.#client, repoId, { keystore }),
1937
+ });
1938
+ }
1752
1939
  /**
1753
1940
  * Wrap this vault's current epoch key to every org member who has
1754
1941
  * published an encryption key but has no `key_envelope` on this vault yet
@@ -2001,7 +2188,15 @@ export class Gitvault {
2001
2188
  // failure in a log line that assumed ordering. Extracted to a standalone
2002
2189
  // function so the outcome-gating is unit-testable with fake thunks,
2003
2190
  // without standing up a live vault — see gitvault-deploy-hooks.test.ts.
2004
- return attachGitvaultDeployHooks(result, () => this.#tryMirrorPush(handle.repo_id, handle.keystore), () => this.#tryReconcileEnvelopeRecipients(handle.vault));
2191
+ const hooked = await attachGitvaultDeployHooks(result, () => this.#tryMirrorPush(handle.repo_id, handle.keystore), () => this.#tryReconcileEnvelopeRecipients(handle.vault));
2192
+ // gitvault-byo-primary-bucket task 3.3 — composed AFTER, not inside,
2193
+ // `attachGitvaultDeployHooks` (kept untouched so its own unit-tested
2194
+ // outcome-gating contract stays byte-for-byte): the SAME "did this
2195
+ // deploy actually land a generation" gate `mirror_push`'s presence
2196
+ // already encodes, so no new outcome-inspection logic here.
2197
+ if (hooked.mirror_push === undefined)
2198
+ return hooked;
2199
+ return { ...hooked, byo_chain_copy: await this.#tryByoChainCopyPush(handle.repo_id, handle.keystore) };
2005
2200
  }
2006
2201
  /**
2007
2202
  * Drain every unvaulted-override journal on this machine: push the exact
@@ -2023,6 +2218,35 @@ export class Gitvault {
2023
2218
  const out = await handle.vault.restoreObjectsInto(options.target_dir);
2024
2219
  return { refs: out.refs, generation: out.generation, retained_refs: out.retained_refs };
2025
2220
  }
2221
+ // ── degraded read (gitvault-byo-primary-bucket, design D4, task 3.4 — mirror half) ──
2222
+ /**
2223
+ * Wrap an ALREADY-RESOLVED vault read with degraded-read fallback: run
2224
+ * `options.attemptLive` (the caller's own live call — its
2225
+ * `vault.materialize()` for a `list`-shaped read, or
2226
+ * `vault.restoreObjectsInto(dir)` for a `fetch`-shaped one), and on a
2227
+ * network-class failure (never on an authorization refusal or any other
2228
+ * 4xx — see `isNetworkClassGitvaultReadError`), fall back to the vault's
2229
+ * configured mirror via the SAME `r402s-recover` engine `recover()` uses.
2230
+ *
2231
+ * Deliberately takes an already-open `vault`/`keystore`/`repo_id` rather
2232
+ * than resolving them itself: named-address resolution (slug-form vs.
2233
+ * id-form), stale-pin recovery, and cross-command vault-instance reuse
2234
+ * within one `git-remote-run402` session are CLI-layer concerns that
2235
+ * already live in `cli/lib/remote-helper-session.mjs` — this method owns
2236
+ * only the trigger discipline and the fallback engine, never vault
2237
+ * resolution, so it composes with that existing flow instead of
2238
+ * duplicating it.
2239
+ *
2240
+ * `out_dir` is REQUIRED for the fallback to ever run — pass the resolved,
2241
+ * git-proven repository directory (`resolveGitInvocationRepo`'s own
2242
+ * `repo_dir`, never a guess from `cwd`). `null` (no resolvable repository,
2243
+ * e.g. a bare `git ls-remote` outside any checkout) disables the fallback
2244
+ * entirely: the original gateway error surfaces exactly as it always did.
2245
+ */
2246
+ async withDegradedRead(options) {
2247
+ const { tryGitvaultDegradedRead } = await this.#degradedRead();
2248
+ return tryGitvaultDegradedRead(options);
2249
+ }
2026
2250
  // ── mirror (gitvault-mirror-and-recover, design D1/D2/D7) ─────────────────
2027
2251
  /**
2028
2252
  * Configure (or replace) the customer-owned mirror destination for one
@@ -2060,8 +2284,19 @@ export class Gitvault {
2060
2284
  const keystore = new GitvaultKeystore(options.keystore_root !== undefined ? { rootDir: options.keystore_root } : {});
2061
2285
  const config = readMirrorConfig(keystore, repoId);
2062
2286
  const base = { repo_id: repoId, validity_not_freshness: GITVAULT_MIRROR_VALIDITY_NOT_FRESHNESS_STATEMENT, keystore_still_required: GITVAULT_MIRROR_KEYSTORE_STILL_REQUIRED_STATEMENT };
2287
+ const lastSuccessAt = config?.last_success_at ?? null;
2063
2288
  if (!config) {
2064
- return { ...base, configured: false, destination: null, credential_kind: null, mirrored_generation: null, newest_generation: null, is_current: null, closing_command: null };
2289
+ // gitvault-mirror-default: the unconfigured branch stays gateway-blind
2290
+ // by construction — it returns before ANY network call, finding included.
2291
+ // `options.is_byo` is a caller-supplied hint (gitvault-byo-primary-bucket
2292
+ // task 3.5) — NEVER derived here, which is what keeps this branch
2293
+ // network-call-free; `repos view` already has `storage_profile` from
2294
+ // its own `status()` read and passes it in.
2295
+ return {
2296
+ ...base, configured: false, destination: null, credential_kind: null, mirrored_generation: null, newest_generation: null, is_current: null, closing_command: null,
2297
+ last_success_at: lastSuccessAt,
2298
+ finding: gitvaultUnmirroredFinding({ configured: false, last_success_at: lastSuccessAt, mirrored_generation: null, is_byo: options.is_byo }),
2299
+ };
2065
2300
  }
2066
2301
  let mirroredGeneration = null;
2067
2302
  try {
@@ -2084,6 +2319,8 @@ export class Gitvault {
2084
2319
  ...base, configured: true, destination: formatMirrorDestination(config.destination), credential_kind: config.credential?.kind ?? null,
2085
2320
  mirrored_generation: mirroredGeneration, newest_generation: newestGeneration, is_current: isCurrent,
2086
2321
  closing_command: isCurrent === false ? "run402 repos mirror --backfill" : null,
2322
+ last_success_at: lastSuccessAt,
2323
+ finding: gitvaultUnmirroredFinding({ configured: true, last_success_at: lastSuccessAt, mirrored_generation: mirroredGeneration, is_byo: options.is_byo }),
2087
2324
  };
2088
2325
  }
2089
2326
  /** List the vault's stored objects, diff against the mirror, fetch + hash-verify + write what's missing, in admission order. Resumable and idempotent. */
@@ -2202,6 +2439,15 @@ export class Gitvault {
2202
2439
  #recovery() {
2203
2440
  return nodeOnly(() => import("../node/gitvault-recover.js"), "recover");
2204
2441
  }
2442
+ #degradedRead() {
2443
+ return nodeOnly(() => import("../node/gitvault-degraded-read.js"), "list");
2444
+ }
2445
+ #byoConfig() {
2446
+ return nodeOnly(() => import("../node/gitvault-byo-config.js"), "init");
2447
+ }
2448
+ #byoProbe() {
2449
+ return nodeOnly(() => import("../node/gitvault-byo-probe.js"), "init");
2450
+ }
2205
2451
  }
2206
2452
  // ─── D7 — progressive terminal-loss warning (repo-first-onramp task 2.7) ────
2207
2453
  //
@@ -2331,6 +2577,37 @@ export function gitvaultKeystoreBackupReminderMessage(trip, coveringRecipients)
2331
2577
  return (`this vault has accrued real value at risk (${reasons.join(", ")}), but it is covered by ${coveringRecipients} recipients today — not the single-principal case. ` +
2332
2578
  `${GITVAULT_DURABILITY_STATEMENT} Back up this machine's keystore anyway: losing it does not lose the vault, but it does lose YOUR access to it.`);
2333
2579
  }
2580
+ /**
2581
+ * gitvault-mirror-default — the pure `vault_unmirrored` computation, in ONE
2582
+ * place so doctor and `repos view` echo the same finding instead of each
2583
+ * deriving their own (the loss-warning pattern above). Present when no mirror
2584
+ * is configured, OR when one is configured but has no success evidence yet —
2585
+ * either the local `last_success_at` stamp (survives a transiently unreachable
2586
+ * mirror) or a chain-verified `mirrored_generation` read from the mirror
2587
+ * itself (covers mirrors synced before the stamp existed). Informational,
2588
+ * never blocking; every input is client-local or read from the CUSTOMER'S
2589
+ * mirror — nothing here touches the gateway.
2590
+ */
2591
+ export function gitvaultUnmirroredFinding(state) {
2592
+ if (state.configured && (state.last_success_at !== null || state.mirrored_generation !== null))
2593
+ return null;
2594
+ return {
2595
+ kind: "vault_unmirrored",
2596
+ message: state.is_byo ? GITVAULT_BYO_UNMIRRORED_REMEDY_STATEMENT : GITVAULT_UNMIRRORED_FINDING_STATEMENT,
2597
+ setup_command: state.configured ? "run402 repos mirror --backfill" : "run402 repos mirror <destination>",
2598
+ };
2599
+ }
2600
+ /**
2601
+ * gitvault-byo-primary-bucket (design D4) — the ONE stderr line a degraded
2602
+ * chain/payload read prints: the fallback's own destination (never a
2603
+ * credential) plus the canonical, mechanism-only statement. `list`/`fetch`
2604
+ * degrading in the SAME `git-remote-run402` session each print their own
2605
+ * line (one per degraded READ, not one per session) — see
2606
+ * `Gitvault.withDegradedRead`'s own doc comment.
2607
+ */
2608
+ export function gitvaultDegradedReadNote(source) {
2609
+ return `degraded read from ${source.destination}: ${GITVAULT_DEGRADED_READ_STATEMENT}`;
2610
+ }
2334
2611
  /** `run402::<org_id>/<project_id>` — what `git-remote-run402` resolves. */
2335
2612
  export function gitvaultRemoteUrl(orgId, projectId) {
2336
2613
  return `run402::${orgId}/${projectId}`;