run402 4.68.3 → 4.69.1

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 (141) hide show
  1. package/cli.mjs +2 -2
  2. package/core-dist/allowance-auth.js +1 -1
  3. package/core-dist/allowance.js +1 -1
  4. package/core-dist/config.js +4 -4
  5. package/core-dist/keystore.js +7 -7
  6. package/core-dist/profiles.js +1 -1
  7. package/core-dist/redact.js +6 -7
  8. package/git-remote-run402.mjs +2 -2
  9. package/gitvault-surface.json +2 -1
  10. package/lib/agent.mjs +1 -1
  11. package/lib/allowance.mjs +3 -4
  12. package/lib/argparse.mjs +7 -9
  13. package/lib/billing.mjs +33 -35
  14. package/lib/buzz-notifications.mjs +3 -2
  15. package/lib/buzz.mjs +14 -8
  16. package/lib/buzz.test.mjs +1 -1
  17. package/lib/command-manifest.mjs +38 -20
  18. package/lib/config.mjs +1 -1
  19. package/lib/deploy-v2.mjs +3 -3
  20. package/lib/doctor-source-scan.mjs +1 -1
  21. package/lib/doctor.mjs +42 -25
  22. package/lib/email.mjs +2 -3
  23. package/lib/harness-context.mjs +1 -2
  24. package/lib/init.mjs +1 -1
  25. package/lib/next-actions.mjs +1 -1
  26. package/lib/org-context.mjs +83 -2
  27. package/lib/org.mjs +231 -114
  28. package/lib/projects.mjs +2 -2
  29. package/lib/remote-helper-session.mjs +26 -32
  30. package/lib/repos.mjs +117 -15
  31. package/lib/rooms-context.mjs +5 -5
  32. package/lib/sdk-errors.mjs +1 -2
  33. package/lib/status.mjs +1 -1
  34. package/lib/update-check.test.mjs +1 -1
  35. package/lib/wallet-context.mjs +10 -10
  36. package/lib/wallet-context.test.mjs +6 -7
  37. package/lib/wallets.mjs +2 -2
  38. package/lib/webhooks.mjs +2 -2
  39. package/package.json +1 -1
  40. package/sdk/core-dist/allowance-auth.js +1 -1
  41. package/sdk/core-dist/allowance.js +1 -1
  42. package/sdk/core-dist/config.js +4 -4
  43. package/sdk/core-dist/keystore.js +7 -7
  44. package/sdk/core-dist/profiles.js +1 -1
  45. package/sdk/core-dist/redact.js +6 -7
  46. package/sdk/dist/delegate-credentials.d.ts +1 -1
  47. package/sdk/dist/delegate-credentials.js +1 -1
  48. package/sdk/dist/errors.d.ts +10 -4
  49. package/sdk/dist/errors.d.ts.map +1 -1
  50. package/sdk/dist/errors.js +2 -3
  51. package/sdk/dist/errors.js.map +1 -1
  52. package/sdk/dist/kernel.js +1 -1
  53. package/sdk/dist/kernel.js.map +1 -1
  54. package/sdk/dist/namespaces/allowance.d.ts +1 -1
  55. package/sdk/dist/namespaces/allowance.js +1 -1
  56. package/sdk/dist/namespaces/allowance.js.map +1 -1
  57. package/sdk/dist/namespaces/assets.js +1 -1
  58. package/sdk/dist/namespaces/assets.js.map +1 -1
  59. package/sdk/dist/namespaces/assets.types.d.ts +1 -1
  60. package/sdk/dist/namespaces/ci.d.ts.map +1 -1
  61. package/sdk/dist/namespaces/ci.js +4 -6
  62. package/sdk/dist/namespaces/ci.js.map +1 -1
  63. package/sdk/dist/namespaces/deploy.js +7 -8
  64. package/sdk/dist/namespaces/deploy.js.map +1 -1
  65. package/sdk/dist/namespaces/gitvault.crypto.d.ts +86 -3
  66. package/sdk/dist/namespaces/gitvault.crypto.d.ts.map +1 -1
  67. package/sdk/dist/namespaces/gitvault.crypto.js +102 -3
  68. package/sdk/dist/namespaces/gitvault.crypto.js.map +1 -1
  69. package/sdk/dist/namespaces/gitvault.d.ts +197 -17
  70. package/sdk/dist/namespaces/gitvault.d.ts.map +1 -1
  71. package/sdk/dist/namespaces/gitvault.js +698 -58
  72. package/sdk/dist/namespaces/gitvault.js.map +1 -1
  73. package/sdk/dist/namespaces/gitvault.types.d.ts +91 -3
  74. package/sdk/dist/namespaces/gitvault.types.d.ts.map +1 -1
  75. package/sdk/dist/namespaces/gitvault.types.js.map +1 -1
  76. package/sdk/dist/namespaces/operator.d.ts +1 -1
  77. package/sdk/dist/namespaces/operator.js +1 -1
  78. package/sdk/dist/namespaces/org.d.ts +14 -1
  79. package/sdk/dist/namespaces/org.d.ts.map +1 -1
  80. package/sdk/dist/namespaces/org.js +15 -0
  81. package/sdk/dist/namespaces/org.js.map +1 -1
  82. package/sdk/dist/namespaces/org.types.d.ts +30 -0
  83. package/sdk/dist/namespaces/org.types.d.ts.map +1 -1
  84. package/sdk/dist/namespaces/projects.types.d.ts +1 -1
  85. package/sdk/dist/namespaces/subdomains.js +1 -1
  86. package/sdk/dist/namespaces/subdomains.js.map +1 -1
  87. package/sdk/dist/namespaces/transfers.d.ts +3 -4
  88. package/sdk/dist/namespaces/transfers.d.ts.map +1 -1
  89. package/sdk/dist/namespaces/transfers.js +1 -2
  90. package/sdk/dist/namespaces/transfers.js.map +1 -1
  91. package/sdk/dist/node/actions-node.d.ts.map +1 -1
  92. package/sdk/dist/node/actions-node.js +3 -4
  93. package/sdk/dist/node/actions-node.js.map +1 -1
  94. package/sdk/dist/node/deploy-manifest.js +2 -2
  95. package/sdk/dist/node/deploy-manifest.js.map +1 -1
  96. package/sdk/dist/node/gitvault-address.js +2 -2
  97. package/sdk/dist/node/gitvault-address.js.map +1 -1
  98. package/sdk/dist/node/gitvault-creation-journal.d.ts +1 -1
  99. package/sdk/dist/node/gitvault-creation-journal.js +2 -2
  100. package/sdk/dist/node/gitvault-creation-journal.js.map +1 -1
  101. package/sdk/dist/node/gitvault-deploy.d.ts +2 -2
  102. package/sdk/dist/node/gitvault-deploy.js +2 -2
  103. package/sdk/dist/node/gitvault-handoff.d.ts +140 -1
  104. package/sdk/dist/node/gitvault-handoff.d.ts.map +1 -1
  105. package/sdk/dist/node/gitvault-handoff.js +226 -22
  106. package/sdk/dist/node/gitvault-handoff.js.map +1 -1
  107. package/sdk/dist/node/gitvault-keystore.d.ts +78 -3
  108. package/sdk/dist/node/gitvault-keystore.d.ts.map +1 -1
  109. package/sdk/dist/node/gitvault-keystore.js +46 -5
  110. package/sdk/dist/node/gitvault-keystore.js.map +1 -1
  111. package/sdk/dist/node/gitvault-mirror.d.ts.map +1 -1
  112. package/sdk/dist/node/gitvault-mirror.js +3 -5
  113. package/sdk/dist/node/gitvault-mirror.js.map +1 -1
  114. package/sdk/dist/node/gitvault-profile-scan.js +1 -1
  115. package/sdk/dist/node/gitvault-publication.d.ts +379 -21
  116. package/sdk/dist/node/gitvault-publication.d.ts.map +1 -1
  117. package/sdk/dist/node/gitvault-publication.js +664 -41
  118. package/sdk/dist/node/gitvault-publication.js.map +1 -1
  119. package/sdk/dist/node/gitvault-recover.d.ts.map +1 -1
  120. package/sdk/dist/node/gitvault-recover.js +7 -10
  121. package/sdk/dist/node/gitvault-recover.js.map +1 -1
  122. package/sdk/dist/node/gitvault-restore.d.ts.map +1 -1
  123. package/sdk/dist/node/gitvault-restore.js +5 -2
  124. package/sdk/dist/node/gitvault-restore.js.map +1 -1
  125. package/sdk/dist/node/gitvault-snapshot.d.ts +16 -2
  126. package/sdk/dist/node/gitvault-snapshot.d.ts.map +1 -1
  127. package/sdk/dist/node/gitvault-snapshot.js +23 -2
  128. package/sdk/dist/node/gitvault-snapshot.js.map +1 -1
  129. package/sdk/dist/node/gitvault-writer-state.d.ts +201 -0
  130. package/sdk/dist/node/gitvault-writer-state.d.ts.map +1 -0
  131. package/sdk/dist/node/gitvault-writer-state.js +357 -0
  132. package/sdk/dist/node/gitvault-writer-state.js.map +1 -0
  133. package/sdk/dist/node/index.d.ts +5 -1
  134. package/sdk/dist/node/index.d.ts.map +1 -1
  135. package/sdk/dist/node/index.js +21 -2
  136. package/sdk/dist/node/index.js.map +1 -1
  137. package/sdk/dist/node/paid-fetch.d.ts.map +1 -1
  138. package/sdk/dist/node/paid-fetch.js +3 -4
  139. package/sdk/dist/node/paid-fetch.js.map +1 -1
  140. package/sdk/dist/node/sites-node.js +1 -1
  141. package/sdk/dist/node/sites-node.js.map +1 -1
@@ -28,8 +28,31 @@
28
28
  * docs/agent-response-design.md a secret-bearing response is never cached,
29
29
  * never persisted into an agent-surface result store, and never logged.
30
30
  */
