run402 4.42.0 → 4.44.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/lib/command-manifest.mjs +6 -3
  2. package/lib/deploy-v2.mjs +27 -4
  3. package/lib/next-actions.mjs +5 -2
  4. package/lib/repos.mjs +571 -123
  5. package/lib/stats.mjs +57 -0
  6. package/package.json +1 -1
  7. package/sdk/dist/index.d.ts +10 -1
  8. package/sdk/dist/index.d.ts.map +1 -1
  9. package/sdk/dist/index.js +11 -0
  10. package/sdk/dist/index.js.map +1 -1
  11. package/sdk/dist/kernel.d.ts +31 -0
  12. package/sdk/dist/kernel.d.ts.map +1 -1
  13. package/sdk/dist/kernel.js +116 -3
  14. package/sdk/dist/kernel.js.map +1 -1
  15. package/sdk/dist/namespaces/gitvault.crypto.d.ts +178 -5
  16. package/sdk/dist/namespaces/gitvault.crypto.d.ts.map +1 -1
  17. package/sdk/dist/namespaces/gitvault.crypto.js +266 -7
  18. package/sdk/dist/namespaces/gitvault.crypto.js.map +1 -1
  19. package/sdk/dist/namespaces/gitvault.d.ts +103 -1
  20. package/sdk/dist/namespaces/gitvault.d.ts.map +1 -1
  21. package/sdk/dist/namespaces/gitvault.js +175 -7
  22. package/sdk/dist/namespaces/gitvault.js.map +1 -1
  23. package/sdk/dist/namespaces/gitvault.types.d.ts +184 -9
  24. package/sdk/dist/namespaces/gitvault.types.d.ts.map +1 -1
  25. package/sdk/dist/namespaces/gitvault.types.js +2 -1
  26. package/sdk/dist/namespaces/gitvault.types.js.map +1 -1
  27. package/sdk/dist/node/gitvault-apply.d.ts +8 -0
  28. package/sdk/dist/node/gitvault-apply.d.ts.map +1 -1
  29. package/sdk/dist/node/gitvault-apply.js +1 -0
  30. package/sdk/dist/node/gitvault-apply.js.map +1 -1
  31. package/sdk/dist/node/gitvault-deploy.d.ts +13 -0
  32. package/sdk/dist/node/gitvault-deploy.d.ts.map +1 -1
  33. package/sdk/dist/node/gitvault-deploy.js +28 -0
  34. package/sdk/dist/node/gitvault-deploy.js.map +1 -1
  35. package/sdk/dist/node/gitvault-keystore.d.ts +54 -4
  36. package/sdk/dist/node/gitvault-keystore.d.ts.map +1 -1
  37. package/sdk/dist/node/gitvault-keystore.js +20 -0
  38. package/sdk/dist/node/gitvault-keystore.js.map +1 -1
  39. package/sdk/dist/node/gitvault-publication.d.ts +328 -4
  40. package/sdk/dist/node/gitvault-publication.d.ts.map +1 -1
  41. package/sdk/dist/node/gitvault-publication.js +560 -14
  42. package/sdk/dist/node/gitvault-publication.js.map +1 -1
  43. package/sdk/dist/node/gitvault-snapshot.d.ts +40 -7
  44. package/sdk/dist/node/gitvault-snapshot.d.ts.map +1 -1
  45. package/sdk/dist/node/gitvault-snapshot.js +67 -19
  46. package/sdk/dist/node/gitvault-snapshot.js.map +1 -1
@@ -37,7 +37,8 @@ import { mkdtempSync, readdirSync, readFileSync, rmSync } from "node:fs";
37
37
  import { tmpdir } from "node:os";
38
38
  import { join } from "node:path";
39
39
  import { LocalError, isRun402Error } from "../errors.js";
40
- import { GITVAULT_FORMAT, GITVAULT_GENESIS_EPOCH, GITVAULT_GENESIS_GENERATION, GITVAULT_HEX16_RE, GITVAULT_OID40_RE, GITVAULT_SUITE, deriveDigestKey, deriveObjectKey, ekFingerprint, formatGitvaultTimestamp, fromBase64url, hexToBytes, jcs, keyedCommitment, newGitvaultId, objectsetContent, openBindingPreimage, openFrame, parseGitvaultStrict, sealFrame, sealKeyEnvelope, sha256Hex, signGitvaultObject, storedBytes, verifyGitvaultObject, } from "../namespaces/gitvault.crypto.js";
40
+ 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, openFrame, openKeyEnvelope, parseGitvaultStrict, pinManifestLedgerId, randomBytes, sealFrame, sealKeyEnvelope, sha256Hex, signGitvaultObject, storedBytes, storedBytesSha256, toBase64url, verifyGitvaultObject, } from "../namespaces/gitvault.crypto.js";
41
+ import { GITVAULT_ZERO_SHA256_SENTINEL } from "../namespaces/gitvault.types.js";
41
42
  import { crossProfileGitvaultHint } from "./gitvault-profile-scan.js";
42
43
  import { GITVAULT_DEPLOY_REF, hardenedGit, hasObject, isAncestor } from "./gitvault-snapshot.js";
43
44
  // ─── Constants (constants.json) ──────────────────────────────────────────────
@@ -340,11 +341,14 @@ export function assertNoTransition(head) {
340
341
  fail("UPGRADE_REQUIRED", `head ${head.generation} carries an admitted "${head.transition.kind}" transition this client cannot validate; staying read-only at the materialized pin and refusing to publish past it`, "verifying head chain", { generation: head.generation, kind: head.transition.kind }, [{ action: "upgrade the client; the vault stays readable at the materialized pin" }]);
341
342
  }
342
343
  const HEX16 = "[0-9a-f]{16}";
343
- /** Ordered longest-suffix-first so `.ticket.json` is never eaten by `.enc`. */
344
+ const SHA256 = "[0-9a-f]{64}";
345
+ /** Ordered longest-suffix-first so `.ticket.json` is never eaten by `.enc`, and the rotation-scoped envelope pattern before the plain one so it never matches short. */
344
346
  const PATH_PATTERNS = [
345
347
  [new RegExp(`^head/(${HEX16})$`), (m) => ({ kind: "head", generation: m[1] })],
346
348
  [new RegExp(`^admissions/(${HEX16})$`), (m) => ({ kind: "admission", generation: m[1] })],
349
+ [new RegExp(`^envelopes/(${HEX16})/(${SHA256})/(ek_[0-9a-f]{32})$`), (m) => ({ kind: "object", read: { object_kind: "key_envelope", epoch: m[1], rotation_id: m[2], recipient_fingerprint: m[3] } })],
347
350
  [/^envelopes\/([0-9a-f]{16})\/(ek_[0-9a-f]{32})$/, (m) => ({ kind: "object", read: { object_kind: "key_envelope", epoch: m[1], recipient_fingerprint: m[2] } })],
351
+ [new RegExp(`^recipient-pins/(${HEX16})\\.json$`), (m) => ({ kind: "object", read: { object_kind: "recipient_pin_manifest", pin_manifest_version: m[1] } })],
348
352
  [/^wal\/(wal_[0-9a-f]{32})\.pack\.enc$/, (m) => ({ kind: "object", read: { object_kind: "wal_pack", object_id: m[1] } })],
349
353
  [/^refs\/(refs_[0-9a-f]{32})\.enc$/, (m) => ({ kind: "object", read: { object_kind: "ref_state", object_id: m[1] } })],
350
354
  [/^retention\/(rr_[0-9a-f]{32})\.enc$/, (m) => ({ kind: "object", read: { object_kind: "retention_roots", object_id: m[1] } })],
@@ -378,9 +382,19 @@ export function gitvaultManifestEntry(object) {
378
382
  }
379
383
  return entry;
380
384
  }
