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
@@ -39,10 +39,11 @@ import { join } from "node:path";
39
39
  import { LocalError, isRun402Error } from "../errors.js";
40
40
  import { fetchGitvaultObjectBytes } from "./gitvault-edge-fetch.js";
41
41
  import { openGitvaultDestinationBackend } from "./gitvault-mirror-backend.js";
42
- import { GITVAULT_FORMAT, GITVAULT_GENESIS_EPOCH, GITVAULT_GENESIS_GENERATION, GITVAULT_HEX16_RE, GITVAULT_OID40_RE, GITVAULT_SUITE, attemptKeyCommitment, bytesToHex, checkFreshEpochKeyAgainstPriorKeys, checkHPartition, computeRotationId, computeTargetPartitionDigest, deriveDigestKey, deriveObjectKey, ekFingerprint, epochRotationKeyCommitment, formatGitvaultTimestamp, fromBase64url, hexToBytes, jcs, keyEnvelopeLedgerId, keyedCommitment, newGitvaultId, newHex32, nextEpoch, objectsetContent, openBindingPreimage, openEpochRotationForRecipient, openFrame, parseGitvaultStrict, parseRotateEpochPayload, pinManifestLedgerId, randomBytes, sealFrame, sealKeyEnvelope, sha256Hex, signGitvaultObject, storedBytes, storedBytesSha256, toBase64url, verifyGitvaultObject, } from "../namespaces/gitvault.crypto.js";
42
+ import { GITVAULT_FORMAT, GITVAULT_GENESIS_EPOCH, GITVAULT_GENESIS_GENERATION, GITVAULT_HEX16_RE, GITVAULT_OID40_RE, GITVAULT_SUITE, attemptKeyCommitment, bytesToHex, checkFreshEpochKeyAgainstPriorKeys, checkHPartition, computeRotationId, computeTargetPartitionDigest, deriveDigestKey, deriveObjectKey, ekFingerprint, vkFingerprint, GITVAULT_TERMINAL_LOSS_DOCTOR_TEXT, GITVAULT_TERMINAL_LOSS_STATEMENT, epochRotationKeyCommitment, formatGitvaultTimestamp, fromBase64url, hexToBytes, jcs, keyEnvelopeLedgerId, keyedCommitment, newGitvaultId, newHex32, nextEpoch, objectsetContent, openBindingPreimage, openEpochRotationForRecipient, openFrame, parseAddWriterKeyPayload, parseGitvaultStrict, parseRotateEpochPayload, pinManifestLedgerId, randomBytes, sealFrame, sealKeyEnvelope, sha256Hex, signGitvaultObject, storedBytes, storedBytesSha256, toBase64url, verifyGitvaultObject, } from "../namespaces/gitvault.crypto.js";
43
43
  import { GITVAULT_ZERO_SHA256_SENTINEL } from "../namespaces/gitvault.types.js";
44
44
  import { gitvaultCheckpointStaleness } from "../namespaces/gitvault.js";
45
45
  import { crossProfileGitvaultHint } from "./gitvault-profile-scan.js";
46
+ import { applyAddWriterKey, applyWriterSetUpdate, buildAddWriterKeyActivationPayload, buildWriterDoorAddWriterKeyPayload, initialWriterState, resolveActiveWriter, validateAddWriterKeyPayload, validateWriterSetUpdate, writerKeyIdOf, } from "./gitvault-writer-state.js";
46
47
  import { GITVAULT_DEPLOY_REF, hardenedGit, hasObject, hasObjects, isAncestor } from "./gitvault-snapshot.js";
47
48
  // ─── Constants (constants.json) ──────────────────────────────────────────────
48
49
  export const GITVAULT_MAX_CANONICAL_REFS = 10_000;