31
- import { LocalError, isRun402Error } from "../errors.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, bytesToHex, hexToBytes, parseGitvaultStrict, randomBytes, sha256Hex, verifyGitvaultObject, toBase64url, } from "./gitvault.crypto.js";
31
+ import { LocalError, isRun402Error, isNetworkError } from "../errors.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, bytesToHex, computeKeystorePossessionProof, computeSigningKeyPossessionSignature, ed25519PublicKey, fromBase64url, hexToBytes, jcs, parseGitvaultStrict, randomBytes, sha256Hex, verifyGitvaultObject, toBase64url, } from "./gitvault.crypto.js";
33
+ /**
34
+ * gitvault-agent-envelopes — per-process memo for the session-start
35
+ * fulfilment (`<keystore root>|<repo_id>`) and the enroll-if-absent step
36
+ * (`<keystore root>`), so a process that opens the same vault many times
37
+ * (a `git push` runs several verbs) reconciles and enrolls once.
38
+ */
39
+ const SESSION_RECONCILED = new Set();
40
+ /**
41
+ * Round 3 blocker 2: integrity verdicts must PROPAGATE through every
42
+ * best-effort reconcile boundary — converting tampering evidence into
43
+ * `skipped_error` at the outer catch is exactly the swallow the inner
44
+ * verifier exists to prevent. Availability and local-policy failures stay
45
+ * best-effort.
46
+ */
47
+ const RECONCILE_FATAL_CODES = new Set([
48
+ "GITVAULT_ENVELOPE_ALTERED",
49
+ "GITVAULT_SIGNATURE_INVALID",
50
+ "VAULT_CREATION_CONFLICT",
51
+ ]);
52
+ function rethrowFatalReconcile(e) {
53
+ if (isRun402Error(e) && RECONCILE_FATAL_CODES.has(String(e.code)))
54
+ throw e;
55
+ }
33
56
  /** A keystore path, or `null` when there is no id to derive it from (or it is malformed). */
34
57
  function safePath(derive, repoId) {
35
58
  if (!repoId)
@@ -419,6 +442,47 @@ export class Gitvault {
419
442
  method: "POST", body: input, context: "re-pinning a gitvault recipient",
420
443
  });
421
444
  }
