run402 4.49.0 → 4.51.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.
@@ -37,7 +37,7 @@ 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, attemptKeyCommitment, bytesToHex, checkFreshEpochKeyAgainstPriorKeys, checkHPartition, computeRotationId, computeTargetPartitionDigest, deriveDigestKey, deriveObjectKey, ekFingerprint, epochRotationKeyCommitment, formatGitvaultTimestamp, fromBase64url, hexToBytes, jcs, keyEnvelopeLedgerId, keyedCommitment, newGitvaultId, newHex32, nextEpoch, objectsetContent, openBindingPreimage, openEpochRotationForRecipient, openFrame, openKeyEnvelope, parseGitvaultStrict, parseRotateEpochPayload, pinManifestLedgerId, randomBytes, sealFrame, sealKeyEnvelope, sha256Hex, signGitvaultObject, storedBytes, storedBytesSha256, toBase64url, 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, openEpochRotationForRecipient, openFrame, parseGitvaultStrict, parseRotateEpochPayload, pinManifestLedgerId, randomBytes, sealFrame, sealKeyEnvelope, sha256Hex, signGitvaultObject, storedBytes, storedBytesSha256, toBase64url, verifyGitvaultObject, } from "../namespaces/gitvault.crypto.js";
41
41
  import { GITVAULT_ZERO_SHA256_SENTINEL } from "../namespaces/gitvault.types.js";
42
42
  import { crossProfileGitvaultHint } from "./gitvault-profile-scan.js";
43
43
  import { GITVAULT_DEPLOY_REF, hardenedGit, hasObject, isAncestor } from "./gitvault-snapshot.js";
@@ -63,6 +63,34 @@ const LIMIT_RE = /^([1-9]|[1-9][0-9]|[1-9][0-9][0-9]|1000)$/;
63
63
  function fail(code, message, context, details, nextActions) {
64
64
  throw new LocalError(message, context, { code, details, ...(nextActions ? { next_actions: nextActions } : {}) });
65
65
  }
66
+ // ─── Reader-provenance strings (D209/D210) ───────────────────────────────────
67
+ /**
68
+ * Resolved once from this package's own `package.json` — mirrors
69
+ * `node/index.ts`'s `readSdkPackageVersion`, duplicated locally rather than
70
+ * imported to avoid a dependency edge from this file (imported by the
71
+ * cross-runtime `namespaces/gitvault.ts` via a dynamic `import()`, task
72
+ * 5.0's public crypto surface) back onto the Node CLI/SDK entry module.
73
+ */
74
+ const GITVAULT_SDK_PACKAGE_VERSION = (() => {
75
+ try {
76
+ const pkg = JSON.parse(readFileSync(new URL("../../package.json", import.meta.url), "utf8"));
77
+ return typeof pkg.version === "string" ? pkg.version : "0.0.0";
78
+ }
79
+ catch {
80
+ return "0.0.0";
81
+ }
82
+ })();
83
+ /**
84
+ * `run402@<version>/<entrypoint>` — the audit-provenance string named on
85
+ * `self_open_attestation.reader_entrypoint` (D209) and
86
+ * `recipient_open_receipt.reader_entrypoint` (D210): "names the client
87
+ * implementation + entry point that produced the evidence." Never an
88
+ * authorization input; no wire grammar is promised for it (protocol-v0.md
89
+ * §4.14, `rotate_epoch_payload.json`'s own field `$comment`).
90
+ */
91
+ export function gitvaultReaderEntrypoint(entrypoint) {
92
+ return `run402@${GITVAULT_SDK_PACKAGE_VERSION}/${entrypoint}`;
93
+ }
66
94
  // ─── Generations ─────────────────────────────────────────────────────────────
67
95
  export function generationToBigInt(generation) {
68
96
  if (!GITVAULT_HEX16_RE.test(generation))
@@ -543,6 +571,37 @@ export function gitvaultManifestEntry(object) {
543
571
  }
544
572
  return entry;
545
573
  }
