run402 4.40.0 → 4.42.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 (59) hide show
  1. package/README.md +7 -4
  2. package/cli.mjs +10 -2
  3. package/lib/argparse.mjs +1 -1
  4. package/lib/assets.mjs +10 -14
  5. package/lib/command-manifest.mjs +58 -49
  6. package/lib/deploy-v2.mjs +7 -10
  7. package/lib/doctor.mjs +12 -5
  8. package/lib/gitvault-scaffold.mjs +1 -1
  9. package/lib/gitvault-target.mjs +7 -9
  10. package/lib/gitvault.mjs +117 -1163
  11. package/lib/init.mjs +2 -2
  12. package/lib/notifications.mjs +4 -8
  13. package/lib/operator.mjs +1 -1
  14. package/lib/org.mjs +3 -3
  15. package/lib/repos.mjs +1261 -249
  16. package/lib/rooms-context.mjs +2 -3
  17. package/lib/rooms.mjs +11 -14
  18. package/lib/update-check.mjs +6 -8
  19. package/package.json +1 -1
  20. package/sdk/dist/errors.d.ts +5 -2
  21. package/sdk/dist/errors.d.ts.map +1 -1
  22. package/sdk/dist/errors.js.map +1 -1
  23. package/sdk/dist/namespaces/admin.d.ts +1 -1
  24. package/sdk/dist/namespaces/admin.js +1 -1
  25. package/sdk/dist/namespaces/deploy.types.d.ts +2 -2
  26. package/sdk/dist/namespaces/gitvault.crypto.d.ts +10 -0
  27. package/sdk/dist/namespaces/gitvault.crypto.d.ts.map +1 -1
  28. package/sdk/dist/namespaces/gitvault.crypto.js +11 -1
  29. package/sdk/dist/namespaces/gitvault.crypto.js.map +1 -1
  30. package/sdk/dist/namespaces/gitvault.d.ts +267 -31
  31. package/sdk/dist/namespaces/gitvault.d.ts.map +1 -1
  32. package/sdk/dist/namespaces/gitvault.js +299 -51
  33. package/sdk/dist/namespaces/gitvault.js.map +1 -1
  34. package/sdk/dist/namespaces/projects.d.ts +1 -1
  35. package/sdk/dist/namespaces/projects.js +1 -1
  36. package/sdk/dist/namespaces/projects.types.d.ts +1 -1
  37. package/sdk/dist/namespaces/projects.types.d.ts.map +1 -1
  38. package/sdk/dist/node/gitvault-apply.d.ts +1 -1
  39. package/sdk/dist/node/gitvault-apply.d.ts.map +1 -1
  40. package/sdk/dist/node/gitvault-apply.js +6 -6
  41. package/sdk/dist/node/gitvault-apply.js.map +1 -1
  42. package/sdk/dist/node/gitvault-creation-journal.js +1 -1
  43. package/sdk/dist/node/gitvault-creation-journal.js.map +1 -1
  44. package/sdk/dist/node/gitvault-deploy.js +2 -2
  45. package/sdk/dist/node/gitvault-deploy.js.map +1 -1
  46. package/sdk/dist/node/gitvault-mirror-backend.js +1 -1
  47. package/sdk/dist/node/gitvault-mirror-backend.js.map +1 -1
  48. package/sdk/dist/node/gitvault-mirror.js +1 -1
  49. package/sdk/dist/node/gitvault-mirror.js.map +1 -1
  50. package/sdk/dist/node/gitvault-publication.d.ts +70 -20
  51. package/sdk/dist/node/gitvault-publication.d.ts.map +1 -1
  52. package/sdk/dist/node/gitvault-publication.js +44 -17
  53. package/sdk/dist/node/gitvault-publication.js.map +1 -1
  54. package/sdk/dist/node/gitvault-recover.d.ts +15 -1
  55. package/sdk/dist/node/gitvault-recover.d.ts.map +1 -1
  56. package/sdk/dist/node/gitvault-recover.js +8 -4
  57. package/sdk/dist/node/gitvault-recover.js.map +1 -1
  58. package/sdk/dist/scoped.d.ts +2 -2
  59. package/sdk/dist/scoped.js +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 } from "../errors.js";
32
- import { 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_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";
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)
@@ -97,8 +97,33 @@ export class Gitvault {
97
97
  return this.#client.request(`/gitvault/v1/vaults?project_id=${encodeURIComponent(projectId)}`, { context: "resolving the project's gitvault" });
98
98
  }