445
+ /**
446
+ * gitvault-agent-envelopes D3 — a KEY-HOLDER explicitly accepts a recipient's
447
+ * CHANGED key. The session-start reconcile refuses to wrap under a
448
+ * fingerprint that differs from this keystore's TOFU pin
449
+ * (`pinned_key_mismatch`) — that refusal is the substitution defence and
450
+ * is never bypassed automatically, not even after an owner's revoke: from
451
+ * this machine's view an owner-driven re-key and a platform substitution
452
+ * look identical, and the audit event is not proof against the platform.
453
+ * Acceptance is a deliberate act naming the new fingerprint (the
454
+ * out-of-band verification point — read it back with the recipient over any
455
+ * channel; it is public data): this records the D197 re-pin receipt with the
456
+ * gateway AND moves the local pin, so the next reconcile wraps. Refuses when
457
+ * `new_fingerprint` is not what the org directory currently serves for the
458
+ * principal (a stale or mistyped fingerprint never pins).
459
+ */
460
+ async acceptRecipientKeyChange(options) {
461
+ const repoId = await this.#resolveRepoId(options);
462
+ const { GitvaultKeystore } = await this.#keystore();
463
+ const keystore = new GitvaultKeystore(options.keystore_root !== undefined ? { rootDir: options.keystore_root } : {});
464
+ const repo = keystore.readRepo(repoId);
465
+ if (!repo)
466
+ throw new LocalError("this keystore holds no repo state for the vault — only a key-holder can accept a recipient's key change", "accepting a recipient key change", { code: "GITVAULT_REPO_STATE_MISSING", details: { repo_id: repoId } });
467
+ const record = await this.get(repoId);
468
+ const directory = await this.#client.request(`/orgs/v1/${encodeURIComponent(record.org_id)}/encryption-keys`, { context: "reading the org encryption-key directory" });
469
+ const served = directory.keys.find((k) => k.principal_id === options.principal_id)?.ek_fingerprint ?? null;
470
+ if (served !== options.new_fingerprint) {
471
+ throw new LocalError(`the org directory currently serves ${served ?? "no key"} for principal ${options.principal_id}, not ${options.new_fingerprint} — refusing to pin a fingerprint the directory does not vouch for`, "accepting a recipient key change", { code: "PIN_CHANGE_UNCONFIRMED", details: { repo_id: repoId, principal_id: options.principal_id, directory_fingerprint: served, requested_fingerprint: options.new_fingerprint } });
472
+ }
473
+ const pins = { ...(repo.envelope_recipient_pins ?? {}) };
474
+ const old = pins[options.principal_id] ?? null;
475
+ let receipt = null;
476
+ if (old && old !== options.new_fingerprint) {
477
+ receipt = await this.repinRecipient(repoId, options.principal_id, { old_ek_fingerprint: old, new_fingerprint: options.new_fingerprint }).catch(() => null);
478
+ }
479
+ else if (!old) {
480
+ receipt = await this.confirmRecipient(repoId, options.principal_id, options.new_fingerprint).catch(() => null);
481
+ }
482
+ pins[options.principal_id] = options.new_fingerprint;
483
+ keystore.updateRepo(repoId, { envelope_recipient_pins: pins });
484
+ return { repo_id: repoId, principal_id: options.principal_id, old_fingerprint: old, new_fingerprint: options.new_fingerprint, receipt };
485
+ }
422
486
  /**
423
487
  * `POST …/recipients/:principal_id/key-revocation` (D199) — declares
424
488
  * `reason:"recipient_key_revoked"` admissible for the NEXT rotation this
@@ -504,6 +568,10 @@ export class Gitvault {
504
568
  const transport = createGitvaultHttpTransport(this.#client, {
505
569
  onObjectStoreOriginObserved: (rid, origins) => keystore.recordObjectStoreOrigins(rid, origins),
506
570
  });
571
+ // gitvault-agent-envelopes D3: enroll the keystore's key if this principal
572
+ // has none (publish + possession proof, one round trip each); a differing
573
+ // current key REFUSES here — rotation is never automatic.
574
+ const enrollment = await this.#ensureEnrolled(keystore);
507
575
  const vault = new GitvaultVault({
508
576
  keystore,
509
577
  transport,
@@ -512,7 +580,245 @@ export class Gitvault {
512
580
  ...(options.verification_budget !== undefined ? { verification_budget: options.verification_budget } : {}),
513
581
  ...(options.service_public_key !== undefined ? { service_public_key: options.service_public_key } : {}),
514
582
  });
515
- return { repo_id: repoId, keystore, transport, vault };
583
+ // D4: a cold keystore restores from its own envelope (or fails
584
+ // GITVAULT_ENVELOPE_PENDING with the exact next actions). The lazy
585
+ // fallback is an ALLOWLIST of typed transport/absence states, not a
586
+ // denylist of known verdicts (consult round 2 §5): a network fault, an
587
+ // older gateway missing the route (404/501), or a vault with no admitted
588
+ // genesis yet (CHAIN_BROKEN — not born, nothing to restore or disclose)
589
+ // leave the handle lazy so the verb fails exactly where it always did
590
+ // (`GITVAULT_REPO_STATE_MISSING` at first use). EVERYTHING else —
591
+ // integrity failures, parse failures, programming errors — propagates.
592
+ // A keystore with NO identity has nothing to restore and nothing that
593
+ // could be disclosed — stay lazy and let the verb hit the ordinary
594
+ // KEYSTORE_MISSING wall (with its cross-profile hint) exactly where it
595
+ // always did, AFTER the caller's own address-pin writes.
596
+ let restored = null;
597
+ try {
598
+ restored = keystore.readIdentity() ? await vault.ensureRepoState() : null;
599
+ }
600
+ catch (e) {
601
+ const code = isRun402Error(e) ? e.code : undefined;
602
+ const status = isRun402Error(e) ? (e.status ?? null) : null;
603
+ const transportAbsence = isNetworkError(e) || status === 404 || status === 501;
604
+ if (code === "CHAIN_BROKEN" || transportAbsence) {
605
+ restored = null;
606
+ }
607
+ else {
608
+ throw e;
609
+ }
610
+ }
611
+ // D5: a key-holder fulfils pending recipients once per process per vault.
612
+ // A just-restored keystore holds K_repo too, but it just LEARNED it from
613
+ // someone else's wrap — it is not the party that owes fulfilment on this
614
+ // open, and wrapping from a first-contact restore before verifying the
615
+ // chain would be premature; the next open in this process does it.
616
+ const policy = options.reconcile ?? "auto";
617
+ // Consult round 2 §4: reconcile WRAPS K_repo — a disclosure — so it runs
618
+ // only when this open VERIFIED custody continuity against the gateway
619
+ // (whoami answered and the current key is this keystore's). A transport
620
+ // fault or unresolved principal is never fail-open into a wrap.
621
+ // `signing_republished` (rev 47) belongs in this allowlist too: it is
622
+ // reached only when the ENCRYPTION half was already active (continuity
623
+ // already held) and a fresh, gateway-verified Ed25519 possession
624
+ // signature was just proven for the signing half — strictly MORE
625
+ // evidence of custody than `already_active` alone, not less.
626
+ const custodyVerified = enrollment.outcome === "already_active" || enrollment.outcome === "enrolled" || enrollment.outcome === "activated_pending" || enrollment.outcome === "signing_republished";
627
+ // Scoped to what was actually verified: keystore + vault + the exact
628
+ // fingerprint whose continuity this open proved. Marked ONLY after a
629
+ // successful reconcile — a transient failure stays retryable, and a
630
+ // `deferred` policy never consumes the marker.
631
+ const sessionKey = `${keystore.rootDir}|${repoId}|${enrollment.ek_fingerprint ?? "none"}`;
632
+ let reconcileRecipients = null;
633
+ if (policy === "forbidden") {
634
+ reconcileRecipients = { attempted: false, outcome: "forbidden" };
635
+ }
636
+ else if (!restored && !custodyVerified) {
637
+ reconcileRecipients = { attempted: false, outcome: "custody_unverified", ...(enrollment.error !== undefined ? { error: enrollment.error } : {}) };
638
+ }
639
+ else if (!restored && !SESSION_RECONCILED.has(sessionKey)) {
640
+ if (policy === "deferred") {
641
+ let pendingCount = null;
642
+ try {
643
+ const coverage = await transport.listEnvelopeRecipients({ repo_id: repoId });
644
+ const covered = new Set(coverage.recipient_fingerprints);
645
+ pendingCount = coverage.desired ? coverage.desired.filter((d) => d.status === "active" && d.ek_fingerprint && !covered.has(d.ek_fingerprint)).length : null;
646
+ }
647
+ catch {
648
+ pendingCount = null;
649
+ }
650
+ reconcileRecipients = { attempted: false, outcome: "deferred_by_local_policy", pending_count: pendingCount };
651
+ }
652
+ else {
653
+ try {
654
+ const result = await vault.reconcileEnvelopeRecipients();
655
+ reconcileRecipients = { attempted: true, outcome: "reconciled", result };
656
+ SESSION_RECONCILED.add(sessionKey);
657
+ }
658
+ catch (e) {
659
+ rethrowFatalReconcile(e);
660
+ reconcileRecipients = { attempted: true, outcome: "skipped_error", error: e instanceof Error ? e.message : String(e) };
661
+ }
662
+ }
663
+ }
664
+ // gitvault-multi-writer (task 5.7) — the SAME "session-start"/"read"
665
+ // wiring point as the envelope reconcile above, but a genuinely simpler
666
+ // policy: no disclosure risk, so only `forbidden` is honored — no
667
+ // custody gate, no `deferred`/memoization branch.
668
+ const writerReconcile = policy === "forbidden" ? { attempted: false, outcome: "forbidden" } : await this.#tryReconcileWriterAdmissions(vault);
669
+ return { repo_id: repoId, keystore, transport, vault, restored, reconcile_recipients: reconcileRecipients, writer_reconcile: writerReconcile, enrollment };
670
+ }
671
+ /**
672
+ * gitvault-agent-envelopes D3 — enroll-if-absent, checked FRESH on every
673
+ * open (consult round 2 §4: memoizing this decision let a long-lived
674
+ * process keep trusting a key an owner had since revoked). Reads
675
+ * `GET /agent/v1/whoami` (the gateway folds the principal's current key
676
+ * metadata in), then:
677
+ * - no current key → publish the keystore's X25519 key as
678
+ * `custody_scheme: keystore_v1`, answer the ECDH possession challenge,
679
+ * activate;
680
+ * - current key = this keystore's key, still `pending` → finish the
681
+ * activation (a crashed earlier enrollment);
682
+ * - current key = this keystore's key, `active` → no-op;
683
+ * - current key ≠ this keystore's key → `GITVAULT_KEY_ROTATION_REQUIRED`.
684
+ * Never published as a rotation: a stolen wallet key must not silently
685
+ * replace a member's decryption identity. The remedies are the
686
+ * gateway's — restore the keystore backup; an org owner revokes the
687
+ * stale key; or, for a sole-member principal, `replace_current`.
688
+ * A credential that resolves to no enrolling principal (a service key)
689
+ * skips enrollment — reads keep working; nothing pretends to be enrolled.
690
+ */
691
+ async #ensureEnrolled(keystore) {
692
+ let identity = keystore.readIdentity();
693
+ let whoami;
694
+ try {
695
+ whoami = await this.#client.request("/agent/v1/whoami", { context: "resolving the enrolling principal" });
696
+ }
697
+ catch (e) {
698
+ // An older gateway, a credential whoami refuses, or a transport fault:
699
+ // enrollment is best-effort on the way IN — the verb itself still runs
700
+ // (and a cold open will say `GITVAULT_ENVELOPE_PENDING` honestly if it
701
+ // needed an envelope this principal never enrolled for). A keystore
702
+ // with no identity reaches the ordinary KEYSTORE_MISSING wall unchanged.
703
+ return { outcome: identity ? "skipped_error" : "skipped_no_identity", ek_fingerprint: identity?.encryption_fingerprint ?? null, signing_fingerprint: identity?.signing_fingerprint ?? null, error: e instanceof Error ? e.message : String(e) };
704
+ }
705
+ if (!whoami.principal) {
706
+ return { outcome: "skipped_no_principal", ek_fingerprint: identity?.encryption_fingerprint ?? null, signing_fingerprint: identity?.signing_fingerprint ?? null };
707
+ }
708
+ if (whoami.principal.type !== "human" && whoami.principal.type !== "agent") {
709
+ // Only custody-eligible principal types enroll (consult round 2 §4):
710
+ // the gateway would refuse the publish anyway, but by then an orphan
711
+ // identity would already exist on disk.
712
+ return { outcome: "skipped_not_enrollable", ek_fingerprint: identity?.encryption_fingerprint ?? null, signing_fingerprint: identity?.signing_fingerprint ?? null };
713
+ }
714
+ if (identity?.enrolled_principal_id && identity.enrolled_principal_id !== whoami.principal.id) {
715
+ throw new LocalError(`this keystore identity enrolled as principal ${identity.enrolled_principal_id}, but the active credential resolves to ${whoami.principal.id} — refusing cross-profile reuse of a custody identity`, "enrolling the keystore encryption key", { code: "GITVAULT_IDENTITY_PROFILE_MISMATCH", details: { enrolled_principal_id: identity.enrolled_principal_id, principal_id: whoami.principal.id, keystore_root: keystore.rootDir } });
716
+ }
717
+ const current = whoami.encryption_key ?? null;
718
+ // A member joining from a FRESH machine has no keystore identity yet.
719
+ // Mint one only now — a real gateway confirmed an enrollable principal
720
+ // with no published key, so this identity is about to become that
721
+ // principal's key. (Vault creation mints its own via the six-stage
722
+ // journal; nothing else ever mints.) A principal that already HAS a
723
+ // published key but no local identity lost its keystore — that is the
724
+ // rotation case below, never a fresh mint.
725
+ if (!identity && !current)
726
+ identity = keystore.ensureIdentity();
727
+ if (!identity) {
728
+ throw new LocalError(`this principal has a published encryption key (${current.ek_fingerprint}) but this keystore holds no identity — the keystore that enrolled it is not this one; rotation is never automatic`, "enrolling the keystore encryption key", {
729
+ code: "GITVAULT_KEY_ROTATION_REQUIRED",
730
+ details: { current_ek_fingerprint: current.ek_fingerprint, current_state: current.state, local_ek_fingerprint: null, keystore_root: keystore.rootDir },
731
+ next_actions: [
732
+ { type: "edit_request", why: "restore the keystore backup that holds the current key (~/.config/run402/gitvault or the wallet profile's gitvault dir)" },
733
+ { type: "edit_request", why: "have an org owner revoke the stale key: DELETE /orgs/v1/:org_id/members/:principal_id/encryption-key (owner + step-up); your next gitvault operation then enrolls a fresh key" },
734
+ { type: "edit_request", why: "if this principal is the sole custody-eligible member of every org it belongs to: POST /agent/v1/whoami/encryption-key with replace_current: true" },
735
+ ],
736
+ });
737
+ }
738
+ if (current && current.ek_fingerprint !== identity.encryption_fingerprint) {
739
+ throw new LocalError(`this principal's published encryption key (${current.ek_fingerprint}) is not this keystore's key (${identity.encryption_fingerprint}); rotation is never automatic`, "enrolling the keystore encryption key", {
740
+ code: "GITVAULT_KEY_ROTATION_REQUIRED",
741
+ details: { current_ek_fingerprint: current.ek_fingerprint, current_state: current.state, local_ek_fingerprint: identity.encryption_fingerprint, keystore_root: keystore.rootDir },
742
+ next_actions: [
743
+ { type: "edit_request", why: "restore the keystore backup that holds the current key (~/.config/run402/gitvault or the wallet profile's gitvault dir)" },
744
+ { type: "edit_request", why: "have an org owner revoke the stale key: DELETE /orgs/v1/:org_id/members/:principal_id/encryption-key (owner + step-up); your next gitvault operation then enrolls this key" },
745
+ { type: "edit_request", why: "if this principal is the sole custody-eligible member of every org it belongs to: POST /agent/v1/whoami/encryption-key with replace_current: true" },
746
+ ],
747
+ });
748
+ }
749
+ // gitvault-multi-writer rev 47 (task 5.3, design D9) — the signing half's
750
+ // OWN continuity check, parallel to the encryption half's: does the
751
+ // directory's currently-published signing_fingerprint already equal
752
+ // this keystore's? UNLIKE the encryption half this is never a rotation
753
+ // refusal (D9: the signing half is always freely republishable) — a
754
+ // mismatch just means the publish call below is still needed, even
755
+ // when the encryption half is already fully active.
756
+ const needsSigningPublish = identity.signing_fingerprint !== (current?.signing_fingerprint ?? null);
757
+ if (current && current.state === "active" && !needsSigningPublish) {
758
+ keystore.bindIdentityPrincipal(whoami.principal.id);
759
+ return { outcome: "already_active", ek_fingerprint: current.ek_fingerprint, signing_fingerprint: identity.signing_fingerprint };
760
+ }
761
+ const keypair = keystore.encryptionKeypair(identity);
762
+ if (!keypair) {
763
+ throw new LocalError("the keystore identity has no X25519 private key — it cannot enroll or open envelopes", "enrolling the keystore encryption key", { code: "VAULT_UNRECOVERABLE", details: { statement: GITVAULT_TERMINAL_LOSS_STATEMENT } });
764
+ }
765
+ // The signing half rides the SAME publish call as the encryption half
766
+ // (task 4.1's gateway contract: one round trip, both halves, all-or-
767
+ // none). `keystore.signingKeypair` is `null` only for a read-only
768
+ // recovery identity that lost its signing seed — that keystore can
769
+ // still enroll its encryption half (existing behavior, unchanged); it
770
+ // simply cannot become a vault writer until the seed is recovered.
771
+ const signingKeypair = keystore.signingKeypair(identity);
772
+ const signingFields = signingKeypair
773
+ ? {
774
+ signing_pubkey: identity.signing_pubkey,
775
+ signing_fingerprint: identity.signing_fingerprint,
776
+ possession_signature: computeSigningKeyPossessionSignature({
777
+ signing_seed: signingKeypair.seed,
778
+ principal_id: whoami.principal.id,
779
+ signing_pubkey: identity.signing_pubkey,
780
+ encryption_pubkey: identity.encryption_pubkey,
781
+ }),
782
+ }
783
+ : {};
784
+ try {
785
+ const published = await this.#client.request("/agent/v1/whoami/encryption-key", { method: "POST", body: { public_key: identity.encryption_pubkey, ek_fingerprint: identity.encryption_fingerprint, custody_scheme: "keystore_v1", ...signingFields }, context: "publishing the keystore encryption key" });
786
+ if (published.state !== "active") {
787
+ if (!published.activation) {
788
+ throw new LocalError("the gateway published the key as pending without an activation challenge", "enrolling the keystore encryption key", { code: "KEY_NOT_PENDING", details: { state: published.state } });
789
+ }
790
+ const proof = computeKeystorePossessionProof({
791
+ private_key: keypair.private_key,
792
+ epk_b64u: published.activation.epk,
793
+ challenge_id: published.activation.challenge_id,
794
+ encryption_key_id: published.encryption_key_id,
795
+ public_key_b64u: identity.encryption_pubkey,
796
+ });
797
+ await this.#client.request("/agent/v1/whoami/encryption-key/activate", {
798
+ method: "POST",
799
+ body: { challenge_id: published.activation.challenge_id, proof },
800
+ context: "proving possession of the keystore encryption key",
801
+ });
802
+ }
803
+ }
804
+ catch (e) {
805
+ // The ONE refusal that must surface is a rotation (the gateway saw a
806
+ // different current key than whoami just reported — a race with a
807
+ // concurrent publish); everything else stays best-effort.
808
+ if (isRun402Error(e) && e.code === "KEY_ROTATION_REQUIRED")
809
+ throw e;
810
+ return { outcome: "skipped_error", ek_fingerprint: identity.encryption_fingerprint, signing_fingerprint: identity.signing_fingerprint ?? null, error: e instanceof Error ? e.message : String(e) };
811
+ }
812
+ keystore.bindIdentityPrincipal(whoami.principal.id);
813
+ // The early `already_active` return above only fires when NEITHER half
814
+ // needs work, so reaching here with `current.state === "active"` means
815
+ // specifically that the ENCRYPTION half was already fine and only the
816
+ // signing half triggered this publish — `signing_republished`, not
817
+ // `activated_pending` (which would wrongly imply an ECDH activation
818
+ // challenge was just completed; none was, `published.state` was
819
+ // already `"active"` and the activation branch above never ran).
820
+ const outcome = !current ? "enrolled" : current.state === "active" ? "signing_republished" : "activated_pending";
821
+ return { outcome, ek_fingerprint: identity.encryption_fingerprint, signing_fingerprint: identity.signing_fingerprint ?? null };
516
822
  }