381
- /** The stable key both sides agree on, used to pair receipts back to requests. */
385
+ /**
386
+ * The stable key both sides agree on, used to pair receipts back to
387
+ * requests — MIRRORS the gateway's `keyEnvelopeLedgerId`/`pinManifestLedgerId`
388
+ * (services/gitvault/epoch-rotation.ts) exactly; drift here breaks receipt
389
+ * pairing at upload finalize for a rotation-attempt envelope or a pin
390
+ * manifest.
391
+ */
382
392
  export function gitvaultLedgerId(read) {
383
- return read.object_kind === "key_envelope" ? `key_envelope:${read.epoch}:${read.recipient_fingerprint}` : String(read.object_id);
393
+ if (read.object_kind === "key_envelope")
394
+ return keyEnvelopeLedgerId(read.epoch, read.recipient_fingerprint, read.rotation_id ?? null);
395
+ if (read.object_kind === "recipient_pin_manifest")
396
+ return pinManifestLedgerId(read.pin_manifest_version);
397
+ return String(read.object_id);
384
398
  }
385
399
  function b64(bytes) { return Buffer.from(bytes).toString("base64"); }
386
400
  function b64u(bytes) { return Buffer.from(bytes).toString("base64url"); }
@@ -445,6 +459,20 @@ export function createGitvaultHttpTransport(client, options = {}) {
445
459
  return null;
446
460
  if (isRun402Error(e) && e.code === "RESOURCE_NOT_FOUND")
447
461
  return null;
462
+ // A known live gateway gap (confirmed 2026-08-27 against
463
+ // src_c78d2f710a8f49d22f9c66faf2a915cd): `POST …/object-reads`
464
+ // validates every null-idScalar (path-addressed) object_kind against
465
+ // key_envelope's `{epoch, recipient_fingerprint}` shape, never having
466
+ // been generalized for the SECOND path-addressed kind D197 shipped —
467
+ // `recipient_pin_manifest`'s real `{pin_manifest_version}` fields —
468
+ // so a genuine read always 400s `"epoch must be 16 hex"`. This is a
469
+ // read-back of a manifest neither uploaded nor cached by THIS
470
+ // keystore (a cache hit in `readPinManifestObject` never reaches
471
+ // here at all) — re-thrown with an attribution that names the gap
472
+ // instead of leaving a caller to read this as a client validation bug.
473
+ if (ref.read.object_kind === "recipient_pin_manifest" && isRun402Error(e) && e.code === "VALIDATION_FAILED") {
474
+ throw new LocalError(`the gateway's object-reads route does not yet accept recipient_pin_manifest reads by pin_manifest_version (its null-idScalar branch is still hardcoded to key_envelope's epoch/recipient_fingerprint shape) — this vault's pin manifest at ${path} cannot be read back over the network by a keystore that did not itself just publish it`, "reading gitvault object", { code: "GITVAULT_PIN_MANIFEST_READ_UNSUPPORTED", details: { path, pin_manifest_version: ref.read.pin_manifest_version }, cause: e });
475
+ }
448
476
  throw e;
449
477
  }
450
478
  const target = presigned.reads[0];
