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
@@ -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
@@ -377,7 +463,14 @@ export class Gitvault {
377
463
  const [{ GitvaultVault, createGitvaultHttpTransport }, { GitvaultKeystore }] = await Promise.all([this.#publication(), this.#keystore()]);
378
464
  const repoId = await this.#resolveRepoId(options);
379
465
  const keystore = new GitvaultKeystore(options.keystore_root !== undefined ? { rootDir: options.keystore_root } : {});
380
- const transport = createGitvaultHttpTransport(this.#client);
466
+ // gitvault-object-host-predial (design D1, task 1.2): this is the ONE
467
+ // transport whose object reads back the returned handle's vault, so
468
+ // wiring the origin-observation hook here (and nowhere else `open()`
469
+ // is reached from — `openOrCreate`'s own allocation-only transport
470
+ // never serves object reads) covers every read path.
471
+ const transport = createGitvaultHttpTransport(this.#client, {
472
+ onObjectStoreOriginObserved: (rid, origins) => keystore.recordObjectStoreOrigins(rid, origins),
473
+ });
381
474
  const vault = new GitvaultVault({
382
475
  keystore,
383
476
  transport,
@@ -526,14 +619,28 @@ export class Gitvault {
526
619
  */
527
620
  async init(options) {
528
621
  const [{ createGitvaultHttpTransport }, { GitvaultKeystore }, { createGitvault }] = await Promise.all([this.#publication(), this.#keystore(), this.#creation()]);
622
+ const { parseMirrorDestinationUrl, formatMirrorDestination } = await this.#mirrorConfig();
529
623
  const keystore = new GitvaultKeystore(options.keystore_root !== undefined ? { rootDir: options.keystore_root } : {});
624
+ const transport = createGitvaultHttpTransport(this.#client);
625
+ let byoWriteTarget;
626
+ let byoDestinationAddress;
627
+ if (options.byo) {
628
+ const destination = parseMirrorDestinationUrl(options.byo.destination_url, { region: options.byo.region, endpoint: options.byo.endpoint });
629
+ byoWriteTarget = { destination, ...(options.byo.credential ? { credential: options.byo.credential } : {}) };
630
+ byoDestinationAddress = formatMirrorDestination(destination);
631
+ // gitvault-byo-primary-bucket task 3.1 (design D6) — probe BEFORE any
632
+ // allocation request; a failed probe throws and nothing is created.
633
+ const { probeGitvaultByoDestination } = await this.#byoProbe();
634
+ await probeGitvaultByoDestination(destination, options.byo.credential);
635
+ }
530
636
  const created = await createGitvault({
531
637
  keystore,
532
- transport: createGitvaultHttpTransport(this.#client),
638
+ transport,
533
639
  org_id: options.org_id,
534
640
  project_id: options.project_id,
535
641
  ...(options.client_creation_id !== undefined ? { client_creation_id: options.client_creation_id } : {}),
536
642
  ...(options.service_public_key !== undefined ? { service_public_key: options.service_public_key } : {}),
643
+ ...(byoWriteTarget ? { storage_profile: "byo", byo_destination: byoDestinationAddress, byo_write_target: byoWriteTarget } : {}),
537
644
  });
538
645
  let remote = null;
539
646
  if (options.scaffold_git !== false && options.repo_dir) {
@@ -545,6 +652,31 @@ export class Gitvault {
545
652
  ...(options.remote_url !== undefined ? { remote_url: options.remote_url } : {}),
546
653
  });
547
654
  }
655
+ // gitvault-byo-primary-bucket task 3.1/3.5 — read back the AUTHORITATIVE
656
+ // storage_profile from the vault record rather than trusting what THIS
657
+ // call requested: an idempotent replay against a pre-existing vault
658
+ // (a different client_creation_id, a different machine) can legitimately
659
+ // resolve to a vault whose real profile disagrees with what was just
660
+ // asked for (`allocateVault`'s own doc comment: "a replay reads the
661
+ // EXISTING row's profile, never re-derives it"). Only when the SERVER
662
+ // confirms "byo" is the local write-credential config actually saved —
663
+ // never speculatively, and never when it disagrees.
664
+ let storageProfile = "managed";
665
+ let byoDestination = null;
666
+ if (byoWriteTarget) {
667
+ const record = await transport.getVaultRecord({ repo_id: created.repo_id });
668
+ storageProfile = record.storage_profile ?? "managed";
669
+ byoDestination = record.byo_destination ?? null;
670
+ if (storageProfile === "byo") {
671
+ const { saveByoConfig } = await this.#byoConfig();
672
+ saveByoConfig(keystore, { repo_id: created.repo_id, destination: byoWriteTarget.destination, ...(byoWriteTarget.credential ? { credential: byoWriteTarget.credential } : {}) });
673
+ }
674
+ // storageProfile !== "byo" here means this project's vault already
675
+ // existed as a MANAGED vault under a different creation attempt —
676
+ // nothing is saved locally, and the result below reports the TRUE
677
+ // profile so the caller can tell the user honestly rather than
678
+ // silently writing a bogus BYO config for a managed vault.
679
+ }
548
680
  return {
549
681
  repo_id: created.repo_id,
550
682
  project_id: options.project_id,
@@ -553,6 +685,8 @@ export class Gitvault {
553
685
  remote,
554
686
  deduplicated: created.how === "reconciled",
555
687
  terminal_loss_statement: GITVAULT_TERMINAL_LOSS_STATEMENT,
688
+ storage_profile: storageProfile,
689
+ byo_destination: byoDestination,
556
690
  };
557
691
  }
558
692
  /**
@@ -966,12 +1100,16 @@ export class Gitvault {
966
1100
  // above (already returned/committed) — a mirror failure is a named
967
1101
  // pending finding reported BESIDE the vault result, on its own field.
968
1102
  const mirrorPush = await this.#tryMirrorPush(handle.repo_id, handle.keystore);
1103
+ // gitvault-byo-primary-bucket task 3.3 — the SAME non-blocking contract,
1104
+ // fired right beside the mirror hook (skipped_no_mirror with no network
1105
+ // call for any managed vault or any machine with no local BYO config).
1106
+ const byoChainCopy = await this.#tryByoChainCopyPush(handle.repo_id, handle.keystore);
969
1107
  // Deploy-time reconcile hook: fires on every successful push,
970
1108
  // best-effort — a reconcile failure (including a read-only principal
971
1109
  // with no signing key) is reported BESIDE the vault result, never a
972
1110
  // `push()` throw, same non-blocking contract as the mirror hook above.
973
1111
  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 };
1112
+ return { ...result, snapshot, gitvault_commit: snapshot.oid, gitvault_commit_line: line, mirror_push: mirrorPush, byo_chain_copy: byoChainCopy, reconcile_recipients: reconcileRecipients };
975
1113
  }
976
1114
  /** Best-effort dual-push: catches EVERYTHING, including the lazy module import itself, so a mirror problem can never surface as a `push()` throw. */
977
1115
  /**
@@ -1020,6 +1158,23 @@ export class Gitvault {
1020
1158
  return { attempted: false, outcome: "skipped_no_mirror", error: e instanceof Error ? e.message : String(e) };
1021
1159
  }
1022
1160
  }
1161
+ /**
1162
+ * gitvault-byo-primary-bucket task 3.3 — the chain's every-push dual-write
1163
+ * into a BYO vault's own destination, mirroring {@link #tryMirrorPush}'s
1164
+ * exact contract byte-for-byte (best-effort, catches EVERYTHING, never
1165
+ * throws, never alters the vault outcome already committed above — a
1166
+ * chain-copy failure is a named pending finding reported BESIDE the vault
1167
+ * result on its own field, never a `push()`/`deploy()` throw).
1168
+ */
1169
+ async #tryByoChainCopyPush(repoId, keystore) {
1170
+ try {
1171
+ const { byoChainCopyPushForGeneration } = await this.#mirror();
1172
+ return await byoChainCopyPushForGeneration(this.#client, repoId, { keystore });
1173
+ }
1174
+ catch (e) {
1175
+ return { attempted: false, outcome: "skipped_no_mirror", error: e instanceof Error ? e.message : String(e) };
1176
+ }
1177
+ }
1023
1178
  /** 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
1179
  async #tryReconcileEnvelopeRecipients(vault) {
1025
1180
  try {
@@ -1657,6 +1812,14 @@ export class Gitvault {
1657
1812
  * to 7, `GITVAULT_EPOCH_NOT_OPENABLE` at epoch 2/rotation …") is exactly
1658
1813
  * what this split makes representable. `refs`/`head_target`/
1659
1814
  * `pin_after.highest_materialized` all reflect `decryptable_to_generation`.
1815
+ *
1816
+ * **gitvault-byo-primary-bucket task 3.3 (design D6).** For a
1817
+ * `storage_profile: "byo"` vault, `fsck` ALSO adjudicates the customer's
1818
+ * own bucket against run402's signed chain — see {@link
1819
+ * #checkByoPresence} for the full contract (automatic, never a flag;
1820
+ * fails soft with no local credentials; throws
1821
+ * `GITVAULT_BYO_OBJECT_MISSING` on a confirmed absence). A managed vault
1822
+ * is byte-identical to before this task.
1660
1823
  */
1661
1824
  async fsck(options = {}) {
1662
1825
  const before = await this.status(options);
@@ -1701,6 +1864,14 @@ export class Gitvault {
1701
1864
  chain_verified_to_generation: state.generation,
1702
1865
  decryptable_to_generation: decryptableToGeneration,
1703
1866
  });
1867
+ // gitvault-byo-primary-bucket task 3.3 (design D6) — LAST, deliberately:
1868
+ // everything above (chain verify + its pin persist, retained-refs
1869
+ // reconcile, open-proof submission) is real, valid work regardless of
1870
+ // what the customer's own bucket holds — the signed chain is run402-
1871
+ // authoritative either way (design D9) — so a missing-object refusal
1872
+ // below must never cost any of it. See `#checkByoPresence`'s own doc
1873
+ // comment for the throw/no-throw split.
1874
+ const byoPresence = await this.#checkByoPresence(before.vault, repoId, handle.keystore);
1704
1875
  return {
1705
1876
  repo_id: repoId,
1706
1877
  write,
@@ -1717,6 +1888,7 @@ export class Gitvault {
1717
1888
  mirror,
1718
1889
  retained_refs: retainedRefs,
1719
1890
  open_proof: openProof,
1891
+ ...(byoPresence !== undefined ? { byo_presence: byoPresence } : {}),
1720
1892
  };
1721
1893
  }
1722
1894
  /**
@@ -1749,6 +1921,28 @@ export class Gitvault {
1749
1921
  }),
1750
1922
  });
1751
1923
  }
1924
+ /**
1925
+ * gitvault-byo-primary-bucket task 3.3 (design D6) — `repos fsck`'s
1926
+ * wiring of the shipped read-half primitive
1927
+ * {@link import("../node/gitvault-mirror.js").verifyByoObjectsPresent}.
1928
+ * Thin wrapper over {@link computeByoPresenceOutcome} (the testable
1929
+ * decision function, factored out the same way {@link
1930
+ * computeOpenProofOutcome} is factored out of `#submitFsckOpenProof`)
1931
+ * supplying THIS instance's real `hasLocalConfig`/`verifyPresence`
1932
+ * dependencies (`readByoConfig` against the real keystore,
1933
+ * `verifyByoObjectsPresent` against the real HTTP client). See that
1934
+ * function's own doc comment for the full gating/throw contract.
1935
+ */
1936
+ async #checkByoPresence(vault, repoId, keystore) {
1937
+ const { readByoConfig } = await this.#byoConfig();
1938
+ const { verifyByoObjectsPresent } = await this.#mirror();
1939
+ return computeByoPresenceOutcome({
1940
+ repoId,
1941
+ vault,
1942
+ hasLocalConfig: () => readByoConfig(keystore, repoId) !== null,
1943
+ verifyPresence: () => verifyByoObjectsPresent(this.#client, repoId, { keystore }),
1944
+ });
1945
+ }
1752
1946
  /**
1753
1947
  * Wrap this vault's current epoch key to every org member who has
1754
1948
  * published an encryption key but has no `key_envelope` on this vault yet
@@ -2001,7 +2195,15 @@ export class Gitvault {
2001
2195
  // failure in a log line that assumed ordering. Extracted to a standalone
2002
2196
  // function so the outcome-gating is unit-testable with fake thunks,
2003
2197
  // 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));
2198
+ const hooked = await attachGitvaultDeployHooks(result, () => this.#tryMirrorPush(handle.repo_id, handle.keystore), () => this.#tryReconcileEnvelopeRecipients(handle.vault));
2199
+ // gitvault-byo-primary-bucket task 3.3 — composed AFTER, not inside,
2200
+ // `attachGitvaultDeployHooks` (kept untouched so its own unit-tested
2201
+ // outcome-gating contract stays byte-for-byte): the SAME "did this
2202
+ // deploy actually land a generation" gate `mirror_push`'s presence
2203
+ // already encodes, so no new outcome-inspection logic here.
2204
+ if (hooked.mirror_push === undefined)
2205
+ return hooked;
2206
+ return { ...hooked, byo_chain_copy: await this.#tryByoChainCopyPush(handle.repo_id, handle.keystore) };
2005
2207
  }
2006
2208
  /**
2007
2209
  * Drain every unvaulted-override journal on this machine: push the exact
@@ -2023,6 +2225,35 @@ export class Gitvault {
2023
2225
  const out = await handle.vault.restoreObjectsInto(options.target_dir);
2024
2226
  return { refs: out.refs, generation: out.generation, retained_refs: out.retained_refs };
2025
2227
  }
2228
+ // ── degraded read (gitvault-byo-primary-bucket, design D4, task 3.4 — mirror half) ──
2229
+ /**
2230
+ * Wrap an ALREADY-RESOLVED vault read with degraded-read fallback: run
2231
+ * `options.attemptLive` (the caller's own live call — its
2232
+ * `vault.materialize()` for a `list`-shaped read, or
2233
+ * `vault.restoreObjectsInto(dir)` for a `fetch`-shaped one), and on a
2234
+ * network-class failure (never on an authorization refusal or any other
2235
+ * 4xx — see `isNetworkClassGitvaultReadError`), fall back to the vault's
2236
+ * configured mirror via the SAME `r402s-recover` engine `recover()` uses.
2237
+ *
2238
+ * Deliberately takes an already-open `vault`/`keystore`/`repo_id` rather
2239
+ * than resolving them itself: named-address resolution (slug-form vs.
2240
+ * id-form), stale-pin recovery, and cross-command vault-instance reuse
2241
+ * within one `git-remote-run402` session are CLI-layer concerns that
2242
+ * already live in `cli/lib/remote-helper-session.mjs` — this method owns
2243
+ * only the trigger discipline and the fallback engine, never vault
2244
+ * resolution, so it composes with that existing flow instead of
2245
+ * duplicating it.
2246
+ *
2247
+ * `out_dir` is REQUIRED for the fallback to ever run — pass the resolved,
2248
+ * git-proven repository directory (`resolveGitInvocationRepo`'s own
2249
+ * `repo_dir`, never a guess from `cwd`). `null` (no resolvable repository,
2250
+ * e.g. a bare `git ls-remote` outside any checkout) disables the fallback
2251
+ * entirely: the original gateway error surfaces exactly as it always did.
2252
+ */
2253
+ async withDegradedRead(options) {
2254
+ const { tryGitvaultDegradedRead } = await this.#degradedRead();
2255
+ return tryGitvaultDegradedRead(options);
2256
+ }
2026
2257
  // ── mirror (gitvault-mirror-and-recover, design D1/D2/D7) ─────────────────
2027
2258
  /**
2028
2259
  * Configure (or replace) the customer-owned mirror destination for one
@@ -2060,8 +2291,19 @@ export class Gitvault {
2060
2291
  const keystore = new GitvaultKeystore(options.keystore_root !== undefined ? { rootDir: options.keystore_root } : {});
2061
2292
  const config = readMirrorConfig(keystore, repoId);
2062
2293
  const base = { repo_id: repoId, validity_not_freshness: GITVAULT_MIRROR_VALIDITY_NOT_FRESHNESS_STATEMENT, keystore_still_required: GITVAULT_MIRROR_KEYSTORE_STILL_REQUIRED_STATEMENT };
2294
+ const lastSuccessAt = config?.last_success_at ?? null;
2063
2295
  if (!config) {
2064
- return { ...base, configured: false, destination: null, credential_kind: null, mirrored_generation: null, newest_generation: null, is_current: null, closing_command: null };
2296
+ // gitvault-mirror-default: the unconfigured branch stays gateway-blind
2297
+ // by construction — it returns before ANY network call, finding included.
2298
+ // `options.is_byo` is a caller-supplied hint (gitvault-byo-primary-bucket
2299
+ // task 3.5) — NEVER derived here, which is what keeps this branch
2300
+ // network-call-free; `repos view` already has `storage_profile` from
2301
+ // its own `status()` read and passes it in.
2302
+ return {
2303
+ ...base, configured: false, destination: null, credential_kind: null, mirrored_generation: null, newest_generation: null, is_current: null, closing_command: null,
2304
+ last_success_at: lastSuccessAt,
2305
+ finding: gitvaultUnmirroredFinding({ configured: false, last_success_at: lastSuccessAt, mirrored_generation: null, is_byo: options.is_byo }),
2306
+ };
2065
2307
  }
2066
2308
  let mirroredGeneration = null;
2067
2309
  try {
@@ -2084,6 +2326,8 @@ export class Gitvault {
2084
2326
  ...base, configured: true, destination: formatMirrorDestination(config.destination), credential_kind: config.credential?.kind ?? null,
2085
2327
  mirrored_generation: mirroredGeneration, newest_generation: newestGeneration, is_current: isCurrent,
2086
2328
  closing_command: isCurrent === false ? "run402 repos mirror --backfill" : null,
2329
+ last_success_at: lastSuccessAt,
2330
+ finding: gitvaultUnmirroredFinding({ configured: true, last_success_at: lastSuccessAt, mirrored_generation: mirroredGeneration, is_byo: options.is_byo }),
2087
2331
  };
2088
2332
  }
2089
2333
  /** 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 +2446,15 @@ export class Gitvault {
2202
2446
  #recovery() {
2203
2447
  return nodeOnly(() => import("../node/gitvault-recover.js"), "recover");
2204
2448
  }
2449
+ #degradedRead() {
2450
+ return nodeOnly(() => import("../node/gitvault-degraded-read.js"), "list");
2451
+ }
2452
+ #byoConfig() {
2453
+ return nodeOnly(() => import("../node/gitvault-byo-config.js"), "init");
2454
+ }
2455
+ #byoProbe() {
2456
+ return nodeOnly(() => import("../node/gitvault-byo-probe.js"), "init");
2457
+ }
2205
2458
  }
2206
2459
  // ─── D7 — progressive terminal-loss warning (repo-first-onramp task 2.7) ────
2207
2460
  //
@@ -2331,6 +2584,37 @@ export function gitvaultKeystoreBackupReminderMessage(trip, coveringRecipients)
2331
2584
  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
2585
  `${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
2586
  }
2587
+ /**
2588
+ * gitvault-mirror-default — the pure `vault_unmirrored` computation, in ONE
2589
+ * place so doctor and `repos view` echo the same finding instead of each
2590
+ * deriving their own (the loss-warning pattern above). Present when no mirror
2591
+ * is configured, OR when one is configured but has no success evidence yet —
2592
+ * either the local `last_success_at` stamp (survives a transiently unreachable
2593
+ * mirror) or a chain-verified `mirrored_generation` read from the mirror
2594
+ * itself (covers mirrors synced before the stamp existed). Informational,
2595
+ * never blocking; every input is client-local or read from the CUSTOMER'S
2596
+ * mirror — nothing here touches the gateway.
2597
+ */
2598
+ export function gitvaultUnmirroredFinding(state) {
2599
+ if (state.configured && (state.last_success_at !== null || state.mirrored_generation !== null))
2600
+ return null;
2601
+ return {
2602
+ kind: "vault_unmirrored",
2603
+ message: state.is_byo ? GITVAULT_BYO_UNMIRRORED_REMEDY_STATEMENT : GITVAULT_UNMIRRORED_FINDING_STATEMENT,
2604
+ setup_command: state.configured ? "run402 repos mirror --backfill" : "run402 repos mirror <destination>",
2605
+ };
2606
+ }
2607
+ /**
2608
+ * gitvault-byo-primary-bucket (design D4) — the ONE stderr line a degraded
2609
+ * chain/payload read prints: the fallback's own destination (never a
2610
+ * credential) plus the canonical, mechanism-only statement. `list`/`fetch`
2611
+ * degrading in the SAME `git-remote-run402` session each print their own
2612
+ * line (one per degraded READ, not one per session) — see
2613
+ * `Gitvault.withDegradedRead`'s own doc comment.
2614
+ */
2615
+ export function gitvaultDegradedReadNote(source) {
2616
+ return `degraded read from ${source.destination}: ${GITVAULT_DEGRADED_READ_STATEMENT}`;
2617
+ }
2334
2618
  /** `run402::<org_id>/<project_id>` — what `git-remote-run402` resolves. */
2335
2619
  export function gitvaultRemoteUrl(orgId, projectId) {
2336
2620
  return `run402::${orgId}/${projectId}`;