517
823
  /**
518
824
  * Open a vault, allocating it first when it does not exist yet (D2 — lazy
@@ -1013,6 +1319,23 @@ export class Gitvault {
1013
1319
  else if (options.refs === true && !holdsRepoKey) {
1014
1320
  warnings.push({ kind: "refs_unavailable", message: "this machine does not hold K_repo for the vault, so its ref map cannot be decrypted here" });
1015
1321
  }
1322
+ // gitvault-agent-envelopes D3/D5: a KEY-HOLDER's ordinary read is a
1323
+ // session start — enroll this keystore's key if absent and fulfil every
1324
+ // pending desired recipient once per process (best-effort, reported,
1325
+ // never folded into the observation above). `status` on a machine that
1326
+ // holds no K_repo has nothing to fulfil and stays a pure read.
1327
+ let reconcileRecipients = null;
1328
+ let enrollment = null;
1329
+ if (repoId && holdsRepoKey && options.reconcile !== "forbidden") {
1330
+ try {
1331
+ const handle = await this.open({ ...options, repo_id: repoId });
1332
+ reconcileRecipients = handle.reconcile_recipients;
1333
+ enrollment = handle.enrollment;
1334
+ }
1335
+ catch (e) {
1336
+ warnings.push({ kind: "reconcile_unavailable", message: `the session-start envelope fulfilment could not run: ${e instanceof Error ? e.message : String(e)}` });
1337
+ }
1338
+ }
1016
1339
  const nextActions = [];
1017
1340
  // `run402 init` scaffolds the git remote and deliberately allocates
1018
1341
  // nothing; pointing at it here sent users to a command that silently did
@@ -1027,6 +1350,8 @@ export class Gitvault {
1027
1350
  repo_id: repoId,
1028
1351
  project_id: options.project_id ?? record?.project_id ?? null,
1029
1352
  vault: record,
1353
+ reconcile_recipients: reconcileRecipients,
1354
+ enrollment,
1030
1355
  keystore: {
1031
1356
  present: keystorePresent,
1032
1357
  identity_fingerprint: identityFingerprint,
@@ -1142,7 +1467,13 @@ export class Gitvault {
1142
1467
  // with no signing key) is reported BESIDE the vault result, never a
1143
1468
  // `push()` throw, same non-blocking contract as the mirror hook above.
1144
1469
  const reconcileRecipients = await this.#tryReconcileEnvelopeRecipients(handle.vault);
1145
- return { ...result, snapshot, gitvault_commit: snapshot.oid, gitvault_commit_line: line, mirror_push: mirrorPush, byo_chain_copy: byoChainCopy, reconcile_recipients: reconcileRecipients };
1470
+ // gitvault-multi-writer (task 5.7) the SAME best-effort contract,
1471
+ // fired right beside the encryption-recipient reconcile above: a fresh
1472
+ // push proves this session's own key is an active writer, so this is
1473
+ // the natural place a just-admitted member's own next push clears
1474
+ // whatever candidacy it left behind for OTHER pending members too.
1475
+ const writerReconcile = await this.#tryReconcileWriterAdmissions(handle.vault);
1476
+ return { ...result, snapshot, gitvault_commit: snapshot.oid, gitvault_commit_line: line, mirror_push: mirrorPush, byo_chain_copy: byoChainCopy, reconcile_recipients: reconcileRecipients, writer_reconcile: writerReconcile };
1146
1477
  }
1147
1478
  // ── Handoff / resume (kygit-handoff design D1-D10) ─────────────────────────
1148
1479
  /**
@@ -1159,7 +1490,7 @@ export class Gitvault {
1159
1490
  */