@@ -533,14 +534,20 @@ export function checkChainLink(input) {
533
534
  * rotation is ACTIVATED") — `checkChainLink`'s own D194 epoch-continuity
534
535
  * check already validates its structural admissibility, and
535
536
  * {@link parseRotateEpochPayload} / the caller's own envelope-open step
536
- * (`GitvaultVault.verifyToNewest`) handle it fully. The other three kinds
537
- * (`add_envelope`, `add_writer_key`, `transfer_binding`) remain genuinely
538
- * unactivated and stay fail-closed exactly as before.
537
+ * (`GitvaultVault.verifyToNewest`) handle it fully. `add_writer_key` is
538
+ * EXEMPT as of rev 47 (gitvault-multi-writer) the same way — {@link
539
+ * parseAddWriterKeyPayload} + `validateAddWriterKeyPayload`
540
+ * (`gitvault-writer-state.js`) handle it fully, INSIDE `verifyToNewest`'s
541
+ * own loop, BEFORE this function ever runs on that head (so a transition
542
+ * that fails writer validation never reaches here at all — this function
543
+ * only ever sees ones that already passed). The other two kinds
544
+ * (`add_envelope`, `transfer_binding`) remain genuinely unactivated and stay
545
+ * fail-closed exactly as before.
539
546
  */
540
547
  export function assertNoTransition(head) {
541
548
  if (head.transition === null)
542
549
  return;
543
- if (head.transition.kind === "rotate_epoch")
550
+ if (head.transition.kind === "rotate_epoch" || head.transition.kind === "add_writer_key")
544
551
  return;
545
552
  const kinds = ["add_envelope", "rotate_epoch", "add_writer_key", "transfer_binding"];
546
553
  if (!kinds.includes(head.transition.kind))
@@ -791,12 +798,9 @@ export function createGitvaultHttpTransport(client, options = {}) {
791
798
  if (isRun402Error(e) && e.code === "RESOURCE_NOT_FOUND")
792
799
  return null;
793
800
  // The gateway accepts `{object_kind: "recipient_pin_manifest",
794
- // pin_manifest_version}` reads (fixed 2026-08-28; before that its
795
- // null-idScalar validation was hardcoded to key_envelope's
796
- // `{epoch, recipient_fingerprint}` shape and 400'd every such read
797
- // with "epoch must be 16 hex"). A VALIDATION_FAILED here therefore
801
+ // pin_manifest_version}` reads. A VALIDATION_FAILED here therefore
798
802
  // indicates a genuinely malformed request and propagates as-is —
799
- // EXCEPT the pre-fix epoch-shape complaint, which a fixed gateway can
803
+ // EXCEPT the epoch-shape complaint, which a current gateway can
800
804
  // never emit for this kind: that signature can only mean an unfixed
801
805
  // (older/staging) gateway behind RUN402_API_BASE, so name it rather
802
806
  // than letting it read as a client validation bug.
@@ -1339,7 +1343,7 @@ export function createGitvaultHttpTransport(client, options = {}) {
1339
1343
  parsed = null;
1340
1344
  }
1341
1345
  if (!r.ok) {
1342
- // kychee-com/run402#578 fix 1: the gateway's error envelope is FLAT
1346
+ // The gateway's error envelope is FLAT
1343
1347
  // (docs/style.md §Errors — `buildErrorEnvelope` in the gateway) —
1344
1348
  // `code`, `message`, `details`, `trace_id`, and `next_actions` all
1345
1349
  // ride at the TOP level, never nested under an `error` object (that
@@ -1593,6 +1597,7 @@ export class GitvaultVault {
1593
1597
  stateRestorePlan = null;
1594
1598
  retries;
1595
1599
  servicePublicKey;
1600
+ allowUnauthenticatedSalvage;
1596
1601
  genesisCache = null;
1597
1602
  /**
1598
1603
  * gitvault-byo-primary-bucket task 3.2 — this vault's resolved BYO write
@@ -1613,6 +1618,7 @@ export class GitvaultVault {
1613
1618
  this.budget = options.verification_budget ?? GITVAULT_VERIFICATION_BUDGET_HEADS;
1614
1619
  this.retries = options.conflict_retries ?? GITVAULT_PUSH_CONFLICT_RETRIES;
1615
1620
  this.servicePublicKey = options.service_public_key ?? null;
1621
+ this.allowUnauthenticatedSalvage = options.allow_unauthenticated_salvage ?? false;
1616
1622
  }
1617
1623
  static open(options) {
1618
1624
  const v = new GitvaultVault(options);
@@ -1620,7 +1626,7 @@ export class GitvaultVault {
1620
1626
  return v;
1621
1627
  }
1622
1628
  repoFile() {
1623
- // Cross-profile hint (kychee-com/run402#564): a keystore-miss on the
1629
+ // Cross-profile hint: a keystore-miss on the
1624
1630
  // ACTIVE profile is enriched, when the scan finds one, with which OTHER
1625
1631
  // local wallet profile holds this repo's key — a purely local
1626
1632
  // directory/filename read, appended after the existing remedy rather
@@ -1638,6 +1644,137 @@ export class GitvaultVault {
1638
1644
  }
1639
1645
  return repo;
1640
1646
  }
1647
+ /**
1648
+ * gitvault-agent-envelopes D4 — the COLD OPEN. A keystore that holds an
1649
+ * identity but no repo file for this vault (a member joining from a fresh
1650
+ * machine, or a creator whose repo file was lost) restores `K_repo` from
1651
+ * its OWN `key_envelope` instead of dying `GITVAULT_REPO_STATE_MISSING`:
1652
+ *
1653
+ * 1. genesis (the writer-key source) is fetched and signature-verified;
1654
+ * 2. its creator fingerprints are compared against the control plane's
1655
+ * SIGNED allocation record — `platform_attested`, never `receipt`
1656
+ * (the platform serves both sides of that comparison; a substituted
1657
+ * genesis needs a substituted allocation, which the org's owners can
1658
+ * see — TOFU + audit, human-envelopes D4's tier);
1659
+ * 3. the envelope-recipients read says whether THIS fingerprint is
1660
+ * covered — if not, `GITVAULT_ENVELOPE_PENDING` names the key-holders
1661
+ * who can fulfil and the exact next actions (never a terminal error:
1662
+ * the desired state already records this member; any key-holder's
1663
+ * next gitvault operation wraps);
1664
+ * 4. the base envelope is fetched, opened, and the repo file written
1665
+ * `restored_from_envelope` with the genesis hash PINNED (a later open
1666
+ * seeing a different genesis for this repo_id refuses
1667
+ * `VAULT_CREATION_CONFLICT`).
1668
+ *
1669
+ * Rotation epochs are NOT opened here — `verifyToNewest` walks them and
1670
+ * opens each rotation-scoped envelope this identity is included in, exactly
1671
+ * as it does for every other reader (`openEpochRotationForRecipient`).
1672
+ *
1673
+ * Returns `null` when the repo file already existed (nothing restored).
1674
+ */
1675
+ async ensureRepoState() {
1676
+ if (this.keystore.readRepo(this.repoId))
1677
+ return null;
1678
+ const identity = this.keystore.readIdentity();
1679
+ if (!identity) {
1680
+ fail("KEYSTORE_MISSING", "no gitvault identity in the keystore", "opening gitvault vault", undefined, [
1681
+ { action: "restore ~/.config/run402/gitvault from backup or accept vault loss" },
1682
+ ...crossProfileGitvaultHint(this.repoId),
1683
+ ]);
1684
+ }
1685
+ const ownKeypair = this.keystore.encryptionKeypair(identity);
1686
+ if (!ownKeypair) {
1687
+ fail("VAULT_UNRECOVERABLE", GITVAULT_TERMINAL_LOSS_DOCTOR_TEXT, "opening gitvault vault", { statement: GITVAULT_TERMINAL_LOSS_STATEMENT, repo_id: this.repoId });
1688
+ }
1689
+ const ownFingerprint = identity.encryption_fingerprint;
1690
+ const genesisBytes = await this.transport.getGenesis({ repo_id: this.repoId });
1691
+ if (!genesisBytes)
1692
+ fail("CHAIN_BROKEN", "the vault has no admitted genesis", "opening gitvault vault", { repo_id: this.repoId });
1693
+ const genesis = parseGitvaultStrict(new TextDecoder().decode(genesisBytes));
1694
+ if (genesis.epoch !== GITVAULT_GENESIS_EPOCH) {
1695
+ fail("VAULT_CREATION_CONFLICT", `the served genesis declares epoch ${genesis.epoch}, not the genesis epoch`, "opening gitvault vault", { repo_id: this.repoId, epoch: genesis.epoch });
1696
+ }
1697
+ if (genesis.repo_id !== this.repoId) {
1698
+ fail("VAULT_CREATION_CONFLICT", `the served genesis names repo ${genesis.repo_id}, not ${this.repoId}`, "opening gitvault vault", { repo_id: this.repoId, served: genesis.repo_id });
1699
+ }
1700
+ // Platform-attested creator anchor. The allocation is optional on the
1701
+ // wire (older gateways) — absent means the restore is unauthenticated
1702
+ // salvage, and the result says so.
1703
+ let allocationAttested = false;
1704
+ const record = await this.transport.getVaultRecord({ repo_id: this.repoId });
1705
+ const allocation = record.allocation ?? null;
1706
+ if (!allocation && !this.allowUnauthenticatedSalvage) {
1707
+ // Consult round 2 §5: an absent allocation on an ordinary open is not a
1708
+ // downgrade to salvage — it is a refusal. A malicious or broken serving
1709
+ // path that cannot forge the SIGNED allocation must not get a vault
1710
+ // opened by simply omitting it.
1711
+ fail("GITVAULT_ALLOCATION_UNATTESTED", "the control plane returned no signed allocation record for this vault — refusing an unattested cold restore (recovery flows may opt into explicit unauthenticated salvage)", "opening gitvault vault", { repo_id: this.repoId });
1712
+ }
1713
+ if (allocation) {
1714
+ const genesisEk = ekFingerprint(fromBase64url(genesis.creator_encryption_pubkey, "genesis.creator_encryption_pubkey"));
1715
+ const genesisVk = vkFingerprint(fromBase64url(genesis.creator_signing_pubkey, "genesis.creator_signing_pubkey"));
1716
+ if (genesisEk !== allocation.creator_encryption_fingerprint || genesisVk !== allocation.creator_signing_fingerprint) {
1717
+ fail("VAULT_CREATION_CONFLICT", "the served genesis's creator keys do not match the control plane's signed allocation record for this vault — refusing a substituted genesis", "opening gitvault vault", { repo_id: this.repoId, genesis_creator_encryption_fingerprint: genesisEk, allocation_creator_encryption_fingerprint: allocation.creator_encryption_fingerprint, genesis_creator_signing_fingerprint: genesisVk, allocation_creator_signing_fingerprint: allocation.creator_signing_fingerprint });
1718
+ }
1719
+ allocationAttested = true;
1720
+ }
1721
+ const coverage = await this.transport.listEnvelopeRecipients({ repo_id: this.repoId });
1722
+ if (!coverage.recipient_fingerprints.includes(ownFingerprint)) {
1723
+ // Who can fulfil: every current directory recipient that IS covered
1724
+ // (they hold K_repo), named by principal id — best-effort, the read may
1725
+ // 403 for a caller whose membership is still propagating.
1726
+ let keyHolders = [];
1727
+ try {
1728
+ const directory = await this.transport.listOrgEncryptionKeys({ org_id: genesis.org_id });
1729
+ keyHolders = directory.keys
1730
+ .filter((k) => coverage.recipient_fingerprints.includes(k.ek_fingerprint))
1731
+ .map((k) => ({ principal_id: k.principal_id, display_name: k.display_name ?? null, ek_fingerprint: k.ek_fingerprint }));
1732
+ }
1733
+ catch {
1734
+ // best-effort — the refusal is complete without the roster
1735
+ }
1736
+ const desired = coverage.desired?.find((d) => d.ek_fingerprint === ownFingerprint) ?? null;
1737
+ fail("GITVAULT_ENVELOPE_PENDING", `this keystore (fingerprint ${ownFingerprint}) is not yet a recipient on vault ${this.repoId} — no key_envelope has been wrapped for it; a key-holder's next gitvault operation fulfils the org's desired state`, "opening gitvault vault", {
1738
+ repo_id: this.repoId,
1739
+ org_id: genesis.org_id,
1740
+ own_fingerprint: ownFingerprint,
1741
+ desired_state: desired ? desired.status : (coverage.desired ? "not_desired" : "unknown"),
1742
+ key_holders: keyHolders,
1743
+ covering_recipient_count: coverage.recipient_fingerprints.length,
1744
+ }, [
1745
+ { action: "run402 repos access", why: "poll — this_keystore.covered_on_this_vault flips true once a key-holder has wrapped this vault to your key" },
1746
+ { 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; nothing else is required of them" },
1747
+ ]);
1748
+ }
1749
+ const envelopeBytes = await this.transport.getObject({ repo_id: this.repoId, path: gitvaultPaths.envelope(genesis.epoch, ownFingerprint) });
1750
+ if (!envelopeBytes) {
1751
+ fail("GITVAULT_EPOCH_NOT_OPENABLE", `the envelope-recipients read lists this keystore (fingerprint ${ownFingerprint}) as covered, but its base key_envelope could not be retrieved — a server-side or network problem, not a problem with your key; retry`, "opening gitvault vault", { repo_id: this.repoId, epoch: genesis.epoch, recipient_fingerprint: ownFingerprint, reason: "envelope_fetch_failed" });
1752
+ }
1753
+ const envelope = parseGitvaultStrict(new TextDecoder().decode(envelopeBytes));
1754
+ if (envelope.repo_id !== this.repoId || envelope.epoch !== genesis.epoch || envelope.recipient_fingerprint !== ownFingerprint) {
1755
+ fail("GITVAULT_ENVELOPE_ALTERED", "the retrieved base key_envelope does not address this vault, epoch, and recipient — refusing to open it", "opening gitvault vault", { repo_id: this.repoId, epoch: genesis.epoch, recipient_fingerprint: ownFingerprint, envelope: { repo_id: envelope.repo_id, epoch: envelope.epoch, recipient_fingerprint: envelope.recipient_fingerprint } });
1756
+ }
1757
+ const restored = await this.keystore.restoreRepoFromEnvelope({ genesis, envelope, allocation_attested: allocationAttested });
1758
+ this.keystore.writeCachedGenesis(this.repoId, restored.repo.genesis_sha256, genesisBytes);
1759
+ this.genesisCache = { genesis, sha256: restored.repo.genesis_sha256 };
1760
+ return {
1761
+ repo_id: this.repoId,
1762
+ org_id: genesis.org_id,
1763
+ provenance: "restored_from_envelope",
1764
+ trust: restored.trust,
1765
+ continuity: restored.continuity,
1766
+ independently_verified: restored.independently_verified,
1767
+ epoch: envelope.epoch,
1768
+ recipient_fingerprint: ownFingerprint,
1769
+ };
1770
+ }
1771
+ /** {@link open}, but a missing repo file triggers {@link ensureRepoState} first. */
1772
+ static async openOrRestore(options) {
1773
+ const v = new GitvaultVault(options);
1774
+ const restored = await v.ensureRepoState();
1775
+ v.repoFile();
1776
+ return { vault: v, restored };
1777
+ }
1641
1778
  kRepo() { return hexToBytes(this.repoFile().k_repo_hex); }
1642
1779
  epoch() { return this.repoFile().epoch; }
1643
1780
  /**
@@ -1946,10 +2083,44 @@ export class GitvaultVault {
1946
2083
  const decryptValidate = options.decryptValidate ?? false;
1947
2084
  const strict = options.strict ?? true;
1948
2085
  const { genesis, sha256: genesisSha } = await this.genesis();
2086
+ // gitvault-multi-writer rev 47: `writerKey`/`writerKeyId` (kept for the
2087
+ // rotation-envelope open below, which is signed by whoever DROVE that
2088
+ // specific rotation — resolved per-rotation from `writerState`, not this
2089
+ // fallback) name just the genesis creator, retained as the seed for
2090
+ // `writerState` and as the fallback signer for methods that do not
2091
+ // resolve a per-rotation writer.
1949
2092
  const writerKey = genesis.creator_signing_pubkey;
1950
2093
  const writerKeyId = genesis.writer_key_id;
1951
2094
  const repo = this.repoFile();
1952
2095
  let pin = repo.verified_prefix ?? repo.head_pin ?? { generation: GITVAULT_GENESIS_GENERATION, head_sha256: genesisSha, pinned_at: formatGitvaultTimestamp(this.now()) };
2096
+ // gitvault-multi-writer rev 47 (task 5.1): the writer set as chain state.
2097
+ // Trusted as the starting point at `pin`'s generation exactly the same
2098
+ // way `pin` itself is trusted as the starting point for the head chain —
2099
+ // `writer_set_pin` advances in LOCKSTEP with `head_pin`/`verified_prefix`
2100
+ // (see `GitvaultRepoFile.writer_set_pin`'s own doc comment), so a repeat
2101
+ // call never re-walks the writer set from genesis. `initialWriterState`
2102
+ // covers the true first-ever call (no persisted pin yet).
2103
+ //
2104
+ // `burnedWriterKeyIds`/`consumedHandoffIds` are NOT persisted and reset
2105
+ // to empty on every resume — deliberately, not an oversight. This is safe
2106
+ // for THIS function's own job (verifying an ALREADY-ADMITTED chain):
2107
+ // the gateway is the sole admission authority, so a burned key or a
2108
+ // consumed handoff_id can never appear in a real `add_writer_key`
2109
+ // transition on the actual chain — the gateway already refused it before
2110
+ // it could be admitted. These two sets only matter as a CLIENT-SIDE
2111
+ // pre-check before PROPOSING a new transition (tasks 5.4/5.5), where an
2112
+ // incomplete local set costs at most a wasted round trip — the gateway's
2113
+ // own fence-time re-check (which DOES hold the complete history) is the
2114
+ // real backstop either way. If a future caller ever needs these complete
2115
+ // across a resume, persist them on `writer_set_pin` too; not needed yet.
2116
+ let writerState = repo.writer_set_pin
2117
+ ? { version: repo.writer_set_pin.version, writers: repo.writer_set_pin.writers, sha256: repo.writer_set_pin.sha256, burnedWriterKeyIds: new Set(), consumedHandoffIds: new Set() }
2118
+ : initialWriterState(this.repoId, genesis);
2119
+ const persistWriterSetPin = () => {
2120
+ if (!persist)
2121
+ return;
2122
+ this.keystore.updateRepo(this.repoId, { writer_set_pin: { version: writerState.version, sha256: writerState.sha256, writers: [...writerState.writers], pinned_at: formatGitvaultTimestamp(this.now()) } });
2123
+ };
1953
2124
  // gitvault-composite-state-read design D1: try the pin-current fast path
1954
2125
  // FIRST — see {@link tryStateFastPath}'s own doc comment. `null` means
1955
2126
  // ineligible (genuinely more than one generation behind, or the pin's
@@ -2033,7 +2204,11 @@ export class GitvaultVault {
2033
2204
  payload: rot.payload,
2034
2205
  own_fingerprint: ownFingerprint,
2035
2206
  own_encryption_keypair: ownKeypair,
2036
- writer_signing_public_key: writerKey,
2207
+ // gitvault-multi-writer rev 47: the writer who actually drove
2208
+ // THIS rotation (resolved by the forward walk when it collected
2209
+ // `rot` — see `GitvaultEncounteredRotation.signing_pubkey`'s own
2210
+ // doc comment for the one narrower gap this falls back for).
2211
+ writer_signing_public_key: rot.signing_pubkey ?? writerKey,
2037
2212
  get_envelope_bytes: (path) => this.transport.getObject({ repo_id: this.repoId, path }),
2038
2213
  envelope_path: (epoch, fp, rotationId) => gitvaultPaths.envelope(epoch, fp, rotationId),
2039
2214
  });
@@ -2050,7 +2225,12 @@ export class GitvaultVault {
2050
2225
  // Design D2: ref_state + retention_roots both ride `head`, so one
2051
2226
  // batched presign (or a cache hit) serves both instead of two
2052
2227
  // independent presign-then-GET round trips.
2053
- const { refState, roots } = await this.openMaterializeCarriers(head.ref_state, gitvaultPaths.refState(head.ref_state.object_id), head.retention_roots, gitvaultPaths.retentionRoots(head.retention_roots.object_id), writerKey, { epoch: head.epoch, k_repo: kRepo });
2228
+ // gitvault-multi-writer rev 47: `head`'s OWN signer, resolved from
2229
+ // the writer state — by the time this runs, `writerState` already
2230
+ // reflects `head`'s own transition (if any), so this correctly finds
2231
+ // a just-self-admitted handoff writer too, not only a pre-existing one.
2232
+ const headSigner = resolveActiveWriter(writerState, head.writer_key_id)?.signing_pubkey ?? writerKey;
2233
+ const { refState, roots } = await this.openMaterializeCarriers(head.ref_state, gitvaultPaths.refState(head.ref_state.object_id), head.retention_roots, gitvaultPaths.retentionRoots(head.retention_roots.object_id), headSigner, { epoch: head.epoch, k_repo: kRepo });
2054
2234
  if (refState.generation !== head.generation || roots.generation !== head.generation)
2055
2235
  fail("CHAIN_UNUSABLE", "carrier generation does not match the head", "materializing gitvault head");
2056
2236
  decryptedRefState = refState;
@@ -2157,6 +2337,7 @@ export class GitvaultVault {
2157
2337
  if (verified >= this.budget) {
2158
2338
  if (persist)
2159
2339
  this.keystore.updateRepo(this.repoId, { verified_prefix: pin });
2340
+ persistWriterSetPin();
2160
2341
  persistMaterializedIfAny();
2161
2342
  fail("VERIFICATION_BUDGET_EXCEEDED", persist
2162
2343
  ? `${verified} heads verified this call; the verified prefix (generation ${pin.generation}) is persisted — call again to continue`
@@ -2166,7 +2347,53 @@ export class GitvaultVault {
2166
2347
  if (!bytes)
2167
2348
  fail("CHAIN_BROKEN", `listed head ${entry.generation} is absent from storage`, "verifying gitvault chain", { generation: entry.generation });
2168
2349
  const head = parseGitvaultStrict(new TextDecoder().decode(bytes));
2169
- checkChainLink({ head, stored_bytes: bytes, listed_sha256: entry.stored_bytes_sha256, expected_generation: nextGeneration(pin.generation), prev_sha256: pin.head_sha256, repo_id: this.repoId, writer_public_key: writerKey, writer_key_id: writerKeyId, prev_epoch: prevEpoch });
2350
+ // gitvault-multi-writer rev 47 (task 5.1): resolve WHO must have
2351
+ // signed this specific head from the writer state as of the
2352
+ // PREDECESSOR generation (`writerState`, not yet advanced past this
2353
+ // head) — never the single fixed genesis key. A head carrying an
2354
+ // admitted `add_writer_key` transition is the one case the signer
2355
+ // might NOT already be in `writerState`: the "handoff" door is
2356
+ // self-signed by the key being added, which is only proven legitimate
2357
+ // by `validateAddWriterKeyPayload` succeeding on THIS SAME payload —
2358
+ // so that validation runs BEFORE signature verification, not after,
2359
+ // and its own success is what licenses trusting `added_writer.signing_pubkey`
2360
+ // as the correct verification key.
2361
+ const failClosedTransition = (code, detail) => {
2362
+ if (persist)
2363
+ this.keystore.updateRepo(this.repoId, { head_pin: pin, verified_prefix: null });
2364
+ persistWriterSetPin();
2365
+ persistMaterializedIfAny();
2366
+ fail(code, `head ${head.generation}: ${detail}`, "verifying gitvault chain", { generation: head.generation });
2367
+ };
2368
+ const isWriterAdd = head.transition !== null && head.transition.kind === "add_writer_key";
2369
+ let addWriterKeyPayload = null;
2370
+ let resolvedWriterPubkey = writerKey;
2371
+ let resolvedWriterKeyId = writerKeyId;
2372
+ if (isWriterAdd) {
2373
+ addWriterKeyPayload = parseAddWriterKeyPayload(head); // throws CHAIN_BROKEN on any wire-shape defect
2374
+ const v = validateAddWriterKeyPayload(this.repoId, writerState, addWriterKeyPayload, head.writer_key_id);
2375
+ if (!v.ok)
2376
+ failClosedTransition(v.code, v.detail);
2377
+ if (addWriterKeyPayload.authorization.kind === "writer") {
2378
+ // The head's own signer is an EXISTING writer — same resolution as the ordinary case below.
2379
+ const signer = resolveActiveWriter(writerState, head.writer_key_id); // non-null: v.ok already proved it
2380
+ resolvedWriterPubkey = signer.signing_pubkey;
2381
+ resolvedWriterKeyId = signer.writer_key_id;
2382
+ }
2383
+ else {
2384
+ // "handoff" door: self-signed by the key being admitted — validated above, not yet in writerState.
2385
+ resolvedWriterPubkey = addWriterKeyPayload.added_writer.signing_pubkey;
2386
+ resolvedWriterKeyId = addWriterKeyPayload.added_writer.writer_key_id;
2387
+ }
2388
+ }
2389
+ else {
2390
+ const signer = resolveActiveWriter(writerState, head.writer_key_id);
2391
+ if (!signer)
2392
+ failClosedTransition("GITVAULT_WRITER_NOT_ADMITTED", `writer_key_id ${head.writer_key_id} is not a currently admitted writer`);
2393
+ resolvedWriterPubkey = signer.signing_pubkey;
2394
+ resolvedWriterKeyId = signer.writer_key_id;
2395
+ }
2396
+ checkChainLink({ head, stored_bytes: bytes, listed_sha256: entry.stored_bytes_sha256, expected_generation: nextGeneration(pin.generation), prev_sha256: pin.head_sha256, repo_id: this.repoId, writer_public_key: resolvedWriterPubkey, writer_key_id: resolvedWriterKeyId, prev_epoch: prevEpoch });
2170
2397
  try {
2171
2398
  assertNoTransition(head);
2172
2399
  }
@@ -2174,6 +2401,7 @@ export class GitvaultVault {
2174
2401
  // fail closed: pin stays BELOW the transition head; the verified prefix is cleared (this is the final state, not a budget pause)
2175
2402
  if (persist)
2176
2403
  this.keystore.updateRepo(this.repoId, { head_pin: pin, verified_prefix: null });
2404
+ persistWriterSetPin();
2177
2405
  persistMaterializedIfAny();
2178
2406
  throw e;
2179
2407
  }
@@ -2186,7 +2414,33 @@ export class GitvaultVault {
2186
2414
  // later `materialize()` call over an already-chain-verified prefix
2187
2415
  // can still resolve the epoch keys it needs.
2188
2416
  rotationPayload = parseRotateEpochPayload(head);
2189
- rotations.push({ generation: head.generation, epoch: head.epoch, payload: rotationPayload });
2417
+ rotations.push({ generation: head.generation, epoch: head.epoch, payload: rotationPayload, signing_pubkey: resolvedWriterPubkey });
2418
+ // gitvault-multi-writer rev 47 (D227): writer REMOVAL rides
2419
+ // rotate_epoch as an additive `writer_set_update` field. The
2420
+ // "blocked set" gate inside `validateWriterSetUpdate` is a
2421
+ // gateway-only, live-state fact this reader cannot independently
2422
+ // re-derive (the module's own header doc names this boundary) —
2423
+ // trusting the update's OWN declared `removed[]` as the "blocked
2424
+ // set" input makes that ONE check a structural no-op while every
2425
+ // OTHER invariant (base/next commitments, the writers-array
2426
+ // arithmetic, the signer-survives-or-explicit-terminal rule) is
2427
+ // still independently re-verified in full. Honest: this reader
2428
+ // trusts the GATEWAY already answered "were these the right keys
2429
+ // to remove" at admission time, same as it already trusts every
2430
+ // other admission decision it did not itself make.
2431
+ if (rotationPayload.writer_set_update) {
2432
+ const wsu = rotationPayload.writer_set_update;
2433
+ const declaredBlocked = new Set(wsu.removed.map((r) => r.writer_key_id));
2434
+ const allowEmpty = wsu.writers.length === 0;
2435
+ const wv = validateWriterSetUpdate(this.repoId, writerState, wsu, head.writer_key_id, declaredBlocked, allowEmpty);
2436
+ if (!wv.ok)
2437
+ failClosedTransition(wv.code, wv.detail);
2438
+ writerState = applyWriterSetUpdate(this.repoId, writerState, wsu.removed.map((r) => r.writer_key_id));
2439
+ }
2440
+ }
2441
+ if (isWriterAdd && addWriterKeyPayload) {
2442
+ const consumedHandoffId = addWriterKeyPayload.authorization.kind === "handoff" ? addWriterKeyPayload.authorization.grant.handoff_id ?? null : null;
2443
+ writerState = applyAddWriterKey(this.repoId, writerState, { writer_key_id: addWriterKeyPayload.added_writer.writer_key_id, signing_pubkey: addWriterKeyPayload.added_writer.signing_pubkey }, consumedHandoffId);
2190
2444
  }
2191
2445
  prevEpoch = head.epoch;
2192
2446
  pin = { generation: head.generation, head_sha256: entry.stored_bytes_sha256, pinned_at: formatGitvaultTimestamp(this.now()) };
@@ -2205,6 +2459,7 @@ export class GitvaultVault {
2205
2459
  // verified prefix persists per page (resumable) — skipped entirely in no-write mode
2206
2460
  if (persist)
2207
2461
  this.keystore.updateRepo(this.repoId, { verified_prefix: pin });
2462
+ persistWriterSetPin();
2208
2463
  persistMaterializedIfAny();
2209
2464
  const next = nextListingRequest(request, page);
2210
2465
  if (!next)
@@ -2240,9 +2495,16 @@ export class GitvaultVault {
2240
2495
  while (cur && !epochKeys[cur.epoch]) {
2241
2496
  if (cur.transition !== null && cur.transition.kind === "rotate_epoch") {
2242
2497
  const payload = parseRotateEpochPayload(cur);
2243
- backwardRotations.unshift({ generation: cur.generation, epoch: cur.epoch, payload });
2498
+ // gitvault-multi-writer rev 47: best-effort resolution against the
2499
+ // FINAL (post-forward-walk) writer state — correct unless `cur`'s
2500
+ // own signer was removed by a LATER rotation between `cur` and
2501
+ // `lastHead`, the one documented residual gap (falls back to the
2502
+ // genesis-creator key at the `openEpochRotationForRecipient`
2503
+ // call site when this resolves to `undefined`).
2504
+ const curSigner = resolveActiveWriter(writerState, cur.writer_key_id)?.signing_pubkey;
2505
+ backwardRotations.unshift({ generation: cur.generation, epoch: cur.epoch, payload, signing_pubkey: curSigner });
2244
2506
  if (!rotations.some((r) => r.generation === cur.generation))
2245
- rotations.push({ generation: cur.generation, epoch: cur.epoch, payload });
2507
+ rotations.push({ generation: cur.generation, epoch: cur.epoch, payload, signing_pubkey: curSigner });
2246
2508
  }
2247
2509
  if (cur.generation === "0000000000000001") {
2248
2510
  cur = null;
@@ -2277,6 +2539,7 @@ export class GitvaultVault {
2277
2539
  }
2278
2540
  if (persist)
2279
2541
  this.keystore.updateRepo(this.repoId, { head_pin: pin, verified_prefix: null });
2542
+ persistWriterSetPin();
2280
2543
  persistMaterializedIfAny();
2281
2544
  // `strict` (materialize()'s ordinary, fail-closed read path) is enforced
2282
2545
  // HERE, once, after the full keyless chain walk has already run to
@@ -2498,10 +2761,9 @@ export class GitvaultVault {
2498
2761
  * **This is task 1.1's residual WORKAROUND, not the design D5 ideal.** D5
2499
2762
  * describes a recipient-set change as an epoch rotation — "history epochs
2500
2763
  * stay wrapped as they were; a new member reads from their first covered
2501
- * epoch forward" — which needs a protocol revision (task 1, BLOCKED as of
2502
- * 2026-08-26: V0 pins `epoch` to the single constant
2503
- * `GITVAULT_GENESIS_EPOCH` on every head, so there is no "forward" to
2504
- * speak of yet). What this method actually does, legally, without any
2764
+ * epoch forward" — which needs a protocol revision: V0 pins `epoch` to
2765
+ * the single constant `GITVAULT_GENESIS_EPOCH` on every head, so there
2766
+ * is no "forward" to speak of. What this method actually does, legally, without any
2505
2767
  * protocol change: `key_envelope` objects are never head-referenced (not
2506
2768
  * even genesis's own envelope is), so uploading an ADDITIONAL one at
2507
2769
  * `envelopes/<current epoch>/<recipient fingerprint>` for a missing
@@ -2524,7 +2786,7 @@ export class GitvaultVault {
2524
2786
  * this SDK does not make unattended.
2525
2787
  *
2526
2788
  * **The gateway directory route carries `public_key` on every row**
2527
- * (`GET /orgs/v1/:org_id/encryption-keys`, deployed 2026-08-26 — see the
2789
+ * (`GET /orgs/v1/:org_id/encryption-keys` — see the
2528
2790
  * doc comment on {@link GitvaultOrgEncryptionKeyEntry}), so against a
2529
2791
  * current gateway entries actually wrap. A directory entry that arrives
2530
2792
  * WITHOUT the field (an older/rolling-deploy gateway) is still tolerated
@@ -2613,6 +2875,33 @@ export class GitvaultVault {
2613
2875
  // recipient is now genuinely covered. Treat exactly that code as a
2614
2876
  // benign race, not a failure; anything else propagates.
2615
2877
  if (isRun402Error(e) && e.code === "GITVAULT_OBJECT_EXISTS_DIFFERENT") {
2878
+ // gitvault-agent-envelopes (consult 7.6): a benign race is only
2879
+ // benign once the WINNING envelope has been read back and verified
2880
+ // — same repo, same epoch, this recipient, signed by the vault's
2881
+ // registered writer. Anything else is recorded as skipped, never as
2882
+ // coverage, and never pinned.
2883
+ const winnerPath = gitvaultPaths.envelope(epoch, sealed.receipt.recipient_fingerprint);
2884
+ const winnerBytes = await this.transport.getObject({ repo_id: this.repoId, path: winnerPath }).catch(() => null);
2885
+ let winnerOk = false;
2886
+ if (winnerBytes) {
2887
+ try {
2888
+ const winner = parseGitvaultStrict(new TextDecoder().decode(winnerBytes));
2889
+ const g = (await this.genesis()).genesis;
2890
+ winnerOk = winner.repo_id === this.repoId && winner.epoch === epoch && winner.recipient_fingerprint === entry.ek_fingerprint
2891
+ && winner.created_by === g.writer_key_id
2892
+ && verifyGitvaultObject(winner, g.creator_signing_pubkey);
2893
+ }
2894
+ catch {
2895
+ winnerOk = false;
2896
+ }
2897
+ }
2898
+ if (!winnerOk) {
2899
+ // Consult round 2 §7: a conflicting IMMUTABLE object whose stored
2900
+ // winner does not verify is evidence of tampering, equivocation,
2901
+ // or broken object identity — categorically different from a
2902
+ // stale recipient. FATAL, never an ordinary skip.
2903
+ fail("GITVAULT_ENVELOPE_ALTERED", `a conflicting key_envelope already stored at ${winnerPath} does not verify as this vault's writer-signed envelope for this recipient — refusing to treat the conflict as a benign race`, "reconciling gitvault envelope recipients", { repo_id: this.repoId, path: winnerPath, epoch, recipient_fingerprint: entry.ek_fingerprint });
2904
+ }
2616
2905
  alreadyCovered.push(entry.ek_fingerprint);
2617
2906
  }
2618
2907
  else {
@@ -2657,6 +2946,49 @@ export class GitvaultVault {
2657
2946
  return kp.seed;
2658
2947
  }
2659
2948
  writerKeyId() { return this.keystore.ensureIdentity().signing_fingerprint; }
2949
+ /**
2950
+ * gitvault-multi-writer (task 5.8) — the push pre-check: before signing an
2951
+ * ORDINARY head (any operation that expects this session to already be an
2952
+ * active writer), refuse EARLY and LOCALLY when this session's own key is
2953
+ * not (or is no longer) an active writer, rather than let real
2954
+ * crypto/upload work run only to be refused by the gateway with a less
2955
+ * specific message once the head finally reaches it. Reads the FRESHLY-
2956
+ * pinned `writer_set_pin`; every call site below calls this immediately
2957
+ * after its own `materialize()`/`verifyToNewest()` (same generation the
2958
+ * caller is about to build against), so this performs no verification of
2959
+ * its own — it only reads what the caller already froze.
2960
+ *
2961
+ * `removedMidRace` (design D8/D10) distinguishes the two shapes this
2962
+ * refusal takes: `false` (the default) is the ORDINARY pre-check — this
2963
+ * session was never (or is not currently) an admitted writer, thrown as
2964
+ * `GITVAULT_WRITER_NOT_ADMITTED`. `true` is the CAS-LOSER path
2965
+ * specifically: a retry, after re-materializing from the winner, that
2966
+ * discovers THIS session's own key was removed by whatever won the race —
2967
+ * "stop if removed" in D8's loser-rule sequence (fetch winner → verify →
2968
+ * apply writer transition → stop if removed → rebase → rebuild → bounded
2969
+ * backoff) — thrown as the more specific, client-local
2970
+ * `GITVAULT_WRITER_REMOVED` (D10) instead: a real prior attempt just lost
2971
+ * to a removal, not a caller who was never eligible.
2972
+ *
2973
+ * Deliberately NOT called for a "handoff"-door `add_writer_key` head
2974
+ * ({@link submitWriterActivationHead}): there the signer is BY DESIGN not
2975
+ * yet a writer — becoming one is what that exact head does.
2976
+ */
2977
+ assertCallerIsWriter(context, removedMidRace = false) {
2978
+ const pin = this.repoFile().writer_set_pin;
2979
+ const myKey = this.writerKeyId();
2980
+ if (pin && pin.writers.some((w) => w.writer_key_id === myKey))
2981
+ return;
2982
+ const code = removedMidRace ? "GITVAULT_WRITER_REMOVED" : "GITVAULT_WRITER_NOT_ADMITTED";
2983
+ fail(code, removedMidRace
2984
+ ? `this session's writer key (${myKey}) was removed while a conflicting head was admitted first`
2985
+ : `this session's writer key (${myKey}) is not an active writer on this vault`, context, {}, [
2986
+ {
2987
+ type: "request_writer_sync",
2988
+ why: "This session's key is not (yet, or no longer) an admitted writer on this vault. If you were just added as an org member, any current writer's next gitvault operation (push, deploy, or r.gitvault.reconcile()) admits pending writers automatically.",
2989
+ },
2990
+ ]);
2991
+ }
2660
2992
  /** The owner's full signing keypair, or `GITVAULT_READ_ONLY` — same refusal path as {@link signer}, which returns only the seed; {@link sealKeyEnvelope} needs both halves. */
2661
2993
  signingKeypair() {
2662
2994
  const identity = this.keystore.ensureIdentity();
@@ -2872,7 +3204,17 @@ export class GitvaultVault {
2872
3204
  if (!block)
2873
3205
  fail("CHECKPOINT_INCOMPLETE", `head ${head.generation} carries no checkpoint to verify`, "verifying a stored checkpoint", { generation: head.generation });
2874
3206
  const { genesis } = await this.genesis();
2875
- const writerKey = genesis.creator_signing_pubkey;
3207
+ // gitvault-multi-writer rev 47: `head` may have been signed by any
3208
+ // CURRENTLY-KNOWN writer, not just the genesis creator — resolved from
3209
+ // the keystore's persisted `writer_set_pin` (the same substrate
3210
+ // `verifyToNewest` maintains), falling back to the genesis-creator key
3211
+ // for a pre-rev-47 keystore or one that hasn't run `verifyToNewest` yet
3212
+ // (byte-identical to this method's own pre-rev-47 behavior in that case).
3213
+ // A writer removed SINCE this checkpoint's own head was admitted is the
3214
+ // same documented residual as the backward-catch-up decrypt path above —
3215
+ // `writer_set_pin` only remembers currently-active writers, not burned ones.
3216
+ const pinnedWriters = this.repoFile().writer_set_pin?.writers ?? [];
3217
+ const writerKey = pinnedWriters.find((w) => w.writer_key_id === head.writer_key_id)?.signing_pubkey ?? genesis.creator_signing_pubkey;
2876
3218
  const claimBytes = await this.transport.getObject({ repo_id: this.repoId, path: gitvaultPaths.claimSet(block.claim_set.object_id), expected_sha256: block.claim_set.stored_bytes_sha256 });
2877
3219
  if (!claimBytes || sha256Hex(claimBytes) !== block.claim_set.stored_bytes_sha256) {
2878
3220
  fail("CHECKPOINT_INCOMPLETE", `checkpoint claim set ${block.claim_set.object_id} is absent or does not match the head's receipt`, "verifying a stored checkpoint", { object_id: block.claim_set.object_id });
@@ -2945,11 +3287,17 @@ export class GitvaultVault {
2945
3287
  const generations = [...chain.keys()].sort();
2946
3288
  const out = [];
2947
3289
  const { genesis } = await this.genesis();
3290
+ // gitvault-multi-writer rev 47: `verifyToNewest()` above already
3291
+ // persisted the current `writer_set_pin` — same per-head resolution as
3292
+ // `verifyStoredCheckpoint`, same documented residual (a writer removed
3293
+ // since falls back to the genesis-creator key).
3294
+ const pinnedWriters = this.repoFile().writer_set_pin?.writers ?? [];
2948
3295
  for (const gen of generations) {
2949
3296
  const entry = chain.get(gen);
2950
3297
  let claimSet = null;
2951
3298
  const block = entry.head.checkpoint;
2952
3299
  if (block) {
3300
+ const writerKey = pinnedWriters.find((w) => w.writer_key_id === entry.head.writer_key_id)?.signing_pubkey ?? genesis.creator_signing_pubkey;
2953
3301
  // Plaintext-structured and stored-bytes-receipted: no decryption, but
2954
3302
  // the hash and the owner signature are still checked before a single
2955
3303
  // pack receipt inside it is believed.
@@ -2958,7 +3306,7 @@ export class GitvaultVault {
2958
3306
  fail("CHECKPOINT_INCOMPLETE", `checkpoint claim set ${block.claim_set.object_id} (generation ${gen}) is absent or altered`, "walking the gitvault chain", { generation: gen, object_id: block.claim_set.object_id });
2959
3307
  }
2960
3308
  claimSet = parseGitvaultStrict(new TextDecoder().decode(bytes));
2961
- if (!verifyGitvaultObject(claimSet, genesis.creator_signing_pubkey)) {
3309
+ if (!verifyGitvaultObject(claimSet, writerKey)) {
2962
3310
  fail("CHECKPOINT_INCOMPLETE", `checkpoint claim set ${claimSet.object_id} signature fails`, "walking the gitvault chain", { generation: gen });
2963
3311
  }
2964
3312
  }
@@ -3041,7 +3389,7 @@ export class GitvaultVault {
3041
3389
  rawPackBytes += pack.length;
3042
3390
  }
3043
3391
  }
3044
- // kychee-com/run402#565: everything above this point is REAL local work —
3392
+ // everything above this point is REAL local work —
3045
3393
  // real signing, real pack building, real encryption. Stopping HERE is what
3046
3394
  // makes the dry run honest: nothing below this line has run yet, so
3047
3395
  // nothing was uploaded and no generation was admitted.
@@ -3055,7 +3403,13 @@ export class GitvaultVault {
3055
3403
  ref_state: { object_id: refState.object.object_id, object_kind: "ref_state", ciphertext_sha256: refState.upload.sha256, size_bytes: refState.upload.size_bytes },
3056
3404
  retention_roots: { object_id: rootsObj.object.object_id, object_kind: "retention_roots", ciphertext_sha256: rootsObj.upload.sha256, size_bytes: rootsObj.upload.size_bytes },
3057
3405
  checkpoint, checkpoint_purpose: checkpoint ? "ordinary_push" : null, capture_binding: binding, repair: null,
3058
- });
3406
+ },
3407
+ // gitvault-multi-writer (task 5.6) — an `add_writer_key` activation
3408
+ // head rides THIS same content-neutral machinery: `input.transition`
3409
+ // is undefined for every pre-existing caller (push/publishCheckpoint),
3410
+ // so their heads are byte-for-byte unchanged; `submitWriterActivationHead`
3411
+ // below is the one caller that passes it.
3412
+ input.transition !== undefined ? { transition: input.transition } : undefined);
3059
3413
  const admitted = await this.admit(head);
3060
3414
  if (admitted.outcome === "conflict")
3061
3415
  return { outcome: "conflict", generation, winner: admitted.winner };
@@ -3073,6 +3427,14 @@ export class GitvaultVault {
3073
3427
  let conflicts = 0;
3074
3428
  let base = options.base ?? (await this.materialize());
3075
3429
  for (;;) {
3430
+ // gitvault-multi-writer (task 5.8) — checked fresh on EVERY attempt
3431
+ // (including a CAS-loser retry, design D8's "stop if removed"): a
3432
+ // concurrent rotation could remove this session's own writer key
3433
+ // between one attempt and the next. `conflicts > 0` is exactly the
3434
+ // CAS-loser path (this iteration only runs after losing a race and
3435
+ // re-materializing from the winner) — GITVAULT_WRITER_REMOVED there,
3436
+ // the ordinary GITVAULT_WRITER_NOT_ADMITTED on the first attempt.
3437
+ this.assertCallerIsWriter("publishing gitvault head", conflicts > 0);
3076
3438
  const evaluation = await evaluateRefTransaction(base.refs, options.transaction, { isAncestor: (a, d) => isAncestor(this.git(), a, d), protocol_refs: options.protocol_refs });
3077
3439
  const published = await this.publishGeneration({
3078
3440
  base, refs: evaluation.refs, dropped: evaluation.dropped, head_target: options.head_target ?? base.head_target,
@@ -3094,7 +3456,7 @@ export class GitvaultVault {
3094
3456
  }
3095
3457
  }
3096
3458
  /**
3097
- * A REAL preview of what {@link push} would publish (kychee-com/run402#565)
3459
+ * A REAL preview of what {@link push} would publish
3098
3460
  * — runs the SAME local pipeline `push` runs (materialize → evaluate →
3099
3461
  * evolve retention roots → build refState/retentionRoots → build packs or a
3100
3462
  * checkpoint set → seal/encrypt) and stops BEFORE the two network
@@ -3302,9 +3664,9 @@ export class GitvaultVault {
3302
3664
  * `POST …/object-reads` rejected every `recipient_pin_manifest` read
3303
3665
  * (its null-`idScalar` validation was hardcoded to `key_envelope`'s
3304
3666
  * `{epoch, recipient_fingerprint}` shape, never generalized when D197
3305
- * shipped the second path-addressed kind), so the network fallback below
3306
- * always 400'd. That gateway bug is fixed (deployed and live-verified
3307
- * 2026-08-28): the network path works for any keystore, including
3667
+ * shipped the second path-addressed kind), which 400'd the network
3668
+ * fallback below. That gateway bug is fixed: the network path works for
3669
+ * any keystore, including
3308
3670
  * §4.11's fresh-client "SEEDS its local pin file from it" onboarding.
3309
3671
  * The cache stays purely as the round-trip saver described above.
3310
3672
  */
@@ -3319,7 +3681,11 @@ export class GitvaultVault {
3319
3681
  }
3320
3682
  const manifest = parseGitvaultStrict(new TextDecoder().decode(bytes));
3321
3683
  const { genesis } = await this.genesis();
3322
- if (!verifyGitvaultObject(manifest, genesis.creator_signing_pubkey)) {
3684
+ // gitvault-multi-writer rev 47: resolve the manifest's OWN declared
3685
+ // signer against the persisted writer set (same pattern + same
3686
+ // documented residual as `verifyStoredCheckpoint`/`chainEntries` above).
3687
+ const manifestSigner = this.repoFile().writer_set_pin?.writers.find((w) => w.writer_key_id === manifest.writer_key_id)?.signing_pubkey ?? genesis.creator_signing_pubkey;
3688
+ if (!verifyGitvaultObject(manifest, manifestSigner)) {
3323
3689
  fail("GITVAULT_SIGNATURE_INVALID", `recipient_pin_manifest ${receipt.pin_manifest_version} signature does not verify`, "resolving the effective recipient pin manifest", { pin_manifest_version: receipt.pin_manifest_version });
3324
3690
  }
3325
3691
  const pinnedFingerprintOf = new Map(manifest.pins.map((p) => [p.principal_id, p.ek_fingerprint]));
@@ -3392,6 +3758,7 @@ export class GitvaultVault {
3392
3758
  let conflicts = 0;
3393
3759
  for (;;) {
3394
3760
  const base = await this.materialize();
3761
+ this.assertCallerIsWriter("publishing a recipient_pin_manifest update", conflicts > 0); // gitvault-multi-writer task 5.8
3395
3762
  const prior = await this.loadEffectivePinManifest(base.generation);
3396
3763
  const { nextVersion, manifestSha, upload: manifestUpload, pins } = this.buildPinManifestUpdate(prior, [input]);
3397
3764
  await this.uploadAll([manifestUpload]);
@@ -3459,10 +3826,10 @@ export class GitvaultVault {
3459
3826
  * `publishPinManifestUpdate` is an ORDINARY admission (`transition:
3460
3827
  * null`) and is therefore itself refused `EPOCH_ROTATION_REQUIRED` while
3461
3828
  * this vault has an urgent/migration condition outstanding — the exact
3462
- * state a `rotateEpoch` call is being made to clear. Reproduced live in
3463
- * production 2026-08-27 (a vault's first `epoch_secret_exposed` rekey:
3464
- * `/confirm` minted a receipt server-side, but the ordinary push that
3465
- * would publish it never admitted). Pass the pending receipted updates
3829
+ * state a `rotateEpoch` call is being made to clear. On an
3830
+ * `epoch_secret_exposed` rekey `/confirm` mints a receipt server-side,
3831
+ * but the ordinary push that would publish it never admits. Pass the
3832
+ * pending receipted updates
3466
3833
  * here instead — they ride the SAME head as this rotation's `transition`,
3467
3834
  * which IS `EPOCH_ROTATION_REQUIRED`'s own escape valve, so the publish
3468
3835
  * is no longer blocked. **This does NOT include these principals in
@@ -3490,6 +3857,7 @@ export class GitvaultVault {
3490
3857
  let conflicts = 0;
3491
3858
  for (;;) {
3492
3859
  const base = await this.materialize();
3860
+ this.assertCallerIsWriter("driving an epoch rotation", conflicts > 0); // gitvault-multi-writer task 5.8
3493
3861
  const repo = this.repoFile();
3494
3862
  const currentEpoch = base.head?.epoch ?? this.epoch();
3495
3863
  const newEpoch = nextEpoch(currentEpoch);
@@ -3579,6 +3947,65 @@ export class GitvaultVault {
3579
3947
  excluded_keyless_principal_ids: excludedKeyless,
3580
3948
  excluded_unconfirmed_principal_ids: excludedUnconfirmed,
3581
3949
  });
3950
+ // gitvault-multi-writer (task 5.9, D6/D227) — fold in a writer_set_update
3951
+ // whenever the gateway's own blocked set (`ineligible_members`, read
3952
+ // fresh) is non-empty. This is what closes D6's "ordinary
3953
+ // admissions... refuse EPOCH_ROTATION_REQUIRED... until a rotate_epoch
3954
+ // carries writer_set_update" deadlock, automatically, on EVERY
3955
+ // rotation regardless of `options.reason` — the SAME "self-healing"
3956
+ // shape `pending_confirmations` already gives the recipient side.
3957
+ //
3958
+ // Deliberately NO caller-supplied "also remove this OTHER writer key"
3959
+ // option: the gateway's own admission rule requires `removed[]` to
3960
+ // equal the gateway-computed blocked set EXACTLY (D6's own words,
3961
+ // confirmed against `validateWriterSetUpdate`'s identical
3962
+ // RECIPIENT_SET_MISMATCH check) — a client-named superset would be
3963
+ // refused, not honored. A genuinely NEW, deliberate writer-key
3964
+ // revocation (the "repos access revoke-key" aspiration this task's
3965
+ // own text names) needs a gateway-side "declare this writer key
3966
+ // revoked" mutation FIRST (the writer-dimension analog of
3967
+ // `declareRecipientKeyRevoked`) to populate `ineligible_members` in
3968
+ // the first place — confirmed absent from every gateway route as of
3969
+ // this task (`grep -rn "declareWriterKeyRevoked|revoke-key"
3970
+ // packages/gateway/src/routes/` — zero hits). That server-side
3971
+ // mutation is real, new, owner+step-up-gated authorization logic, not
3972
+ // a client-side gap this task can close — filed as a residual rather
3973
+ // than half-built against a route that doesn't exist. Once it ships,
3974
+ // this SAME automatic fold-in picks up whatever it gateway-blocks
3975
+ // with zero client changes — the fold-in doesn't care WHY a key
3976
+ // is blocked, only THAT it is.
3977
+ const writerPin = this.repoFile().writer_set_pin;
3978
+ const vaultRecord = await this.transport.getVaultRecord({ repo_id: this.repoId });
3979
+ const ineligible = vaultRecord.ineligible_members ?? [];
3980
+ let writerSetUpdate = null;
3981
+ if (ineligible.length > 0) {
3982
+ if (!writerPin) {
3983
+ fail("GITVAULT_WRITER_STATE_UNAVAILABLE", "no locally verified writer_set_pin for this vault — the chain must be verified (materialize/verifyToNewest) before a writer_set_update can be built against a known base writer set", "computing the writer_set_update");
3984
+ }
3985
+ const predecessorState = { version: writerPin.version, writers: writerPin.writers, sha256: writerPin.sha256, burnedWriterKeyIds: new Set(), consumedHandoffIds: new Set() };
3986
+ // A rotation's own top-level `reason:"epoch_secret_exposed"` is the
3987
+ // one unambiguous signal worth carrying down to each removed entry
3988
+ // (a genuinely distinct, meaningful narrative); everything else —
3989
+ // including the gateway's own finer-grained `ineligible_members[].
3990
+ // reason` taxonomy (membership_revoked/role_below_developer/
3991
+ // encryption_key_revoked/gateway_blocked_pending_removal), which
3992
+ // has no clean mapping onto this field's narrower 3-value wire
3993
+ // vocabulary — collapses to the safe, generic "member_removed".
3994
+ const perEntryReason = options.reason === "epoch_secret_exposed" ? "epoch_secret_exposed" : "member_removed";
3995
+ const removed = ineligible.map((m) => ({ writer_key_id: m.writer_key_id, principal_id: m.principal_id, reason: perEntryReason }));
3996
+ const removedIds = removed.map((r) => r.writer_key_id);
3997
+ const nextState = applyWriterSetUpdate(this.repoId, predecessorState, removedIds);
3998
+ const update = {
3999
+ base_version: writerPin.version, base_sha256: writerPin.sha256,
4000
+ next_version: nextState.version, next_sha256: nextState.sha256,
4001
+ removed, writers: nextState.writers,
4002
+ };
4003
+ const wsuCheck = validateWriterSetUpdate(this.repoId, predecessorState, update, this.writerKeyId(), new Set(removedIds), options.force_empty_writer_set === true);
4004
+ if (!wsuCheck.ok) {
4005
+ fail(wsuCheck.code, `internal: the writer_set_update this producer built is not valid (${wsuCheck.detail}) — refusing to submit a rotation the gateway would refuse`, "computing the writer_set_update", { removed: removedIds });
4006
+ }
4007
+ writerSetUpdate = { base_version: update.base_version, base_sha256: update.base_sha256, next_version: update.next_version, next_sha256: update.next_sha256, removed, writers: [...update.writers] };
4008
+ }
3582
4009
  // D195: build + sign the rotation_attempt_descriptor, derive rotation_id, submit the create-only CAS BEFORE any envelope upload.
3583
4010
  const clientIdempotencyKey = options.client_idempotency_key ?? newHex32();
3584
4011
  const descriptorFields = {
@@ -3587,6 +4014,8 @@ export class GitvaultVault {
3587
4014
  recipient_state_version: options.recipient_state_version, recipient_revocation_version: options.recipient_revocation_version,
3588
4015
  pin_manifest_sha256: pinManifest.pinManifestSha256, target_partition_digest: targetPartitionDigest,
3589
4016
  client_idempotency_key: clientIdempotencyKey, writer_key_id: this.writerKeyId(),
4017
+ // D227 (rev 47): present together IFF this rotation carries a writer_set_update — frozen at THIS attempt's own admission fence, mirroring recipient_state_version/recipient_revocation_version's existing discipline.
4018
+ ...(writerSetUpdate ? { writer_revocation_version: vaultRecord.writer_revocation_version ?? "0", writer_set_base_sha256: writerSetUpdate.base_sha256, writer_set_next_sha256: writerSetUpdate.next_sha256 } : {}),
3590
4019
  };
3591
4020
  const attemptCommitment = attemptKeyCommitment(kE, this.repoId, newEpoch, descriptorFields);
3592
4021
  const signedDescriptor = signGitvaultObject({ ...descriptorFields, attempt_key_commitment: attemptCommitment }, this.signer());
@@ -3614,6 +4043,8 @@ export class GitvaultVault {
3614
4043
  pin_manifest_sha256: pinManifest.pinManifestSha256, target_partition_digest: targetPartitionDigest,
3615
4044
  epoch_key_commitment: epochKeyCommitmentValue, excluded_keyless_principal_ids: excludedKeyless, excluded_unconfirmed_principal_ids: excludedUnconfirmed,
3616
4045
  recipient_authority_attestation: null, envelopes: sealedReceipts,
4046
+ // gitvault-multi-writer (task 5.9, D227): present IFF this rotation ALSO removes one or more writers.
4047
+ ...(writerSetUpdate ? { writer_set_update: writerSetUpdate } : {}),
3617
4048
  };
3618
4049
  // D209 (rev 44) — round-trip THIS principal's own new-epoch
3619
4050
  // key_envelope through the REAL reader entry point
@@ -3623,11 +4054,10 @@ export class GitvaultVault {
3623
4054
  // EPOCH_ROTATION_SELF_OPEN_UNPROVEN on any rotate_epoch admission
3624
4055
  // that omits this or whose claim disagrees with its own
3625
4056
  // server-computed writer-in-envelopes biconditional — this call site
3626
- // is the fix for the exact gap the 2026-08-28 drill found (production
3627
- // clients could not READ a rotated vault because the reader had never
3628
- // implemented rotation traversal, while the write side was green on
3629
- // its own tests). This SUPERSEDES the old post-commit self-check
3630
- // below (which called openKeyEnvelope directly, bypassing the
4057
+ // closes the gap where a client can WRITE a rotated vault it cannot
4058
+ // READ, because the reader never implements rotation traversal while
4059
+ // the write side stays green on its own tests. It replaces a bare
4060
+ // post-commit self-check (calling openKeyEnvelope directly, bypassing the
3631
4061
  // membership lookup, the envelope_path callback derivation, and the
3632
4062
  // reader's own error framing) — moving the round-trip BEFORE
3633
4063
  // admission means a genuine failure aborts this call before anything
@@ -3737,12 +4167,20 @@ export class GitvaultVault {
3737
4167
  if (pinManifestFold) {
3738
4168
  this.keystore.updateRepo(this.repoId, { known_pin_manifest: { pin_manifest_version: pinManifestFold.nextVersion, stored_bytes_sha256: pinManifestFold.manifestSha, pins: pinManifestFold.pins } });
3739
4169
  }
4170
+ // gitvault-multi-writer (task 5.9) — the SAME local-pin advance
4171
+ // #publishAddWriterKeyTransition performs on ITS success path: this
4172
+ // head just admitted a writer_set_update, so the locally-pinned
4173
+ // writer set is now stale until refreshed here.
4174
+ if (writerSetUpdate) {
4175
+ this.keystore.updateRepo(this.repoId, { writer_set_pin: { version: writerSetUpdate.next_version, sha256: writerSetUpdate.next_sha256, writers: [...writerSetUpdate.writers], pinned_at: formatGitvaultTimestamp(this.now()) } });
4176
+ }
3740
4177
  return {
3741
4178
  outcome: "admitted", generation, head_sha256: admitted.head_sha256, new_epoch: newEpoch, rotation_id: rotationId, reason: options.reason,
3742
4179
  included: included.map((p) => ({ principal_id: p.principal_id, ek_fingerprint: p.ek_fingerprint })),
3743
4180
  excluded_keyless_principal_ids: excludedKeyless, excluded_unconfirmed_principal_ids: excludedUnconfirmed,
3744
4181
  admission_record_sha256: admitted.admission_record_sha256, capture_receipt: admitted.capture_receipt, self_check: selfCheck,
3745
4182
  pin_manifest_published: pinManifestFold ? { pin_manifest_version: pinManifestFold.nextVersion, stored_bytes_sha256: pinManifestFold.manifestSha, principal_ids: pendingConfirmations.map((p) => p.principal_id) } : null,
4183
+ writers_removed: writerSetUpdate ? writerSetUpdate.removed.map((r) => ({ writer_key_id: r.writer_key_id, principal_id: r.principal_id, reason: r.reason })) : [],
3746
4184
  };
3747
4185
  }
3748
4186
  }
@@ -3756,6 +4194,183 @@ export class GitvaultVault {
3756
4194
  const counters = await this.transport.declareRecipientKeyRevoked({ repo_id: this.repoId, principal_id: principalId });
3757
4195
  return this.rotateEpoch({ reason: "recipient_key_revoked", recipient_state_version: counters.recipient_state_version, recipient_revocation_version: counters.recipient_revocation_version, client_idempotency_key: options.client_idempotency_key });
3758
4196
  }
4197
+ // ── writer activation (gitvault-multi-writer task 5.6, design D4/D5) ──────
4198
+ /**
4199
+ * Submit a REF-NEUTRAL `add_writer_key{authorization.kind:"handoff"}`
4200
+ * activation head — no ref/checkpoint/repair changes ride it (protocol
4201
+ * §4.17's own "ref-neutral head shape... for the activation head" rule),
4202
+ * built by `publishGeneration` the SAME way every ordinary no-op push
4203
+ * would be (`refs`/`head_target` carried forward from `base` UNCHANGED),
4204
+ * just with `transition` set. Retries `HEAD_CAS_CONFLICT` like every
4205
+ * other publish path here. The caller (`resume()`) has already built and
4206
+ * locally sanity-checked the grant + acceptance; this method only reads
4207
+ * the FRESHLY-verified `writer_set_pin` (set by the `materialize()` this
4208
+ * same call performs, mirroring `push()`'s own base-then-retry shape) to
4209
+ * compute `base_writer_set`/`next_writer_set`, signs the head under
4210
+ * `this.signer()` (the ADDED key itself — protocol §4.17's "head signed
4211
+ * by the added key" — since `signer()` reads straight from THIS
4212
+ * keystore's identity, and `resume()` mints/loads that identity before
4213
+ * ever reaching this call), and advances the local `writer_set_pin` on
4214
+ * success so a subsequent read reflects the new writer without a second
4215
+ * chain walk.
4216
+ *
4217
+ * Crash-resumable (task 5.6): the chain burns `handoff_id` single-use, so
4218
+ * a naive resubmission after a crash between a prior attempt's successful
4219
+ * admission and this checkout learning about it would be refused
4220
+ * VALIDATION_FAILED. Returns `{outcome:"already_admitted", generation}`
4221
+ * instead of submitting anything whenever the freshly-verified writer set
4222
+ * ALREADY contains `input.addedWriterKeyId` — the caller treats both
4223
+ * outcomes as success (the writer IS active either way) and proceeds.
4224
+ */
4225
+ async submitWriterActivationHead(input) {
4226
+ return this.#publishAddWriterKeyTransition(input.addedWriterKeyId, (pin) => buildAddWriterKeyActivationPayload(this.repoId, pin, { writer_key_id: input.addedWriterKeyId, signing_pubkey: input.addedSigningPubkeyB64u, principal_id: input.addedPrincipalId }, input.handoffId, input.grant, input.acceptance), "submitting a writer activation head",
4227
+ // gitvault-multi-writer task 5.8: the handoff door is signed by the
4228
+ // ADDED key itself, which is by design NOT YET a writer — the pre-
4229
+ // check must be SKIPPED here, or this call could never succeed.
4230
+ false);
4231
+ }
4232
+ /**
4233
+ * gitvault-multi-writer (task 5.7) — the "writer"-door twin of {@link
4234
+ * submitWriterActivationHead}: an ALREADY-ACTIVE writer (this vault
4235
+ * session's own key) admits `input`, an eligible org member with a
4236
+ * published signing key but no writer standing yet, via a fresh
4237
+ * `add_writer_key{"writer"}` head. No grant/acceptance — the carrying
4238
+ * head's own signer being an active writer at the predecessor generation
4239
+ * IS the authorization (`validateAddWriterKeyPayload`'s `"writer"`
4240
+ * branch), re-verified admission-side by `checkTransitionAdmissible`.
4241
+ * Task 5.8 adds the SAME local pre-check every other head-signing path
4242
+ * gets: if THIS session's own key is not (or is no longer) an active
4243
+ * writer, refuse EARLY and LOCALLY (`GITVAULT_WRITER_NOT_ADMITTED` /
4244
+ * `GITVAULT_WRITER_REMOVED`) rather than let the head reach the gateway
4245
+ * only to be refused there — redundant with `reconcileWriterAdmissions`'s
4246
+ * own upfront gate for that caller, but this method is public and a
4247
+ * direct caller should get the same fast, specific refusal.
4248
+ */
4249
+ async admitPendingWriter(input) {
4250
+ return this.#publishAddWriterKeyTransition(input.addedWriterKeyId, (pin) => buildWriterDoorAddWriterKeyPayload(this.repoId, pin, { writer_key_id: input.addedWriterKeyId, signing_pubkey: input.addedSigningPubkeyB64u, principal_id: input.addedPrincipalId }), "admitting a pending writer", true);
4251
+ }
4252
+ /**
4253
+ * Shared publish+retry+pin-update tail for BOTH doors of `add_writer_key`
4254
+ * ({@link submitWriterActivationHead}'s handoff door, task 5.6; {@link
4255
+ * admitPendingWriter}'s writer door, task 5.7). `buildPayload` constructs
4256
+ * a fresh {@link AddWriterKeyPayload} against whatever `writer_set_pin`
4257
+ * the CURRENT attempt's freshly-materialized base carries, so a
4258
+ * conflict-retry rebuilds against the winner's own pin, never a stale
4259
+ * one. Crash/race-resumable the same way for both doors: if
4260
+ * `addedWriterKeyId` is ALREADY in the freshly-verified writer set before
4261
+ * anything is built, returns `{outcome:"already_admitted"}` instead of
4262
+ * attempting a resubmission the chain would refuse.
4263
+ *
4264
+ * `requireCallerIsWriter` (task 5.8) gates {@link assertCallerIsWriter} —
4265
+ * `false` for the handoff door (the signer is deliberately not yet a
4266
+ * writer), `true` for the writer door (the signer must already be one).
4267
+ */
4268
+ async #publishAddWriterKeyTransition(addedWriterKeyId, buildPayload, errorContext, requireCallerIsWriter) {
4269
+ let conflicts = 0;
4270
+ for (;;) {
4271
+ const base = await this.materialize();
4272
+ if (requireCallerIsWriter)
4273
+ this.assertCallerIsWriter(errorContext, conflicts > 0);
4274
+ const pin = this.repoFile().writer_set_pin;
4275
+ if (!pin) {
4276
+ fail("GITVAULT_WRITER_STATE_UNAVAILABLE", "no locally verified writer_set_pin for this vault — the chain must be verified (materialize/verifyToNewest) before an activation head can be built against a known base writer set", errorContext);
4277
+ }
4278
+ if (pin.writers.some((w) => w.writer_key_id === addedWriterKeyId)) {
4279
+ return { outcome: "already_admitted", generation: base.generation };
4280
+ }
4281
+ const payload = buildPayload(pin);
4282
+ const nextState = payload.next_writer_set;
4283
+ const payloadBytes = jcs(payload);
4284
+ const transition = { kind: "add_writer_key", payload_format: "base64url-jcs", payload: toBase64url(payloadBytes), payload_sha256: sha256Hex(payloadBytes) };
4285
+ const published = await this.publishGeneration({
4286
+ base, refs: base.refs, dropped: [], head_target: base.head_target,
4287
+ force_checkpoint: false, cutoff: null, transition,
4288
+ });
4289
+ if (published.outcome === "conflict") {
4290
+ conflicts += 1;
4291
+ if (conflicts > this.retries)
4292
+ fail("HEAD_CAS_CONFLICT", `the writer activation lost ${conflicts} races at generation ${published.generation}; giving up`, errorContext, { generation: published.generation, winner: published.winner });
4293
+ continue;
4294
+ }
4295
+ if (published.outcome === "dry_run")
4296
+ fail("GIT_COMMAND_FAILED", "internal: #publishAddWriterKeyTransition() received a dry-run result it never requested", errorContext);
4297
+ this.keystore.updateRepo(this.repoId, { writer_set_pin: { version: nextState.version, sha256: nextState.sha256, writers: [...nextState.writers], pinned_at: formatGitvaultTimestamp(this.now()) } });
4298
+ return {
4299
+ outcome: "activated",
4300
+ result: { generation: published.generation, head_sha256: published.head_sha256, head: published.head, admission_record_sha256: published.admission_record_sha256, capture_receipt: published.capture_receipt, form: published.form, conflicts_retried: conflicts, refs: published.refs, checkpoint_staleness: this.checkpointStalenessNow(published.generation) },
4301
+ };
4302
+ }
4303
+ }
4304
+ /**
4305
+ * gitvault-multi-writer (task 5.7) — the vault's OWN writer-admission
4306
+ * reconcile: reads `pending_writers[]` off the vault record (eligible org
4307
+ * members with no writer standing yet), resolves each candidate's
4308
+ * published signing key off the org's encryption-key directory (the SAME
4309
+ * `/orgs/v1/:org_id/encryption-keys` row {@link
4310
+ * reconcileEnvelopeRecipients} reads, widened D9 to also carry the
4311
+ * signing half), and admits each via {@link admitPendingWriter} — one
4312
+ * `add_writer_key{"writer"}` head per candidate, sequentially: each
4313
+ * admission materializes fresh against the PRIOR one's own updated writer
4314
+ * set, so there is no batched/parallel form.
4315
+ *
4316
+ * Requires THIS session's own key to already be an active writer (the
4317
+ * chain's own authorization rule for the "writer" door) — checked ONCE,
4318
+ * upfront, off the locally-pinned `writer_set_pin` (no network call),
4319
+ * rather than discovered N times over from N identical gateway refusals:
4320
+ * a session that reached this call already pushed successfully as an
4321
+ * active writer in every wired call site (push/snapshot/deploy) OR is a
4322
+ * member that simply is not one yet (session-start/read) — the SAME
4323
+ * "not yet admitted" case {@link Gitvault.push}'s own pre-push check
4324
+ * (task 5.8) names, not a new refusal shape. Returns `{eligible: false}`
4325
+ * with every other field empty (never throws) for that case: every OTHER
4326
+ * `pending_writers[]` entry would fail identically, so there is nothing
4327
+ * this call can usefully do, and "I am not a writer" is not this vault's
4328
+ * fault — but it is distinguishable from "there was nothing pending"
4329
+ * (`eligible: true`, still all empty), which matters to a caller trying
4330
+ * to explain an all-empty result to a human.
4331
+ */
4332
+ async reconcileWriterAdmissions() {
4333
+ const repo = this.repoFile();
4334
+ const admitted = [];
4335
+ const alreadyCovered = [];
4336
+ const skipped = [];
4337
+ const assembleResult = (eligible) => ({ repo_id: this.repoId, org_id: repo.org_id, eligible, admitted, already_covered: alreadyCovered, skipped });
4338
+ const myWriterKeyId = this.writerKeyId();
4339
+ const startingPin = repo.writer_set_pin;
4340
+ if (!startingPin || !startingPin.writers.some((w) => w.writer_key_id === myWriterKeyId))
4341
+ return assembleResult(false);
4342
+ const record = await this.transport.getVaultRecord({ repo_id: this.repoId });
4343
+ const pending = record.pending_writers ?? [];
4344
+ if (pending.length === 0)
4345
+ return assembleResult(true);
4346
+ const directory = await this.transport.listOrgEncryptionKeys({ org_id: repo.org_id });
4347
+ const byPrincipal = new Map(directory.keys.map((k) => [k.principal_id, k]));
4348
+ for (const candidate of pending) {
4349
+ const currentPin = this.repoFile().writer_set_pin;
4350
+ if (currentPin?.writers.some((w) => w.writer_key_id === candidate.writer_key_id)) {
4351
+ alreadyCovered.push(candidate.writer_key_id);
4352
+ continue;
4353
+ }
4354
+ const entry = byPrincipal.get(candidate.principal_id);
4355
+ const signingPubkey = entry?.signing_pubkey;
4356
+ if (typeof signingPubkey !== "string" || signingPubkey.length === 0) {
4357
+ skipped.push({ principal_id: candidate.principal_id, writer_key_id: candidate.writer_key_id, reason: "missing_signing_pubkey" });
4358
+ continue;
4359
+ }
4360
+ if (writerKeyIdOf(signingPubkey) !== candidate.writer_key_id) {
4361
+ skipped.push({ principal_id: candidate.principal_id, writer_key_id: candidate.writer_key_id, reason: "invalid_signing_pubkey" });
4362
+ continue;
4363
+ }
4364
+ const outcome = await this.admitPendingWriter({ addedWriterKeyId: candidate.writer_key_id, addedSigningPubkeyB64u: signingPubkey, addedPrincipalId: candidate.principal_id });
4365
+ if (outcome.outcome === "already_admitted") {
4366
+ alreadyCovered.push(candidate.writer_key_id);
4367
+ }
4368
+ else {
4369
+ admitted.push({ principal_id: candidate.principal_id, writer_key_id: candidate.writer_key_id, generation: outcome.result.generation });
4370
+ }
4371
+ }
4372
+ return assembleResult(true);
4373
+ }
3759
4374
  // ── repair (owner-only; mandatory fresh checkpoint; repair resource lane server-side) ──
3760
4375
  /**
3761
4376
  * Publish a repair head over `base_generation` (§4.3): superseded tips that
@@ -3766,6 +4381,14 @@ export class GitvaultVault {
3766
4381
  */
3767
4382
  async repair(input) {
3768
4383
  const newest = await this.verifyToNewest();
4384
+ // gitvault-multi-writer task 5.8 — repair requires OWNER+step-up
4385
+ // server-side (`authority.repair`), but that is layered ON TOP of the
4386
+ // baseline writer requirement every head needs (`authority.writer`,
4387
+ // confirmed against the gateway's own admission fixture: an owner who
4388
+ // is not also a chain-recognized writer is refused just the same). No
4389
+ // retry loop here (a losing repair is a hard failure below, not a
4390
+ // rebuild-and-retry), so there is no CAS-loser variant to distinguish.
4391
+ this.assertCallerIsWriter("preparing repair");
3769
4392
  const baseGen = generationToBigInt(input.base_generation);
3770
4393
  const newestGen = generationToBigInt(newest.generation);
3771
4394
  if (baseGen >= newestGen)