99
99
  /**
100
- * Resolve a vault by its address-form `org-slug/name` (repo-first-onramp
101
- * task 4.3, design D6) — `GET /gitvault/v1/vaults?repo=<org-slug>/<name>`.
100
+ * Every vault the organization owns, one round trip — `repos list`'s bulk
101
+ * read. See {@link
102
+ * GitvaultOrgVaultsListing}'s doc comment for the FROZEN response shape and
103
+ * the 404-until-shipped fallback contract.
104
+ */
105
+ async listByOrg(orgId) {
106
+ // The gateway keyset-paginates (has_more/next_cursor, opaque store-and-
107
+ // echo). This method's contract is "the org's vaults", so it follows the
108
+ // cursor and aggregates — a silently-truncated page one would be the
109
+ // Faithful breach agent-response-design.md names. The page bound is a
110
+ // runaway guard, far above any real org; hitting it surfaces has_more:
111
+ // true honestly instead of looping forever on a misbehaving cursor.
112
+ const all = [];
113
+ let cursor;
114
+ for (let page = 0; page < 100; page++) {
115
+ const url = `/gitvault/v1/vaults?org_id=${encodeURIComponent(orgId)}` + (cursor ? `&cursor=${encodeURIComponent(cursor)}` : "");
116
+ const res = await this.#client.request(url, { context: "listing the organization's vaults" });
117
+ all.push(...res.vaults);
118
+ if (!res.has_more || !res.next_cursor)
119
+ return { vaults: all, has_more: false, next_cursor: null };
120
+ cursor = res.next_cursor;
121
+ }
122
+ return { vaults: all, has_more: true, next_cursor: cursor ?? null };
123
+ }
124
+ /**
125
+ * Resolve a vault by its address-form `org-slug/name` —
126
+ * `GET /gitvault/v1/vaults?repo=<org-slug>/<name>`.
102
127
  * `RESOURCE_NOT_FOUND` for no such org OR no such name (deliberately
103
128
  * collapsed — see the design's slug-namespace-probing note);
104
129
  * `SLUG_RELEASED` (read it with {@link gitvaultSlugReleasedInfo}) while the
@@ -110,7 +135,7 @@ export class Gitvault {
110
135
  }
111
136
  /**
112
137
  * Resolve a parsed remote address (`parseGitvaultRemoteUrl`'s output),
113
- * dispatching on its form (design D6): id-form resolves exactly like
138
+ * dispatching on its form: id-form resolves exactly like
114
139
  * {@link forProject}; slug-form resolves via {@link forRepo}. A pure read —
115
140
  * no pinning, no creation. Node-only callers wanting BOTH should use
116
141
  * {@link resolveOrCreateAddress} instead, which also drives the local pin
@@ -275,9 +300,9 @@ export class Gitvault {
275
300
  }
276
301
  /**
277
302
  * Resolve a parsed remote address to an OPEN handle, pinning `repo_id` in
278
- * local git state on the first successful slug-form resolution (design D6,
279
- * task 4.5), and — when `allow_create` is set and resolution misses —
280
- * push-to-create it (task 4.4/4.5). This is what the remote helper and
303
+ * local git state on the first successful slug-form resolution, and
304
+ * when `allow_create` is set and resolution misses —
305
+ * push-to-create it. This is what the remote helper and
281
306
  * `gitvault snapshot` drive for a `run402::<org-slug>/<name>` remote; an
282
307
  * id-form remote resolves through here too (no pin, since it needs none)
283
308
  * so a caller need not branch on the address's form itself.
@@ -519,6 +544,26 @@ export class Gitvault {
519
544
  pending = [];
520
545
  }
521
546
  }
547
+ // Best-effort covering-recipient count (dogfood item 2): the same
548
+ // envelope-recipients read `access()` uses. Decides whether the V0-A
549
+ // single-principal terminal-loss statement is still honest to print for
550
+ // THIS vault, or whether this client has locally proven a second covering
551
+ // recipient. Only attempted when a vault record is already in hand
552
+ // (`record` non-null means the credentials that fetched it already work)
553
+ // — a session-less or offline caller never pays for a second failing
554
+ // request, and any failure here falls back to exactly today's
555
+ // single-principal behavior, never a new failure mode for `status`.
556
+ let coveringRecipients = null;
557
+ if (repoId && record) {
558
+ try {
559
+ const coverage = await this.#client.request(`/gitvault/v1/vaults/${encodeURIComponent(repoId)}/envelope-recipients`, { context: "reading the gitvault envelope recipients" });
560
+ coveringRecipients = coverage.recipient_fingerprints.length;
561
+ }
562
+ catch {
563
+ coveringRecipients = null;
564
+ }
565
+ }
566
+ const isMultiPrincipal = coveringRecipients !== null && coveringRecipients >= 2;
522
567
  const warnings = [...(record?.warnings ?? [])];
523
568
  if (keystorePresent && !canSign) {
524
569
  warnings.push({ kind: "read_only", message: "the signing key is missing from identity.json — this principal can decrypt and verify but cannot publish a new head" });
@@ -529,18 +574,28 @@ export class Gitvault {
529
574
  // D7 (repo-first-onramp task 2.7): a vault that has accrued enough value
530
575
  // at risk gets a STANDING warning instead of the one-time genesis note.
531
576
  // `record` is `null` for an unallocated project, which is the ordinary
532
- // "nothing to warn about" shape, not a gap in this check.
577
+ // "nothing to warn about" shape, not a gap in this check. Once this
578
+ // client has locally proven >= 2 covering recipients, the SAME threshold
579
+ // still fires — real value at risk still deserves a standing reminder —
580
+ // but the copy downgrades to `keystore_backup_reminder`, which never
581
+ // claims terminal loss (dogfood item 2: the single-principal premise
582
+ // behind `terminal_loss_risk` is provably false for this vault).
533
583
  if (record) {
534
584
  const trip = gitvaultLossWarningTrip(record);
535
585
  if (gitvaultLossWarningTripped(trip)) {
536
- warnings.push({ kind: "terminal_loss_risk", message: gitvaultLossWarningMessage(trip) });
586
+ if (isMultiPrincipal) {
587
+ warnings.push({ kind: "keystore_backup_reminder", message: gitvaultKeystoreBackupReminderMessage(trip, coveringRecipients) });
588
+ }
589
+ else {
590
+ warnings.push({ kind: "terminal_loss_risk", message: gitvaultLossWarningMessage(trip) });
591
+ }
537
592
  }
538
593
  }
539
- // The local id-pin, when there is a repository to read it from (design
540
- // D6, task 4.5) — a pure read, same discipline as `remote` below. Read
594
+ // The local id-pin, when there is a repository to read it from — a pure
595
+ // read, same discipline as `remote` below. Read
541
596
  // BEFORE `remote` so a slug-form remote's `matches` comparison can use
542
597
  // it: the pin is the only LOCAL ground truth a slug-form address's own
543
- // URL text does not carry (kychee-com/run402#562).
598
+ // URL text does not carry.
544
599
  let pinned = null;
545
600
  if (options.repo_dir) {
546
601
  const { readPinnedGitvaultRepo } = await this.#address();
@@ -551,13 +606,10 @@ export class Gitvault {
551
606
  // pure read: `status` must never write git configuration.
552
607
  //
553
608
  // Checks BOTH conventional names, `run402` first then `origin` —
554
- // matching `scaffoldRemote`'s own naming (design D1: it claims `origin`
609
+ // matching `scaffoldRemote`'s own naming: it claims `origin`
555
610
  // additively when the repository has none yet, falling back to `run402`
556
- // only when `origin` is already taken by something else). The common
557
- // case is therefore an `origin` remote, not a `run402` one checking
558
- // only "run402" (the pre-fix behavior) reported `remote: null` for most
559
- // repositories even though a run402-form remote was sitting right there
560
- // under `origin` (kychee-com/run402#559c). A name whose URL exists but
611
+ // only when `origin` is already taken by something else. The common
612
+ // case is therefore an `origin` remote, not a `run402` one. A name whose URL exists but
561
613
  // does not parse as a run402 address (someone's own unrelated remote
562
614
  // happening to be named "run402") is skipped rather than reported, so
563
615
  // the other conventional name still gets a chance.
@@ -585,8 +637,8 @@ export class Gitvault {
585
637
  }
586
638
  else {
587
639
  // Slug-form: the URL's second half is a repo NAME, not a project
588
- // id — comparing it against `project` always mismatched, even for
589
- // a perfectly-configured remote (kychee-com/run402#562). The only
640
+ // id — comparing it against `project` would always mismatch, even for
641
+ // a perfectly-configured remote. The only
590
642
  // LOCAL ground truth for a slug-form remote's real identity is the
591
643
  // id-pin; without one there is nothing to compare against, and
592
644
  // that absence is NOT evidence of a mismatch.
@@ -620,11 +672,11 @@ export class Gitvault {
620
672
  // nothing; pointing at it here sent users to a command that silently did
621
673
  // not do what this line promised (dogfood #1, finding A).
622
674
  if (!record)
623
- nextActions.push({ action: "allocate the project's vault", command: "run402 gitvault init" });
675
+ nextActions.push({ action: "allocate the project's vault", command: "run402 repos create --project <id>" });
624
676
  else if (pending.length > 0)
625
- nextActions.push({ action: `complete ${pending.length} unvaulted-override journal(s)`, command: "run402 gitvault snapshot" });
677
+ nextActions.push({ action: `complete ${pending.length} unvaulted-override journal(s)`, command: "run402 repos snapshot" });
626
678
  else if (record && !holdsRepoKey)
627
- nextActions.push({ action: "this machine holds no key for the vault — allocate resolves to the existing vault and is idempotent", command: "run402 gitvault init" });
679
+ nextActions.push({ action: "this machine holds no key for the vault — allocate resolves to the existing vault and is idempotent", command: "run402 repos create --project <id>" });
628
680
  return {
629
681
  repo_id: repoId,
630
682
  project_id: options.project_id ?? record?.project_id ?? null,
@@ -654,8 +706,10 @@ export class Gitvault {
654
706
  pins: { highest_authenticated: authenticated, highest_materialized: materialized },
655
707
  gitvault_policy: record?.gitvault_policy ?? null,
656
708
  pending_overrides: pending.length,
657
- terminal_loss_statement: GITVAULT_TERMINAL_LOSS_STATEMENT,
658
- terminal_loss_detail: GITVAULT_TERMINAL_LOSS_DOCTOR_TEXT,
709
+ covering_recipients: coveringRecipients,
710
+ terminal_loss_statement: isMultiPrincipal ? null : GITVAULT_TERMINAL_LOSS_STATEMENT,
711
+ terminal_loss_detail: isMultiPrincipal ? null : GITVAULT_TERMINAL_LOSS_DOCTOR_TEXT,
712
+ durability_statement: isMultiPrincipal ? GITVAULT_DURABILITY_STATEMENT : null,
659
713
  warnings,
660
714
  next_actions: nextActions,
661
715
  };
@@ -713,13 +767,12 @@ export class Gitvault {
713
767
  ...(options.checkpoint ? { checkpoint: true } : {}),
714
768
  };
715
769
  const result = await handle.vault.push(push);
716
- // Capture-time dual-push hook (design D6/task 2.4): fires only when a
770
+ // Capture-time dual-push hook: fires only when a
717
771
  // mirror is configured; NEVER throws, NEVER alters the vault outcome
718
772
  // above (already returned/committed) — a mirror failure is a named
719
773
  // pending finding reported BESIDE the vault result, on its own field.
720
774
  const mirrorPush = await this.#tryMirrorPush(handle.repo_id, handle.keystore);
721
- // Deploy-time reconcile hook (design D5's "deploy time" cadence,
722
- // gitvault-human-envelopes task 4.1): fires on every successful push,
775
+ // Deploy-time reconcile hook: fires on every successful push,
723
776
  // best-effort — a reconcile failure (including a read-only principal
724
777
  // with no signing key) is reported BESIDE the vault result, never a
725
778
  // `push()` throw, same non-blocking contract as the mirror hook above.
@@ -952,7 +1005,7 @@ export class Gitvault {
952
1005
  submitted: false,
953
1006
  intent: null,
954
1007
  confirmation: null,
955
- note: "run `run402 gitvault compact` to publish a checkpoint bound to a fresh retention_cutoff ticket, then plan the prune again.",
1008
+ note: "run `run402 repos gc` to publish a checkpoint bound to a fresh retention_cutoff ticket, then plan the prune again.",
956
1009
  };
957
1010
  }
958
1011
  const record = await handle.vault.transport.getVaultRecord({ repo_id: handle.repo_id });
@@ -1075,7 +1128,55 @@ export class Gitvault {
1075
1128
  */