1160
1491
  async handoff(options) {
1161
1492
  const [{ deployRefTransaction }, { captureHandoffSnapshot, snapshotCommitment }, ho] = await Promise.all([this.#publication(), this.#snapshot(), this.#handoff()]);
1162
- const { assembleHandoffKey, deriveHandoffSecrets, sealHandoffEnvelope, assertHandoffNoteHasNoSecret, HANDOFF_ENVELOPE_KIND } = ho;
1493
+ const { assembleHandoffKey, deriveHandoffSecrets, sealHandoffEnvelopeV2, assertHandoffNoteHasNoSecret, buildWriterAdmissionGrant, deriveWriterAdmissionSeed } = ho;
1163
1494
  const handle = options.address ? (await this.resolveOrCreateAddress({ ...options, address: options.address, allow_create: false })).handle : await this.open(options);
1164
1495
  const repoDir = options.repo_dir ?? process.cwd();
1165
1496
  const repoFile = handle.keystore.readRepo(handle.repo_id);
@@ -1167,6 +1498,34 @@ export class Gitvault {
1167
1498
  throw new LocalError(`no local key material for ${handle.repo_id} — this principal is not yet a member with a materialized envelope (push once first)`, "minting a handoff", { code: "GITVAULT_VAULT_UNRESOLVED" });
1168
1499
  }
1169
1500
  const kRepo = hexToBytes(repoFile.k_repo_hex);
1501
+ // gitvault-multi-writer rev 47 (task 5.5, design D4): only an ACTIVE
1502
+ // WRITER may mint — the gateway enforces this authoritatively at mint
1503
+ // time (`403 HANDOFF_MINT_REQUIRES_WRITER`), but failing that late
1504
+ // would mean this call already paid for a checkpoint capture + push +
1505
+ // envelope seal for nothing. `writer_set_pin` is freshly set by the
1506
+ // `verifyToNewest` this vault's `open()` above just ran (never stale by
1507
+ // more than this session's own most recent verify), so checking it
1508
+ // here is a cheap, typed, LOCAL fail-fast — never the check's source of
1509
+ // truth, which stays server-side.
1510
+ const identity = handle.keystore.readIdentity();
1511
+ const localWriterKeyId = identity?.signing_fingerprint ?? null;
1512
+ if (!localWriterKeyId || !repoFile.writer_set_pin?.writers.some((w) => w.writer_key_id === localWriterKeyId)) {
1513
+ throw new LocalError("this keystore's signing key is not an admitted writer of this vault — minting a handoff requires an ACTIVE writer (design D4); an existing writer must add this key first (`run402 org members add` / repos access sync), or push once to reconcile if a pending admission already exists", "minting a handoff", { code: "GITVAULT_WRITER_NOT_ADMITTED" });
1514
+ }
1515
+ // The grant's `minted_role` must EXACTLY predict what the gateway's own
1516
+ // role-attenuation (`services/gitvault/claims.ts mintHandoff`) will
1517
+ // compute, or the grant fails VALIDATION_FAILED after this call has
1518
+ // already paid for the checkpoint push below — `predictMintedRole`
1519
+ // mirrors that formula against the SAME `ORG_ROLE_RANK` lattice
1520
+ // `gitvault-writer-state.ts` already uses elsewhere in this codebase.
1521
+ const { predictMintedRole } = await this.#writerState();
1522
+ const vaultRecord = await this.get(handle.repo_id);
1523
+ const who = await this.#client.request("/agent/v1/whoami", { context: "resolving this principal's org role for the handoff grant" });
1524
+ const membership = who.memberships.find((m) => m.org_id === vaultRecord.org_id && m.status === "active");
1525
+ if (!membership) {
1526
+ throw new LocalError(`this principal has no active membership on ${vaultRecord.org_id} — an active writer must also be an active org member to mint a handoff`, "minting a handoff", { code: "GITVAULT_ACCESS_DENIED" });
1527
+ }
1528
+ const grantMintedRole = predictMintedRole(options.role, membership.role);
1170
1529
  const snapshot = await captureHandoffSnapshot({
1171
1530
  dir: repoDir,
1172
1531
  ...(options.includeSensitive !== undefined ? { includeSensitive: options.includeSensitive } : {}),
@@ -1205,14 +1564,50 @@ export class Gitvault {
1205
1564
  const handoffId = randomHandoffUuid();
1206
1565
  const { key, handoff_id_bytes, master_secret } = assembleHandoffKey(handoffId, randomBytes(32));
1207
1566
  const secrets = deriveHandoffSecrets(handoff_id_bytes, master_secret);
1208
- const sealed = sealHandoffEnvelope(handoff_id_bytes, secrets.wrap_key, {
1209
- v: 1,
1567
+ // gitvault-multi-writer rev 47 (task 5.5, design D4) — the MINTER's own
1568
+ // writer key signs `writer_admission_grant`, authorizing whoever claims
1569
+ // this handoff to become a writer (D224/D225). This is a HARD
1570
+ // requirement distinct from the earlier `writer_set_pin` presence
1571
+ // check: that check only proves the fingerprint is admitted, not that
1572
+ // THIS checkout still holds the seed to sign with — a read-only
1573
+ // recovery identity (no local `signing_seed_hex`) can be an admitted
1574
+ // writer's fingerprint yet be structurally unable to mint from here.
1575
+ //
1576
+ // Built BEFORE the envelope is sealed — design D4's "no hash cycle:
1577
+ // grant first, then seal" — so the v2 envelope below can embed this
1578
+ // grant's own stored-bytes SHA-256, letting the claimant's `resume()`
1579
+ // (task 5.6) cross-check the claim response's grant against what this
1580
+ // call actually sealed, independent of anything the gateway could alter.
1581
+ const signingKeypair = handle.keystore.signingKeypair(identity); // non-null: the writer precheck above already required identity.signing_fingerprint
1582
+ if (!signingKeypair) {
1583
+ throw new LocalError("this keystore has no local signing seed — it can read the vault's writer identity but cannot sign a writer_admission_grant from here (a read-only recovery identity); mint from a checkout that holds the full signing seed", "minting a handoff", { code: "VAULT_UNRECOVERABLE" });
1584
+ }
1585
+ const handoffAdmissionSeed = deriveWriterAdmissionSeed(handoff_id_bytes, master_secret);
1586
+ const grant = buildWriterAdmissionGrant({
1587
+ repo_id: handle.repo_id,
1588
+ handoff_id: handoffId,
1589
+ auth_hash: secrets.auth_hash_hex,
1590
+ checkpoint_generation: pushResult.generation,
1591
+ checkpoint_head_sha256: pushResult.head_sha256,
1592
+ minted_role: grantMintedRole,
1593
+ // Mirrors the gateway's own HANDOFF_DEFAULT_TTL_SECONDS (3600) —
1594
+ // `claim_not_after` is NOT cross-validated against the mint's actual
1595
+ // TTL server-side (only well-formedness is checked), so this only
1596
+ // needs to be a reasonable bound, not an exact prediction.
1597
+ claim_not_after: new Date(Date.now() + (options.ttlSeconds ?? 3600) * 1000).toISOString(),
1598
+ grantor_signing_seed: signingKeypair.seed,
1599
+ handoff_admission_pubkey: ed25519PublicKey(handoffAdmissionSeed),
1600
+ });
1601
+ const grantSha256Local = sha256Hex(jcs(grant));
1602
+ const sealed = sealHandoffEnvelopeV2(handoff_id_bytes, secrets.wrap_key, {
1603
+ v: 2,
1210
1604
  kind: "handoff",
1211
1605
  repo_id: handle.repo_id,
1212
1606
  epoch: repoFile.epoch,
1213
1607
  k_e_hex: repoFile.k_repo_hex,
1214
1608
  checkpoint: { generation: pushResult.generation, commit_oid: snapshot.oid },
1215
1609
  note_schema: "kygit.handoff-note.v1",
1610
+ writer_admission_grant_sha256: grantSha256Local,
1216
1611
  });
1217
1612
  // The wire shape is the gateway's documented one (llms-full.txt
1218
1613
  // "Handoff / resume"): `role` (the minted role), `repo_id` / `org_id` /
@@ -1227,14 +1622,22 @@ export class Gitvault {
1227
1622
  ...(options.ttlSeconds !== undefined ? { expires_in_seconds: options.ttlSeconds } : {}),
1228
1623
  checkpoint: { generation: pushResult.generation, snapshot_oid_hmac: snapshotOidHmac },
1229
1624
  sealed_envelope: sealed.sealed_envelope,
1230
- envelope_kind: sealed.envelope_kind ?? HANDOFF_ENVELOPE_KIND,
1625
+ envelope_kind: sealed.envelope_kind,
1231
1626
  auth_hash: secrets.auth_hash_hex,
1627
+ writer_admission_grant: toBase64url(jcs(grant)),
1232
1628
  },
1233
1629
  context: "minting a handoff key",
1234
1630
  });
1235
1631
  if (response.handoff_id !== handoffId) {
1236
1632
  throw new LocalError(`the gateway minted a different handoff_id (${response.handoff_id}) than requested (${handoffId}) — the assembled key would not match; retry`, "minting a handoff key", { code: "HANDOFF_ID_MISMATCH", details: { requested: handoffId, minted: response.handoff_id } });
1237
1633
  }
1634
+ // The gateway names back the SHA-256 of the EXACT writer_admission_grant
1635
+ // bytes it stored — verified against this call's own local computation
1636
+ // (never the other way around) so a gateway that silently altered the
1637
+ // grant is caught here, before the key is ever handed to a recipient.
1638
+ if (response.writer_admission_grant_sha256 !== grantSha256Local) {
1639
+ throw new LocalError(`the gateway echoed a writer_admission_grant_sha256 (${response.writer_admission_grant_sha256}) that does not match what this call sent (${grantSha256Local}) — the stored grant may not be the one this call signed; do not distribute this handoff key`, "minting a handoff key", { code: "HANDOFF_MINT_GRANT_MISMATCH", details: { expected: grantSha256Local, received: response.writer_admission_grant_sha256 } });
1640
+ }
1238
1641
  return {
1239
1642
  handoff_key: key,
1240
1643
  handoff_id: response.handoff_id,
@@ -1292,49 +1695,129 @@ export class Gitvault {
1292
1695
  * git-config pins only → the session-start reconcile so a principal
1293
1696
  * envelope supersedes the bearer one.
1294
1697
  */
1698
+ /**
1699
+ * gitvault-multi-writer rev 47 (task 5.6, design D4/D5) — order: parse →
1700
+ * ensure wallet → ensure identity → derive auth/wrap/admission → build
1701
+ * acceptance → claim → verify grant → open envelope → check the grant
1702
+ * hash → persist repo file with `pending_writer_admission` → clone →
1703
+ * verify chain → submit the ref-neutral activation head → reconcile
1704
+ * principal envelope → apply the checkpoint. Reported as
1705
+ * `writer_activation` and `reconcile_recipients` (D5's "recipient
1706
+ * coverage"), two blocks, because they are two properties.
1707
+ *
1708
+ * Crash-resumable by construction, not by a special-cased retry branch:
1709
+ * the acceptance is derived fresh every call from the (deterministic)
1710
+ * admission seed + this checkout's own identity, so re-running this
1711
+ * ENTIRE method after a crash anywhere before activation lands is safe —
1712
+ * the claim route is itself idempotent (same-claimant replay returns the
1713
+ * SAME grant). The one step that is NOT safely repeatable is submitting
1714
+ * the activation head a second time (the chain burns `handoff_id`
1715
+ * single-use) — `submitWriterActivationHead` handles that by checking
1716
+ * whether this checkout's own key is ALREADY in the freshly-verified
1717
+ * writer set before ever building a transition, exactly mirroring
1718
+ * `handoff()`'s own writer precheck (task 5.5), just inverted: there
1719
+ * "not yet a writer" refuses; here "already a writer" means skip.
1720
+ */
1295
1721
  async resume(options) {
1296
- const [ho, { GitvaultKeystore }, { createGitvaultHttpTransport }, restore] = await Promise.all([this.#handoff(), this.#keystore(), this.#publication(), this.#restore()]);
1297
- const { parseHandoffKey, deriveHandoffSecrets, openHandoffEnvelope } = ho;
1722
+ const [ho, { GitvaultKeystore }, restore] = await Promise.all([this.#handoff(), this.#keystore(), this.#restore()]);
1723
+ const { parseHandoffKey, deriveHandoffSecrets, deriveWriterAdmissionSeed, buildWriterAcceptance, openHandoffEnvelopeV2 } = ho;
1298
1724
  const { cloneGitvaultRemote, applyHandoffCheckpoint, resolveResumeTargetDir, readGitCommitMessage } = restore;
1725
+ // parse
1299
1726
  const parsed = parseHandoffKey(options.key);
1300
- const secrets = deriveHandoffSecrets(parsed.handoff_id_bytes, parsed.master_secret);
1301
- // A fresh machine has no wallet, and the claim route accepts ONLY a SIWX
1302
- // wallet signature (a control-plane session, delegate, or service key is
1303
- // refused HANDOFF_CLAIM_REQUIRES_WALLET the keystore key the claim
1304
- // publishes is what makes the recipient a real key-holder). Nothing
1305
- // upstream creates the allowance for an unpaid request, so `resume` does
1306
- // it here, exactly as `repos resume --help` promises: a keypair written
1307
- // to the allowance file, no faucet, no tier, no payment. The 2026-09-02
1308
- // rehearsal's Session B hit AUTH_REQUIRED on a bare machine for want of
1309
- // this. A provider without allowance support (an isomorphic one) is
1310
- // left alone — it authenticates however it authenticates.
1727
+ // ensure wallet — A fresh machine has no wallet, and the claim route
1728
+ // accepts ONLY a SIWX wallet signature (a control-plane session,
1729
+ // delegate, or service key is refused HANDOFF_CLAIM_REQUIRES_WALLET —
1730
+ // the keystore key the claim publishes is what makes the recipient a
1731
+ // real key-holder). Nothing upstream creates the allowance for an
1732
+ // unpaid request, so `resume` does it here, exactly as `repos resume
1733
+ // --help` promises: a keypair written to the allowance file, no
1734
+ // faucet, no tier, no payment. Without it a bare machine answers
1735
+ // AUTH_REQUIRED. A provider without
1736
+ // allowance support (an isomorphic one) is left alone — it
1737
+ // authenticates however it authenticates.
1311
1738
  await this.#ensureLocalWallet(options.onLine);
1739
+ // ensure identity — BEFORE claim (D5): the acceptance below needs this
1740
+ // checkout's own signing key, which the claim REQUEST body carries.
1741
+ const keystore = new GitvaultKeystore(options.keystore_root !== undefined ? { rootDir: options.keystore_root } : {});
1742
+ const identity = keystore.ensureIdentity();
1743
+ const signingKeypair = keystore.signingKeypair(identity);
1744
+ if (!signingKeypair) {
1745
+ throw new LocalError("this keystore has no local signing seed — resuming a gitvault-multi-writer handoff requires signing writer_acceptance with a full Ed25519 seed, which a read-only recovery identity does not hold", "resuming a handoff", { code: "VAULT_UNRECOVERABLE" });
1746
+ }
1747
+ const claimantEncryptionPubkeyRaw = fromBase64url(identity.encryption_pubkey, "identity.encryption_pubkey");
1748
+ // derive auth/wrap/admission
1749
+ const secrets = deriveHandoffSecrets(parsed.handoff_id_bytes, parsed.master_secret);
1750
+ const admissionSeed = deriveWriterAdmissionSeed(parsed.handoff_id_bytes, parsed.master_secret);
1751
+ // build acceptance — before claim: design D4's own point is that the
1752
+ // claimant can construct BOTH signatures before ever seeing the stored
1753
+ // grant, since `handoff_id`/`auth_hash` are already independently known.
1754
+ const acceptance = buildWriterAcceptance({
1755
+ handoff_id: parsed.handoff_id,
1756
+ auth_hash: secrets.auth_hash_hex,
1757
+ admission_seed: admissionSeed,
1758
+ claimant_signing_seed: signingKeypair.seed,
1759
+ claimant_encryption_pubkey_raw: claimantEncryptionPubkeyRaw,
1760
+ });
1761
+ // claim
1312
1762
  const claim = await this.#client.request(`/gitvault/v1/handoffs/${encodeURIComponent(parsed.handoff_id)}/claim`, {
1313
1763
  method: "POST",
1314
1764
  // Base64url, per the documented wire contract (openapi: "Base64url — the
1315
1765
  // HKDF-derived auth_secret half of the parsed kgh1_ key"). The gateway
1316
1766
  // decodes base64/base64url and substitutes 32 zero bytes for anything
1317
- // else, so a hex-encoded secret what 4.67.0 through 4.68.0 sent — never
1318
- // matched any stored hash: every claim answered HANDOFF_KEY_INVALID (the
1319
- // 2026-09-02 rehearsal, Session B). Each side's own tests passed; only
1320
- // the cross-side vector below catches this class.
1321
- body: { auth_secret: toBase64url(secrets.auth_secret) },
1767
+ // else, so a hex-encoded secret never matches any stored hash and every
1768
+ // claim answers HANDOFF_KEY_INVALID. Each side's own tests can pass
1769
+ // independently; only the cross-side vector below catches this class.
1770
+ body: { auth_secret: toBase64url(secrets.auth_secret), writer_acceptance: toBase64url(jcs(acceptance)) },
1322
1771
  context: "claiming a handoff key",
1323
1772
  });
1324
1773
  // The claim names the vault by id only (no slug-form address rides the
1325
1774
  // wire), so the default target directory falls back to the vault id —
1326
1775
  // `--to <dir>` names it explicitly.
1327
1776
  const vault = handoffVaultFromWire(claim);
1328
- const payload = openHandoffEnvelope(parsed.handoff_id_bytes, secrets.wrap_key, claim.sealed_envelope, claim.envelope_kind);
1777
+ // verify grant a light structural/binding check, NOT the full
1778
+ // cryptographic verification (which needs the vault's writer set to
1779
+ // resolve the grantor's pubkey, and therefore waits until the chain is
1780
+ // walked below — protocol §4.17's own admission ordering, not skipped,
1781
+ // just not reachable from here yet).
1782
+ let grantBytes;
1783
+ try {
1784
+ grantBytes = fromBase64url(claim.writer_admission_grant, "writer_admission_grant");
1785
+ }
1786
+ catch {
1787
+ throw new LocalError("the claim response's writer_admission_grant is not valid base64url", "resuming a handoff", { code: "VALIDATION_FAILED", details: { field: "writer_admission_grant" } });
1788
+ }
1789
+ let grant;
1790
+ try {
1791
+ grant = JSON.parse(new TextDecoder().decode(grantBytes));
1792
+ }
1793
+ catch {
1794
+ throw new LocalError("the claim response's writer_admission_grant does not decode to valid JSON", "resuming a handoff", { code: "VALIDATION_FAILED", details: { field: "writer_admission_grant" } });
1795
+ }
1796
+ if (grant.handoff_id !== parsed.handoff_id || grant.auth_hash !== secrets.auth_hash_hex || grant.repo_id !== vault.vault_id) {
1797
+ throw new LocalError("the claim response's writer_admission_grant does not bind this handoff — refusing", "resuming a handoff", { code: "VALIDATION_FAILED", details: { field: "writer_admission_grant" } });
1798
+ }
1799
+ const grantSha256 = sha256Hex(grantBytes);
1800
+ // open envelope — v2 (task 5.4): the v2 payload's own
1801
+ // writer_admission_grant_sha256 is what the next step cross-checks;
1802
+ // opening under the SAME v1/v2-requiring rule that refuses a
1803
+ // pre-gitvault-multi-writer mint's envelope outright.
1804
+ const payload = openHandoffEnvelopeV2(parsed.handoff_id_bytes, secrets.wrap_key, claim.sealed_envelope, claim.envelope_kind);
1329
1805
  if (payload.repo_id !== vault.vault_id) {
1330
1806
  throw new LocalError("the opened envelope's repo_id does not match the claim response's vault — refusing", "resuming a handoff", { code: "HANDOFF_ENVELOPE_INVALID" });
1331
1807
  }
1332
- const keystore = new GitvaultKeystore(options.keystore_root !== undefined ? { rootDir: options.keystore_root } : {});
1333
- keystore.ensureIdentity();
1808
+ // check the grant hash design D4's own integrity binding: the SEALED
1809
+ // envelope (wrap_key-authenticated, the gateway never holds wrap_key)
1810
+ // names the grant it was minted alongside; if the claim response's
1811
+ // grant doesn't match, something between mint and claim substituted a
1812
+ // different one — refuse before spending anything on this handoff.
1813
+ if (payload.writer_admission_grant_sha256 !== grantSha256) {
1814
+ throw new LocalError(`the claim response's writer_admission_grant (sha256 ${grantSha256}) does not match the hash sealed into the envelope at mint time (${payload.writer_admission_grant_sha256}) — refusing to activate under a substituted grant`, "resuming a handoff", { code: "HANDOFF_CLAIM_WRITER_KEY_MISMATCH", details: { expected: payload.writer_admission_grant_sha256, received: grantSha256 } });
1815
+ }
1334
1816
  // Genesis must be pinned before ANY materialize call can succeed
1335
1817
  // (`GitvaultVault.genesis()` requires a keystore repo file — this is
1336
1818
  // the one read that happens BEFORE one exists, via the transport
1337
1819
  // directly, mirroring `restoreRepoFromEnvelope`'s own signature check).
1820
+ const { createGitvaultHttpTransport } = await this.#publication();
1338
1821
  const transport = createGitvaultHttpTransport(this.#client);
1339
1822
  const genesisBytes = await transport.getGenesis({ repo_id: vault.vault_id });
1340
1823
  if (!genesisBytes) {
@@ -1345,7 +1828,13 @@ export class Gitvault {
1345
1828
  throw new LocalError("vault_genesis signature does not verify", "resuming a handoff", { code: "GITVAULT_SIGNATURE_INVALID", details: { repo_id: vault.vault_id } });
1346
1829
  }
1347
1830
  const genesisSha = sha256Hex(genesisBytes);
1348
- // Write the repo file to the keystore BEFORE touching disk (design D10).
1831
+ // persist repo file with pending_writer_admission BEFORE clone/chain
1832
+ // work below (design D10's "write to keystore before touching disk",
1833
+ // extended): a crash from here on leaves a durable record of the
1834
+ // ALREADY-VERIFIED grant, so a retry never needs to re-claim (the
1835
+ // acceptance is trivially re-derivable from data already in hand, per
1836
+ // this method's own doc comment above; only the grant is not).
1837
+ const myWriterKeyId = identity.signing_fingerprint;
1349
1838
  keystore.saveRepo({
1350
1839
  repo_id: vault.vault_id,
1351
1840
  org_id: vault.organization_id,
@@ -1357,22 +1846,61 @@ export class Gitvault {
1357
1846
  head_pin: null,
1358
1847
  last_ref_transaction: null,
1359
1848
  provenance: "restored_from_handoff",
1849
+ pending_writer_admission: { handoff_id: parsed.handoff_id, writer_admission_grant: grant, claimed_writer_key_id: myWriterKeyId },
1360
1850
  });
1361
1851
  const targetDir = await resolveResumeTargetDir(options.to, vault.address, vault.vault_id);
1362
1852
  options.onLine?.(`resuming into ${targetDir}`);
1363
1853
  const remoteUrl = gitvaultRemoteUrl(vault.organization_id, vault.project_id);
1364
1854
  await cloneGitvaultRemote(remoteUrl, targetDir);
1365
- const restored = await applyHandoffCheckpoint({ dir: targetDir, stash_oid: payload.checkpoint.commit_oid });
1366
1855
  // Local-only pins (design D10) — never a worktree file, never the
1367
1856
  // global active project. Reuses the SAME pin-writer every other
1368
1857
  // gitvault resolution path uses, which also writes `r402.room`.
1369
1858
  const { pinGitvaultRepo } = await this.#address();
1370
1859
  const addressParts = vault.address ? vault.address.split("/") : null;
1371
1860
  await pinGitvaultRepo(targetDir, vault.vault_id, addressParts && addressParts.length === 2 ? { org_slug: addressParts[0], repo_name: addressParts[1] } : undefined, { project_id: vault.project_id, org_id: vault.organization_id });
1372
- // The bearer envelope is superseded within minutes of use — run the
1373
- // same reconcile `push()` runs, best-effort (never a `resume()` throw).
1374
- const handle = await this.open({ repo_id: vault.vault_id, repo_dir: targetDir, keystore_root: options.keystore_root });
1861
+ // verify chain `open()` here mainly constructs the `GitvaultVault`
1862
+ // instance: its own `ensureRepoState()` (the cold-open restore path)
1863
+ // no-ops the instant it sees a repo file already on disk — and the
1864
+ // `saveRepo` call above just wrote one. The chain walk this method
1865
+ // actually needs happens a moment later, the FIRST time
1866
+ // `submitWriterActivationHead` calls `materialize()` on `handle.vault`
1867
+ // below — `materialize()` unconditionally runs `verifyToNewest()` on
1868
+ // every call, cold-open or not, which is what freshly pins
1869
+ // `writer_set_pin`. `options.reconcile: "forbidden"` here: the
1870
+ // encryption-envelope reconcile is deliberately deferred to its own
1871
+ // explicit step AFTER activation (D5's ordering), not run implicitly
1872
+ // and possibly twice.
1873
+ const handle = await this.open({ repo_id: vault.vault_id, repo_dir: targetDir, keystore_root: options.keystore_root, reconcile: "forbidden" });
1874
+ // `added_writer.principal_id` names the claimant's OWN control-plane
1875
+ // principal — the claim response never carries it (its `membership`
1876
+ // block names the ORG, not the principal), so resolve it fresh here,
1877
+ // the same one-call pattern `handoff()` already uses for its own role
1878
+ // resolution (task 5.5).
1879
+ const who = await this.#client.request("/agent/v1/whoami", { context: "resolving this principal's id for the writer activation head" });
1880
+ // submit the ref-neutral activation head
1881
+ const activation = await handle.vault.submitWriterActivationHead({
1882
+ addedWriterKeyId: myWriterKeyId,
1883
+ addedSigningPubkeyB64u: identity.signing_pubkey,
1884
+ addedPrincipalId: who.principal.id,
1885
+ handoffId: parsed.handoff_id,
1886
+ grant,
1887
+ acceptance: acceptance,
1888
+ });
1889
+ const activationGeneration = activation.outcome === "activated" ? activation.result.generation : activation.generation;
1890
+ // Clearing pending_writer_admission now that the activation head is
1891
+ // (or already was — the idempotent-skip case) admitted mirrors
1892
+ // writer_status flipping to "active" at the same moment, per this
1893
+ // field's own doc comment in gitvault-keystore.ts.
1894
+ keystore.updateRepo(vault.vault_id, { pending_writer_admission: null });
1895
+ // reconcile principal envelope — the bearer envelope is superseded
1896
+ // within minutes of use; run the same reconcile `push()` runs,
1897
+ // best-effort (never a `resume()` throw), NOW that this checkout is an
1898
+ // admitted writer and the reconcile's own wrap step is meaningful.
1375
1899
  const reconcile = await this.#tryReconcileEnvelopeRecipients(handle.vault);
1900
+ // apply the checkpoint — LAST (D5): a failure anywhere above this line
1901
+ // leaves the working tree untouched (freshly cloned, nothing stashed),
1902
+ // the cleanest possible state to retry `resume()` from.
1903
+ const restored = await applyHandoffCheckpoint({ dir: targetDir, stash_oid: payload.checkpoint.commit_oid });
1376
1904
  const senderIsOwner = claim.membership.role === "owner";
1377
1905
  const nextActions = [...(claim.next_actions ?? [])];
1378
1906
  if (claim.kind === "handoff" && senderIsOwner && !nextActions.some((a) => a.type === "remove_member")) {
@@ -1406,6 +1934,7 @@ export class Gitvault {
1406
1934
  membership: handoffMembershipFromWire(claim.membership),
1407
1935
  members: claim.members ?? [],
1408
1936
  expires_at: claim.expires_at,
1937
+ writer_activation: { outcome: "active", writer_key_id: myWriterKeyId, generation: activationGeneration },
1409
1938
  reconcile_recipients: reconcile,
1410
1939
  next_actions: nextActions,
1411
1940
  };
@@ -1474,18 +2003,46 @@ export class Gitvault {
1474
2003
  return { attempted: false, outcome: "skipped_no_mirror", error: e instanceof Error ? e.message : String(e) };
1475
2004
  }
1476
2005
  }
1477
- /** Best-effort envelope-recipient reconcile: catches EVERYTHING so a reconcile problem can never surface as a `push()` throw (mirrors {@link #tryMirrorPush}'s contract exactly). */
2006
+ /** Best-effort envelope-recipient reconcile: availability and local-policy failures never surface as a `push()` throw (mirroring {@link #tryMirrorPush}), but INTEGRITY verdicts do — `RECONCILE_FATAL_CODES` propagates tampering evidence instead of demoting it to `skipped_error` (round 3 blocker 2). */
1478
2007
  async #tryReconcileEnvelopeRecipients(vault) {
1479
2008
  try {
1480
2009
  const result = await vault.reconcileEnvelopeRecipients();
1481
2010
  return { attempted: true, outcome: "reconciled", result };
1482
2011
  }
1483
2012
  catch (e) {
2013
+ rethrowFatalReconcile(e);
2014
+ return { attempted: false, outcome: "skipped_error", error: e instanceof Error ? e.message : String(e) };
2015
+ }
2016
+ }
2017
+ /**
2018
+ * gitvault-multi-writer (task 5.7) — best-effort writer-admission
2019
+ * reconcile, the SAME non-blocking contract as {@link
2020
+ * #tryReconcileEnvelopeRecipients} beside it: availability failures
2021
+ * (network, an older gateway missing `pending_writers`/`signing_pubkey`,
2022
+ * a CAS-conflict-exhausted admission) never surface as a `push()`/
2023
+ * `deploy()`/`open()` throw — `GitvaultVault.reconcileWriterAdmissions()`
2024
+ * ALREADY returns an empty result rather than throwing for the ordinary
2025
+ * "I am not a writer yet" case, so `skipped_error` here is reserved for
2026
+ * genuinely unexpected failures (a transport fault mid-loop, a thrown
2027
+ * chain-integrity verdict from the `materialize()` this reconcile's own
2028
+ * admission attempts re-run). `rethrowFatalReconcile` is reused as-is:
2029
+ * its `RECONCILE_FATAL_CODES` set is envelope-specific today (no writer-
2030
+ * chain-integrity code is in it yet), so this call is a no-op unless a
2031
+ * future code is added there — cheap, forward-compatible, and consistent
2032
+ * with the sibling method rather than a silently different policy.
2033
+ */
2034
+ async #tryReconcileWriterAdmissions(vault) {
2035
+ try {
2036
+ const result = await vault.reconcileWriterAdmissions();
2037
+ return { attempted: true, outcome: "reconciled", result };
2038
+ }
2039
+ catch (e) {
2040
+ rethrowFatalReconcile(e);
1484
2041
  return { attempted: false, outcome: "skipped_error", error: e instanceof Error ? e.message : String(e) };
1485
2042
  }
1486
2043
  }
1487
2044
  /**
1488
- * A REAL preview of what {@link push} would publish (kychee-com/run402#565)
2045
+ * A REAL preview of what {@link push} would publish
1489
2046
  * — never publishes anything, and never allocates. `run402 gitvault
1490
2047
  * snapshot --dry-run` and `git-remote-run402`'s `option dry-run true` are
1491
2048
  * both thin adapters over this method.
@@ -1685,7 +2242,7 @@ export class Gitvault {
1685
2242
  }
1686
2243
  /**
1687
2244
  * Open this vault's compaction headroom grant, shared by `compact()` and
1688
- * `prune({ submit })` (kychee-com/run402#578 fix 3) — both write ceremony
2245
+ * `prune({ submit })` — both write ceremony
1689
2246
  * objects into the vault and both need the same single-flight-per-vault
1690
2247
  * headroom slot. Four outcomes:
1691
2248
  * - opened: the grant is returned, carrying `effective_pool_limit_bytes`.
@@ -1947,7 +2504,7 @@ export class Gitvault {
1947
2504
  implementation_version: prune.GITVAULT_SDK_VERIFIER_VERSION,
1948
2505
  }, handle.vault.signer());
1949
2506
  const intent = prune.buildPruneIntent(core, [ours, options.submit.verifier_receipt], handle.vault.signer());
1950
- // kychee-com/run402#578 fix 3: the submission writes ~8 KB of ceremony
2507
+ // The submission writes ~8 KB of ceremony
1951
2508
  // objects (both verifier receipts + the intent) into the vault — on a
1952
2509
  // vault that is over-quota BECAUSE it just compacted, that write is
1953
2510
  // refused QUOTA_EXCEEDED, so the deletion's own paperwork is blocked by
@@ -2004,7 +2561,7 @@ export class Gitvault {
2004
2561
  await handle.vault.transport.uploadObjects({ repo_id: handle.repo_id, objects });
2005
2562
  }
2006
2563
  catch (e) {
2007
- // kychee-com/run402#578 fix 2: receipt object ids are DETERMINISTIC —
2564
+ // Receipt object ids are DETERMINISTIC —
2008
2565
  // the same core + the same signer produce the same id on every retry —
2009
2566
  // and Ed25519 signing is deterministic too, so the SAME id can only
2010
2567
  // mean the SAME bytes when nothing about the receipt's content
@@ -2081,11 +2638,10 @@ export class Gitvault {
2081
2638
  }
2082
2639
  /**
2083
2640
  * `repos fsck` (repo-surface-consolidation D2/D3): walk the head chain
2084
- * (what `verify()` did) AND materialize the ref map (what `status({refs:
2085
- * true})` used to do before `--refs` was removed from `view`), reporting
2641
+ * AND materialize the ref map, reporting
2086
2642
  * BOTH local trust pins — authenticated and materialized — before and
2087
2643
  * after, with an explicit `local_state_changed` flag. This is the one
2088
- * place chain materialization and pin advance live now; `view` never
2644
+ * place chain materialization and pin advance live; `view` never
2089
2645
  * calls it.
2090
2646
  *
2091
2647
  * `options.write` (default `true`) is the inverse of the CLI's
@@ -2129,7 +2685,10 @@ export class Gitvault {
2129
2685
  : "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 } });
2130
2686
  }
2131
2687
  const write = options.write ?? true;
2132
- const handle = await this.open({ ...options, repo_id: repoId });
2688
+ // gitvault-agent-envelopes D5: fsck is observational it never wraps an
2689
+ // envelope, in write mode or not. Investigating a suspicious pending
2690
+ // recipient must not complete the disclosure being investigated.
2691
+ const handle = await this.open({ ...options, repo_id: repoId, reconcile: "forbidden" });
2133
2692
  const state = await handle.vault.verifyToNewest({ persist: write, decryptValidate: true, strict: false });
2134
2693
  const mirror = options.mirror ? await this.mirrorVerify({ ...options, repo_id: repoId }) : null;
2135
2694
  const decrypt = state.decrypt; // decryptValidate: true always populates this
@@ -2251,12 +2810,10 @@ export class Gitvault {
2251
2810
  * gateway `public_key` gap, and why this is a workaround rather than the
2252
2811
  * eventual epoch-rotation design).
2253
2812
  *
2254
- * `run402 gitvault reconcile` was this method's explicit standalone CLI
2255
- * surface (design D5's "session start" hook); repo-surface-consolidation
2256
- * D5/D7/D10 REMOVED it (no `repos` equivalent `reconcile` is a
2257
- * workaround, not a permanent verb) and it now answers `COMMAND_REMOVED`
2258
- * pointing at `repos access` for inspection. This method itself is
2259
- * unchanged and un-retired: `deploy()` still runs it, best-effort,
2813
+ * `run402 gitvault reconcile` has no `repos` equivalent `reconcile` is
2814
+ * a workaround, not a permanent verb — and answers `COMMAND_REMOVED`
2815
+ * pointing at `repos access` for inspection. This method itself stays:
2816
+ * `deploy()` runs it, best-effort,
2260
2817
  * whenever a deploy lands a new generation in the vault — design D5's
2261
2818
  * "deploy time" hook, "the same 'one command every agent runs' argument
2262
2819
  * that decided deploy-implies-capture." `push()` (capture-and-publish
@@ -2270,6 +2827,27 @@ export class Gitvault {
2270
2827
  const handle = await this.open(options);
2271
2828
  return handle.vault.reconcileEnvelopeRecipients();
2272
2829
  }
2830
+ /**
2831
+ * gitvault-multi-writer (task 5.7) — admit every eligible `pending_writers[]`
2832
+ * candidate (active org membership at role developer+, a published,
2833
+ * possession-verified signing key, not yet in the writer set) via a fresh
2834
+ * `add_writer_key{"writer"}` head per candidate. This session's OWN key
2835
+ * must already be an active writer — see {@link
2836
+ * import("../node/gitvault-publication.js").GitvaultVault.
2837
+ * reconcileWriterAdmissions}'s doc comment for why that is checked
2838
+ * locally, once, rather than surfaced as N identical gateway refusals.
2839
+ *
2840
+ * The explicit standalone entry point for the SAME reconcile task 5.7
2841
+ * also wires onto session-start/push/snapshot/deploy — calling it here
2842
+ * is idempotent-safe alongside whatever `open()` above already ran
2843
+ * internally (an already-admitted candidate is simply reported under
2844
+ * `already_covered`, never re-admitted), mirroring {@link
2845
+ * reconcileEnvelopeRecipients}'s own identical redundancy-tolerant shape.
2846
+ */
2847
+ async reconcile(options = {}) {
2848
+ const handle = await this.open(options);
2849
+ return handle.vault.reconcileWriterAdmissions();
2850
+ }
2273
2851
  /**
2274
2852
  * Drive one epoch rotation (D193-D203, rev 42) — the client half of
2275
2853
  * epoch rotation: sample a fresh `K_e`, compute the H-partition from live
@@ -2312,8 +2890,8 @@ export class Gitvault {
2312
2890
  *
2313
2891
  * **This publish is an ORDINARY admission** and is therefore itself
2314
2892
  * refused `EPOCH_ROTATION_REQUIRED` while this vault has a migration/
2315
- * revocation/exposure condition outstanding (D193) — reproduced live in
2316
- * production 2026-08-27. `#enrichEpochRotationRequiredForPinManifest`
2893
+ * revocation/exposure condition outstanding (D193).
2894
+ * `#enrichEpochRotationRequiredForPinManifest`
2317
2895
  * below decorates that refusal with the remedy: fold the SAME receipt
2318
2896
  * into `rotateEpoch({..., pending_confirmations: [...]})` instead, which
2319
2897
  * durably publishes it on a `rotate_epoch` admission (the gate's own
@@ -2356,6 +2934,28 @@ export class Gitvault {
2356
2934
  const repoId = await this.#resolveRepoId(options);
2357
2935
  const record = await this.get(repoId).catch(() => null);
2358
2936
  const orgId = record?.org_id ?? null;
2937
+ // gitvault-agent-envelopes D3/D5: `access` is one of the ordinary reads a
2938
+ // KEY-HOLDER's session starts with — enroll this keystore's key if absent
2939
+ // and fulfil every pending desired recipient once per process, BEFORE the
2940
+ // roster below is read, so the roster reflects the fulfilment. Reported,
2941
+ // never folded into the roster; a machine holding no K_repo stays a pure
2942
+ // read.
2943
+ let reconcileRecipients = null;
2944
+ let enrollment = null;
2945
+ if (options.reconcile !== "forbidden") {
2946
+ try {
2947
+ const { GitvaultKeystore } = await this.#keystore();
2948
+ const probe = new GitvaultKeystore(options.keystore_root !== undefined ? { rootDir: options.keystore_root } : {});
2949
+ if (probe.readRepo(repoId)) {
2950
+ const handle = await this.open({ ...options, repo_id: repoId });
2951
+ reconcileRecipients = handle.reconcile_recipients;
2952
+ enrollment = handle.enrollment;
2953
+ }
2954
+ }
2955
+ catch {
2956
+ // best-effort — the roster read below is the verb's own result
2957
+ }
2958
+ }
2359
2959
  const [directory, coverage] = await Promise.all([
2360
2960
  orgId
2361
2961
  ? this.#client.request(`/orgs/v1/${encodeURIComponent(orgId)}/encryption-keys`, { context: "reading the org encryption-key directory" })
@@ -2423,7 +3023,41 @@ export class Gitvault {
2423
3023
  // not orphaned/external, it is provably this machine, so it is broken
2424
3024
  // out into `this_keystore` instead of left to read as a misconfiguration.
2425
3025
  const unmatchedRaw = coverage.recipient_fingerprints.filter((fp) => !directoryFingerprints.has(fp) && !staleAccessFingerprints.has(fp));
2426
- const thisKeystore = ownFingerprint !== null && unmatchedRaw.includes(ownFingerprint) ? { fingerprint: ownFingerprint, covered: true } : null;
3026
+ // gitvault-agent-envelopes: THIS machine's keystore is reported for every
3027
+ // principal type — enrolled or not, covered or not — never `null` for a
3028
+ // real keystore (only when there is no local identity at all). The
3029
+ // directory now lists agent keys too, so "own fingerprint in the
3030
+ // directory" is the enrolled case; an unenrolled creator's genesis
3031
+ // envelope still shows as covered-but-unenrolled.
3032
+ let publishState = "unknown";
3033
+ if (ownFingerprint !== null) {
3034
+ try {
3035
+ const who = await this.#client.request("/agent/v1/whoami", { context: "reading this keystore's enrollment state" });
3036
+ const key = who.encryption_key ?? null;
3037
+ publishState = !key ? "absent" : key.ek_fingerprint !== ownFingerprint ? "rotation_required" : key.state === "active" ? "active" : "pending";
3038
+ }
3039
+ catch {
3040
+ publishState = "unknown";
3041
+ }
3042
+ }
3043
+ const thisKeystore = ownFingerprint !== null
3044
+ ? {
3045
+ fingerprint: ownFingerprint,
3046
+ enrolled: publishState === "active",
3047
+ publish_state: publishState,
3048
+ covered_on_this_vault: covered.has(ownFingerprint),
3049
+ covered: covered.has(ownFingerprint),
3050
+ ...(!covered.has(ownFingerprint) && publishState === "active"
3051
+ ? { next_actions: [
3052
+ { action: "run402 repos access", why: "poll — covered_on_this_vault flips true once a key-holder has wrapped this vault to your key" },
3053
+ { action: "ask a key-holder to run any gitvault operation (run402 repos view / git push)", why: "a key-holding client wraps every pending desired recipient on its next operation" },
3054
+ ] }
3055
+ : {}),
3056
+ ...(publishState === "rotation_required"
3057
+ ? { next_actions: [{ action: "restore the keystore backup, or have an org owner revoke the stale key (DELETE /orgs/v1/:org_id/members/:principal_id/encryption-key)", why: "this keystore's key differs from the principal's published key; rotation is never automatic" }] }
3058
+ : {}),
3059
+ }
3060
+ : null;
2427
3061
  const unmatched = thisKeystore ? unmatchedRaw.filter((fp) => fp !== thisKeystore.fingerprint) : unmatchedRaw;
2428
3062
  const gap = envelopeStateAvailable
2429
3063
  ? "history_scope (which epochs each recipient can read) is not available: this read has no per-epoch view, only the vault's " +
@@ -2442,6 +3076,8 @@ export class Gitvault {
2442
3076
  return {
2443
3077
  repo_id: repoId,
2444
3078
  org_id: orgId,
3079
+ reconcile_recipients: reconcileRecipients,
3080
+ enrollment,
2445
3081
  recipients,
2446
3082
  unmatched_covered_fingerprints: unmatched,
2447
3083
  this_keystore: thisKeystore,
@@ -2495,14 +3131,15 @@ export class Gitvault {
2495
3131
  // function so the outcome-gating is unit-testable with fake thunks,
2496
3132
  // without standing up a live vault — see gitvault-deploy-hooks.test.ts.
2497
3133
  const hooked = await attachGitvaultDeployHooks(result, () => this.#tryMirrorPush(handle.repo_id, handle.keystore), () => this.#tryReconcileEnvelopeRecipients(handle.vault));
2498
- // gitvault-byo-primary-bucket task 3.3 composed AFTER, not inside,
2499
- // `attachGitvaultDeployHooks` (kept untouched so its own unit-tested
2500
- // outcome-gating contract stays byte-for-byte): the SAME "did this
2501
- // deploy actually land a generation" gate `mirror_push`'s presence
2502
- // already encodes, so no new outcome-inspection logic here.
3134
+ // gitvault-byo-primary-bucket task 3.3 / gitvault-multi-writer task 5.7
3135
+ // — composed AFTER, not inside, `attachGitvaultDeployHooks` (kept
3136
+ // untouched so its own unit-tested outcome-gating contract stays
3137
+ // byte-for-byte): the SAME "did this deploy actually land a generation"
3138
+ // gate `mirror_push`'s presence already encodes, so no new
3139
+ // outcome-inspection logic here.
2503
3140
  if (hooked.mirror_push === undefined)
2504
3141
  return hooked;
2505
- return { ...hooked, byo_chain_copy: await this.#tryByoChainCopyPush(handle.repo_id, handle.keystore) };
3142
+ return { ...hooked, byo_chain_copy: await this.#tryByoChainCopyPush(handle.repo_id, handle.keystore), writer_reconcile: await this.#tryReconcileWriterAdmissions(handle.vault) };
2506
3143
  }
2507
3144
  /**
2508
3145
  * Drain every unvaulted-override journal on this machine: push the exact
@@ -2748,6 +3385,9 @@ export class Gitvault {
2748
3385
  #handoff() {
2749
3386
  return nodeOnly(() => import("../node/gitvault-handoff.js"), "handoff");
2750
3387
  }
3388
+ #writerState() {
3389
+ return nodeOnly(() => import("../node/gitvault-writer-state.js"), "handoff");
3390
+ }
2751
3391
  #restore() {
2752
3392
  return nodeOnly(() => import("../node/gitvault-restore.js"), "resume");
2753
3393
  }