574
+ // ─── Inline upload (gitvault-composite-state-read design D2) ─────────────────
575
+ /** Mirrors the gateway's `GITVAULT_INLINE_UPLOAD_MAX_OBJECT_BYTES` (`services/gitvault/upload-sessions.ts`). */
576
+ export const GITVAULT_INLINE_UPLOAD_MAX_OBJECT_BYTES = 262_144;
577
+ /** Mirrors the gateway's `GITVAULT_INLINE_UPLOAD_MAX_REQUEST_BYTES`. */
578
+ export const GITVAULT_INLINE_UPLOAD_MAX_REQUEST_BYTES = 1_048_576;
579
+ /**
580
+ * The client-side mirror of the gateway's `isInlineUploadRequest` + per-object/
581
+ * per-request cap check: every object must fit under the PER-OBJECT cap AND
582
+ * the batch's total under the PER-REQUEST cap, or the whole batch takes the
583
+ * presigned session+PUT+finalize shape — no per-object mixing, matching the
584
+ * server's `VALIDATION_FAILED` refusal on a mixed request. An empty batch is
585
+ * never "inline" (nothing to send either way; `upload()`'s own early return
586
+ * already short-circuits before this is consulted, and `putObject` always
587
+ * wraps exactly one object so it never hits this branch).
588
+ *
589
+ * Takes the narrowest shape that satisfies every call site (`GitvaultUploadObject[]`
590
+ * for `uploadObjects`, a single `{bytes}`-shaped array for `putObject`) so
591
+ * neither caller needs to fabricate unrelated fields just to ask the
592
+ * question.
593
+ */
594
+ export function gitvaultInlineUploadEligible(objects) {
595
+ if (objects.length === 0)
596
+ return false;
597
+ let total = 0;
598
+ for (const o of objects) {
599
+ if (o.bytes.length > GITVAULT_INLINE_UPLOAD_MAX_OBJECT_BYTES)
600
+ return false;
601
+ total += o.bytes.length;
602
+ }
603
+ return total <= GITVAULT_INLINE_UPLOAD_MAX_REQUEST_BYTES;
604
+ }
546
605
  /**
547
606
  * The stable key both sides agree on, used to pair receipts back to
548
607
  * requests — MIRRORS the gateway's `keyEnvelopeLedgerId`/`pinManifestLedgerId`
@@ -715,6 +774,42 @@ export function createGitvaultHttpTransport(client, options = {}) {
715
774
  return new Uint8Array(await r.arrayBuffer());
716
775
  });
717
776
  }
777
+ /** Resolve ONE `GET …/state` carrier arm to raw bytes — inline decode, or a plain GET on the presigned URL, `null` on a 404 (mirrors {@link getObjectBytes}'s absent reading; both arms indistinguishable after this). */
778
+ async function resolveVaultStateCarrier(carrier) {
779
+ if ("inline" in carrier)
780
+ return fromBase64url(carrier.inline, "carriers.inline");
781
+ const r = await client.fetch(carrier.presigned_url, { method: "GET" });
782
+ if (r.status === 404)
783
+ return null;
784
+ if (!r.ok)
785
+ fail("GITVAULT_OBJECT_READ_FAILED", `vault-state carrier GET failed (HTTP ${r.status})`, "reading the gitvault vault state", { status: r.status });
786
+ return new Uint8Array(await r.arrayBuffer());
787
+ }
788
+ /**
789
+ * `GET …/state` (design D1): one JSON body carrying the vault record, the
790
+ * newest generation, its head's exact stored bytes, and both carriers —
791
+ * resolved to raw bytes here, verified nowhere here (see the interface's
792
+ * own doc comment on {@link GitvaultTransport.getState}).
793
+ */
794
+ async function getVaultStateOut(repoId) {
795
+ const raw = await client.request(`${base(repoId)}/state`, { context: "reading the gitvault vault state" });
796
+ const head = raw.head ? { stored_bytes: fromBase64url(raw.head.stored_bytes, "head.stored_bytes"), stored_bytes_sha256: raw.head.stored_bytes_sha256 } : null;
797
+ const carriers = raw.carriers
798
+ ? { ref_state: await resolveVaultStateCarrier(raw.carriers.ref_state), retention_roots: await resolveVaultStateCarrier(raw.carriers.retention_roots) }
799
+ : null;
800
+ return { vault: raw.vault, newest_generation: raw.newest_generation, head, carriers };
801
+ }
802
+ /** Pair a `finalize`-shaped response's receipts back onto `objects` by ledger id — shared by the inline and presigned upload paths so neither forks the other's receipt-compare logic. */
803
+ function receiptsFromFinalize(objects, entries, fin) {
804
+ const receipts = new Map(fin.receipts.map((r) => [gitvaultLedgerId(r), r]));
805
+ return objects.map((o, i) => {
806
+ const id = gitvaultLedgerId(entries[i]);
807
+ const r = receipts.get(id);
808
+ if (!r)
809
+ fail("GITVAULT_RECEIPT_MISSING", `finalize returned no receipt for ${id}`, "finalizing gitvault upload session", { object_id: id, path: o.path });
810
+ return { path: o.path, object_id: o.object_id, sha256: r.ciphertext_sha256 ?? r.stored_bytes_sha256 ?? "", size_bytes: r.size_bytes };
811
+ });
812
+ }
718
813
  async function upload(repoId, objects, resourceBinding) {
719
814
  if (objects.length === 0)
720
815
  return [];
@@ -722,6 +817,19 @@ export function createGitvaultHttpTransport(client, options = {}) {
722
817
  // wire — the control plane derives the bucket key itself and refuses an
723
818
  // entry carrying an unexpected member.
724
819
  const entries = objects.map((o) => gitvaultManifestEntry(o));
820
+ if (gitvaultInlineUploadEligible(objects)) {
821
+ // gitvault-composite-state-read design D2: every object fits under the
822
+ // caps — one POST, bytes verified + written server-side, and the
823
+ // response IS the finalize response (no session, no PUTs, no separate
824
+ // finalize call). Same closed-key manifest as the presigned path, plus
825
+ // each entry's own bytes.
826
+ const fin = await client.request(`${base(repoId)}/upload-sessions`, {
827
+ method: "POST",
828
+ body: { objects: entries.map((entry, i) => ({ ...entry, bytes: b64u(objects[i].bytes) })), ...(resourceBinding ? { resource_binding: resourceBinding } : {}) },
829
+ context: "uploading gitvault objects inline",
830
+ });
831
+ return receiptsFromFinalize(objects, entries, fin);
832
+ }
725
833
  const session = await client.request(`${base(repoId)}/upload-sessions`, {
726
834
  method: "POST",
727
835
  body: { objects: entries, ...(resourceBinding ? { resource_binding: resourceBinding } : {}) },
@@ -757,14 +865,7 @@ export function createGitvaultHttpTransport(client, options = {}) {
757
865
  fail("GITVAULT_UPLOAD_FAILED", `presigned PUT failed (HTTP ${r.status}) for ${o.path}`, "uploading gitvault objects", { path: o.path, status: r.status });
758
866
  });
759
867
  const fin = await client.request(`${base(repoId)}/upload-sessions/${encodeURIComponent(session.upload_session_id)}/finalize`, { method: "POST", body: {}, context: "finalizing gitvault upload session" });
760
- const receipts = new Map(fin.receipts.map((r) => [gitvaultLedgerId(r), r]));
761
- return objects.map((o, i) => {
762
- const id = gitvaultLedgerId(entries[i]);
763
- const r = receipts.get(id);
764
- if (!r)
765
- fail("GITVAULT_RECEIPT_MISSING", `finalize returned no receipt for ${id}`, "finalizing gitvault upload session", { object_id: id, path: o.path });
766
- return { path: o.path, object_id: o.object_id, sha256: r.ciphertext_sha256 ?? r.stored_bytes_sha256 ?? "", size_bytes: r.size_bytes };
767
- });
868
+ return receiptsFromFinalize(objects, entries, fin);
768
869
  }
769
870
  async function admit(repoId, generation, bytes, hash, extra = {}) {
770
871
  try {
@@ -846,6 +947,7 @@ export function createGitvaultHttpTransport(client, options = {}) {
846
947
  return { cleared: r.advisory_cleared ?? r.cleared ?? false };
847
948
  },
848
949
  getVaultRecord: ({ repo_id }) => client.request(base(repo_id), { context: "reading the gitvault record" }),
950
+ getState: ({ repo_id }) => getVaultStateOut(repo_id),
849
951
  findVaultByProject: ({ project_id }) => client.request(`/gitvault/v1/vaults?project_id=${encodeURIComponent(project_id)}`, { context: "resolving the project's gitvault" }),
850
952
  findVaultByRepo: ({ org_slug, repo_name }) => client.request(`/gitvault/v1/vaults?repo=${encodeURIComponent(`${org_slug}/${repo_name}`)}`, { context: "resolving the gitvault by repo address" }),
851
953
  listOrgEncryptionKeys: ({ org_id }) => client.request(`/orgs/v1/${encodeURIComponent(org_id)}/encryption-keys`, { context: "reading the org encryption-key directory" }),
@@ -864,6 +966,15 @@ export function createGitvaultHttpTransport(client, options = {}) {
864
966
  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" }),
865
967
  declareEpochSecretExposed: ({ repo_id }) => client.request(`${base(repo_id)}/epoch-secret-exposure`, { method: "POST", body: {}, context: "declaring a gitvault epoch secret exposed" }),
866
968
  declareWriterAuthorityUnavailable: ({ repo_id }) => client.request(`${base(repo_id)}/writer-authority/declare-unavailable`, { method: "POST", body: {}, context: "declaring gitvault writer authority unavailable" }),
969
+ async submitOpenProof({ repo_id, principal_id, ek_fingerprint, chain_verified_to_generation, decryptable_to_generation, reader_entrypoint }) {
970
+ // requestWithResponse (not request) — the gateway's own status code is
971
+ // the ONLY signal distinguishing a fresh mint (201) from an idempotent
972
+ // replay of the tuple's already-committed winner (200); the receipt
973
+ // BODY is identical either way (routes/gitvault.ts:
974
+ // `res.status(inserted ? 201 : 200).json(receipt)`).
975
+ const res = await client.requestWithResponse(`${base(repo_id)}/recipients/${encodeURIComponent(principal_id)}/proof-of-open`, { method: "POST", body: { ek_fingerprint, chain_verified_to_generation, decryptable_to_generation, reader_entrypoint }, context: "submitting a gitvault proof-of-open receipt" });
976
+ return { receipt: res.body, deduplicated: res.status === 200 };
977
+ },
867
978
  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`, {
868
979
  method: "POST",
869
980
  body: {
@@ -1131,6 +1242,130 @@ export class GitvaultVault {
1131
1242
  return this.genesisCache;
1132
1243
  }
1133
1244
  // ── §6.3/6.4 discovery + verification ──
1245
+ /**
1246
+ * gitvault-composite-state-read design D1 — the pin-current fast path
1247
+ * `verifyToNewest` tries FIRST: one `GET …/state` in place of BOTH the
1248
+ * live "server still holds the pin" read {@link readHead} would otherwise
1249
+ * perform AND, when eligible, the `listHeads` walk that would follow it.
1250
+ *
1251
+ * `null` means ineligible — the caller falls straight through to the
1252
+ * UNCHANGED `readHead` + `listHeads` flow, so a `null` here never weakens
1253
+ * verification, it only declines the shortcut:
1254
+ * - the vault is genuinely more than one generation ahead of `pin`
1255
+ * (the listing-walk shape this change does not touch, per design D1:
1256
+ * "a client whose pin is >1 behind newest_generation falls back to
1257
+ * the existing paginated listing + per-head walk");
1258
+ * - OR (one-generation-ahead only) the D194 epoch-continuity check the
1259
+ * ONE new head needs `pin`'s own `.epoch` for, and this call declines
1260
+ * to fetch `pin`'s own head bytes over the network — the entire point
1261
+ * of the shortcut — so it needs a LOCAL source for that epoch: either
1262
+ * `pin` is genesis (a fixed, known epoch), or `pin`'s own head bytes
1263
+ * are already cache-warm (from an earlier call, or from `admit()`'s
1264
+ * own post-push cache write). A cold cache here is not a correctness
1265
+ * problem, only a missed optimization.
1266
+ *
1267
+ * On a non-`null` return, `entries` is exactly what ONE real `listHeads`
1268
+ * page's `heads[]` would have been for this pin (0 items when the pin is
1269
+ * already current, 1 when it is exactly one generation behind — chain
1270
+ * link, gaplessness, and signature all still verified by the UNCHANGED
1271
+ * per-entry loop body {@link verifyToNewest} feeds them through), and
1272
+ * `pinnedHead` is `lastHead`'s INITIAL value: the real, verified head at
1273
+ * `pin.generation` when nothing new needs walking (so it is also the
1274
+ * FINAL value — the loop never runs), or a throwaway placeholder when one
1275
+ * new head is coming (the loop overwrites `lastHead` before anything else
1276
+ * ever reads it again — see `verifyToNewest`'s own `prevEpoch` line,
1277
+ * which is the ONLY thing that reads `lastHead`'s pre-loop value).
1278
+ *
1279
+ * Every byte this method reads from `getState` is cache-WARMED (head +
1280
+ * both carriers, keyed exactly as {@link readCachedHeadBytes}/
1281
+ * {@link openCarrier} already key their own writes) but NEVER trusted
1282
+ * here — every reader downstream re-verifies a cache hit against the hash
1283
+ * it would check network bytes against before using it (this file's own
1284
+ * established cache discipline; see `GitvaultKeystore`'s class doc
1285
+ * comment). A wrong or absent byte this method wrote is therefore just a
1286
+ * cache MISS on the next read, never a verification bypass.
1287
+ */
1288
+ async tryStateFastPath(pin) {
1289
+ const state = await this.transport.getState({ repo_id: this.repoId });
1290
+ // §6.4: the vault's newest generation may never fall below the
1291
+ // authenticated pin — checked here regardless of eligibility below, so
1292
+ // a regressed vault is caught exactly as loudly as it always was, even
1293
+ // when the walk that would otherwise discover it is about to be skipped.
1294
+ checkGenerationRegression(state.newest_generation ?? GITVAULT_GENESIS_GENERATION, pin.generation);
1295
+ // The gateway's own admission ledger advances `newest_generation` to the
1296
+ // GENESIS generation (0) the moment genesis itself is admitted — a vault
1297
+ // between "genesis admitted" and "first ordinary push" reports its own
1298
+ // generation as newest, `null` only for the narrower window before that
1299
+ // (kept here defensively; `state.head`/`state.carriers` are non-null
1300
+ // whenever `newest_generation` is non-null on the real route). EITHER
1301
+ // way, "no ORDINARY head yet" is the same case this file has always
1302
+ // treated specially: genesis is a DIFFERENT stored-object shape
1303
+ // (`vault_genesis` — no `ref_state`/`retention_roots`, {@link
1304
+ // GitvaultVault.genesis} owns verifying it via its OWN cache), so this
1305
+ // path must never parse `state.head` as a {@link GitvaultHead} when it
1306
+ // is actually genesis's bytes.
1307
+ const noOrdinaryHeadYet = state.newest_generation === null || state.newest_generation === GITVAULT_GENESIS_GENERATION;
1308
+ const pinBig = generationToBigInt(pin.generation);
1309
+ const newestBig = noOrdinaryHeadYet ? 0n : generationToBigInt(state.newest_generation);
1310
+ const diff = newestBig - pinBig; // ≥ 0n, guaranteed by the regression check above (which treats `null`/genesis identically to this)
1311
+ if (diff === 0n) {
1312
+ if (noOrdinaryHeadYet)
1313
+ return { entries: [], pinnedHead: null, prevEpoch: GITVAULT_GENESIS_EPOCH }; // matches today's `lastHead = null` for a genesis-only vault
1314
+ if (!state.head)
1315
+ fail("CHAIN_BROKEN", "the vault state reports an admitted generation but carries no head bytes", "verifying gitvault chain", { generation: state.newest_generation });
1316
+ const sha = sha256Hex(state.head.stored_bytes);
1317
+ if (sha !== pin.head_sha256)
1318
+ fail("CHAIN_BROKEN", `pinned head ${pin.generation} no longer hashes to the pin`, "reading pinned gitvault head", { generation: pin.generation });
1319
+ this.keystore.writeCachedHead(this.repoId, pin.generation, sha, state.head.stored_bytes);
1320
+ const head = parseGitvaultStrict(new TextDecoder().decode(state.head.stored_bytes));
1321
+ if (state.carriers)
1322
+ this.warmStateCarrierCache(pin.generation, head, state.carriers);
1323
+ return { entries: [], pinnedHead: head, prevEpoch: head.epoch };
1324
+ }
1325
+ if (diff === 1n) {
1326
+ let prevEpoch;
1327
+ if (pin.generation === GITVAULT_GENESIS_GENERATION) {
1328
+ prevEpoch = GITVAULT_GENESIS_EPOCH;
1329
+ }
1330
+ else {
1331
+ const cached = this.keystore.readCachedHead(this.repoId, pin.generation);
1332
+ if (!cached || sha256Hex(cached.bytes) !== pin.head_sha256)
1333
+ return null; // cold cache — decline the shortcut, never weaken it
1334
+ prevEpoch = parseGitvaultStrict(new TextDecoder().decode(cached.bytes)).epoch;
1335
+ }
1336
+ if (!state.head)
1337
+ fail("CHAIN_BROKEN", "the vault state reports a newer generation but carries no head bytes", "verifying gitvault chain", { generation: state.newest_generation });
1338
+ const newestGeneration = state.newest_generation;
1339
+ const shaOfBytes = sha256Hex(state.head.stored_bytes);
1340
+ if (shaOfBytes !== state.head.stored_bytes_sha256)
1341
+ fail("CHAIN_BROKEN", `head ${newestGeneration}: stored bytes hash does not match its own declared hash`, "verifying head chain", { generation: newestGeneration });
1342
+ this.keystore.writeCachedHead(this.repoId, newestGeneration, shaOfBytes, state.head.stored_bytes);
1343
+ const head = parseGitvaultStrict(new TextDecoder().decode(state.head.stored_bytes));
1344
+ if (state.carriers)
1345
+ this.warmStateCarrierCache(newestGeneration, head, state.carriers);
1346
+ // `pinnedHead` is a throwaway — see this method's own doc comment: the
1347
+ // ONE loop iteration below overwrites `lastHead` before anything but
1348
+ // `prevEpoch` (already resolved above) ever reads it again.
1349
+ return { entries: [{ generation: newestGeneration, stored_bytes_sha256: shaOfBytes }], pinnedHead: null, prevEpoch };
1350
+ }
1351
+ return null; // more than one generation behind — the existing listHeads walk owns this
1352
+ }
1353
+ /**
1354
+ * Warm the D3 carrier cache from a `GET …/state` response's two carriers,
1355
+ * keyed by the SAME `(object_id, ciphertext_sha256)` the carrying head's
1356
+ * own receipts name — a BLIND write (see {@link tryStateFastPath}'s doc
1357
+ * comment: every reader re-verifies a cache hit before trusting it, so
1358
+ * this is safe by construction). Skips a `null` carrier (absent stored
1359
+ * bytes) entirely rather than caching an absence — the existing
1360
+ * `openCarrier`/`decodeCarrierFrame` machinery already has its own
1361
+ * "frame absent" handling (`CHAIN_UNUSABLE`) via a genuine cache miss.
1362
+ */
1363
+ warmStateCarrierCache(generation, head, carriers) {
1364
+ if (carriers.ref_state)
1365
+ this.keystore.writeCachedCarrier(this.repoId, head.ref_state.object_id, generation, head.ref_state.ciphertext_sha256, carriers.ref_state);
1366
+ if (carriers.retention_roots)
1367
+ this.keystore.writeCachedCarrier(this.repoId, head.retention_roots.object_id, generation, head.retention_roots.ciphertext_sha256, carriers.retention_roots);
1368
+ }
1134
1369
  /**
1135
1370
  * List from the authenticated pin and verify every link upward. Persists
1136
1371
  * the verified prefix after each page, so a `VERIFICATION_BUDGET_EXCEEDED`
@@ -1176,13 +1411,26 @@ export class GitvaultVault {
1176
1411
  const writerKeyId = genesis.writer_key_id;
1177
1412
  const repo = this.repoFile();
1178
1413
  let pin = repo.verified_prefix ?? repo.head_pin ?? { generation: GITVAULT_GENESIS_GENERATION, head_sha256: genesisSha, pinned_at: formatGitvaultTimestamp(this.now()) };
1179
- let lastHead = pin.generation === GITVAULT_GENESIS_GENERATION ? null : await this.readHead(pin.generation, pin.head_sha256);
1414
+ // gitvault-composite-state-read design D1: try the pin-current fast path
1415
+ // FIRST — see {@link tryStateFastPath}'s own doc comment. `null` means
1416
+ // ineligible (genuinely more than one generation behind, or the pin's
1417
+ // own epoch could not be resolved without the network read this path
1418
+ // exists to avoid) — the caller falls straight through to the UNCHANGED
1419
+ // readHead + listHeads flow below, byte-identical to before this change.
1420
+ const fastPath = await this.tryStateFastPath(pin);
1421
+ let lastHead = fastPath ? fastPath.pinnedHead : pin.generation === GITVAULT_GENESIS_GENERATION ? null : await this.readHead(pin.generation, pin.head_sha256);
1180
1422
  const anchor = pin.generation;
1181
- let prevEpoch = lastHead?.epoch ?? GITVAULT_GENESIS_EPOCH;
1423
+ let prevEpoch = fastPath ? fastPath.prevEpoch : (lastHead?.epoch ?? GITVAULT_GENESIS_EPOCH);
1182
1424
  let progress = { after_generation: anchor, last_generation: anchor, delivered: 0 };
1183
1425
  let request = { after_generation: anchor, limit: String(GITVAULT_MAX_HEADS_PER_LISTING_PAGE) };
1184
1426
  let verified = 0;
1185
1427
  const rotations = [];
1428
+ // Consumed by (at most) the FIRST for(;;) iteration below — a listHeads
1429
+ // page this call never had to ask the network for, because the state
1430
+ // read above already proved it (0 entries: pin already current; 1 entry:
1431
+ // exactly the ONE new head, chain-linked from `pin` the SAME way a real
1432
+ // listHeads page's entry would be).
1433
+ let syntheticEntries = fastPath ? fastPath.entries : null;
1186
1434
  // ── decrypt-validation state (opt-in) ──
1187
1435
  const identity = decryptValidate ? this.keystore.readIdentity() : null;
1188
1436
  const ownKeypair = identity ? this.keystore.encryptionKeypair(identity) : null;
@@ -1270,7 +1518,14 @@ export class GitvaultVault {
1270
1518
  }
1271
1519
  };
1272
1520
  for (;;) {
1273
- const page = await this.transport.listHeads({ repo_id: this.repoId, ...request });
1521
+ // The synthetic page (0 or 1 entries) is only ever valid for the FIRST
1522
+ // iteration — `has_more: false` guarantees `nextListingRequest` ends
1523
+ // the loop right after it is consumed, so clearing it here is purely
1524
+ // defensive (a real second iteration can never see it non-null).
1525
+ const page = syntheticEntries !== null
1526
+ ? { format: GITVAULT_FORMAT, repo_id: this.repoId, after_generation: request.after_generation, heads: syntheticEntries, has_more: false, next_cursor: null, total: null }
1527
+ : await this.transport.listHeads({ repo_id: this.repoId, ...request });
1528
+ syntheticEntries = null;
1274
1529
  progress = verifyHeadsListingPage(page, request, progress, this.repoId);
1275
1530
  for (const entry of page.heads) {
1276
1531
  if (verified >= this.budget) {
@@ -2552,6 +2807,11 @@ export class GitvaultVault {
2552
2807
  const repo = this.repoFile();
2553
2808
  const currentEpoch = base.head?.epoch ?? this.epoch();
2554
2809
  const newEpoch = nextEpoch(currentEpoch);
2810
+ // Computed here (rather than where it was historically built, just
2811
+ // before the head is signed) because D209's self_open_attestation
2812
+ // needs to name THIS attempt's own admitted generation as
2813
+ // decryptable_to_generation before the head itself is built.
2814
+ const generation = nextGeneration(base.generation);
2555
2815
  // D195's producer obligation: a fresh K_e, checked against EVERY prior
2556
2816
  // epoch key this principal has ever locally held.
2557
2817
  const kE = randomBytes(32);
@@ -2662,13 +2922,78 @@ export class GitvaultVault {
2662
2922
  await this.uploadAll(envelopeUploads);
2663
2923
  sealedReceipts.sort((a, b) => (a.principal_id < b.principal_id ? -1 : a.principal_id > b.principal_id ? 1 : 0));
2664
2924
  const epochKeyCommitmentValue = epochRotationKeyCommitment(kE, this.repoId, newEpoch, rotationId, included.map((p) => p.ek_fingerprint));
2665
- const payload = {
2925
+ const payloadBase = {
2666
2926
  new_epoch: newEpoch, rotation_id: rotationId, reason: options.reason,
2667
2927
  recipient_state_version: options.recipient_state_version, recipient_revocation_version: options.recipient_revocation_version,
2668
2928
  pin_manifest_sha256: pinManifest.pinManifestSha256, target_partition_digest: targetPartitionDigest,
2669
2929
  epoch_key_commitment: epochKeyCommitmentValue, excluded_keyless_principal_ids: excludedKeyless, excluded_unconfirmed_principal_ids: excludedUnconfirmed,
2670
2930
  recipient_authority_attestation: null, envelopes: sealedReceipts,
2671
2931
  };
2932
+ // D209 (rev 44) — round-trip THIS principal's own new-epoch
2933
+ // key_envelope through the REAL reader entry point
2934
+ // (openEpochRotationForRecipient — the exact unit fsck/verifyToNewest
2935
+ // use to open a rotation) BEFORE submitting, and bake the result into
2936
+ // the payload as `self_open_attestation`. A rev-44 gateway refuses
2937
+ // EPOCH_ROTATION_SELF_OPEN_UNPROVEN on any rotate_epoch admission
2938
+ // that omits this or whose claim disagrees with its own
2939
+ // server-computed writer-in-envelopes biconditional — this call site
2940
+ // is the fix for the exact gap the 2026-08-28 drill found (production
2941
+ // clients could not READ a rotated vault because the reader had never
2942
+ // implemented rotation traversal, while the write side was green on
2943
+ // its own tests). This SUPERSEDES the old post-commit self-check
2944
+ // below (which called openKeyEnvelope directly, bypassing the
2945
+ // membership lookup, the envelope_path callback derivation, and the
2946
+ // reader's own error framing) — moving the round-trip BEFORE
2947
+ // admission means a genuine failure aborts this call before anything
2948
+ // is ever submitted to the gateway, rather than leaving a broken
2949
+ // rotation committed server-side that even its own writer cannot
2950
+ // read back. A round-trip failure THROWS
2951
+ // (openEpochRotationForRecipient's own error framing — e.g.
2952
+ // GITVAULT_EPOCH_NOT_OPENABLE / EPOCH_KEY_COMMITMENT_MISMATCH — never
2953
+ // a bare AEAD failure); this call never emits a false attestation.
2954
+ const identity = this.keystore.readIdentity();
2955
+ const ownKeypair = identity ? this.keystore.encryptionKeypair(identity) : null;
2956
+ const ownFingerprint = ownKeypair ? ekFingerprint(ownKeypair.public_key) : null;
2957
+ const ownIncluded = ownFingerprint ? included.find((p) => p.ek_fingerprint === ownFingerprint) : undefined;
2958
+ let selfOpenAttestation;
2959
+ let selfCheck = "not_a_recipient";
2960
+ if (ownFingerprint && ownKeypair && ownIncluded) {
2961
+ await openEpochRotationForRecipient({
2962
+ repo_id: this.repoId,
2963
+ payload: payloadBase,
2964
+ own_fingerprint: ownFingerprint,
2965
+ own_encryption_keypair: ownKeypair,
2966
+ writer_signing_public_key: this.signingKeypair().public_key,
2967
+ get_envelope_bytes: (path) => this.transport.getObject({ repo_id: this.repoId, path }),
2968
+ envelope_path: (epoch, fp, rid) => gitvaultPaths.envelope(epoch, fp, rid),
2969
+ });
2970
+ // openEpochRotationForRecipient already recomputes epoch_key_commitment
2971
+ // from the opened plaintext and compares it to payloadBase's own
2972
+ // (HMAC-derived from kE) — an HMAC collision across different keys
2973
+ // is cryptographically infeasible, so its own pass IS the proof
2974
+ // that the opened secret equals kE. No separate byte comparison
2975
+ // needed here (unlike the old post-commit check, which duplicated
2976
+ // this logic by hand instead of trusting the real entry point).
2977
+ selfOpenAttestation = {
2978
+ outcome: "opened",
2979
+ chain_verified_to_generation: base.generation,
2980
+ decryptable_to_generation: generation,
2981
+ opened_fingerprint: ownFingerprint,
2982
+ reader_entrypoint: gitvaultReaderEntrypoint("openEpochRotationForRecipient"),
2983
+ };
2984
+ selfCheck = "passed";
2985
+ }
2986
+ else {
2987
+ // The normal agent/CI-writer case (D209's "writer_not_recipient"
2988
+ // branch): the admitting principal has no included pair in
2989
+ // envelopes[] (no local encryption identity at all, or one whose
2990
+ // fingerprint is not among `included`), so no self round-trip is
2991
+ // possible. This is NOT a confidentiality gap — the writer sampled
2992
+ // kE itself — and D210's recipient proof-of-open receipts are the
2993
+ // closure for post-rotation readability on this branch.
2994
+ selfOpenAttestation = { outcome: "writer_not_recipient", chain_verified_to_generation: base.generation };
2995
+ }
2996
+ const payload = { ...payloadBase, self_open_attestation: selfOpenAttestation };
2672
2997
  const payloadBytes = jcs(payload);
2673
2998
  const transition = { kind: "rotate_epoch", payload_format: "base64url-jcs", payload: toBase64url(payloadBytes), payload_sha256: sha256Hex(payloadBytes) };
2674
2999
  // The manifest-publish deadlock fix: fold receipted pending
@@ -2687,7 +3012,9 @@ export class GitvaultVault {
2687
3012
  ? this.buildPinManifestUpdate(pinManifest, pendingConfirmations.map((p) => ({ principal_id: p.principal_id, ek_fingerprint: p.ek_fingerprint, confirmed_by: "operator_confirmation", receipt: p.receipt })))
2688
3013
  : null;
2689
3014
  // The new epoch's ref_state/retention_roots — CARRIED FORWARD unchanged, sealed under kE.
2690
- const generation = nextGeneration(base.generation);
3015
+ // (`generation` itself was computed earlier, at the top of this loop
3016
+ // — D209's self_open_attestation needed to name it as
3017
+ // decryptable_to_generation before the head was even built.)
2691
3018
  const refState = this.buildRefState(generation, base.refs, base.head_target, { k_repo: kE, epoch: newEpoch });
2692
3019
  const rootsObj = this.buildRetentionRoots(generation, base.roots, null, { k_repo: kE, epoch: newEpoch });
2693
3020
  const uploads = [refState.upload, rootsObj.upload];
@@ -2710,39 +3037,13 @@ export class GitvaultVault {
2710
3037
  fail("HEAD_CAS_CONFLICT", `the rotation lost ${conflicts} races at generation ${generation}; giving up`, "admitting a rotate_epoch head", { generation, winner: admitted.winner });
2711
3038
  continue;
2712
3039
  }
2713
- // D200's post-commit self-check: verify THIS principal's own envelope
2714
- // (when it is itself a recipient) opens to exactly the committed K_e
2715
- // and reproduces epoch_key_commitment a per-recipient proof only,
2716
- // never a global set-coherence claim.
2717
- let selfCheck = "not_a_recipient";
2718
- const identity = this.keystore.readIdentity();
2719
- const ownKeypair = identity ? this.keystore.encryptionKeypair(identity) : null;
2720
- if (identity && ownKeypair) {
2721
- const ownFingerprint = ekFingerprint(ownKeypair.public_key);
2722
- const own = included.find((p) => p.ek_fingerprint === ownFingerprint);
2723
- const ownPair = own ? sealedReceipts.find((r) => r.principal_id === own.principal_id) : undefined;
2724
- if (own && ownPair) {
2725
- const envelopeBytes = await this.transport.getObject({ repo_id: this.repoId, path: gitvaultPaths.envelope(newEpoch, own.ek_fingerprint, rotationId) });
2726
- if (!envelopeBytes || sha256Hex(envelopeBytes) !== ownPair.envelope.stored_bytes_sha256) {
2727
- 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 });
2728
- }
2729
- const envelopeObj = parseGitvaultStrict(new TextDecoder().decode(envelopeBytes));
2730
- // Verification key: this producer's OWN signing key — only the
2731
- // vault's single registered writer key can sign a rotate_epoch
2732
- // head/descriptor at all (v0 single-writer model), so a rotation
2733
- // this call itself drove was necessarily signed by `this.signingKeypair()`.
2734
- const recoveredKe = await openKeyEnvelope({ envelope: envelopeObj, recipient: ownKeypair, signer_public_key: this.signingKeypair().public_key });
2735
- if (bytesToHex(recoveredKe) !== bytesToHex(kE)) {
2736
- 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");
2737
- }
2738
- const recomputed = epochRotationKeyCommitment(recoveredKe, this.repoId, newEpoch, rotationId, included.map((p) => p.ek_fingerprint));
2739
- if (recomputed !== epochKeyCommitmentValue) {
2740
- 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");
2741
- }
2742
- selfCheck = "passed";
2743
- }
2744
- }
2745
- // Advance the local pointer only after the self-check (when applicable) confirms this principal genuinely holds the committed K_e.
3040
+ // The pre-submission D209 round-trip above already confirmed (when
3041
+ // applicable `selfCheck === "passed"`) that this principal's own
3042
+ // opened envelope recovers exactly the K_e it sealed and reproduces
3043
+ // epoch_key_commitment, through the real reader entry point. A
3044
+ // genuine self-check failure would have thrown BEFORE this head was
3045
+ // ever built or submitted, so the local pointer advances
3046
+ // unconditionally here.
2746
3047
  this.keystore.recordEpochRotation(this.repoId, { new_epoch: newEpoch, new_k_repo_hex: bytesToHex(kE) });
2747
3048
  // Same local-cache short-circuit as publishPinManifestUpdate's own
2748
3049
  // success path (see readPinManifestObject's doc comment) — the fold