1076
1129
  async verify(options = {}) {
1077
1130
  const handle = await this.open(options);
1078
- return handle.vault.verifyToNewest();
1131
+ return handle.vault.verifyToNewest({ persist: options.persist ?? true });
1132
+ }
1133
+ /**
1134
+ * `repos fsck` (repo-surface-consolidation D2/D3): walk the head chain
1135
+ * (what `verify()` did) AND materialize the ref map (what `status({refs:
1136
+ * true})` used to do before `--refs` was removed from `view`), reporting
1137
+ * BOTH local trust pins — authenticated and materialized — before and
1138
+ * after, with an explicit `local_state_changed` flag. This is the one
1139
+ * place chain materialization and pin advance live now; `view` never
1140
+ * calls it.
1141
+ *
1142
+ * `options.write` (default `true`) is the inverse of the CLI's
1143
+ * `--no-write`: `false` still walks and decrypts everything (the returned
1144
+ * `refs`/`verified_to_generation` are real, computed answers, not
1145
+ * estimates) but persists neither pin — a genuine audit mode, not a
1146
+ * simulation. `options.mirror` additionally runs the keyless mirror
1147
+ * verification ({@link mirrorVerify}) and folds its report in; that half
1148
+ * proves the mirror's validity, never its freshness (its own honesty
1149
+ * statements ride the result unchanged).
1150
+ */
1151
+ async fsck(options = {}) {
1152
+ const before = await this.status(options);
1153
+ const repoId = before.repo_id;
1154
+ if (!repoId) {
1155
+ throw new LocalError(options.repo_id || options.project_id
1156
+ ? "no vault is allocated for this project yet — nothing to fsck"
1157
+ : "pass repo_id, or project_id to resolve it from the control plane", "running gitvault fsck", { code: "GITVAULT_VAULT_UNRESOLVED", details: { project_id: options.project_id ?? null } });
1158
+ }
1159
+ const write = options.write ?? true;
1160
+ const handle = await this.open({ ...options, repo_id: repoId });
1161
+ const state = await handle.vault.materialize({ persist: write });
1162
+ const mirror = options.mirror ? await this.mirrorVerify({ ...options, repo_id: repoId }) : null;
1163
+ const pinBefore = { highest_authenticated: before.pins.highest_authenticated, highest_materialized: before.pins.highest_materialized };
1164
+ const pinAfter = write
1165
+ ? { highest_authenticated: state.generation, highest_materialized: state.generation }
1166
+ : pinBefore;
1167
+ const localStateChanged = write && (pinBefore.highest_authenticated !== pinAfter.highest_authenticated || pinBefore.highest_materialized !== pinAfter.highest_materialized);
1168
+ return {
1169
+ repo_id: repoId,
1170
+ write,
1171
+ verified_from_generation: pinBefore.highest_authenticated,
1172
+ verified_to_generation: state.generation,
1173
+ local_state_changed: localStateChanged,
1174
+ pin_before: pinBefore,
1175
+ pin_after: pinAfter,
1176
+ refs: { ...state.refs },
1177
+ head_target: state.head_target,
1178
+ mirror,
1179
+ };
1079
1180
  }