@@ -590,6 +618,20 @@ export function createGitvaultHttpTransport(client, options = {}) {
590
618
  findVaultByRepo: ({ org_slug, repo_name }) => client.request(`/gitvault/v1/vaults?repo=${encodeURIComponent(`${org_slug}/${repo_name}`)}`, { context: "resolving the gitvault by repo address" }),
591
619
  listOrgEncryptionKeys: ({ org_id }) => client.request(`/orgs/v1/${encodeURIComponent(org_id)}/encryption-keys`, { context: "reading the org encryption-key directory" }),
592
620
  listEnvelopeRecipients: ({ repo_id }) => client.request(`${base(repo_id)}/envelope-recipients`, { context: "reading the gitvault envelope recipients" }),
621
+ // ── epoch rotation (D193-D203, rev 42, §9.2) ──
622
+ async createRotationAttempt({ repo_id, descriptor }) {
623
+ const res = await client.request(`${base(repo_id)}/rotation-attempts`, {
624
+ method: "POST",
625
+ body: descriptor,
626
+ context: "creating a gitvault rotation attempt",
627
+ });
628
+ return { rotation_id: res.rotation_id, descriptor: res.descriptor, deduplicated: res.deduplicated ?? false };
629
+ },
630
+ confirmRecipient: ({ repo_id, principal_id, new_fingerprint }) => client.request(`${base(repo_id)}/recipients/${encodeURIComponent(principal_id)}/confirm`, { method: "POST", body: { new_fingerprint }, context: "confirming a gitvault recipient's first pin" }),
631
+ repinRecipient: ({ repo_id, principal_id, old_ek_fingerprint, new_fingerprint }) => client.request(`${base(repo_id)}/recipients/${encodeURIComponent(principal_id)}/repin`, { method: "POST", body: { old_ek_fingerprint, new_fingerprint }, context: "re-pinning a gitvault recipient" }),
632
+ declareRecipientKeyRevoked: ({ repo_id, principal_id }) => client.request(`${base(repo_id)}/recipients/${encodeURIComponent(principal_id)}/key-revocation`, { method: "POST", body: {}, context: "declaring a gitvault recipient key revoked" }),
633
+ declareEpochSecretExposed: ({ repo_id }) => client.request(`${base(repo_id)}/epoch-secret-exposure`, { method: "POST", body: {}, context: "declaring a gitvault epoch secret exposed" }),
634
+ declareWriterAuthorityUnavailable: ({ repo_id }) => client.request(`${base(repo_id)}/writer-authority/declare-unavailable`, { method: "POST", body: {}, context: "declaring gitvault writer authority unavailable" }),
593
635
  acquireMaintenanceLease: ({ repo_id, base_head_sha256, current_checkpoint_hash, r1_size_bytes, r2_cap_size_bytes, p_before_c1_size_bytes, p_before_c2_size_bytes }) => client.request(`${base(repo_id)}/maintenance-leases`, {
594
636
  method: "POST",
595
637
  body: {
@@ -660,8 +702,18 @@ export const gitvaultPaths = {
660
702
  cutoffTicket: (id) => `retention/${id}.ticket.json`,
661
703
  /** `verifier-receipts/<vr>.json` — plaintext-structured, uploaded before a prune intent may reference it (§7.3). */
662
704
  verifierReceipt: (id) => `verifier-receipts/${id}.json`,
663
- /** `envelopes/<epoch>/<recipient_fingerprint>` — mirrors `gitvault-creation-journal.ts`'s private `envelopePath` (the genesis creator's envelope); this is the same addressing for every OTHER recipient's `key_envelope`. */
664
- envelope: (epoch, recipientFingerprint) => `envelopes/${epoch}/${recipientFingerprint}`,
705
+ /**
706
+ * `envelopes/<epoch>/<recipient_fingerprint>` mirrors
707
+ * `gitvault-creation-journal.ts`'s private `envelopePath` (the genesis
708
+ * creator's envelope); this is the same addressing for every OTHER
709
+ * recipient's `key_envelope`. `rotationId` present (D195, rev 42) widens
710
+ * this to `envelopes/<epoch>/<rotation_id>/<recipient_fingerprint>` — a
711
+ * rotation-attempt envelope's own path (protocol §1: `rotation_id`
712
+ * ABSENT, never explicit null, in the genesis/ADD-workaround case).
713
+ */
714
+ envelope: (epoch, recipientFingerprint, rotationId) => rotationId ? `envelopes/${epoch}/${rotationId}/${recipientFingerprint}` : `envelopes/${epoch}/${recipientFingerprint}`,
715
+ /** `recipient-pins/<pin_manifest_version>.json` (D197, rev 42) — version-addressed, plaintext-structured, writer-signed. */
716
+ pinManifest: (pinManifestVersion) => `recipient-pins/${pinManifestVersion}.json`,
665
717
  };
666
718
  /** A transport-agnostic view of git ops the publication needs (the local repository). */
667
719
  export class GitvaultVault {
@@ -988,8 +1040,17 @@ export class GitvaultVault {
988
1040
  return { repo_id: this.repoId, org_id: repo.org_id, epoch, wrapped, already_covered: alreadyCovered, skipped };
989
1041
  }
990
1042
  // ── object building ──
991
- seal(kind, objectId, plaintext, path) {
992
- const sealed = sealFrame({ k_obj: deriveObjectKey(this.kRepo(), this.repoId, this.epoch(), kind, objectId), repo_id: this.repoId, object_kind: kind, object_id: objectId, epoch: this.epoch(), plaintext });
1043
+ /**
1044
+ * `keyOverride` (D194, rev 42): a `rotate_epoch` head's OWN `ref_state`/
1045
+ * `retention_roots` must be sealed under the NEWLY-sampled `K_e` at the
1046
+ * NEW epoch, never under `this.kRepo()`/`this.epoch()` (the about-to-be-
1047
+ * superseded current key) — every other call site keeps calling `seal`
1048
+ * with no override, unaffected.
1049
+ */
1050
+ seal(kind, objectId, plaintext, path, keyOverride) {
1051
+ const kRepo = keyOverride?.k_repo ?? this.kRepo();
1052
+ const epoch = keyOverride?.epoch ?? this.epoch();
1053
+ const sealed = sealFrame({ k_obj: deriveObjectKey(kRepo, this.repoId, epoch, kind, objectId), repo_id: this.repoId, object_kind: kind, object_id: objectId, epoch, plaintext });
993
1054
  return { path, object_kind: kind, object_id: objectId, bytes: sealed.frame, sha256: sealed.ciphertext_sha256, size_bytes: sealed.size_bytes };
994
1055
  }
995
1056
  /**
@@ -1017,7 +1078,7 @@ export class GitvaultVault {
1017
1078
  fail("GITVAULT_READ_ONLY", "the signing key is missing from identity.json; this principal is read-only", "signing gitvault object", undefined, [{ action: "stay read-only at the materialized pin" }]);
1018
1079
  return kp;
1019
1080
  }
1020
- buildRefState(generation, refs, headTarget) {
1081
+ buildRefState(generation, refs, headTarget, keyOverride) {
1021
1082
  if (headTarget.kind === "symref" && !BRANCH_REF_RE.test(headTarget.ref))
1022
1083
  fail("REFNAME_UNSUPPORTED", `head_target symref must name a refs/heads/* branch: ${headTarget.ref}`, "building ref_state");
1023
1084
  if (headTarget.kind === "detached" && !GITVAULT_OID40_RE.test(headTarget.oid))
@@ -1031,15 +1092,15 @@ export class GitvaultVault {
1031
1092
  const plaintext = storedBytes(object);
1032
1093
  if (plaintext.length > GITVAULT_MAX_REF_STATE_OBJECT_BYTES)
1033
1094
  fail("REF_STATE_LIMIT_EXCEEDED", "ref_state object exceeds 32 MiB", "building ref_state");
1034
- return { object, upload: this.seal("ref_state", id, plaintext, gitvaultPaths.refState(id)) };
1095
+ return { object, upload: this.seal("ref_state", id, plaintext, gitvaultPaths.refState(id), keyOverride) };
1035
1096
  }
1036
- buildRetentionRoots(generation, roots, cutoff) {
1097
+ buildRetentionRoots(generation, roots, cutoff, keyOverride) {
1037
1098
  const id = newGitvaultId("rr");
1038
1099
  const object = signGitvaultObject({ format: GITVAULT_FORMAT, object_kind: "retention_roots", suite: GITVAULT_SUITE, repo_id: this.repoId, object_id: id, generation, cutoff, roots: [...roots].sort(compareRoots) }, this.signer());
1039
1100
  const plaintext = storedBytes(object);
1040
1101
  if (plaintext.length > GITVAULT_MAX_REF_STATE_OBJECT_BYTES)
1041
1102
  fail("REF_STATE_LIMIT_EXCEEDED", "retention_roots object exceeds 32 MiB", "building retention_roots");
1042
- return { object, upload: this.seal("retention_roots", id, plaintext, gitvaultPaths.retentionRoots(id)) };
1103
+ return { object, upload: this.seal("retention_roots", id, plaintext, gitvaultPaths.retentionRoots(id), keyOverride) };
1043
1104
  }
1044
1105
  /** Plaintext, independently non-thin packs covering `reachable(tips) ∖ reachable(base)`, split at the multi-object target. */
1045
1106
  async buildPacks(tips, base) {
@@ -1518,8 +1579,31 @@ export class GitvaultVault {
1518
1579
  }
1519
1580
  return issued;
1520
1581
  }
1521
- signHead(fields) {
1522
- return signGitvaultObject({ format: GITVAULT_FORMAT, object_kind: "head", suite: GITVAULT_SUITE, repo_id: this.repoId, generation: fields.generation, prev_sha256: fields.prev_sha256, epoch: GITVAULT_GENESIS_EPOCH, wal_entries: fields.wal_entries, ref_state: fields.ref_state, retention_roots: fields.retention_roots, checkpoint: fields.checkpoint, checkpoint_purpose: fields.checkpoint_purpose, capture_binding: fields.capture_binding, repair: fields.repair, transition: null, writer_key_id: this.writerKeyId(), created_at: formatGitvaultTimestamp(this.now()) }, this.signer());
1582
+ /**
1583
+ * `overrides.epoch` (D194, rev 42 fix): defaults to `this.epoch()` the
1584
+ * LOCALLY KNOWN current epoch — rather than the fixed genesis constant.
1585
+ * This is load-bearing, not cosmetic: once ANY rotation has landed, every
1586
+ * ORDINARY (non-`rotate_epoch`) head this principal signs must still
1587
+ * claim the vault's CURRENT epoch (protocol §4.3's chain-link rule —
1588
+ * "every head's epoch equals its predecessor's UNLESS this head admits a
1589
+ * `rotate_epoch` transition"); hard-coding the generation-1 constant here
1590
+ * would make EVERY ordinary push after a vault's first rotation refuse
1591
+ * `CHAIN_BROKEN` forever. `rotateEpoch` passes `overrides.epoch = new_epoch`
1592
+ * explicitly for the ONE head that legitimately claims a DIFFERENT epoch
1593
+ * than `this.epoch()` currently reads (the local pointer only advances
1594
+ * AFTER a successful admit, via `keystore.recordEpochRotation`).
1595
+ */
1596
+ signHead(fields, overrides) {
1597
+ const unsigned = {
1598
+ format: GITVAULT_FORMAT, object_kind: "head", suite: GITVAULT_SUITE, repo_id: this.repoId,
1599
+ generation: fields.generation, prev_sha256: fields.prev_sha256, epoch: overrides?.epoch ?? this.epoch(),
1600
+ wal_entries: fields.wal_entries, ref_state: fields.ref_state, retention_roots: fields.retention_roots,
1601
+ checkpoint: fields.checkpoint, checkpoint_purpose: fields.checkpoint_purpose, capture_binding: fields.capture_binding,
1602
+ repair: fields.repair, transition: overrides?.transition ?? null,
1603
+ ...(overrides?.pin_manifest ? { pin_manifest: overrides.pin_manifest } : {}),
1604
+ writer_key_id: this.writerKeyId(), created_at: formatGitvaultTimestamp(this.now()),
1605
+ };
1606
+ return signGitvaultObject(unsigned, this.signer());
1523
1607
  }
1524
1608
  /** Admit a signed head; on success read it back from storage and compare BEFORE any pin advances. */
1525
1609
  async admit(head) {
@@ -1536,6 +1620,468 @@ export class GitvaultVault {
1536
1620
  this.keystore.updateRepo(this.repoId, { head_pin: pin, materialized_pin: pin, verified_prefix: null });
1537
1621
  return { outcome: "admitted", head_sha256: hash, admission_record_sha256: result.admission_record_sha256, capture_receipt: result.capture_receipt };
1538
1622
  }
1623
+ // ── epoch rotation (D193-D203, rev 42, change gitvault-human-envelopes) ──
1624
+ /**
1625
+ * Walk the chain BACKWARD from the current tip to find the nearest
1626
+ * admitted `recipient_pin_manifest` receipt (D197: "a fresh client... reads
1627
+ * the LATEST admitted manifest, walking `prev_sha256` back to the nearest
1628
+ * head carrying one"). Heads are plaintext-structured/signed (never
1629
+ * encrypted) so this needs no key material — only signature verification
1630
+ * against the creator's own pubkey. Falls back to `vault_genesis.pin_manifest`
1631
+ * (D198 N-recipient genesis — this SDK does not BUILD one, but reads one
1632
+ * correctly for interop), then to the zero-value sentinel (no manifest has
1633
+ * ever been admitted — every principal starts `excluded_unconfirmed`).
1634
+ *
1635
+ * Cost is O(distance to the nearest pin-manifest-bearing head) — for a
1636
+ * vault that has never published one, that is every generation back to
1637
+ * genesis. There is no index that avoids this in protocol v0 (the same
1638
+ * cost class `verifyToNewest`'s own chain walk already has); a vault with
1639
+ * a long, pin-manifest-free history pays it once per rotation.
1640
+ */
1641
+ async loadEffectivePinManifest(newestGeneration) {
1642
+ const { genesis } = await this.genesis();
1643
+ let gen = generationToBigInt(newestGeneration);
1644
+ while (gen >= 1n) {
1645
+ const generation = gen.toString(16).padStart(16, "0");
1646
+ const bytes = await this.transport.getObject({ repo_id: this.repoId, path: gitvaultPaths.head(generation) });
1647
+ if (!bytes)
1648
+ fail("CHAIN_BROKEN", `head ${generation} is missing from storage while resolving the effective pin manifest`, "resolving the effective recipient pin manifest", { generation });
1649
+ const head = parseGitvaultStrict(new TextDecoder().decode(bytes));
1650
+ if (head.pin_manifest)
1651
+ return this.readPinManifestObject(head.pin_manifest);
1652
+ gen -= 1n;
1653
+ }
1654
+ if (genesis.pin_manifest)
1655
+ return this.readPinManifestObject(genesis.pin_manifest);
1656
+ return { pinManifestVersion: "0".repeat(16), pinManifestSha256: GITVAULT_ZERO_SHA256_SENTINEL, pinnedFingerprintOf: new Map() };
1657
+ }
1658
+ /**
1659
+ * Local-cache short-circuit (see {@link GitvaultRepoFile.known_pin_manifest}'s
1660
+ * own doc comment for why this is safe): a manifest THIS keystore itself
1661
+ * just built, signed, and admitted is resolved from the on-disk cache
1662
+ * instead of a network `object-reads` round trip — same return shape,
1663
+ * skipped only on an exact `(pin_manifest_version, stored_bytes_sha256)`
1664
+ * match against `receipt`. A miss (cache absent, or naming a DIFFERENT
1665
+ * manifest — e.g. one another principal/machine published) falls through
1666
+ * to the unchanged network path below.
1667
+ *
1668
+ * **Known gap this happens to route around, not fix:** the gateway's
1669
+ * `POST …/object-reads` route does not yet recognize `recipient_pin_manifest`
1670
+ * as a readable `object_kind` at all (`services/gitvault/reads.ts`
1671
+ * `validateReadRequest`'s null-`idScalar` branch is still hardcoded to
1672
+ * `key_envelope`'s `{epoch, recipient_fingerprint}` shape — the ONE other
1673
+ * path-addressed kind, `recipient_pin_manifest`, was never folded in when
1674
+ * D197 shipped it with its own `{pin_manifest_version}` `pathFields`).
1675
+ * Confirmed live 2026-08-27 against `src_c78d2f710a8f49d22f9c66faf2a915cd`:
1676
+ * every read attempt fails `400 VALIDATION_FAILED "objects[0]: epoch must
1677
+ * be 16 hex"`. This cache unblocks the SAME keystore re-reading its OWN
1678
+ * just-published manifest (this ceremony's actual need) but does nothing
1679
+ * for a genuinely fresh keystore/machine reading an EXISTING manifest for
1680
+ * the first time (§4.11's "fresh client... SEEDS its local pin file from
1681
+ * it" onboarding path) — that still needs the gateway fix.
1682
+ */
1683
+ async readPinManifestObject(receipt) {
1684
+ const known = this.repoFile().known_pin_manifest;
1685
+ if (known && known.pin_manifest_version === receipt.pin_manifest_version && known.stored_bytes_sha256 === receipt.stored_bytes_sha256) {
1686
+ return { pinManifestVersion: known.pin_manifest_version, pinManifestSha256: known.stored_bytes_sha256, pinnedFingerprintOf: new Map(known.pins.map((p) => [p.principal_id, p.ek_fingerprint])) };
1687
+ }
1688
+ const bytes = await this.transport.getObject({ repo_id: this.repoId, path: gitvaultPaths.pinManifest(receipt.pin_manifest_version) });
1689
+ if (!bytes || sha256Hex(bytes) !== receipt.stored_bytes_sha256) {
1690
+ fail("GITVAULT_RECEIPT_MISMATCH", `recipient_pin_manifest ${receipt.pin_manifest_version} is absent or does not match its receipted hash`, "resolving the effective recipient pin manifest", { pin_manifest_version: receipt.pin_manifest_version });
1691
+ }
1692
+ const manifest = parseGitvaultStrict(new TextDecoder().decode(bytes));
1693
+ const { genesis } = await this.genesis();
1694
+ if (!verifyGitvaultObject(manifest, genesis.creator_signing_pubkey)) {
1695
+ 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 });
1696
+ }
1697
+ const pinnedFingerprintOf = new Map(manifest.pins.map((p) => [p.principal_id, p.ek_fingerprint]));
1698
+ return { pinManifestVersion: receipt.pin_manifest_version, pinManifestSha256: receipt.stored_bytes_sha256, pinnedFingerprintOf };
1699
+ }
1700
+ /**
1701
+ * Build the successor `recipient_pin_manifest` object from the currently-
1702
+ * effective PREDECESSOR manifest plus a batch of receipted updates (D197
1703
+ * full-map conservation: `next_map = prior_map + receipt-authorized
1704
+ * additions/replacements`). Shared by {@link publishPinManifestUpdate} (a
1705
+ * single-entry ordinary-push publish) and {@link rotateEpoch}'s
1706
+ * `pending_confirmations` fold (a multi-entry publish riding the SAME
1707
+ * head as the rotation, D196). Validates EVERY update's receipt against
1708
+ * `prior` before building — a receipt issued against a stale predecessor
1709
+ * fails closed here (`VALIDATION_FAILED`), never silently overwritten,
1710
+ * matching the gateway's own admission-time field-by-field check.
1711
+ *
1712
+ * Does NOT admit anything — the caller uploads `.upload` and attaches the
1713
+ * returned `{object_kind, pin_manifest_version, stored_bytes_sha256,
1714
+ * size_bytes}` receipt shape to whichever head it is publishing on.
1715
+ */
1716
+ buildPinManifestUpdate(prior, updates) {
1717
+ for (const u of updates) {
1718
+ if (u.receipt.base_pin_manifest_sha256 !== prior.pinManifestSha256) {
1719
+ fail("VALIDATION_FAILED", `the confirmation receipt for ${u.principal_id} was issued against a different predecessor manifest than the one currently effective — obtain a fresh /confirm or /repin`, "publishing a recipient_pin_manifest update", { principal_id: u.principal_id, receipt_base: u.receipt.base_pin_manifest_sha256, current_base: prior.pinManifestSha256 });
1720
+ }
1721
+ }
1722
+ const updateIds = new Set(updates.map((u) => u.principal_id));
1723
+ if (updateIds.size !== updates.length) {
1724
+ fail("VALIDATION_FAILED", "duplicate principal_id across pin-manifest updates in one publish", "publishing a recipient_pin_manifest update", { principal_ids: updates.map((u) => u.principal_id) });
1725
+ }
1726
+ const nextVersion = (generationToBigInt(prior.pinManifestVersion) + 1n).toString(16).padStart(16, "0");
1727
+ const pins = [];
1728
+ for (const [principalId, ekFp] of prior.pinnedFingerprintOf) {
1729
+ if (updateIds.has(principalId))
1730
+ continue; // replaced below
1731
+ pins.push({ principal_id: principalId, ek_fingerprint: ekFp, pinned_at: formatGitvaultTimestamp(this.now()), confirmed_by: "operator_confirmation", confirmation_receipt_sha256: GITVAULT_ZERO_SHA256_SENTINEL });
1732
+ }
1733
+ for (const u of updates) {
1734
+ pins.push({ principal_id: u.principal_id, ek_fingerprint: u.ek_fingerprint, pinned_at: formatGitvaultTimestamp(this.now()), confirmed_by: "operator_confirmation", confirmation_receipt_sha256: storedBytesSha256(u.receipt) });
1735
+ }
1736
+ pins.sort((a, b) => (a.principal_id < b.principal_id ? -1 : a.principal_id > b.principal_id ? 1 : 0));
1737
+ const unsignedManifest = { format: GITVAULT_FORMAT, object_kind: "recipient_pin_manifest", suite: GITVAULT_SUITE, repo_id: this.repoId, pin_manifest_version: nextVersion, base_pin_manifest_sha256: prior.pinManifestSha256, pins, writer_key_id: this.writerKeyId() };
1738
+ const manifest = signGitvaultObject(unsignedManifest, this.signer());
1739
+ const manifestBytes = storedBytes(manifest);
1740
+ const manifestSha = sha256Hex(manifestBytes);
1741
+ const upload = { path: gitvaultPaths.pinManifest(nextVersion), object_kind: "recipient_pin_manifest", object_id: null, bytes: manifestBytes, sha256: manifestSha, size_bytes: String(manifestBytes.length) };
1742
+ return { nextVersion, manifestSha, upload, pins: pins.map((p) => ({ principal_id: p.principal_id, ek_fingerprint: p.ek_fingerprint })) };
1743
+ }
1744
+ /**
1745
+ * Build a `recipient_pin_manifest` update (D197 full-map conservation) and
1746
+ * publish it via an ORDINARY head (`gitvault.writer`-sufficient — the
1747
+ * OWNER-GATED half of the ceremony already happened at `/confirm`/`/repin`,
1748
+ * which is what produced `receipt`; PUBLISHING the resulting entry is
1749
+ * ordinary-writer authority, same split as envelope-wrap authority
1750
+ * always had). Carries the SAME refs/roots forward unchanged, at the
1751
+ * CURRENT epoch (this is not a rotation — no new epoch, no new K_e).
1752
+ *
1753
+ * **This is an ORDINARY admission (`transition: null`) and is therefore
1754
+ * itself refused `EPOCH_ROTATION_REQUIRED` while a migration/revocation/
1755
+ * exposure condition is outstanding on this vault (D193) — the exact
1756
+ * deadlock the incident behind {@link GitvaultVault.rotateEpoch}'s
1757
+ * `pending_confirmations` parameter closes.** When this vault is in that
1758
+ * state, fold the receipt into `rotateEpoch({..., pending_confirmations:
1759
+ * [{principal_id, ek_fingerprint, receipt}]})` instead of calling this
1760
+ * method directly — that submission carries a `rotate_epoch` transition,
1761
+ * which IS the gate's own escape valve.
1762
+ */
1763
+ async publishPinManifestUpdate(input) {
1764
+ let conflicts = 0;
1765
+ for (;;) {
1766
+ const base = await this.materialize();
1767
+ const prior = await this.loadEffectivePinManifest(base.generation);
1768
+ const { nextVersion, manifestSha, upload: manifestUpload, pins } = this.buildPinManifestUpdate(prior, [input]);
1769
+ await this.uploadAll([manifestUpload]);
1770
+ const generation = nextGeneration(base.generation);
1771
+ const refState = this.buildRefState(generation, base.refs, base.head_target);
1772
+ const rootsObj = this.buildRetentionRoots(generation, base.roots, null);
1773
+ await this.uploadAll([refState.upload, rootsObj.upload]);
1774
+ const head = this.signHead({
1775
+ generation, prev_sha256: base.head_sha256, wal_entries: [],
1776
+ ref_state: { object_id: refState.object.object_id, object_kind: "ref_state", ciphertext_sha256: refState.upload.sha256, size_bytes: refState.upload.size_bytes },
1777
+ retention_roots: { object_id: rootsObj.object.object_id, object_kind: "retention_roots", ciphertext_sha256: rootsObj.upload.sha256, size_bytes: rootsObj.upload.size_bytes },
1778
+ checkpoint: null, checkpoint_purpose: null, capture_binding: null, repair: null,
1779
+ }, { pin_manifest: { object_kind: "recipient_pin_manifest", pin_manifest_version: nextVersion, stored_bytes_sha256: manifestSha, size_bytes: String(manifestUpload.size_bytes) } });
1780
+ const admitted = await this.admit(head);
1781
+ if (admitted.outcome === "conflict") {
1782
+ conflicts += 1;
1783
+ if (conflicts > this.retries)
1784
+ fail("HEAD_CAS_CONFLICT", `the pin-manifest update lost ${conflicts} races at generation ${generation}; giving up`, "publishing a recipient_pin_manifest update", { generation, winner: admitted.winner });
1785
+ continue;
1786
+ }
1787
+ // Cache OUR OWN just-admitted manifest (see readPinManifestObject's
1788
+ // doc comment) so a LATER call on this vault — most importantly
1789
+ // rotateEpoch's own loadEffectivePinManifest, which MUST read the
1790
+ // predecessor manifest back to compute confirmed() (D196) — resolves
1791
+ // it locally instead of a network object-reads round trip.
1792
+ this.keystore.updateRepo(this.repoId, { known_pin_manifest: { pin_manifest_version: nextVersion, stored_bytes_sha256: manifestSha, pins } });
1793
+ return { generation, head_sha256: admitted.head_sha256, head, admission_record_sha256: admitted.admission_record_sha256, capture_receipt: admitted.capture_receipt, form: "wal", conflicts_retried: conflicts, refs: base.refs };
1794
+ }
1795
+ }
1796
+ /**
1797
+ * Drive one epoch rotation to a committed head (D193-D203). This is the
1798
+ * producer's obligations in full: sample a FRESH `K_e` independent of
1799
+ * every prior epoch key this principal has locally held; compute the H
1800
+ * bijection from the live desired-recipient state + the effective pin
1801
+ * manifest; seal one `key_envelope` per included recipient from the SAME
1802
+ * `K_e`; submit the create-only `rotation_attempt_descriptor` BEFORE any
1803
+ * envelope upload; upload the envelopes; submit the `rotate_epoch` head;
1804
+ * on a CAS conflict, retry from a fresh `materialize()` (the SAME
1805
+ * conflict-retry shape `push()` uses); after commit, verify this
1806
+ * principal's OWN envelope (when it is itself a recipient) opens to
1807
+ * exactly the committed `K_e` and reproduces `epoch_key_commitment`
1808
+ * (D200's narrowed per-recipient self-check — never a global proof);
1809
+ * advance the local keystore's current epoch/key pointer AND retain the
1810
+ * prior key in `epoch_keys`.
1811
+ *
1812
+ * **`recipient_state_version`/`recipient_revocation_version` are REQUIRED
1813
+ * inputs, not discovered here.** The gateway exposes NO general read route
1814
+ * for `internal.gitvault_recipient_state_counters` (D194) — the ONLY
1815
+ * client-visible read of these two org-scoped counters today is the
1816
+ * response of `POST …/recipients/:principal_id/key-revocation`
1817
+ * ({@link GitvaultTransport.declareRecipientKeyRevoked}), which is why
1818
+ * {@link rotateEpochForKeyRevocation} (below) is the one fully
1819
+ * self-contained entry point. For `member_removed` / `elective_rekey` /
1820
+ * `epoch_secret_exposed`, a caller that does not already know the current
1821
+ * counter pair cannot discover it from any shipped gateway route — this
1822
+ * is a confirmed gap in the live gateway (verified against
1823
+ * `packages/gateway/src/services/gitvault/reads.ts:getVaultRecord` and
1824
+ * every `routes/gitvault*.ts` handler, not inferred), not a client
1825
+ * limitation this SDK can work around. Passing a stale/guessed pair fails
1826
+ * safely: the admission fence's D194 frozen-counter comparison refuses
1827
+ * `RECIPIENT_SET_MISMATCH` rather than silently canonizing against wrong
1828
+ * evidence.
1829
+ *
1830
+ * **`options.pending_confirmations` (the manifest-publish deadlock fix).**
1831
+ * `publishPinManifestUpdate` is an ORDINARY admission (`transition:
1832
+ * null`) and is therefore itself refused `EPOCH_ROTATION_REQUIRED` while
1833
+ * this vault has an urgent/migration condition outstanding — the exact
1834
+ * state a `rotateEpoch` call is being made to clear. Reproduced live in
1835
+ * production 2026-08-27 (a vault's first `epoch_secret_exposed` rekey:
1836
+ * `/confirm` minted a receipt server-side, but the ordinary push that
1837
+ * would publish it never admitted). Pass the pending receipted updates
1838
+ * here instead — they ride the SAME head as this rotation's `transition`,
1839
+ * which IS `EPOCH_ROTATION_REQUIRED`'s own escape valve, so the publish
1840
+ * is no longer blocked. **This does NOT include these principals in
1841
+ * THIS rotation's `envelopes[]`** — protocol-v0.md D196 is explicit: "a
1842
+ * manifest update riding the SAME head never self-authorizes its own
1843
+ * recipients"; `confirmed(h)` for THIS rotation still reads only the
1844
+ * PREDECESSOR manifest, unchanged. They land in
1845
+ * `excluded_unconfirmed_principal_ids` here (same as without folding) and
1846
+ * become eligible starting at the NEXT rotation, once this manifest is
1847
+ * the admitted predecessor. See {@link GitvaultRotationResult.pin_manifest_published}.
1848
+ *
1849
+ * **Honest residual — this does not rescue a vault with ZERO ever-
1850
+ * confirmed principals.** If `included` would be empty even with the
1851
+ * fold (no predecessor-confirmed principal exists at all — e.g. a
1852
+ * grandfathered pre-rev-42 vault whose bare genesis never published a
1853
+ * pin manifest and which was never bootstrapped before its
1854
+ * `migration_rotation_required` flag was set), the gateway refuses
1855
+ * `EPOCH_ROTATION_WOULD_LEAVE_VAULT_UNCOVERED` regardless of what rides
1856
+ * along on `pin_manifest` — D196's same-head exclusion makes THIS
1857
+ * impossible to route around from the client. That is a genuine,
1858
+ * currently-open protocol gap (not something this parameter can paper
1859
+ * over) and needs an operator-side decision, not a client workaround.
1860
+ */
1861
+ async rotateEpoch(options) {
1862
+ let conflicts = 0;
1863
+ for (;;) {
1864
+ const base = await this.materialize();
1865
+ const repo = this.repoFile();
1866
+ const currentEpoch = base.head?.epoch ?? this.epoch();
1867
+ const newEpoch = nextEpoch(currentEpoch);
1868
+ // D195's producer obligation: a fresh K_e, checked against EVERY prior
1869
+ // epoch key this principal has ever locally held.
1870
+ const kE = randomBytes(32);
1871
+ const priorKnownKeys = Object.values(repo.epoch_keys ?? { [repo.epoch]: repo.k_repo_hex }).map(hexToBytes);
1872
+ checkFreshEpochKeyAgainstPriorKeys(kE, priorKnownKeys);
1873
+ // Live desired-recipient state (H) + the effective pin manifest.
1874
+ const envelopeRecipients = await this.transport.listEnvelopeRecipients({ repo_id: this.repoId });
1875
+ const desired = envelopeRecipients.desired;
1876
+ if (!desired) {
1877
+ fail("GITVAULT_DESIRED_STATE_UNAVAILABLE", "the gateway did not report desired-recipient state (desired[]) for this vault — an epoch rotation cannot compute its H-partition without it", "computing the epoch-rotation H-partition");
1878
+ }
1879
+ const pinManifest = await this.loadEffectivePinManifest(base.generation);
1880
+ // D196's H-partition: included / excluded_keyless / excluded_unconfirmed.
1881
+ // A `pending_removal` desired-recipient row is EXCLUDED from H entirely
1882
+ // (the gateway's own H is `status='active'` only) — that omission is
1883
+ // the entire forward-revocation point for reason:"member_removed".
1884
+ const included = [];
1885
+ const excludedKeyless = [];
1886
+ const excludedUnconfirmed = [];
1887
+ const pinBlocked = [];
1888
+ for (const d of desired) {
1889
+ if (d.status !== "active")
1890
+ continue;
1891
+ if (!d.ek_fingerprint || !d.public_key) {
1892
+ excludedKeyless.push(d.principal_id);
1893
+ continue;
1894
+ }
1895
+ const pinned = pinManifest.pinnedFingerprintOf.get(d.principal_id);
1896
+ if (pinned === undefined) {
1897
+ excludedUnconfirmed.push(d.principal_id);
1898
+ continue;
1899
+ }
1900
+ if (pinned !== d.ek_fingerprint) {
1901
+ // A live pin exists but disagrees with the directory's current
1902
+ // fingerprint (the principal re-enrolled a key since their last
1903
+ // confirmation). Neither "included" (D196 requires the envelope
1904
+ // fingerprint to equal the PINNED one) nor "excluded_unconfirmed"
1905
+ // (D196/gateway validation requires pinnedFingerprintOf to LACK
1906
+ // them entirely) is a legal bucket for this principal under the
1907
+ // protocol as specified — the owner must run `/repin` first.
1908
+ pinBlocked.push({ principal_id: d.principal_id, directory_fingerprint: d.ek_fingerprint, pinned_fingerprint: pinned });
1909
+ continue;
1910
+ }
1911
+ let pub;
1912
+ try {
1913
+ pub = fromBase64url(d.public_key, "public_key");
1914
+ }
1915
+ catch {
1916
+ pinBlocked.push({ principal_id: d.principal_id, directory_fingerprint: d.ek_fingerprint, pinned_fingerprint: pinned });
1917
+ continue;
1918
+ }
1919
+ if (ekFingerprint(pub) !== d.ek_fingerprint) {
1920
+ pinBlocked.push({ principal_id: d.principal_id, directory_fingerprint: d.ek_fingerprint, pinned_fingerprint: pinned });
1921
+ continue;
1922
+ }
1923
+ included.push({ principal_id: d.principal_id, ek_fingerprint: d.ek_fingerprint, public_key: pub });
1924
+ }
1925
+ if (pinBlocked.length > 0) {
1926
+ fail("GITVAULT_ROTATION_BLOCKED_PIN_MISMATCH", `${pinBlocked.length} principal(s) have a live pin that disagrees with their current directory key and cannot be safely included or excluded under the protocol's own H-partition rules — run the /repin ceremony for each, then retry: ${pinBlocked.map((p) => p.principal_id).join(", ")}`, "computing the epoch-rotation H-partition", { blocked: pinBlocked });
1927
+ }
1928
+ if (options.reason === "elective_rekey" && (excludedKeyless.length > 0 || excludedUnconfirmed.length > 0)) {
1929
+ fail("EPOCH_ROTATION_INCOMPLETE_ENROLLMENT", `reason:"elective_rekey" refuses a target set that is a proper subset of the vault's current actual coverage — ${excludedKeyless.length} keyless, ${excludedUnconfirmed.length} unconfirmed`, "computing the epoch-rotation H-partition", { excluded_keyless_principal_ids: excludedKeyless, excluded_unconfirmed_principal_ids: excludedUnconfirmed });
1930
+ }
1931
+ if (included.length === 0) {
1932
+ fail("EPOCH_ROTATION_WOULD_LEAVE_VAULT_UNCOVERED", "every eligible principal is excluded — this reason would leave the vault with no recipient able to decrypt the new epoch", "computing the epoch-rotation H-partition");
1933
+ }
1934
+ // Self-consistency (defense in depth — the gateway's own recomputation under its live lock is authoritative).
1935
+ const desiredIds = new Set(desired.filter((d) => d.status === "active").map((d) => d.principal_id));
1936
+ const keyedIds = new Set(desired.filter((d) => d.status === "active" && d.ek_fingerprint).map((d) => d.principal_id));
1937
+ const hCheck = checkHPartition({ desiredPrincipalIds: desiredIds, keyedPrincipalIds: keyedIds, pinnedFingerprintOf: pinManifest.pinnedFingerprintOf, included, excludedKeylessPrincipalIds: excludedKeyless, excludedUnconfirmedPrincipalIds: excludedUnconfirmed });
1938
+ if (!hCheck.ok)
1939
+ fail("VALIDATION_FAILED", `internal: the H-partition this producer built is not a valid bijection over H (${hCheck.detail}) — refusing to submit a rotation the gateway would refuse`, "computing the epoch-rotation H-partition");
1940
+ const targetPartitionDigest = computeTargetPartitionDigest({
1941
+ recipient_state_version: options.recipient_state_version,
1942
+ recipient_revocation_version: options.recipient_revocation_version,
1943
+ pin_manifest_version: pinManifest.pinManifestVersion,
1944
+ pin_manifest_sha256: pinManifest.pinManifestSha256,
1945
+ included: included.map((p) => ({ principal_id: p.principal_id, ek_fingerprint: p.ek_fingerprint })),
1946
+ excluded_keyless_principal_ids: excludedKeyless,
1947
+ excluded_unconfirmed_principal_ids: excludedUnconfirmed,
1948
+ });
1949
+ // D195: build + sign the rotation_attempt_descriptor, derive rotation_id, submit the create-only CAS BEFORE any envelope upload.
1950
+ const clientIdempotencyKey = options.client_idempotency_key ?? newHex32();
1951
+ const descriptorFields = {
1952
+ format: GITVAULT_FORMAT, object_kind: "rotation_attempt_descriptor", suite: GITVAULT_SUITE,
1953
+ repo_id: this.repoId, base_head_sha256: base.head_sha256, new_epoch: newEpoch,
1954
+ recipient_state_version: options.recipient_state_version, recipient_revocation_version: options.recipient_revocation_version,
1955
+ pin_manifest_sha256: pinManifest.pinManifestSha256, target_partition_digest: targetPartitionDigest,
1956
+ client_idempotency_key: clientIdempotencyKey, writer_key_id: this.writerKeyId(),
1957
+ };
1958
+ const attemptCommitment = attemptKeyCommitment(kE, this.repoId, newEpoch, descriptorFields);
1959
+ const signedDescriptor = signGitvaultObject({ ...descriptorFields, attempt_key_commitment: attemptCommitment }, this.signer());
1960
+ const rotationId = computeRotationId(signedDescriptor);
1961
+ const attemptOut = await this.transport.createRotationAttempt({ repo_id: this.repoId, descriptor: signedDescriptor });
1962
+ if (attemptOut.rotation_id !== rotationId) {
1963
+ fail("ROTATION_ID_MISMATCH", "the gateway's own re-derived rotation_id disagrees with this client's derivation — refusing to proceed", "creating a rotation attempt", { client_derived: rotationId, gateway_derived: attemptOut.rotation_id });
1964
+ }
1965
+ // Seal one key_envelope per included recipient, all from the SAME kE (D200's global one-key property depends on this).
1966
+ const sealedReceipts = [];
1967
+ const envelopeUploads = [];
1968
+ const nowIso = formatGitvaultTimestamp(this.now());
1969
+ for (const p of included) {
1970
+ const sealed = await sealKeyEnvelope({ k_repo: kE, repo_id: this.repoId, epoch: newEpoch, recipient_public_key: p.public_key, signer: this.signingKeypair(), created_at: nowIso, rotation_id: rotationId });
1971
+ sealedReceipts.push({ principal_id: p.principal_id, envelope: sealed.receipt });
1972
+ envelopeUploads.push({ path: gitvaultPaths.envelope(newEpoch, p.ek_fingerprint, rotationId), object_kind: "key_envelope", object_id: null, bytes: sealed.stored_bytes, sha256: sealed.stored_bytes_sha256, size_bytes: sealed.size_bytes });
1973
+ }
1974
+ if (envelopeUploads.length > 0)
1975
+ await this.uploadAll(envelopeUploads);
1976
+ sealedReceipts.sort((a, b) => (a.principal_id < b.principal_id ? -1 : a.principal_id > b.principal_id ? 1 : 0));
1977
+ const epochKeyCommitmentValue = epochRotationKeyCommitment(kE, this.repoId, newEpoch, rotationId, included.map((p) => p.ek_fingerprint));
1978
+ const payload = {
1979
+ new_epoch: newEpoch, rotation_id: rotationId, reason: options.reason,
1980
+ recipient_state_version: options.recipient_state_version, recipient_revocation_version: options.recipient_revocation_version,
1981
+ pin_manifest_sha256: pinManifest.pinManifestSha256, target_partition_digest: targetPartitionDigest,
1982
+ epoch_key_commitment: epochKeyCommitmentValue, excluded_keyless_principal_ids: excludedKeyless, excluded_unconfirmed_principal_ids: excludedUnconfirmed,
1983
+ recipient_authority_attestation: null, envelopes: sealedReceipts,
1984
+ };
1985
+ const payloadBytes = jcs(payload);
1986
+ const transition = { kind: "rotate_epoch", payload_format: "base64url-jcs", payload: toBase64url(payloadBytes), payload_sha256: sha256Hex(payloadBytes) };
1987
+ // The manifest-publish deadlock fix: fold receipted pending
1988
+ // confirmations into THIS SAME head's `pin_manifest` field (schema-
1989
+ // legal, §4.3 — `transition` and `pin_manifest` are independent
1990
+ // optional fields on one `head`). Built from `pinManifest`, the
1991
+ // PREDECESSOR already loaded above for the H-partition — NEVER used
1992
+ // to recompute `included`/`excludedUnconfirmed` above, which is what
1993
+ // keeps this conformant with D196 ("a manifest update riding the SAME
1994
+ // head never self-authorizes its own recipients"). This durably
1995
+ // publishes the receipts (unblocking every future ordinary admission
1996
+ // once this rotation clears the urgent/migration condition) without
1997
+ // claiming these principals are covered by THIS rotation's envelopes.
1998
+ const pendingConfirmations = options.pending_confirmations ?? [];
1999
+ const pinManifestFold = pendingConfirmations.length > 0
2000
+ ? this.buildPinManifestUpdate(pinManifest, pendingConfirmations.map((p) => ({ principal_id: p.principal_id, ek_fingerprint: p.ek_fingerprint, confirmed_by: "operator_confirmation", receipt: p.receipt })))
2001
+ : null;
2002
+ // The new epoch's ref_state/retention_roots — CARRIED FORWARD unchanged, sealed under kE.
2003
+ const generation = nextGeneration(base.generation);
2004
+ const refState = this.buildRefState(generation, base.refs, base.head_target, { k_repo: kE, epoch: newEpoch });
2005
+ const rootsObj = this.buildRetentionRoots(generation, base.roots, null, { k_repo: kE, epoch: newEpoch });
2006
+ const uploads = [refState.upload, rootsObj.upload];
2007
+ if (pinManifestFold)
2008
+ uploads.push(pinManifestFold.upload);
2009
+ await this.uploadAll(uploads);
2010
+ const head = this.signHead({
2011
+ generation, prev_sha256: base.head_sha256, wal_entries: [],
2012
+ ref_state: { object_id: refState.object.object_id, object_kind: "ref_state", ciphertext_sha256: refState.upload.sha256, size_bytes: refState.upload.size_bytes },
2013
+ retention_roots: { object_id: rootsObj.object.object_id, object_kind: "retention_roots", ciphertext_sha256: rootsObj.upload.sha256, size_bytes: rootsObj.upload.size_bytes },
2014
+ checkpoint: null, checkpoint_purpose: null, capture_binding: null, repair: null,
2015
+ }, {
2016
+ epoch: newEpoch, transition,
2017
+ ...(pinManifestFold ? { pin_manifest: { object_kind: "recipient_pin_manifest", pin_manifest_version: pinManifestFold.nextVersion, stored_bytes_sha256: pinManifestFold.manifestSha, size_bytes: pinManifestFold.upload.size_bytes } } : {}),
2018
+ });
2019
+ const admitted = await this.admit(head);
2020
+ if (admitted.outcome === "conflict") {
2021
+ conflicts += 1;
2022
+ if (conflicts > this.retries)
2023
+ fail("HEAD_CAS_CONFLICT", `the rotation lost ${conflicts} races at generation ${generation}; giving up`, "admitting a rotate_epoch head", { generation, winner: admitted.winner });
2024
+ continue;
2025
+ }
2026
+ // D200's post-commit self-check: verify THIS principal's own envelope
2027
+ // (when it is itself a recipient) opens to exactly the committed K_e
2028
+ // and reproduces epoch_key_commitment — a per-recipient proof only,
2029
+ // never a global set-coherence claim.
2030
+ let selfCheck = "not_a_recipient";
2031
+ const identity = this.keystore.readIdentity();
2032
+ const ownKeypair = identity ? this.keystore.encryptionKeypair(identity) : null;
2033
+ if (identity && ownKeypair) {
2034
+ const ownFingerprint = ekFingerprint(ownKeypair.public_key);
2035
+ const own = included.find((p) => p.ek_fingerprint === ownFingerprint);
2036
+ const ownPair = own ? sealedReceipts.find((r) => r.principal_id === own.principal_id) : undefined;
2037
+ if (own && ownPair) {
2038
+ const envelopeBytes = await this.transport.getObject({ repo_id: this.repoId, path: gitvaultPaths.envelope(newEpoch, own.ek_fingerprint, rotationId) });
2039
+ if (!envelopeBytes || sha256Hex(envelopeBytes) !== ownPair.envelope.stored_bytes_sha256) {
2040
+ fail("GITVAULT_RECEIPT_MISMATCH", "this principal's own rotation-attempt envelope is absent or altered after commit", "verifying the committed epoch key", { rotation_id: rotationId });
2041
+ }
2042
+ const envelopeObj = parseGitvaultStrict(new TextDecoder().decode(envelopeBytes));
2043
+ // Verification key: this producer's OWN signing key — only the
2044
+ // vault's single registered writer key can sign a rotate_epoch
2045
+ // head/descriptor at all (v0 single-writer model), so a rotation
2046
+ // this call itself drove was necessarily signed by `this.signingKeypair()`.
2047
+ const recoveredKe = await openKeyEnvelope({ envelope: envelopeObj, recipient: ownKeypair, signer_public_key: this.signingKeypair().public_key });
2048
+ if (bytesToHex(recoveredKe) !== bytesToHex(kE)) {
2049
+ fail("GITVAULT_EPOCH_ROTATION_SELF_CHECK_FAILED", "this principal's own opened envelope does not recover the K_e it sealed — refusing to advance the local epoch pointer", "verifying the committed epoch key");
2050
+ }
2051
+ const recomputed = epochRotationKeyCommitment(recoveredKe, this.repoId, newEpoch, rotationId, included.map((p) => p.ek_fingerprint));
2052
+ if (recomputed !== epochKeyCommitmentValue) {
2053
+ fail("GITVAULT_EPOCH_ROTATION_SELF_CHECK_FAILED", "epoch_key_commitment does not reproduce from this principal's own opened K_e", "verifying the committed epoch key");
2054
+ }
2055
+ selfCheck = "passed";
2056
+ }
2057
+ }
2058
+ // Advance the local pointer only after the self-check (when applicable) confirms this principal genuinely holds the committed K_e.
2059
+ this.keystore.recordEpochRotation(this.repoId, { new_epoch: newEpoch, new_k_repo_hex: bytesToHex(kE) });
2060
+ // Same local-cache short-circuit as publishPinManifestUpdate's own
2061
+ // success path (see readPinManifestObject's doc comment) — the fold
2062
+ // built and admitted its OWN recipient_pin_manifest on this SAME head.
2063
+ if (pinManifestFold) {
2064
+ this.keystore.updateRepo(this.repoId, { known_pin_manifest: { pin_manifest_version: pinManifestFold.nextVersion, stored_bytes_sha256: pinManifestFold.manifestSha, pins: pinManifestFold.pins } });
2065
+ }
2066
+ return {
2067
+ outcome: "admitted", generation, head_sha256: admitted.head_sha256, new_epoch: newEpoch, rotation_id: rotationId, reason: options.reason,
2068
+ included: included.map((p) => ({ principal_id: p.principal_id, ek_fingerprint: p.ek_fingerprint })),
2069
+ excluded_keyless_principal_ids: excludedKeyless, excluded_unconfirmed_principal_ids: excludedUnconfirmed,
2070
+ admission_record_sha256: admitted.admission_record_sha256, capture_receipt: admitted.capture_receipt, self_check: selfCheck,
2071
+ pin_manifest_published: pinManifestFold ? { pin_manifest_version: pinManifestFold.nextVersion, stored_bytes_sha256: pinManifestFold.manifestSha, principal_ids: pendingConfirmations.map((p) => p.principal_id) } : null,
2072
+ };
2073
+ }
2074
+ }
2075
+ /**
2076
+ * The ONE fully self-contained rotation entry point: declares
2077
+ * `reason:"recipient_key_revoked"` (owner + step-up) for `principal_id`,
2078
+ * takes the D194 counters straight off that call's OWN response, and
2079
+ * drives the rotation with them — no external counter source needed.
2080
+ */
2081
+ async rotateEpochForKeyRevocation(principalId, options = {}) {
2082
+ const counters = await this.transport.declareRecipientKeyRevoked({ repo_id: this.repoId, principal_id: principalId });
2083
+ 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 });
2084
+ }
1539
2085
  // ── repair (owner-only; mandatory fresh checkpoint; repair resource lane server-side) ──
1540
2086
  /**
1541
2087
  * Publish a repair head over `base_generation` (§4.3): superseded tips that