1080
1181
  /**
1081
1182
  * Wrap this vault's current epoch key to every org member who has
@@ -1086,19 +1187,133 @@ export class Gitvault {
1086
1187
  * gateway `public_key` gap, and why this is a workaround rather than the
1087
1188
  * eventual epoch-rotation design).
1088
1189
  *
1089
- * `run402 gitvault reconcile [--repo <id>]` is the explicit standalone
1090
- * CLI surface (design D5's "session start" hook). `deploy()` runs this
1091
- * itself, best-effort, whenever a deploy lands a new generation in the
1092
- * vault design D5's "deploy time" hook, "the same 'one command every
1093
- * agent runs' argument that decided deploy-implies-capture." `push()`
1094
- * (capture-and-publish outside a deploy) runs the identical hook after
1095
- * every successful publish, for the vault-only-project cadence. See
1096
- * `#tryReconcileEnvelopeRecipients` below for both call sites.
1190
+ * `run402 gitvault reconcile` was this method's explicit standalone CLI
1191
+ * surface (design D5's "session start" hook); repo-surface-consolidation
1192
+ * D5/D7/D10 REMOVED it (no `repos` equivalent `reconcile` is a
1193
+ * workaround, not a permanent verb) and it now answers `COMMAND_REMOVED`
1194
+ * pointing at `repos access` for inspection. This method itself is
1195
+ * unchanged and un-retired: `deploy()` still runs it, best-effort,
1196
+ * whenever a deploy lands a new generation in the vault design D5's
1197
+ * "deploy time" hook, "the same 'one command every agent runs' argument
1198
+ * that decided deploy-implies-capture." `push()` (capture-and-publish
1199
+ * outside a deploy) runs the identical hook after every successful
1200
+ * publish, for the vault-only-project cadence. See
1201
+ * `#tryReconcileEnvelopeRecipients` below for both call sites, and
1202
+ * {@link Gitvault.access} for the READ half repo-surface-consolidation
1203
+ * ships in its place.
1097
1204
  */
1098
1205
  async reconcileEnvelopeRecipients(options = {}) {
1099
1206
  const handle = await this.open(options);
1100
1207
  return handle.vault.reconcileEnvelopeRecipients();
1101
1208
  }
1209
+ /**
1210
+ * `repos access` (repo-surface-consolidation D5/D10) — a READ-ONLY report
1211
+ * of who can open this vault, composed from whatever the live gateway
1212
+ * surface exposes today. Never wraps, never mutates a `key_envelope` —
1213
+ * that mutating half stays {@link reconcileEnvelopeRecipients}, reachable
1214
+ * only through the deploy/push best-effort hooks until `access repair`
1215
+ * ships (gated on `gitvault-human-envelopes`' epoch-rotation work). See
1216
+ * {@link GitvaultAccessResult}'s doc comment for the honest gap this
1217
+ * reports rather than invents.
1218
+ */
1219
+ async access(options = {}) {
1220
+ const repoId = await this.#resolveRepoId(options);
1221
+ const record = await this.get(repoId).catch(() => null);
1222
+ const orgId = record?.org_id ?? null;
1223
+ const [directory, coverage] = await Promise.all([
1224
+ orgId
1225
+ ? this.#client.request(`/orgs/v1/${encodeURIComponent(orgId)}/encryption-keys`, { context: "reading the org encryption-key directory" })
1226
+ : Promise.resolve({ org_id: "", keys: [] }),
1227
+ this.#client.request(`/gitvault/v1/vaults/${encodeURIComponent(repoId)}/envelope-recipients`, { context: "reading the gitvault envelope recipients" }),
1228
+ ]);
1229
+ const covered = new Set(coverage.recipient_fingerprints);
1230
+ // `desired` is OPTIONAL on the wire (see GitvaultEnvelopeRecipientsResponse):
1231
+ // absent means an older gateway that predates this field — genuinely
1232
+ // unknown, not "no desired recipients." Distinguish "absent" from
1233
+ // "present and empty" via the array itself, not a boolean flag, so a
1234
+ // null/undefined check is the single source of truth for both this
1235
+ // method and any future caller of the same wire type.
1236
+ const desiredList = coverage.desired ?? null;
1237
+ const envelopeStateAvailable = desiredList !== null;
1238
+ const desiredByPrincipal = new Map(desiredList?.map((d) => [d.principal_id, d]) ?? []);
1239
+ // Node-only, best-effort: THIS machine's own local TOFU pins, and this
1240
+ // keystore's own encryption-key fingerprint (used below to keep the
1241
+ // vault creator's own machine out of `unmatched_covered_fingerprints` —
1242
+ // the org directory only lists human-enrolled keys, so a wallet-principal
1243
+ // creator's fingerprint is legitimately absent from it, not orphaned).
1244
+ // Never fails the whole read — a browser/worker caller, or a machine that
1245
+ // has never wrapped anyone, simply reports every `tofu_pin` as `null` and
1246
+ // `this_keystore` as `null`.
1247
+ let pins = {};
1248
+ let ownFingerprint = null;
1249
+ try {
1250
+ const { GitvaultKeystore } = await this.#keystore();
1251
+ const keystore = new GitvaultKeystore(options.keystore_root !== undefined ? { rootDir: options.keystore_root } : {});
1252
+ pins = keystore.readRepo(repoId)?.envelope_recipient_pins ?? {};
1253
+ ownFingerprint = keystore.readIdentity()?.encryption_fingerprint ?? null;
1254
+ }
1255
+ catch {
1256
+ // Not Node, or no local keystore for this repo — pins/ownFingerprint stay empty/null.
1257
+ }
1258
+ const recipients = directory.keys.map((k) => {
1259
+ const pinned = pins[k.principal_id];
1260
+ const desired = desiredByPrincipal.get(k.principal_id) ?? null;
1261
+ const envelopeState = desired == null ? null : desired.status === "pending_removal" ? "pending_removal" : covered.has(k.ek_fingerprint) ? "converged" : "pending";
1262
+ return {
1263
+ principal_id: k.principal_id,
1264
+ display_name: k.display_name,
1265
+ fingerprint: k.ek_fingerprint,
1266
+ covered: covered.has(k.ek_fingerprint),
1267
+ envelope_state: envelopeState,
1268
+ tofu_pin: pinned !== undefined ? { fingerprint: pinned, matches_directory: pinned === k.ek_fingerprint } : null,
1269
+ };
1270
+ });
1271
+ const directoryFingerprints = new Set(directory.keys.map((k) => k.ek_fingerprint));
1272
+ // Removed members (desired status pending_removal) whose fingerprint is
1273
+ // STILL covered: real, continuing access that membership removal did not
1274
+ // revoke, because gitvault v0 has no epoch-rotation mechanism yet. See
1275
+ // GitvaultAccessResult's doc comment.
1276
+ const staleAccess = (desiredList ?? [])
1277
+ .filter((d) => d.status === "pending_removal" && d.ek_fingerprint != null && covered.has(d.ek_fingerprint))
1278
+ .map((d) => ({ principal_id: d.principal_id, display_name: d.display_name, fingerprint: d.ek_fingerprint }));
1279
+ const staleAccessFingerprints = new Set(staleAccess.map((s) => s.fingerprint));
1280
+ // Genuinely unexplained: covering fingerprints that match neither a
1281
+ // current directory entry NOR a desired-state row (already broken out
1282
+ // above as stale_access) — orphaned, externally revoked, or a recipient
1283
+ // outside this org's membership model entirely. THIS keystore's own
1284
+ // fingerprint is a separate, locally-provable case: the org directory
1285
+ // only lists human-enrolled keys, so the vault creator's own
1286
+ // wallet-principal keystore legitimately never appears there — that is
1287
+ // not orphaned/external, it is provably this machine, so it is broken
1288
+ // out into `this_keystore` instead of left to read as a misconfiguration.
1289
+ const unmatchedRaw = coverage.recipient_fingerprints.filter((fp) => !directoryFingerprints.has(fp) && !staleAccessFingerprints.has(fp));
1290
+ const thisKeystore = ownFingerprint !== null && unmatchedRaw.includes(ownFingerprint) ? { fingerprint: ownFingerprint, covered: true } : null;
1291
+ const unmatched = thisKeystore ? unmatchedRaw.filter((fp) => fp !== thisKeystore.fingerprint) : unmatchedRaw;
1292
+ const gap = envelopeStateAvailable
1293
+ ? "history_scope (which epochs each recipient can read) is not available: gitvault protocol v0 pins a single fixed epoch for a " +
1294
+ "vault's entire lifetime, so there is no per-epoch scope to report — that lands with the epoch-rotation protocol revision " +
1295
+ "gitvault-human-envelopes is mid-fold on, under adversarial review. envelope_state per recipient IS available today, from the " +
1296
+ `gateway's desired-recipient-state substrate (desired_state_version ${String(coverage.desired_state_version ?? "unknown")}): ` +
1297
+ "\"converged\" means desired and covered, \"pending\" means desired but not yet wrapped, \"pending_removal\" means membership " +
1298
+ "removed them but this vault has not been re-keyed away from them. pending_removal does NOT mean revoked — with covered:true " +
1299
+ "they still decrypt this vault's entire history, past and future, until epoch rotation ships; see stale_access for exactly who."
1300
+ : "the gateway did not report desired-recipient state (desired[]) for this vault_id — likely an older gateway than this SDK " +
1301
+ "expects, so per-recipient envelope_state and stale_access are unavailable. history_scope is unavailable regardless: gitvault " +
1302
+ "protocol v0 pins a single fixed epoch, so there is no per-epoch scope to report. This reports what the read surface has today: " +
1303
+ "the org's directory of encryption-key-holding members, which of the vault's current envelope-recipient fingerprints match a " +
1304
+ "directory entry (covered), and this machine's own local TOFU pin per principal when it has ever wrapped one (tofu_pin) — never a server-side fact.";
1305
+ return {
1306
+ repo_id: repoId,
1307
+ org_id: orgId,
1308
+ recipients,
1309
+ unmatched_covered_fingerprints: unmatched,
1310
+ this_keystore: thisKeystore,
1311
+ stale_access: staleAccess,
1312
+ envelope_state_available: envelopeStateAvailable,
1313
+ history_scope_available: false,
1314
+ gap,
1315
+ };
1316
+ }
1102
1317
  /**
1103
1318
  * The push-gated deploy: both lanes under one fresh `capture_id`, resolving
1104
1319
  * to exactly one of five outcomes — `DEPLOYED_AND_VAULTED`,
@@ -1216,7 +1431,7 @@ export class Gitvault {
1216
1431
  return {
1217
1432
  ...base, configured: true, destination: formatMirrorDestination(config.destination), credential_kind: config.credential?.kind ?? null,
1218
1433
  mirrored_generation: mirroredGeneration, newest_generation: newestGeneration, is_current: isCurrent,
1219
- closing_command: isCurrent === false ? "run402 gitvault mirror sync" : null,
1434
+ closing_command: isCurrent === false ? "run402 repos mirror --backfill" : null,
1220
1435
  };
1221
1436
  }
1222
1437
  /** List the vault's stored objects, diff against the mirror, fetch + hash-verify + write what's missing, in admission order. Resumable and idempotent. */
@@ -1226,7 +1441,7 @@ export class Gitvault {
1226
1441
  const keystore = new GitvaultKeystore(options.keystore_root !== undefined ? { rootDir: options.keystore_root } : {});
1227
1442
  return mirrorSync(this.#client, repoId, { keystore });
1228
1443
  }
1229
- /** Keyless integrity probe against the CONFIGURED mirror: discovery + chain verification + closure/absence adjudication, never decryption (`run402 gitvault mirror verify`). */
1444
+ /** Keyless integrity probe against the CONFIGURED mirror: discovery + chain verification + closure/absence adjudication, never decryption (`run402 repos fsck --mirror`). */
1230
1445
  async mirrorVerify(options = {}) {
1231
1446
  const [{ GitvaultKeystore }, { readMirrorConfig }, { openGitvaultMirrorBackend }, { verifyGitvaultMirror }] = await Promise.all([
1232
1447
  this.#keystore(), this.#mirrorConfig(), this.#mirrorBackend(), this.#recovery(),
@@ -1235,7 +1450,7 @@ export class Gitvault {
1235
1450
  const keystore = new GitvaultKeystore(options.keystore_root !== undefined ? { rootDir: options.keystore_root } : {});
1236
1451
  const config = readMirrorConfig(keystore, repoId);
1237
1452
  if (!config)
1238
- throw new LocalError(`no mirror is configured for ${repoId}`, "verifying gitvault mirror", { code: "GITVAULT_MIRROR_NOT_CONFIGURED", details: { repo_id: repoId }, next_actions: [{ action: "run402 gitvault mirror set <destination>" }] });
1453
+ throw new LocalError(`no mirror is configured for ${repoId}`, "verifying gitvault mirror", { code: "GITVAULT_MIRROR_NOT_CONFIGURED", details: { repo_id: repoId }, next_actions: [{ action: "run402 repos mirror <destination>" }] });
1239
1454
  const backend = openGitvaultMirrorBackend(config.destination, repoId, config.credential);
1240
1455
  return verifyGitvaultMirror(backend, { keystore });
1241
1456
  }
@@ -1336,10 +1551,16 @@ export const GITVAULT_LOSS_WARNING_THRESHOLDS = {
1336
1551
  * strings (protocol convention for values that could exceed safe-integer
1337
1552
  * precision in principle; comfortably within it here).
1338
1553
  *
1339
- * There is deliberately no companion "is this resolved" function: V0-A
1340
- * cannot detect a second principal or human envelope able to open the vault
1341
- * (design D7's stricter-than-drafted resolution), so once tripped this has
1342
- * nothing further to compute — a caller does not un-trip it, ever, in V0.
1554
+ * There is deliberately no companion "is this resolved" function on the
1555
+ * TRIP computation itself: whether the generations/bytes/days thresholds
1556
+ * crossed is independent of how many principals cover the vault, and this
1557
+ * function stays a pure threshold check forever — a caller does not un-trip
1558
+ * it, ever, in V0. What DOES now exist, one call site up in `status()`, is a
1559
+ * way to downgrade the COPY once tripped: a locally-provable read of the
1560
+ * vault's covering-recipient count (the same envelope-recipients read
1561
+ * `Gitvault.access` uses) can show this is no longer the single-principal
1562
+ * case the message below describes. See
1563
+ * {@link gitvaultKeystoreBackupReminderMessage} for that downgraded form.
1343
1564
  */
1344
1565
  export function gitvaultLossWarningTrip(record, now = new Date()) {
1345
1566
  const generations = Number(record.admitted_generations ?? "0");
@@ -1356,12 +1577,8 @@ export function gitvaultLossWarningTrip(record, now = new Date()) {
1356
1577
  export function gitvaultLossWarningTripped(trip) {
1357
1578
  return trip.generations || trip.source_bytes || trip.days_since_genesis;
1358
1579
  }
1359
- /**
1360
- * The standing warning text (design D7): names what tripped, states the
1361
- * resolution honestly (a second principal or human envelope — nothing this
1362
- * client can verify yet), and never claims an attestation would clear it.
1363
- */
1364
- export function gitvaultLossWarningMessage(trip) {
1580
+ /** Shared by both D7 message forms below — which threshold(s) tripped, in prose. */
1581
+ function gitvaultLossWarningReasons(trip) {
1365
1582
  const reasons = [];
1366
1583
  if (trip.generations)
1367
1584
  reasons.push(`≥${GITVAULT_LOSS_WARNING_THRESHOLDS.generations} generations`);
@@ -1369,10 +1586,41 @@ export function gitvaultLossWarningMessage(trip) {
1369
1586
  reasons.push(`≥${Math.round(GITVAULT_LOSS_WARNING_THRESHOLDS.source_bytes / (1024 * 1024))} MB of source`);
1370
1587
  if (trip.days_since_genesis)
1371
1588
  reasons.push(`≥${GITVAULT_LOSS_WARNING_THRESHOLDS.days_since_genesis} days since genesis`);
1589
+ return reasons;
1590
+ }
1591
+ /**
1592
+ * The standing warning text (design D7): names what tripped, states the
1593
+ * resolution honestly (a second principal or human envelope — nothing this
1594
+ * client can verify yet), and never claims an attestation would clear it.
1595
+ *
1596
+ * This is the SINGLE-PRINCIPAL form — use it only when the vault's
1597
+ * covering-recipient count is unknown or <= 1. Once a caller has locally
1598
+ * proven >= 2 covering recipients, its premise ("only one principal can open
1599
+ * it") is false; use {@link gitvaultKeystoreBackupReminderMessage} instead.
1600
+ */
1601
+ export function gitvaultLossWarningMessage(trip) {
1602
+ const reasons = gitvaultLossWarningReasons(trip);
1372
1603
  return (`this vault has accrued real value at risk (${reasons.join(", ")}) while only one principal can open it. ` +
1373
1604
  "Only a second principal — another keystore, or later a human envelope — demonstrably able to open the vault clears this warning; " +
1374
1605
  "this client cannot verify that yet, so it stands until you add one. No attestation or flag clears it.");
1375
1606
  }
1607
+ /**
1608
+ * D7's downgraded form (dogfood item 2): once `status()` has locally proven
1609
+ * this vault carries >= 2 covering recipients (via the same envelope-recipients
1610
+ * read `Gitvault.access` uses), the single-principal premise behind
1611
+ * {@link gitvaultLossWarningMessage} is false for THIS vault — printing it
1612
+ * anyway would be a false terminal-loss claim. The composite threshold still
1613
+ * fires the SAME way (this function does not touch `gitvaultLossWarningTrip`/
1614
+ * `gitvaultLossWarningTripped` at all — a real value-at-risk signal is still
1615
+ * worth a reminder), but the copy switches to the protocol's own
1616
+ * keystore-qualified durability sentence instead of asserting single-principal
1617
+ * risk, and never claims terminal loss.
1618
+ */
1619
+ export function gitvaultKeystoreBackupReminderMessage(trip, coveringRecipients) {
1620
+ const reasons = gitvaultLossWarningReasons(trip);
1621
+ return (`this vault has accrued real value at risk (${reasons.join(", ")}), but it is covered by ${coveringRecipients} recipients today — not the single-principal case. ` +
1622
+ `${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.`);
1623
+ }
1376
1624
  /** `run402::<org_id>/<project_id>` — what `git-remote-run402` resolves. */
1377
1625
  export function gitvaultRemoteUrl(orgId, projectId) {
1378
1626
  return `run402::${orgId}/${projectId}`;