run402 4.24.0 → 4.25.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 (114) hide show
  1. package/cli.mjs +6 -6
  2. package/git-remote-run402.mjs +270 -0
  3. package/lib/command-manifest.mjs +11 -1
  4. package/lib/credentials.mjs +4 -0
  5. package/lib/domains.mjs +0 -14
  6. package/lib/gitvault.mjs +370 -0
  7. package/lib/init.mjs +143 -1
  8. package/lib/pay.test.mjs +0 -4
  9. package/package.json +8 -2
  10. package/sdk/dist/index.d.ts +20 -3
  11. package/sdk/dist/index.d.ts.map +1 -1
  12. package/sdk/dist/index.js +22 -3
  13. package/sdk/dist/index.js.map +1 -1
  14. package/sdk/dist/namespaces/admin.d.ts +0 -5
  15. package/sdk/dist/namespaces/admin.d.ts.map +1 -1
  16. package/sdk/dist/namespaces/admin.js +0 -9
  17. package/sdk/dist/namespaces/admin.js.map +1 -1
  18. package/sdk/dist/namespaces/assets.d.ts +1 -20
  19. package/sdk/dist/namespaces/assets.d.ts.map +1 -1
  20. package/sdk/dist/namespaces/assets.js +0 -27
  21. package/sdk/dist/namespaces/assets.js.map +1 -1
  22. package/sdk/dist/namespaces/auth.js +1 -1
  23. package/sdk/dist/namespaces/auth.js.map +1 -1
  24. package/sdk/dist/namespaces/domains.d.ts +0 -19
  25. package/sdk/dist/namespaces/domains.d.ts.map +1 -1
  26. package/sdk/dist/namespaces/domains.js +0 -19
  27. package/sdk/dist/namespaces/domains.js.map +1 -1
  28. package/sdk/dist/namespaces/gitvault.crypto.d.ts +319 -0
  29. package/sdk/dist/namespaces/gitvault.crypto.d.ts.map +1 -0
  30. package/sdk/dist/namespaces/gitvault.crypto.js +690 -0
  31. package/sdk/dist/namespaces/gitvault.crypto.js.map +1 -0
  32. package/sdk/dist/namespaces/gitvault.d.ts +473 -0
  33. package/sdk/dist/namespaces/gitvault.d.ts.map +1 -0
  34. package/sdk/dist/namespaces/gitvault.js +714 -0
  35. package/sdk/dist/namespaces/gitvault.js.map +1 -0
  36. package/sdk/dist/namespaces/gitvault.types.d.ts +481 -0
  37. package/sdk/dist/namespaces/gitvault.types.d.ts.map +1 -0
  38. package/sdk/dist/namespaces/gitvault.types.js +19 -0
  39. package/sdk/dist/namespaces/gitvault.types.js.map +1 -0
  40. package/sdk/dist/namespaces/org.d.ts +1 -3
  41. package/sdk/dist/namespaces/org.d.ts.map +1 -1
  42. package/sdk/dist/namespaces/org.js +6 -10
  43. package/sdk/dist/namespaces/org.js.map +1 -1
  44. package/sdk/dist/namespaces/pay.d.ts +0 -8
  45. package/sdk/dist/namespaces/pay.d.ts.map +1 -1
  46. package/sdk/dist/namespaces/pay.js.map +1 -1
  47. package/sdk/dist/namespaces/projects.d.ts +2 -2
  48. package/sdk/dist/namespaces/projects.d.ts.map +1 -1
  49. package/sdk/dist/namespaces/projects.js +4 -12
  50. package/sdk/dist/namespaces/projects.js.map +1 -1
  51. package/sdk/dist/namespaces/secrets.d.ts +0 -2
  52. package/sdk/dist/namespaces/secrets.d.ts.map +1 -1
  53. package/sdk/dist/namespaces/secrets.js +3 -10
  54. package/sdk/dist/namespaces/secrets.js.map +1 -1
  55. package/sdk/dist/namespaces/subdomains.d.ts +0 -2
  56. package/sdk/dist/namespaces/subdomains.d.ts.map +1 -1
  57. package/sdk/dist/namespaces/subdomains.js +5 -16
  58. package/sdk/dist/namespaces/subdomains.js.map +1 -1
  59. package/sdk/dist/namespaces/transfers.d.ts +0 -2
  60. package/sdk/dist/namespaces/transfers.d.ts.map +1 -1
  61. package/sdk/dist/namespaces/transfers.js +8 -10
  62. package/sdk/dist/namespaces/transfers.js.map +1 -1
  63. package/sdk/dist/namespaces/wallets.d.ts +0 -12
  64. package/sdk/dist/namespaces/wallets.d.ts.map +1 -1
  65. package/sdk/dist/namespaces/wallets.js +0 -14
  66. package/sdk/dist/namespaces/wallets.js.map +1 -1
  67. package/sdk/dist/node/gitvault-creation-journal.d.ts +222 -0
  68. package/sdk/dist/node/gitvault-creation-journal.d.ts.map +1 -0
  69. package/sdk/dist/node/gitvault-creation-journal.js +365 -0
  70. package/sdk/dist/node/gitvault-creation-journal.js.map +1 -0
  71. package/sdk/dist/node/gitvault-deploy.d.ts +211 -0
  72. package/sdk/dist/node/gitvault-deploy.d.ts.map +1 -0
  73. package/sdk/dist/node/gitvault-deploy.js +292 -0
  74. package/sdk/dist/node/gitvault-deploy.js.map +1 -0
  75. package/sdk/dist/node/gitvault-keystore.d.ts +207 -0
  76. package/sdk/dist/node/gitvault-keystore.d.ts.map +1 -0
  77. package/sdk/dist/node/gitvault-keystore.js +529 -0
  78. package/sdk/dist/node/gitvault-keystore.js.map +1 -0
  79. package/sdk/dist/node/gitvault-prune.d.ts +341 -0
  80. package/sdk/dist/node/gitvault-prune.d.ts.map +1 -0
  81. package/sdk/dist/node/gitvault-prune.js +410 -0
  82. package/sdk/dist/node/gitvault-prune.js.map +1 -0
  83. package/sdk/dist/node/gitvault-publication.d.ts +730 -0
  84. package/sdk/dist/node/gitvault-publication.d.ts.map +1 -0
  85. package/sdk/dist/node/gitvault-publication.js +1475 -0
  86. package/sdk/dist/node/gitvault-publication.js.map +1 -0
  87. package/sdk/dist/node/gitvault-snapshot.d.ts +190 -0
  88. package/sdk/dist/node/gitvault-snapshot.d.ts.map +1 -0
  89. package/sdk/dist/node/gitvault-snapshot.js +505 -0
  90. package/sdk/dist/node/gitvault-snapshot.js.map +1 -0
  91. package/sdk/dist/node/index.d.ts +11 -0
  92. package/sdk/dist/node/index.d.ts.map +1 -1
  93. package/sdk/dist/node/index.js +9 -0
  94. package/sdk/dist/node/index.js.map +1 -1
  95. package/sdk/dist/node/paid-fetch.d.ts.map +1 -1
  96. package/sdk/dist/node/paid-fetch.js +0 -4
  97. package/sdk/dist/node/paid-fetch.js.map +1 -1
  98. package/sdk/dist/node/sites-node.d.ts +1 -8
  99. package/sdk/dist/node/sites-node.d.ts.map +1 -1
  100. package/sdk/dist/node/sites-node.js +1 -7
  101. package/sdk/dist/node/sites-node.js.map +1 -1
  102. package/sdk/dist/scoped.d.ts +4 -35
  103. package/sdk/dist/scoped.d.ts.map +1 -1
  104. package/sdk/dist/scoped.js +9 -74
  105. package/sdk/dist/scoped.js.map +1 -1
  106. package/lib/sender-domain.mjs +0 -36
  107. package/sdk/dist/deprecate.d.ts +0 -23
  108. package/sdk/dist/deprecate.d.ts.map +0 -1
  109. package/sdk/dist/deprecate.js +0 -42
  110. package/sdk/dist/deprecate.js.map +0 -1
  111. package/sdk/dist/namespaces/sender-domain.d.ts +0 -47
  112. package/sdk/dist/namespaces/sender-domain.d.ts.map +0 -1
  113. package/sdk/dist/namespaces/sender-domain.js +0 -45
  114. package/sdk/dist/namespaces/sender-domain.js.map +0 -1
@@ -0,0 +1,1475 @@
1
+ /**
2
+ * gitvault — publication (protocol rev 40 §6 + §4.4–4.7 + §5A client side;
3
+ * task 5.4).
4
+ *
5
+ * What lives here:
6
+ * - the PURE evaluators: ref transactions (§6.1 — force-with-lease only,
7
+ * fast-forward for non-force branch updates, immutable tags, deletes need
8
+ * expected-old, pairwise-distinct refs refused BEFORE evaluation, the
9
+ * §6.5 cardinality bounds), retention-root evolution (§4.5 — map keyed
10
+ * `(ref, oid)`, renewal, expiry ONLY at checkpoints against the pre-signing
11
+ * cutoff ticket, strict `effective + 90d < cutoff_at`), the heads-listing
12
+ * page contract (§6.3 / D186 — anchor echo, cursor echo, has_more/
13
+ * next_cursor coupling, strictly-above-anchor, gapless across pages,
14
+ * truthful total), chain linkage (`CHAIN_BROKEN`), and the transition
15
+ * fail-closed rule (`UPGRADE_REQUIRED`);
16
+ * - {@link GitvaultTransport} — the control-plane + bucket operations the
17
+ * vault needs (extends task 5.3's creation transport) — and
18
+ * {@link createGitvaultHttpTransport}, the `fetch`-backed implementation
19
+ * over the SDK kernel (upload sessions → presigned PUT with
20
+ * `If-None-Match: *` → finalize);
21
+ * - {@link GitvaultVault} — discover/verify to newest with the RESUMABLE
22
+ * verification budget, materialize (decrypt ref_state + retention_roots →
23
+ * the materialized pin), push (pack_set or a checkpoint-bearing head when
24
+ * the delta exceeds the 64-receipt budget, upload with receipt-compare,
25
+ * admission with 409 re-apply-and-retry, head read-back before any pin
26
+ * advances), checkpoint build + acceptance self-check, and repair (the
27
+ * mandatory fresh checkpoint + the exact repair-root algebra).
28
+ *
29
+ * Dual pins (§6.4) live in the keystore repo file: `head_pin` is
30
+ * `highest_authenticated`, `materialized_pin` is `highest_materialized` — the
31
+ * ONLY push base. A regression below the authenticated pin is
32
+ * `GENERATION_REGRESSION`; an authenticated-but-undecryptable head is
33
+ * `CHAIN_UNUSABLE` (read-only at the materialized pin); an admitted non-null
34
+ * transition is `UPGRADE_REQUIRED` (read-only past it, no publish).
35
+ */
36
+ import { mkdtempSync, readdirSync, readFileSync, rmSync } from "node:fs";
37
+ import { tmpdir } from "node:os";
38
+ import { join } from "node:path";
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, formatGitvaultTimestamp, hexToBytes, jcs, keyedCommitment, newGitvaultId, objectsetContent, openBindingPreimage, openFrame, parseGitvaultStrict, sealFrame, sha256Hex, signGitvaultObject, storedBytes, verifyGitvaultObject, } from "../namespaces/gitvault.crypto.js";
41
+ import { GITVAULT_DEPLOY_REF, hardenedGit, hasObject, isAncestor } from "./gitvault-snapshot.js";
42
+ // ─── Constants (constants.json) ──────────────────────────────────────────────
43
+ export const GITVAULT_MAX_CANONICAL_REFS = 10_000;
44
+ export const GITVAULT_MAX_REF_UPDATES_PER_TRANSACTION = 1_000;
45
+ export const GITVAULT_MAX_RETENTION_ROOT_ENTRIES = 50_000;
46
+ export const GITVAULT_MAX_REPAIR_ADDED_ROOTS = 10_000;
47
+ export const GITVAULT_MAX_WAL_RECEIPTS_PER_HEAD = 64;
48
+ export const GITVAULT_MAX_CHECKPOINT_PACKS = 4_096;
49
+ export const GITVAULT_MAX_CHECKPOINT_TOTAL_STORED_BYTES = 858993459200n;
50
+ export const GITVAULT_MULTI_OBJECT_PACK_TARGET_BYTES = 201_326_592;
51
+ export const GITVAULT_MAX_REF_STATE_OBJECT_BYTES = 33_554_432;
52
+ export const GITVAULT_MAX_HEADS_PER_LISTING_PAGE = 1_000;
53
+ export const GITVAULT_VERIFICATION_BUDGET_HEADS = 100_000;
54
+ export const GITVAULT_RETENTION_MIN_DAYS = 90;
55
+ /** Default admission-conflict retries before the push gives up (each retry re-verifies + re-applies to the winner). */
56
+ export const GITVAULT_PUSH_CONFLICT_RETRIES = 5;
57
+ const CANONICAL_REF_RE = /^(?!.*\.\.)(?!.*\/\/)(?!.*@\{)refs\/(heads|tags|run402)\/[^\u0000-\u0020\u007f~^:?*[\\]+$/;
58
+ const BRANCH_REF_RE = /^(?!.*\.\.)(?!.*\/\/)(?!.*@\{)refs\/heads\/[^\u0000-\u0020\u007f~^:?*[\\]+$/;
59
+ const CURSOR_RE = /^[A-Za-z0-9_-]{1,256}$/;
60
+ const LIMIT_RE = /^([1-9]|[1-9][0-9]|[1-9][0-9][0-9]|1000)$/;
61
+ function fail(code, message, context, details, nextActions) {
62
+ throw new LocalError(message, context, { code, details, ...(nextActions ? { next_actions: nextActions } : {}) });
63
+ }
64
+ // ─── Generations ─────────────────────────────────────────────────────────────
65
+ export function generationToBigInt(generation) {
66
+ if (!GITVAULT_HEX16_RE.test(generation))
67
+ fail("CHAIN_BROKEN", `malformed generation: ${generation}`, "parsing generation");
68
+ return BigInt(`0x${generation}`);
69
+ }
70
+ export function bigIntToGeneration(value) {
71
+ if (value < 0n || value > 0xffffffffffffffffn)
72
+ fail("CHAIN_BROKEN", "generation out of range", "formatting generation");
73
+ return value.toString(16).padStart(16, "0");
74
+ }
75
+ export function nextGeneration(generation) {
76
+ return bigIntToGeneration(generationToBigInt(generation) + 1n);
77
+ }
78
+ /**
79
+ * Evaluate a §6.1 transaction against the materialized map. Refusals in
80
+ * order: pairwise-distinct refs (before evaluation), grammar, the update cap,
81
+ * then per-update semantics collected into ONE `REF_EXPECTED_OLD_MISMATCH`
82
+ * (every failing update listed), then the resulting-state cardinality.
83
+ */
84
+ export async function evaluateRefTransaction(current, transaction, options) {
85
+ const updates = transaction.updates;
86
+ if (!Array.isArray(updates) || updates.length === 0)
87
+ fail("REF_TRANSACTION_INVALID", "a ref transaction needs at least one update", "evaluating ref transaction");
88
+ if (updates.length > GITVAULT_MAX_REF_UPDATES_PER_TRANSACTION) {
89
+ fail("REF_STATE_LIMIT_EXCEEDED", `${updates.length} updates exceed the ${GITVAULT_MAX_REF_UPDATES_PER_TRANSACTION}-update transaction bound`, "evaluating ref transaction", { updates: updates.length, bound: GITVAULT_MAX_REF_UPDATES_PER_TRANSACTION });
90
+ }
91
+ const seen = new Set();
92
+ for (const u of updates) {
93
+ if (seen.has(u.ref))
94
+ fail("REF_TRANSACTION_DUPLICATE_REF", `two updates name ${u.ref}; a transaction must name pairwise-distinct refs (refused before evaluation, never last-wins)`, "evaluating ref transaction", { ref: u.ref });
95
+ seen.add(u.ref);
96
+ }
97
+ for (const u of updates) {
98
+ if (!CANONICAL_REF_RE.test(u.ref))
99
+ fail("REFNAME_UNSUPPORTED", `${JSON.stringify(u.ref)} is not a canonical refs/heads|tags|run402 name`, "evaluating ref transaction", { ref: u.ref });
100
+ if (u.ref.startsWith("refs/run402/") && (options.protocol_refs ?? "refuse") === "refuse") {
101
+ fail("REFNAME_UNSUPPORTED", `${u.ref} is protocol-owned (refs/run402/*); user pushes to it are refused`, "evaluating ref transaction", { ref: u.ref });
102
+ }
103
+ if (u.expected_old_oid !== null && !GITVAULT_OID40_RE.test(u.expected_old_oid))
104
+ fail("REF_TRANSACTION_INVALID", `expected_old_oid for ${u.ref} is not a 40-hex oid`, "evaluating ref transaction", { ref: u.ref });
105
+ if (u.new_oid !== null && !GITVAULT_OID40_RE.test(u.new_oid))
106
+ fail("REF_TRANSACTION_INVALID", `new_oid for ${u.ref} is not a 40-hex oid`, "evaluating ref transaction", { ref: u.ref });
107
+ if (u.new_oid === null && u.expected_old_oid === null)
108
+ fail("REF_TRANSACTION_INVALID", `${u.ref}: a delete requires expected_old_oid`, "evaluating ref transaction", { ref: u.ref });
109
+ }
110
+ const failures = [];
111
+ const next = { ...current };
112
+ const dropped = [];
113
+ for (const u of updates) {
114
+ const cur = current[u.ref] ?? null;
115
+ if (cur !== u.expected_old_oid) {
116
+ failures.push({ ref: u.ref, reason: "expected_old_mismatch", expected_old_oid: u.expected_old_oid, current_oid: cur });
117
+ continue;
118
+ }
119
+ if (u.new_oid === null) {
120
+ // delete — expected-old already matched and is non-null by grammar
121
+ delete next[u.ref];
122
+ dropped.push({ ref: u.ref, oid: cur, reason: "deleted" });
123
+ continue;
124
+ }
125
+ if (cur === null) {
126
+ next[u.ref] = u.new_oid;
127
+ continue;
128
+ } // creation (the only null-expected-old case)
129
+ if (cur === u.new_oid)
130
+ continue; // no-op update — legal, nothing dropped
131
+ const isTag = u.ref.startsWith("refs/tags/");
132
+ if (isTag && !u.force) {
133
+ failures.push({ ref: u.ref, reason: "tag_immutable", expected_old_oid: u.expected_old_oid, current_oid: cur });
134
+ continue;
135
+ }
136
+ const ff = isTag ? false : await options.isAncestor(cur, u.new_oid);
137
+ if (!u.force && !ff) {
138
+ failures.push({ ref: u.ref, reason: "non_fast_forward", expected_old_oid: u.expected_old_oid, current_oid: cur });
139
+ continue;
140
+ }
141
+ next[u.ref] = u.new_oid;
142
+ if (!ff)
143
+ dropped.push({ ref: u.ref, oid: cur, reason: "force_displaced" });
144
+ }
145
+ if (failures.length > 0) {
146
+ fail("REF_EXPECTED_OLD_MISMATCH", `${failures.length} update(s) refused: ${failures.map((f) => `${f.ref} (${f.reason})`).join(", ")}`, "evaluating ref transaction", { failures }, [{ action: "refetch, reapply the transaction to the winner's map, retry" }]);
147
+ }
148
+ assertRefMapCardinality(next);
149
+ return { refs: next, dropped };
150
+ }
151
+ /** §6.5 bound: ≤ 10 000 canonical refs and the serialized map ≤ 32 MiB. */
152
+ export function assertRefMapCardinality(refs) {
153
+ const n = Object.keys(refs).length;
154
+ if (n > GITVAULT_MAX_CANONICAL_REFS)
155
+ fail("REF_STATE_LIMIT_EXCEEDED", `${n} canonical refs exceed the ${GITVAULT_MAX_CANONICAL_REFS} bound`, "checking ref-state cardinality", { refs: n });
156
+ if (jcs(refs).length > GITVAULT_MAX_REF_STATE_OBJECT_BYTES)
157
+ fail("REF_STATE_LIMIT_EXCEEDED", "the serialized ref map exceeds 32 MiB", "checking ref-state cardinality");
158
+ }
159
+ /** The §4.4 deploy-ref move: force-with-lease `refs/run402/deploys/latest` → `oid` (creation when absent). */
160
+ export function deployRefTransaction(current, oid) {
161
+ const old = current[GITVAULT_DEPLOY_REF] ?? null;
162
+ return { updates: [{ ref: GITVAULT_DEPLOY_REF, expected_old_oid: old, new_oid: oid, force: old !== null }] };
163
+ }
164
+ // ─── §4.5 Retention roots (pure) ─────────────────────────────────────────────
165
+ /** `effective_admitted_at = max(prepared_at, storage creation time of the winning admission record)` (§4.10). */
166
+ export function effectiveAdmittedAt(preparedAt, recordStorageCreatedAt) {
167
+ return Date.parse(preparedAt) >= Date.parse(recordStorageCreatedAt) ? preparedAt : recordStorageCreatedAt;
168
+ }
169
+ /** A root may be removed iff `effective_admitted_at + 90 days < cutoff_at` (STRICT; §4.5a). */
170
+ export function isRootEligibleForRemoval(effectiveAdmittedAtIso, cutoffAtIso, retentionDays = GITVAULT_RETENTION_MIN_DAYS) {
171
+ const expiry = Date.parse(effectiveAdmittedAtIso) + retentionDays * 24 * 60 * 60 * 1000;
172
+ return expiry < Date.parse(cutoffAtIso);
173
+ }
174
+ export function compareRoots(a, b) {
175
+ if (a.dropped_at_generation !== b.dropped_at_generation)
176
+ return a.dropped_at_generation < b.dropped_at_generation ? -1 : 1;
177
+ if (a.ref !== b.ref)
178
+ return a.ref < b.ref ? -1 : 1;
179
+ return a.oid === b.oid ? 0 : a.oid < b.oid ? -1 : 1;
180
+ }
181
+ /** roots(g+1) = roots(g) ∪ dropped (RENEWING an existing `(ref, oid)` key) ∖ {expired, only at a checkpoint with a ticket}. */
182
+ export function evolveRetentionRoots(previous, options) {
183
+ const map = new Map();
184
+ for (const r of previous)
185
+ map.set(`${r.ref}\0${r.oid}`, { ...r });
186
+ for (const d of options.dropped)
187
+ map.set(`${d.ref}\0${d.oid}`, { ref: d.ref, oid: d.oid, dropped_at_generation: options.generation });
188
+ let roots = [...map.values()];
189
+ if (options.checkpoint_cutoff) {
190
+ const { cutoff_at, effectiveAdmittedAt: resolve } = options.checkpoint_cutoff;
191
+ roots = roots.filter((r) => {
192
+ const eff = resolve(r.dropped_at_generation);
193
+ return eff === null ? true : !isRootEligibleForRemoval(eff, cutoff_at);
194
+ });
195
+ }
196
+ roots.sort(compareRoots);
197
+ if (roots.length > GITVAULT_MAX_RETENTION_ROOT_ENTRIES)
198
+ fail("REF_STATE_LIMIT_EXCEEDED", `${roots.length} retention roots exceed the ${GITVAULT_MAX_RETENTION_ROOT_ENTRIES} bound`, "evolving retention roots", { roots: roots.length });
199
+ return roots;
200
+ }
201
+ /** Validate a listing request before it is sent (the request schema, D186). */
202
+ export function validateHeadsListingRequest(request) {
203
+ if (!GITVAULT_HEX16_RE.test(request.after_generation))
204
+ fail("GITVAULT_LISTING_REQUEST_INVALID", "after_generation is REQUIRED and must be 16 lowercase hex (the verification anchor)", "listing heads", { after_generation: request.after_generation });
205
+ if (!LIMIT_RE.test(request.limit))
206
+ fail("GITVAULT_LISTING_REQUEST_INVALID", "limit is REQUIRED and must be 1..1000", "listing heads", { limit: request.limit });
207
+ if (request.cursor !== undefined && !CURSOR_RE.test(request.cursor))
208
+ fail("INVALID_CURSOR", "cursor violates the opaque-token grammar; restart from after_generation without a cursor and echo each page's next_cursor unchanged", "listing heads", { cursor: request.cursor });
209
+ }
210
+ /**
211
+ * Validate one page against the request and the sequence so far. Returns the
212
+ * advanced progress. Refusals: anchor not echoed / wrong vault / coupling
213
+ * violation / retired member → `GITVAULT_LISTING_PAGE_INVALID`; an entry at or
214
+ * below the anchor → `GENERATION_REGRESSION`; a gap within or across pages →
215
+ * `CHAIN_BROKEN`; an untruthful final `total` → `CHAIN_BROKEN`.
216
+ */
217
+ export function verifyHeadsListingPage(page, request, progress, expectedRepoId) {
218
+ const p = page;
219
+ if (p.format !== GITVAULT_FORMAT)
220
+ fail("GITVAULT_LISTING_PAGE_INVALID", "listing page has the wrong format", "listing heads");
221
+ if ("next_after_generation" in p)
222
+ fail("GITVAULT_LISTING_PAGE_INVALID", "listing page carries the retired next_after_generation member (D186 forbids it)", "listing heads");
223
+ if (expectedRepoId && p.repo_id !== expectedRepoId)
224
+ fail("GITVAULT_LISTING_PAGE_INVALID", "listing page is for a different vault", "listing heads", { repo_id: p.repo_id });
225
+ if (p.after_generation !== request.after_generation || p.after_generation !== progress.after_generation)
226
+ fail("GITVAULT_LISTING_PAGE_INVALID", "the page did not echo the request's after_generation anchor", "listing heads", { echoed: p.after_generation, anchor: request.after_generation });
227
+ if (typeof p.has_more !== "boolean")
228
+ fail("GITVAULT_LISTING_PAGE_INVALID", "has_more must be a boolean", "listing heads");
229
+ if (p.has_more === false && p.next_cursor !== null)
230
+ fail("GITVAULT_LISTING_PAGE_INVALID", "has_more=false with a non-null next_cursor is contradictory", "listing heads");
231
+ if (p.has_more === true && (typeof p.next_cursor !== "string" || !CURSOR_RE.test(p.next_cursor)))
232
+ fail("GITVAULT_LISTING_PAGE_INVALID", "has_more=true requires a non-null opaque next_cursor", "listing heads");
233
+ if (!Array.isArray(p.heads) || p.heads.length > GITVAULT_MAX_HEADS_PER_LISTING_PAGE || p.heads.length > Number(request.limit))
234
+ fail("GITVAULT_LISTING_PAGE_INVALID", "heads[] exceeds the page limit", "listing heads");
235
+ if (p.total !== null && !/^(0|[1-9][0-9]{0,14})$/.test(p.total))
236
+ fail("GITVAULT_LISTING_PAGE_INVALID", "total must be an exact decimal string or null", "listing heads");
237
+ let last = generationToBigInt(progress.last_generation);
238
+ const anchor = generationToBigInt(progress.after_generation);
239
+ let delivered = progress.delivered;
240
+ for (const entry of p.heads) {
241
+ const g = generationToBigInt(entry.generation);
242
+ if (!/^[0-9a-f]{64}$/.test(entry.stored_bytes_sha256))
243
+ fail("CHAIN_BROKEN", `malformed stored_bytes_sha256 at generation ${entry.generation}`, "listing heads");
244
+ if (g <= anchor)
245
+ fail("GENERATION_REGRESSION", `the listing delivered generation ${entry.generation}, at or below the verification anchor ${progress.after_generation}`, "listing heads", { generation: entry.generation, anchor: progress.after_generation });
246
+ if (g !== last + 1n)
247
+ fail("CHAIN_BROKEN", `generation gap: expected ${bigIntToGeneration(last + 1n)}, got ${entry.generation}`, "listing heads", { expected: bigIntToGeneration(last + 1n), got: entry.generation });
248
+ last = g;
249
+ delivered += 1;
250
+ }
251
+ if (p.has_more === false && p.total !== null && BigInt(p.total) !== BigInt(delivered)) {
252
+ fail("CHAIN_BROKEN", `the final page claims total ${p.total} but ${delivered} generations were delivered above the anchor; total is exact or null, never a nearby number`, "listing heads", { total: p.total, delivered });
253
+ }
254
+ return { after_generation: progress.after_generation, last_generation: bigIntToGeneration(last), delivered };
255
+ }
256
+ /**
257
+ * The continuation request for `page`, or `null` when the sequence is complete.
258
+ * The anchor stays CONSTANT and `next_cursor` is echoed UNCHANGED — the cursor
259
+ * is stored and echoed, never parsed or edited (a client that re-anchors or
260
+ * edits a byte earns `INVALID_CURSOR` from the platform, D186).
261
+ */
262
+ export function nextListingRequest(request, page) {
263
+ if (!page.has_more)
264
+ return null;
265
+ if (page.next_cursor === null)
266
+ fail("GITVAULT_LISTING_PAGE_INVALID", "has_more=true requires a non-null opaque next_cursor", "listing heads");
267
+ return { after_generation: request.after_generation, cursor: page.next_cursor, limit: request.limit };
268
+ }
269
+ /**
270
+ * §6.4: the vault's newest generation may never fall BELOW the authenticated
271
+ * pin. A listing (or a storage read) that says otherwise is a rollback, not a
272
+ * quiet vault — `GENERATION_REGRESSION`, no publish.
273
+ */
274
+ export function checkGenerationRegression(listedNewestGeneration, pinnedGeneration) {
275
+ if (generationToBigInt(listedNewestGeneration) < generationToBigInt(pinnedGeneration)) {
276
+ fail("GENERATION_REGRESSION", `the vault's newest generation ${listedNewestGeneration} is below the authenticated pin ${pinnedGeneration}; refusing to publish over a rollback`, "verifying gitvault chain", { listed_newest_generation: listedNewestGeneration, pinned_generation: pinnedGeneration }, [{ action: "do not publish; escalate — the vault regressed below a generation this client authenticated" }]);
277
+ }
278
+ }
279
+ /** `SHA-256("r402s/v0/open-binding" ‖ lp(client_open_id) ‖ lp(base_head) ‖ lp_opt(prior) ‖ lp(cap))`. */
280
+ export function openBindingDigest(clientOpenId, record) {
281
+ return sha256Hex(openBindingPreimage(clientOpenId, record.base_head_sha256, record.prior_checkpoint_claim_set_sha256, record.r2_cap_size_bytes));
282
+ }
283
+ /**
284
+ * Recompute the binding from the record's OWN fields and compare it bytewise
285
+ * with the signed issuance digest. The error registry has no dedicated code for
286
+ * the fence inequality (D145), so the client surfaces
287
+ * `GITVAULT_OPEN_BINDING_MISMATCH`; a same-`client_open_id` retry that carries a
288
+ * DIFFERENT binding is the registry's `CLIENT_OPEN_ID_CONFLICT`.
289
+ */
290
+ export function checkOpenBinding(clientOpenId, record, issuanceOpenBindingSha256, options = {}) {
291
+ const recomputed = openBindingDigest(clientOpenId, record);
292
+ if (recomputed === issuanceOpenBindingSha256)
293
+ return;
294
+ if (options.retry) {
295
+ fail("CLIENT_OPEN_ID_CONFLICT", `client_open_id ${clientOpenId} was already opened with a different request binding`, "checking maintenance open binding", { recomputed, issued: issuanceOpenBindingSha256 });
296
+ }
297
+ fail("GITVAULT_OPEN_BINDING_MISMATCH", `the C1 record does not recompute to the signed issuance binding for ${clientOpenId}`, "checking maintenance open binding", { recomputed, issued: issuanceOpenBindingSha256 });
298
+ }
299
+ /** Verify one link: bytes hash to the listing, strict parse, generation, prev linkage, epoch pin, repo, writer signature. */
300
+ export function checkChainLink(input) {
301
+ const h = input.head;
302
+ const hash = sha256Hex(input.stored_bytes);
303
+ if (hash !== input.listed_sha256)
304
+ fail("CHAIN_BROKEN", `head ${input.expected_generation}: stored bytes hash ${hash} ≠ the listing's ${input.listed_sha256}`, "verifying head chain", { generation: input.expected_generation });
305
+ if (h.format !== GITVAULT_FORMAT || h.object_kind !== "head" || h.suite !== GITVAULT_SUITE)
306
+ fail("CHAIN_BROKEN", `head ${input.expected_generation}: not an r402s/v0 head`, "verifying head chain");
307
+ if (h.repo_id !== input.repo_id)
308
+ fail("CHAIN_BROKEN", `head ${input.expected_generation}: repo_id ${h.repo_id} ≠ ${input.repo_id}`, "verifying head chain");
309
+ if (h.generation !== input.expected_generation)
310
+ fail("CHAIN_BROKEN", `head generation ${h.generation} ≠ expected ${input.expected_generation} (generation must equal newest+1)`, "verifying head chain", { got: h.generation, expected: input.expected_generation });
311
+ if (h.prev_sha256 !== input.prev_sha256)
312
+ fail("CHAIN_BROKEN", `head ${h.generation}: prev_sha256 does not name the predecessor's stored bytes`, "verifying head chain", { prev_sha256: h.prev_sha256, expected: input.prev_sha256 });
313
+ if (h.epoch !== GITVAULT_GENESIS_EPOCH)
314
+ fail("CHAIN_BROKEN", `head ${h.generation}: epoch ${h.epoch} breaks the V0 pin`, "verifying head chain");
315
+ if (h.writer_key_id !== input.writer_key_id)
316
+ fail("CHAIN_BROKEN", `head ${h.generation}: writer_key_id ${h.writer_key_id} is not the registered writer`, "verifying head chain");
317
+ if (!verifyGitvaultObject(h, input.writer_public_key))
318
+ fail("CHAIN_BROKEN", `head ${h.generation}: signature does not verify under the registered writer key`, "verifying head chain");
319
+ if ((h.checkpoint === null) !== (h.checkpoint_purpose === null))
320
+ fail("CHAIN_BROKEN", `head ${h.generation}: checkpoint/checkpoint_purpose must be null together`, "verifying head chain");
321
+ if (h.checkpoint !== null && h.wal_entries.length !== 0)
322
+ fail("CHAIN_BROKEN", `head ${h.generation}: a checkpoint-bearing head must carry an empty WAL set`, "verifying head chain");
323
+ if ((h.repair !== null) !== (h.checkpoint_purpose === "repair"))
324
+ fail("CHAIN_BROKEN", `head ${h.generation}: repair ⇔ checkpoint_purpose "repair"`, "verifying head chain");
325
+ if (h.wal_entries.length > GITVAULT_MAX_WAL_RECEIPTS_PER_HEAD)
326
+ fail("CHAIN_BROKEN", `head ${h.generation}: ${h.wal_entries.length} WAL receipts exceed the 64 budget`, "verifying head chain");
327
+ }
328
+ /**
329
+ * The transition fail-closed rule: a V0 client that encounters an ADMITTED
330
+ * non-null transition stops advancing — read-only at the materialized pin,
331
+ * no publish past it, `UPGRADE_REQUIRED`. Unknown kinds are a parse reject.
332
+ */
333
+ export function assertNoTransition(head) {
334
+ if (head.transition === null)
335
+ return;
336
+ const kinds = ["add_envelope", "rotate_epoch", "add_writer_key", "transfer_binding"];
337
+ if (!kinds.includes(head.transition.kind))
338
+ fail("CHAIN_BROKEN", `head ${head.generation}: unknown transition kind ${String(head.transition.kind)} (closed enum)`, "verifying head chain");
339
+ 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" }]);
340
+ }
341
+ const HEX16 = "[0-9a-f]{16}";
342
+ /** Ordered longest-suffix-first so `.ticket.json` is never eaten by `.enc`. */
343
+ const PATH_PATTERNS = [
344
+ [new RegExp(`^head/(${HEX16})$`), (m) => ({ kind: "head", generation: m[1] })],
345
+ [new RegExp(`^admissions/(${HEX16})$`), (m) => ({ kind: "admission", generation: m[1] })],
346
+ [/^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] } })],
347
+ [/^wal\/(wal_[0-9a-f]{32})\.pack\.enc$/, (m) => ({ kind: "object", read: { object_kind: "wal_pack", object_id: m[1] } })],
348
+ [/^refs\/(refs_[0-9a-f]{32})\.enc$/, (m) => ({ kind: "object", read: { object_kind: "ref_state", object_id: m[1] } })],
349
+ [/^retention\/(rr_[0-9a-f]{32})\.enc$/, (m) => ({ kind: "object", read: { object_kind: "retention_roots", object_id: m[1] } })],
350
+ [/^checkpoints\/(chk_[0-9a-f]{32})\.manifest\.enc$/, (m) => ({ kind: "object", read: { object_kind: "checkpoint_manifest", object_id: m[1] } })],
351
+ [/^checkpoints\/(ckp_[0-9a-f]{32})\.pack\.enc$/, (m) => ({ kind: "object", read: { object_kind: "checkpoint_pack", object_id: m[1] } })],
352
+ [/^checkpoints\/(ccs_[0-9a-f]{32})\.claims\.json$/, (m) => ({ kind: "object", read: { object_kind: "checkpoint_claim_set", object_id: m[1] } })],
353
+ [/^maintenance\/(msc_[0-9a-f]{32})\.stage\.json$/, (m) => ({ kind: "object", read: { object_kind: "maintenance_stage_claim_set", object_id: m[1] } })],
354
+ [/^maintenance\/(msp_[0-9a-f]{32})\.page\.json$/, (m) => ({ kind: "object", read: { object_kind: "maintenance_stage_page", object_id: m[1] } })],
355
+ [/^verifier-receipts\/(vr_[0-9a-f]{32})\.json$/, (m) => ({ kind: "object", read: { object_kind: "verifier_receipt", object_id: m[1] } })],
356
+ ];
357
+ /** `null` for a path with no wire identity (e.g. a locally-held cutoff ticket). */
358
+ export function gitvaultWireRefForPath(path) {
359
+ for (const [re, build] of PATH_PATTERNS) {
360
+ const m = re.exec(path);
361
+ if (m)
362
+ return build(m);
363
+ }
364
+ return null;
365
+ }
366
+ /** The manifest entry for one upload — closed-key, exactly what the control plane validates. */
367
+ export function gitvaultManifestEntry(object) {
368
+ const ref = gitvaultWireRefForPath(object.path);
369
+ if (!ref || ref.kind !== "object") {
370
+ fail("GITVAULT_UPLOAD_SESSION_INVALID", `${object.path} is not an uploadable object path; the control plane addresses uploads by object_kind + ledger identity`, "building the gitvault upload manifest", { path: object.path });
371
+ }
372
+ const entry = { ...ref.read, sha256: object.sha256, size_bytes: object.size_bytes };
373
+ if (entry.object_kind === "wal_pack") {
374
+ if (object.base_generation === undefined)
375
+ fail("GITVAULT_UPLOAD_SESSION_INVALID", "a wal_pack upload must declare base_generation (§4.1)", "building the gitvault upload manifest", { path: object.path });
376
+ entry.base_generation = object.base_generation;
377
+ }
378
+ return entry;
379
+ }
380
+ /** The stable key both sides agree on, used to pair receipts back to requests. */
381
+ export function gitvaultLedgerId(read) {
382
+ return read.object_kind === "key_envelope" ? `key_envelope:${read.epoch}:${read.recipient_fingerprint}` : String(read.object_id);
383
+ }
384
+ function b64(bytes) { return Buffer.from(bytes).toString("base64"); }
385
+ function b64u(bytes) { return Buffer.from(bytes).toString("base64url"); }
386
+ /**
387
+ * The `fetch`-backed transport over the SDK kernel. Presigned PUTs carry
388
+ * `If-None-Match: *` (create-only — the bucket policy demands it) and the
389
+ * FULL_OBJECT SHA-256 checksum header.
390
+ */
391
+ export function createGitvaultHttpTransport(client, options = {}) {
392
+ const vid = (repoId) => encodeURIComponent((options.vaultIdFor ?? ((r) => r))(repoId));
393
+ const base = (repoId) => `/gitvault/v1/vaults/${vid(repoId)}`;
394
+ /**
395
+ * Read raw bytes from a generation-addressed route (heads, admission records).
396
+ *
397
+ * Deliberately NOT `client.request` — that parses JSON, and re-serializing a
398
+ * parsed head would verify the SDK's own canonicalizer instead of the bytes
399
+ * the host actually stored. §0's client obligation is to hash what was
400
+ * served, so this fetches the response body untouched (authenticated by the
401
+ * same credential provider the kernel uses).
402
+ */
403
+ async function getGenerationBytes(repoId, route, generation) {
404
+ const path = `${base(repoId)}/${route}/${encodeURIComponent(generation)}`;
405
+ const auth = (await client.credentials.getAuth(path, { method: `gitvault.read_${route}` })) ?? {};
406
+ const r = await client.fetch(`${client.apiBase}${path}`, { method: "GET", headers: { ...auth, accept: "application/json" } });
407
+ if (r.status === 404)
408
+ return null;
409
+ if (!r.ok) {
410
+ // Surface the registry code the control plane actually sent (e.g.
411
+ // `GITVAULT_ACCESS_DENIED`) rather than flattening every refusal into a
412
+ // generic read failure — the codes carry the caller's next action.
413
+ let code = "GITVAULT_OBJECT_READ_FAILED";
414
+ let message = `${route}/${generation} read failed (HTTP ${r.status})`;
415
+ try {
416
+ const envelope = (await r.json());
417
+ if (typeof envelope?.error?.code === "string")
418
+ code = envelope.error.code;
419
+ if (typeof envelope?.error?.message === "string")
420
+ message = envelope.error.message;
421
+ }
422
+ catch {
423
+ // a non-JSON body — keep the generic code
424
+ }
425
+ fail(code, message, `reading gitvault ${route}`, { generation, status: r.status });
426
+ }
427
+ return new Uint8Array(await r.arrayBuffer());
428
+ }
429
+ /** Presign + fetch one object by its ledger identity (`POST …/object-reads`). */
430
+ async function getObjectBytes(repoId, path) {
431
+ const ref = gitvaultWireRefForPath(path);
432
+ if (!ref)
433
+ fail("GITVAULT_OBJECT_READ_FAILED", `${path} has no control-plane wire identity; it is not a readable vault object`, "reading gitvault object", { path });
434
+ if (ref.kind === "head")
435
+ return getGenerationBytes(repoId, "heads", ref.generation);
436
+ if (ref.kind === "admission")
437
+ return getGenerationBytes(repoId, "admissions", ref.generation);
438
+ let presigned;
439
+ try {
440
+ presigned = await client.request(`${base(repoId)}/object-reads`, { method: "POST", body: { objects: [ref.read] }, context: "resolving gitvault object" });
441
+ }
442
+ catch (e) {
443
+ if (isRun402Error(e) && e.status === 404)
444
+ return null;
445
+ if (isRun402Error(e) && e.code === "RESOURCE_NOT_FOUND")
446
+ return null;
447
+ throw e;
448
+ }
449
+ const target = presigned.reads[0];
450
+ if (!target)
451
+ return null;
452
+ const r = await client.fetch(target.url, { method: "GET" });
453
+ if (r.status === 404)
454
+ return null;
455
+ if (!r.ok)
456
+ fail("GITVAULT_OBJECT_READ_FAILED", `object GET failed (HTTP ${r.status}) for ${path}`, "reading gitvault object", { path, status: r.status });
457
+ return new Uint8Array(await r.arrayBuffer());
458
+ }
459
+ async function upload(repoId, objects, resourceBinding) {
460
+ if (objects.length === 0)
461
+ return [];
462
+ // The manifest is closed-key: `path` is client-local and MUST NOT ride the
463
+ // wire — the control plane derives the bucket key itself and refuses an
464
+ // entry carrying an unexpected member.
465
+ const entries = objects.map((o) => gitvaultManifestEntry(o));
466
+ const session = await client.request(`${base(repoId)}/upload-sessions`, {
467
+ method: "POST",
468
+ body: { objects: entries, ...(resourceBinding ? { resource_binding: resourceBinding } : {}) },
469
+ context: "opening gitvault upload session",
470
+ });
471
+ const issued = new Map(session.objects.map((u) => [gitvaultLedgerId(u), u]));
472
+ for (let i = 0; i < objects.length; i++) {
473
+ const o = objects[i];
474
+ const id = gitvaultLedgerId(entries[i]);
475
+ const target = issued.get(id);
476
+ if (!target)
477
+ fail("GITVAULT_UPLOAD_SESSION_INVALID", `the session issued no upload for ${id}`, "uploading gitvault objects", { object_id: id, path: o.path });
478
+ // `If-None-Match: *` (create-only) and the FULL_OBJECT `x-amz-checksum-sha256`
479
+ // are SIGNED INTO the presigned URL, so they must go out exactly as the
480
+ // server issued them — a dropped or altered header is a signature
481
+ // mismatch, not a silently unconditional or unchecked write. The locals
482
+ // below are only a fallback for a transport that omits them; the
483
+ // server's copy always wins.
484
+ const r = await client.fetch(target.put.url, {
485
+ method: "PUT",
486
+ headers: { "If-None-Match": "*", "Content-Length": o.size_bytes, "x-amz-checksum-sha256": b64(hexToBytes(o.sha256)), ...(target.put.headers ?? {}) },
487
+ body: o.bytes,
488
+ });
489
+ if (r.status === 412 || r.status === 409) {
490
+ // create-only: the key exists — legal only if it is byte-identical (read-and-compare)
491
+ const existing = await getObjectBytes(repoId, o.path);
492
+ if (!existing || sha256Hex(existing) !== o.sha256)
493
+ fail("GITVAULT_OBJECT_EXISTS_DIFFERENT", `${o.path} already exists with different bytes`, "uploading gitvault objects", { path: o.path });
494
+ continue;
495
+ }
496
+ if (!r.ok)
497
+ fail("GITVAULT_UPLOAD_FAILED", `presigned PUT failed (HTTP ${r.status}) for ${o.path}`, "uploading gitvault objects", { path: o.path, status: r.status });
498
+ }
499
+ const fin = await client.request(`${base(repoId)}/upload-sessions/${encodeURIComponent(session.upload_session_id)}/finalize`, { method: "POST", body: {}, context: "finalizing gitvault upload session" });
500
+ const receipts = new Map(fin.receipts.map((r) => [gitvaultLedgerId(r), r]));
501
+ return objects.map((o, i) => {
502
+ const id = gitvaultLedgerId(entries[i]);
503
+ const r = receipts.get(id);
504
+ if (!r)
505
+ fail("GITVAULT_RECEIPT_MISSING", `finalize returned no receipt for ${id}`, "finalizing gitvault upload session", { object_id: id, path: o.path });
506
+ return { path: o.path, object_id: o.object_id, sha256: r.ciphertext_sha256 ?? r.stored_bytes_sha256 ?? "", size_bytes: r.size_bytes };
507
+ });
508
+ }
509
+ async function admit(repoId, generation, bytes, hash, extra = {}) {
510
+ try {
511
+ const r = await client.request(`${base(repoId)}/admissions`, {
512
+ method: "POST",
513
+ body: { generation, stored_bytes: b64u(bytes), stored_bytes_sha256: hash, ...extra },
514
+ context: "admitting gitvault head",
515
+ });
516
+ return { outcome: "admitted", admission_record_sha256: r.admission_record_sha256, capture_receipt: r.capture_receipt ?? null };
517
+ }
518
+ catch (e) {
519
+ if (isRun402Error(e) && e.code === "HEAD_CAS_CONFLICT") {
520
+ const winner = (e.details?.winner) ?? null;
521
+ if (winner)
522
+ return { outcome: "conflict", winner };
523
+ }
524
+ throw e;
525
+ }
526
+ }
527
+ return {
528
+ // ── creation (5.3) ──
529
+ async allocate(request) {
530
+ // The route wraps the signed allocation object under `allocation` and
531
+ // adds routing sugar (`allocation_sha256`, `deduplicated`, next_actions).
532
+ // The vault verifies the SIGNED object, so unwrap it here.
533
+ const res = await client.request("/gitvault/v1/vaults", { method: "POST", body: request, context: "allocating gitvault" });
534
+ return res.allocation ?? res;
535
+ },
536
+ async putObject(request) {
537
+ const [r] = await upload(request.repo_id, [{ path: request.path, object_kind: "key_envelope", object_id: null, bytes: request.bytes, sha256: request.expected_sha256, size_bytes: request.expected_size_bytes }]);
538
+ return { stored_bytes_sha256: r.sha256, size_bytes: r.size_bytes };
539
+ },
540
+ getObject: ({ repo_id, path }) => getObjectBytes(repo_id, path),
541
+ async admitGenesis(request) {
542
+ try {
543
+ const r = await admit(request.repo_id, GITVAULT_GENESIS_GENERATION, request.stored_bytes, request.stored_bytes_sha256, { allocation_generation: request.allocation_generation });
544
+ if (r.outcome === "admitted")
545
+ return { outcome: "admitted", admitted_sha256: request.stored_bytes_sha256 };
546
+ return { outcome: "already_admitted", admitted_sha256: r.winner.stored_bytes_sha256 };
547
+ }
548
+ catch (e) {
549
+ if (isRun402Error(e) && e.code === "ALLOCATION_SUPERSEDED")
550
+ return { outcome: "allocation_superseded" };
551
+ throw e;
552
+ }
553
+ },
554
+ getGenesis: ({ repo_id }) => getObjectBytes(repo_id, `head/${GITVAULT_GENESIS_GENERATION}`),
555
+ // ── publication (5.4) ──
556
+ async listHeads(request) {
557
+ validateHeadsListingRequest(request);
558
+ const qs = new URLSearchParams({ after_generation: request.after_generation, limit: request.limit });
559
+ if (request.cursor !== undefined)
560
+ qs.set("cursor", request.cursor);
561
+ return client.request(`${base(request.repo_id)}/heads?${qs.toString()}`, { context: "listing gitvault heads" });
562
+ },
563
+ uploadObjects: ({ repo_id, objects, resource_binding }) => upload(repo_id, objects, resource_binding),
564
+ admitHead: (r) => admit(r.repo_id, r.generation, r.stored_bytes, r.stored_bytes_sha256),
565
+ // Both routes SHIPPED (`routes/gitvault-admission.ts`). `retention-cutoffs`
566
+ // answers `{ticket, receipt, next_actions}`, which IS
567
+ // `GitvaultRetentionCutoffIssued` plus routing sugar — no unwrap needed.
568
+ requestRetentionCutoff: ({ repo_id, base_head_sha256 }) => client.request(`${base(repo_id)}/retention-cutoffs`, { method: "POST", body: { base_head_sha256 }, context: "requesting retention cutoff ticket" }),
569
+ async exchangeActivationToken({ repo_id, operation_id, capture_receipt }) {
570
+ // The route wraps the SIGNED token under `activation_token` and adds
571
+ // routing sugar (`object_id`, `reissued`, next_actions) — same envelope
572
+ // shape as `allocate` above, and unwrapped for the same reason: the
573
+ // caller verifies the SIGNED object, and `checkActivationTokenBinding`
574
+ // compares nine fields that all live INSIDE it. Passing the envelope on
575
+ // as the token mismatches every one of them
576
+ // (`GITVAULT_TOKEN_BINDING_MISMATCH`) and the deploy never commits — even
577
+ // though the envelope's sibling `object_id` makes the shape look close
578
+ // enough to be a plausible token at a glance. Pinned by
579
+ // `gitvault-wire-shapes.test.ts`.
580
+ const res = await client.request(`${base(repo_id)}/activation-tokens`, { method: "POST", body: { operation_id, capture_receipt }, context: "exchanging capture receipt for activation token" });
581
+ return res.activation_token ?? res;
582
+ },
583
+ async submitOverrideCompletion({ repo_id, operation_id, capture_receipt }) {
584
+ const r = await client.request(`${base(repo_id)}/override-completions`, { method: "POST", body: { operation_id, capture_receipt }, context: "submitting override completion" });
585
+ return { cleared: r.advisory_cleared ?? r.cleared ?? false };
586
+ },
587
+ getVaultRecord: ({ repo_id }) => client.request(base(repo_id), { context: "reading the gitvault record" }),
588
+ findVaultByProject: ({ project_id }) => client.request(`/gitvault/v1/vaults?project_id=${encodeURIComponent(project_id)}`, { context: "resolving the project's gitvault" }),
589
+ 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`, {
590
+ method: "POST",
591
+ body: {
592
+ base_head_sha256,
593
+ current_checkpoint_hash: current_checkpoint_hash ?? null,
594
+ r1_size_bytes,
595
+ r2_cap_size_bytes,
596
+ p_before_c1_size_bytes: p_before_c1_size_bytes ?? "0",
597
+ p_before_c2_size_bytes: p_before_c2_size_bytes ?? "0",
598
+ },
599
+ context: "acquiring the gitvault maintenance lease",
600
+ }),
601
+ heartbeatMaintenanceLease: ({ repo_id, maintenance_lease_id, holder_token }) => client.request(`${base(repo_id)}/maintenance-leases/${encodeURIComponent(maintenance_lease_id)}/heartbeat`, { method: "POST", body: { holder_token }, context: "renewing the gitvault maintenance lease" }),
602
+ releaseMaintenanceLease: ({ repo_id, maintenance_lease_id, holder_token }) => client.request(`${base(repo_id)}/maintenance-leases/${encodeURIComponent(maintenance_lease_id)}`, { method: "DELETE", body: { holder_token }, context: "releasing the gitvault maintenance lease" }),
603
+ // ── prune (§7.3) ──
604
+ async submitPruneIntent({ repo_id, intent_bytes }) {
605
+ // Deliberately NOT `client.request`: that serializes a body object, and
606
+ // the gateway strict-parses + signature-verifies THESE bytes (the route
607
+ // is registered through `express.raw`). Re-serializing an equal-valued
608
+ // object would change what was signed. Same raw-fetch shape the
609
+ // generation-addressed reads use, so it inherits the same credentials.
610
+ const path = `${base(repo_id)}/prune-intents`;
611
+ const auth = (await client.credentials.getAuth(path, { method: "gitvault.prune" })) ?? {};
612
+ const r = await client.fetch(`${client.apiBase}${path}`, {
613
+ method: "POST",
614
+ headers: { ...auth, "content-type": "application/json", accept: "application/json" },
615
+ body: intent_bytes,
616
+ });
617
+ const text = await r.text();
618
+ let parsed = null;
619
+ try {
620
+ parsed = text.length > 0 ? JSON.parse(text) : null;
621
+ }
622
+ catch {
623
+ parsed = null;
624
+ }
625
+ if (!r.ok) {
626
+ const envelope = parsed;
627
+ fail(envelope?.error?.code ?? "GITVAULT_PRUNE_SUBMIT_FAILED", envelope?.error?.message ?? `prune intent submission failed (HTTP ${r.status})`, "submitting the gitvault prune intent", { status: r.status, details: envelope?.error?.details ?? null });
628
+ }
629
+ const body = (parsed ?? {});
630
+ return { ...body, stored: body.stored === true };
631
+ },
632
+ async getPruneIntent({ repo_id, prune_intent_object_id }) {
633
+ try {
634
+ return await client.request(`${base(repo_id)}/prune-intents/${encodeURIComponent(prune_intent_object_id)}`, { context: "reading the gitvault prune intent" });
635
+ }
636
+ catch (e) {
637
+ // Authorize-before-reveal: a malformed, absent, or foreign id all
638
+ // return the SAME envelope, so `null` is the only honest reading.
639
+ if (isRun402Error(e) && (e.status === 404 || e.code === "RESOURCE_NOT_FOUND"))
640
+ return null;
641
+ throw e;
642
+ }
643
+ },
644
+ };
645
+ }
646
+ // ─── Storage paths (§3) ──────────────────────────────────────────────────────
647
+ export const gitvaultPaths = {
648
+ head: (generation) => `head/${generation}`,
649
+ admission: (generation) => `admissions/${generation}`,
650
+ wal: (id) => `wal/${id}.pack.enc`,
651
+ refState: (id) => `refs/${id}.enc`,
652
+ retentionRoots: (id) => `retention/${id}.enc`,
653
+ checkpointManifest: (id) => `checkpoints/${id}.manifest.enc`,
654
+ checkpointPack: (id) => `checkpoints/${id}.pack.enc`,
655
+ claimSet: (id) => `checkpoints/${id}.claims.json`,
656
+ cutoffTicket: (id) => `retention/${id}.ticket.json`,
657
+ /** `verifier-receipts/<vr>.json` — plaintext-structured, uploaded before a prune intent may reference it (§7.3). */
658
+ verifierReceipt: (id) => `verifier-receipts/${id}.json`,
659
+ };
660
+ /** A transport-agnostic view of git ops the publication needs (the local repository). */
661
+ export class GitvaultVault {
662
+ keystore;
663
+ transport;
664
+ repoId;
665
+ repoDir;
666
+ now;
667
+ budget;
668
+ retries;
669
+ servicePublicKey;
670
+ genesisCache = null;
671
+ constructor(options) {
672
+ this.keystore = options.keystore;
673
+ this.transport = options.transport;
674
+ this.repoId = options.repo_id;
675
+ this.repoDir = options.repo_dir ?? null;
676
+ this.now = options.now ?? (() => new Date());
677
+ this.budget = options.verification_budget ?? GITVAULT_VERIFICATION_BUDGET_HEADS;
678
+ this.retries = options.conflict_retries ?? GITVAULT_PUSH_CONFLICT_RETRIES;
679
+ this.servicePublicKey = options.service_public_key ?? null;
680
+ }
681
+ static open(options) {
682
+ const v = new GitvaultVault(options);
683
+ v.repoFile(); // KEYSTORE_MISSING / GITVAULT_REPO_STATE_MISSING surface here
684
+ return v;
685
+ }
686
+ repoFile() {
687
+ if (!this.keystore.readIdentity())
688
+ fail("KEYSTORE_MISSING", "no gitvault identity in the keystore", "opening gitvault vault", undefined, [{ action: "restore ~/.run402/source from backup or accept vault loss" }]);
689
+ const repo = this.keystore.readRepo(this.repoId);
690
+ if (!repo)
691
+ fail("GITVAULT_REPO_STATE_MISSING", `no keystore repo file for ${this.repoId}; restore it from the principal's own envelope (keystore.restoreRepoFromEnvelope)`, "opening gitvault vault", { repo_id: this.repoId });
692
+ return repo;
693
+ }
694
+ kRepo() { return hexToBytes(this.repoFile().k_repo_hex); }
695
+ epoch() { return this.repoFile().epoch; }
696
+ git() {
697
+ if (!this.repoDir)
698
+ fail("GITVAULT_REPO_DIR_REQUIRED", "this operation needs the local git repository (repo_dir)", "gitvault publication");
699
+ return this.repoDir;
700
+ }
701
+ /** Fetch + pin-check the genesis (the writer key source). */
702
+ async genesis() {
703
+ if (this.genesisCache)
704
+ return this.genesisCache;
705
+ const repo = this.repoFile();
706
+ const bytes = await this.transport.getGenesis({ repo_id: this.repoId });
707
+ if (!bytes)
708
+ fail("CHAIN_BROKEN", "the vault has no admitted genesis", "reading gitvault genesis", { repo_id: this.repoId });
709
+ const sha256 = sha256Hex(bytes);
710
+ if (sha256 !== repo.genesis_sha256)
711
+ fail("VAULT_CREATION_CONFLICT", `the admitted genesis (${sha256}) is not the pinned one (${repo.genesis_sha256}); refusing a substituted vault`, "reading gitvault genesis", { admitted: sha256, pinned: repo.genesis_sha256 });
712
+ const genesis = parseGitvaultStrict(new TextDecoder().decode(bytes));
713
+ this.genesisCache = { genesis, sha256 };
714
+ return this.genesisCache;
715
+ }
716
+ // ── §6.3/6.4 discovery + verification ──
717
+ /**
718
+ * List from the authenticated pin and verify every link upward. Persists
719
+ * the verified prefix after each page, so a `VERIFICATION_BUDGET_EXCEEDED`
720
+ * continues rather than restarts. Returns the newest verified state.
721
+ */
722
+ async verifyToNewest() {
723
+ const { genesis, sha256: genesisSha } = await this.genesis();
724
+ const writerKey = genesis.creator_signing_pubkey;
725
+ const writerKeyId = genesis.writer_key_id;
726
+ const repo = this.repoFile();
727
+ let pin = repo.verified_prefix ?? repo.head_pin ?? { generation: GITVAULT_GENESIS_GENERATION, head_sha256: genesisSha, pinned_at: formatGitvaultTimestamp(this.now()) };
728
+ let lastHead = pin.generation === GITVAULT_GENESIS_GENERATION ? null : await this.readHead(pin.generation, pin.head_sha256);
729
+ const anchor = pin.generation;
730
+ let progress = { after_generation: anchor, last_generation: anchor, delivered: 0 };
731
+ let request = { after_generation: anchor, limit: String(GITVAULT_MAX_HEADS_PER_LISTING_PAGE) };
732
+ let verified = 0;
733
+ for (;;) {
734
+ const page = await this.transport.listHeads({ repo_id: this.repoId, ...request });
735
+ progress = verifyHeadsListingPage(page, request, progress, this.repoId);
736
+ for (const entry of page.heads) {
737
+ if (verified >= this.budget) {
738
+ this.keystore.updateRepo(this.repoId, { verified_prefix: pin });
739
+ fail("VERIFICATION_BUDGET_EXCEEDED", `${verified} heads verified this call; the verified prefix (generation ${pin.generation}) is persisted — call again to continue`, "verifying gitvault chain", { verified_through: pin.generation }, [{ action: "resume verification from the persisted verified prefix" }]);
740
+ }
741
+ const bytes = await this.transport.getObject({ repo_id: this.repoId, path: gitvaultPaths.head(entry.generation) });
742
+ if (!bytes)
743
+ fail("CHAIN_BROKEN", `listed head ${entry.generation} is absent from storage`, "verifying gitvault chain", { generation: entry.generation });
744
+ const head = parseGitvaultStrict(new TextDecoder().decode(bytes));
745
+ checkChainLink({ head, stored_bytes: bytes, listed_sha256: entry.stored_bytes_sha256, expected_generation: nextGeneration(pin.generation), prev_sha256: pin.head_sha256, repo_id: this.repoId, writer_public_key: writerKey, writer_key_id: writerKeyId });
746
+ try {
747
+ assertNoTransition(head);
748
+ }
749
+ catch (e) {
750
+ // fail closed: pin stays BELOW the transition head; the verified prefix is cleared (this is the final state, not a budget pause)
751
+ this.keystore.updateRepo(this.repoId, { head_pin: pin, verified_prefix: null });
752
+ throw e;
753
+ }
754
+ pin = { generation: head.generation, head_sha256: entry.stored_bytes_sha256, pinned_at: formatGitvaultTimestamp(this.now()) };
755
+ lastHead = head;
756
+ verified += 1;
757
+ }
758
+ // verified prefix persists per page (resumable)
759
+ this.keystore.updateRepo(this.repoId, { verified_prefix: pin });
760
+ const next = nextListingRequest(request, page);
761
+ if (!next)
762
+ break;
763
+ request = next;
764
+ }
765
+ this.keystore.updateRepo(this.repoId, { head_pin: pin, verified_prefix: null });
766
+ return { generation: pin.generation, head_sha256: pin.head_sha256, head: lastHead, genesis };
767
+ }
768
+ async readHead(generation, expectedSha256) {
769
+ const bytes = await this.transport.getObject({ repo_id: this.repoId, path: gitvaultPaths.head(generation) });
770
+ // Absent ⇒ the vault no longer holds a generation this client authenticated: a ROLLBACK, not a
771
+ // broken link (chain-005). Present-but-different bytes IS a broken link (substituted object).
772
+ if (!bytes) {
773
+ fail("GENERATION_REGRESSION", `the vault no longer holds the authenticated generation ${generation}; it regressed below the pin`, "reading pinned gitvault head", { generation }, [
774
+ { action: "do not publish; escalate — the vault regressed below a generation this client authenticated" },
775
+ ]);
776
+ }
777
+ if (sha256Hex(bytes) !== expectedSha256)
778
+ fail("CHAIN_BROKEN", `pinned head ${generation} no longer hashes to the pin`, "reading pinned gitvault head", { generation });
779
+ return parseGitvaultStrict(new TextDecoder().decode(bytes));
780
+ }
781
+ /** Decrypt one encrypted carrier object by its receipt; any failure is `CHAIN_UNUSABLE`. */
782
+ async openCarrier(kind, receipt, path, writerKey) {
783
+ const frame = await this.transport.getObject({ repo_id: this.repoId, path });
784
+ if (!frame)
785
+ fail("CHAIN_UNUSABLE", `${kind} ${receipt.object_id} is absent from storage`, "materializing gitvault head", { object_id: receipt.object_id }, [{ action: "stay read-only at the materialized pin; run the repair path" }]);
786
+ let plaintext;
787
+ try {
788
+ plaintext = openFrame({ k_obj: deriveObjectKey(this.kRepo(), this.repoId, this.epoch(), kind, receipt.object_id), repo_id: this.repoId, object_kind: kind, object_id: receipt.object_id, epoch: this.epoch(), frame, expected_ciphertext_sha256: receipt.ciphertext_sha256 });
789
+ }
790
+ catch (e) {
791
+ fail("CHAIN_UNUSABLE", `${kind} ${receipt.object_id} cannot be opened: ${e.message}`, "materializing gitvault head", { object_id: receipt.object_id }, [{ action: "stay read-only at the materialized pin; run the repair path" }]);
792
+ }
793
+ const object = parseGitvaultStrict(new TextDecoder().decode(plaintext));
794
+ if (object.object_kind !== kind || object.object_id !== receipt.object_id || object.repo_id !== this.repoId || !verifyGitvaultObject(object, writerKey)) {
795
+ fail("CHAIN_UNUSABLE", `${kind} ${receipt.object_id} plaintext fails its identity/signature checks`, "materializing gitvault head", { object_id: receipt.object_id });
796
+ }
797
+ return object;
798
+ }
799
+ /** Verify to newest, then decrypt + apply its carriers — advancing the materialized pin. */
800
+ async materialize() {
801
+ const state = await this.verifyToNewest();
802
+ const writerKey = state.genesis.creator_signing_pubkey;
803
+ if (!state.head) {
804
+ this.keystore.updateRepo(this.repoId, { materialized_pin: { generation: state.generation, head_sha256: state.head_sha256, pinned_at: formatGitvaultTimestamp(this.now()) } });
805
+ return { ...state, ref_state: null, retention_roots: null, refs: {}, roots: [], head_target: { kind: "symref", ref: "refs/heads/main" } };
806
+ }
807
+ const refState = await this.openCarrier("ref_state", state.head.ref_state, gitvaultPaths.refState(state.head.ref_state.object_id), writerKey);
808
+ const roots = await this.openCarrier("retention_roots", state.head.retention_roots, gitvaultPaths.retentionRoots(state.head.retention_roots.object_id), writerKey);
809
+ if (refState.generation !== state.generation || roots.generation !== state.generation)
810
+ fail("CHAIN_UNUSABLE", "carrier generation does not match the head", "materializing gitvault head");
811
+ this.keystore.updateRepo(this.repoId, { materialized_pin: { generation: state.generation, head_sha256: state.head_sha256, pinned_at: formatGitvaultTimestamp(this.now()) } });
812
+ return { ...state, ref_state: refState, retention_roots: roots, refs: { ...refState.refs }, roots: roots.roots.map((r) => ({ ...r })), head_target: refState.head_target };
813
+ }
814
+ // ── object building ──
815
+ seal(kind, objectId, plaintext, path) {
816
+ 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 });
817
+ return { path, object_kind: kind, object_id: objectId, bytes: sealed.frame, sha256: sealed.ciphertext_sha256, size_bytes: sealed.size_bytes };
818
+ }
819
+ /**
820
+ * The owner signing seed, or `GITVAULT_READ_ONLY`.
821
+ *
822
+ * Public so the prune lane signs its intent core, wrapper, and verifier
823
+ * receipt through the SAME refusal path every other signed object uses — a
824
+ * second "get the seed" helper is a second place for a read-only principal to
825
+ * slip through. The vault is already open by the time this is reachable, so
826
+ * `ensureIdentity` never MINTS here (it would refuse at `repoFile()` first).
827
+ */
828
+ signer() {
829
+ const identity = this.keystore.ensureIdentity();
830
+ const kp = this.keystore.signingKeypair(identity);
831
+ if (!kp)
832
+ 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" }]);
833
+ return kp.seed;
834
+ }
835
+ writerKeyId() { return this.keystore.ensureIdentity().signing_fingerprint; }
836
+ buildRefState(generation, refs, headTarget) {
837
+ if (headTarget.kind === "symref" && !BRANCH_REF_RE.test(headTarget.ref))
838
+ fail("REFNAME_UNSUPPORTED", `head_target symref must name a refs/heads/* branch: ${headTarget.ref}`, "building ref_state");
839
+ if (headTarget.kind === "detached" && !GITVAULT_OID40_RE.test(headTarget.oid))
840
+ fail("REF_TRANSACTION_INVALID", "detached head_target needs a 40-hex oid", "building ref_state");
841
+ assertRefMapCardinality(refs);
842
+ const sorted = {};
843
+ for (const k of Object.keys(refs).sort())
844
+ sorted[k] = refs[k];
845
+ const id = newGitvaultId("refs");
846
+ const object = signGitvaultObject({ format: GITVAULT_FORMAT, object_kind: "ref_state", suite: GITVAULT_SUITE, repo_id: this.repoId, object_id: id, generation, refs: sorted, head_target: headTarget }, this.signer());
847
+ const plaintext = storedBytes(object);
848
+ if (plaintext.length > GITVAULT_MAX_REF_STATE_OBJECT_BYTES)
849
+ fail("REF_STATE_LIMIT_EXCEEDED", "ref_state object exceeds 32 MiB", "building ref_state");
850
+ return { object, upload: this.seal("ref_state", id, plaintext, gitvaultPaths.refState(id)) };
851
+ }
852
+ buildRetentionRoots(generation, roots, cutoff) {
853
+ const id = newGitvaultId("rr");
854
+ 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());
855
+ const plaintext = storedBytes(object);
856
+ if (plaintext.length > GITVAULT_MAX_REF_STATE_OBJECT_BYTES)
857
+ fail("REF_STATE_LIMIT_EXCEEDED", "retention_roots object exceeds 32 MiB", "building retention_roots");
858
+ return { object, upload: this.seal("retention_roots", id, plaintext, gitvaultPaths.retentionRoots(id)) };
859
+ }
860
+ /** Plaintext, independently non-thin packs covering `reachable(tips) ∖ reachable(base)`, split at the multi-object target. */
861
+ async buildPacks(tips, base) {
862
+ const dir = this.git();
863
+ const uniqueTips = [...new Set(tips)].filter((t) => GITVAULT_OID40_RE.test(t));
864
+ if (uniqueTips.length === 0)
865
+ return [];
866
+ const presentBase = [];
867
+ for (const b of new Set(base))
868
+ if (GITVAULT_OID40_RE.test(b) && (await hasObject(dir, b)))
869
+ presentBase.push(b);
870
+ const tmp = mkdtempSync(join(tmpdir(), "run402-gitvault-packs-"));
871
+ try {
872
+ const revs = [...uniqueTips, ...presentBase.map((b) => `^${b}`)].join("\n") + "\n";
873
+ await hardenedGit(dir, ["pack-objects", "--revs", "--no-reuse-delta", "--delta-base-offset", `--max-pack-size=${GITVAULT_MULTI_OBJECT_PACK_TARGET_BYTES}`, "-q", join(tmp, "p")], { input: revs });
874
+ const files = readdirSync(tmp).filter((f) => f.endsWith(".pack")).sort();
875
+ return files.map((f) => new Uint8Array(readFileSync(join(tmp, f))));
876
+ }
877
+ finally {
878
+ rmSync(tmp, { recursive: true, force: true });
879
+ }
880
+ }
881
+ /** Sorted unique object ids reachable from `tips` (the `"objectset"` content). */
882
+ async objectSet(tips) {
883
+ return this.objectSetIn(this.git(), tips);
884
+ }
885
+ /**
886
+ * The same `"objectset"` content computed in an ARBITRARY repository.
887
+ *
888
+ * The prune lane's restore-and-verify pass runs against a scratch clone-back,
889
+ * not the working tree, and must recompute the digest there with the same
890
+ * canonicalization the manifest was built with — hence one implementation,
891
+ * parameterized by directory, rather than a second rev-list at the call site.
892
+ */
893
+ async objectSetIn(dir, tips) {
894
+ const unique = [...new Set(tips)].filter((t) => GITVAULT_OID40_RE.test(t));
895
+ if (unique.length === 0)
896
+ return [];
897
+ const out = await hardenedGit(dir, ["rev-list", "--objects", "--no-object-names", ...unique]);
898
+ return objectsetContent(out.lines().map((l) => l.trim())).oids;
899
+ }
900
+ /**
901
+ * Decrypt one generation's `retention_roots` carrier by its head receipt.
902
+ *
903
+ * `materialize()` opens only the NEWEST carrier; the prune lane must compare
904
+ * consecutive generations to see which roots LEFT the map, so it needs any
905
+ * generation's. Same `openCarrier` path, same `CHAIN_UNUSABLE` semantics — a
906
+ * carrier that cannot be opened is never silently treated as empty.
907
+ */
908
+ async openRetentionRootsAt(receipt) {
909
+ const { genesis } = await this.genesis();
910
+ return this.openCarrier("retention_roots", receipt, gitvaultPaths.retentionRoots(receipt.object_id), genesis.creator_signing_pubkey);
911
+ }
912
+ digest(label, content) {
913
+ return keyedCommitment(deriveDigestKey(this.kRepo(), this.repoId, this.epoch(), label), content);
914
+ }
915
+ /**
916
+ * The §1 keyed commitment under one of the five `K_digest` labels.
917
+ *
918
+ * Public because the prune lane needs `gcrootset` (over the GC root set's
919
+ * sorted receipts) and `rootset` (over the retention-roots carrier) and must
920
+ * compute them with the SAME key derivation the checkpoint manifest uses —
921
+ * two derivations for one commitment is how a verifier and a publisher stop
922
+ * agreeing. Keyed by design (§7.3): a server-comparable plaintext digest
923
+ * would be a confirmation oracle.
924
+ */
925
+ keyedDigest(label, content) {
926
+ return this.digest(label, content);
927
+ }
928
+ /** Strip the single top-level signature — the commitment preimage shape carriers use. */
929
+ digestPreimage(o) {
930
+ return this.withoutSignature(o);
931
+ }
932
+ withoutSignature(o) {
933
+ const { signature: _s, ...rest } = o;
934
+ return rest;
935
+ }
936
+ /** Coverage tips (§4.7): canonical refs ∪ unexpired roots ∪ the HEAD target (detached commit; an unborn symref contributes nothing). */
937
+ static coverageTips(refs, roots, headTarget) {
938
+ const tips = new Set(Object.values(refs));
939
+ for (const r of roots)
940
+ tips.add(r.oid);
941
+ if (headTarget.kind === "detached")
942
+ tips.add(headTarget.oid);
943
+ return [...tips].sort();
944
+ }
945
+ /**
946
+ * Build a checkpoint set (§4.7): manifest + packs + the owner-signed claim
947
+ * set, with the acceptance self-check (restore into an empty scratch, every
948
+ * covered tip resolves, full connectivity, all three keyed commitments
949
+ * recomputed). Coverage above the V0 maximum → `CHECKPOINT_SET_LIMIT_EXCEEDED`.
950
+ */
951
+ async buildCheckpoint(input) {
952
+ const tips = GitvaultVault.coverageTips(input.ref_state.refs, input.retention_roots.roots, input.ref_state.head_target);
953
+ for (const t of tips)
954
+ if (!(await hasObject(this.git(), t)))
955
+ fail("CHECKPOINT_INCOMPLETE", `covered tip ${t} is not present locally; the checkpoint cannot be built from this repository`, "building checkpoint set", { oid: t });
956
+ const packs = await this.buildPacks(tips, []);
957
+ if (packs.length > GITVAULT_MAX_CHECKPOINT_PACKS)
958
+ fail("CHECKPOINT_SET_LIMIT_EXCEEDED", `${packs.length} packs exceed the ${GITVAULT_MAX_CHECKPOINT_PACKS}-pack V0 checkpoint maximum`, "building checkpoint set", undefined, [{ action: "compact or prune before adding coverage" }]);
959
+ const entries = [];
960
+ const objects = [];
961
+ let totalPlain = 0n;
962
+ let totalStored = 0n;
963
+ for (const pack of packs) {
964
+ const id = newGitvaultId("ckp");
965
+ const upload = this.seal("checkpoint_pack", id, pack, gitvaultPaths.checkpointPack(id));
966
+ objects.push(upload);
967
+ entries.push({ object_id: id, plaintext_sha256: sha256Hex(pack), plaintext_size_bytes: String(pack.length), ciphertext_sha256: upload.sha256, size_bytes: upload.size_bytes });
968
+ totalPlain += BigInt(pack.length);
969
+ totalStored += BigInt(upload.size_bytes);
970
+ }
971
+ const objectIds = await this.objectSet(tips);
972
+ const manifestUnsigned = {
973
+ format: GITVAULT_FORMAT, object_kind: "checkpoint_manifest", suite: GITVAULT_SUITE, repo_id: this.repoId, object_id: newGitvaultId("chk"),
974
+ covers_through_generation: input.generation, git_object_format: "sha1", packs: entries, total_plaintext_size_bytes: String(totalPlain),
975
+ ref_state_hmac: this.digest("refmap", this.withoutSignature(input.ref_state)),
976
+ retention_roots_hmac: this.digest("rootset", this.withoutSignature(input.retention_roots)),
977
+ object_set_hmac: this.digest("objectset", { oids: objectIds }),
978
+ };
979
+ const manifest = signGitvaultObject(manifestUnsigned, this.signer());
980
+ const manifestUpload = this.seal("checkpoint_manifest", manifest.object_id, storedBytes(manifest), gitvaultPaths.checkpointManifest(manifest.object_id));
981
+ objects.unshift(manifestUpload);
982
+ totalStored += BigInt(manifestUpload.size_bytes);
983
+ if (totalStored > GITVAULT_MAX_CHECKPOINT_TOTAL_STORED_BYTES)
984
+ fail("CHECKPOINT_SET_LIMIT_EXCEEDED", "the checkpoint's stored bytes exceed the 800 GiB V0 maximum", "building checkpoint set", { total_stored_size_bytes: String(totalStored) }, [{ action: "compact or prune before adding coverage" }]);
985
+ const claimSet = signGitvaultObject({
986
+ format: GITVAULT_FORMAT, object_kind: "checkpoint_claim_set", suite: GITVAULT_SUITE, repo_id: this.repoId, object_id: newGitvaultId("ccs"),
987
+ manifest_receipt: { object_id: manifest.object_id, object_kind: "checkpoint_manifest", ciphertext_sha256: manifestUpload.sha256, size_bytes: manifestUpload.size_bytes },
988
+ ordered_pack_receipts: entries.map((e) => ({ object_id: e.object_id, object_kind: "checkpoint_pack", ciphertext_sha256: e.ciphertext_sha256, size_bytes: e.size_bytes })),
989
+ total_stored_size_bytes: String(totalStored), covers_through_generation: input.generation, writer_key_id: this.writerKeyId(),
990
+ }, this.signer());
991
+ const claimBytes = storedBytes(claimSet);
992
+ const claimUpload = { path: gitvaultPaths.claimSet(claimSet.object_id), object_kind: "checkpoint_claim_set", object_id: claimSet.object_id, bytes: claimBytes, sha256: sha256Hex(claimBytes), size_bytes: String(claimBytes.length) };
993
+ objects.push(claimUpload);
994
+ const built = { manifest, claim_set: claimSet, claim_set_receipt: { object_id: claimSet.object_id, object_kind: "checkpoint_claim_set", stored_bytes_sha256: claimUpload.sha256, size_bytes: claimUpload.size_bytes }, objects, packs, covered_tips: tips };
995
+ await this.acceptCheckpoint(built, input.ref_state, input.retention_roots);
996
+ return built;
997
+ }
998
+ /** §4.7 acceptance: restore from the set ALONE into an empty scratch; every covered ref resolves; fsck connectivity; recompute the three commitments. */
999
+ async acceptCheckpoint(built, refState, roots) {
1000
+ checkClaimSetEquality(built.claim_set, built.manifest, built.claim_set.covers_through_generation);
1001
+ const scratch = mkdtempSync(join(tmpdir(), "run402-gitvault-accept-"));
1002
+ try {
1003
+ await hardenedGit(scratch, ["init", "-q", "--bare", "--object-format=sha1", "."]);
1004
+ for (const pack of built.packs) {
1005
+ await hardenedGit(scratch, ["index-pack", "--stdin", "--strict"], { input: pack });
1006
+ }
1007
+ const tips = GitvaultVault.coverageTips(refState.refs, roots.roots, refState.head_target);
1008
+ for (const t of tips) {
1009
+ if (!(await hasObject(scratch, t)))
1010
+ fail("CHECKPOINT_INCOMPLETE", `covered tip ${t} does not resolve from the restored set`, "accepting checkpoint set", { oid: t }, [{ action: "rebuild the checkpoint set; restorers fall back to WAL replay" }]);
1011
+ }
1012
+ const fsck = await hardenedGit(scratch, ["fsck", "--no-dangling", "--connectivity-only", ...tips], { okStatuses: [1, 2] });
1013
+ if (fsck.status !== 0)
1014
+ fail("CHECKPOINT_INCOMPLETE", `fsck reports missing connectivity: ${fsck.stderr.slice(0, 300)}`, "accepting checkpoint set");
1015
+ const restored = tips.length === 0 ? [] : objectsetContent((await hardenedGit(scratch, ["rev-list", "--objects", "--no-object-names", ...tips])).lines().map((l) => l.trim())).oids;
1016
+ if (this.digest("objectset", { oids: restored }) !== built.manifest.object_set_hmac)
1017
+ fail("CHECKPOINT_INCOMPLETE", "restored object set does not match the manifest's object_set_hmac", "accepting checkpoint set");
1018
+ if (this.digest("refmap", this.withoutSignature(refState)) !== built.manifest.ref_state_hmac)
1019
+ fail("CHECKPOINT_INCOMPLETE", "ref_state_hmac mismatch", "accepting checkpoint set");
1020
+ if (this.digest("rootset", this.withoutSignature(roots)) !== built.manifest.retention_roots_hmac)
1021
+ fail("CHECKPOINT_INCOMPLETE", "retention_roots_hmac mismatch", "accepting checkpoint set");
1022
+ }
1023
+ finally {
1024
+ rmSync(scratch, { recursive: true, force: true });
1025
+ }
1026
+ }
1027
+ /**
1028
+ * The §4.7 acceptance run against a checkpoint ALREADY IN STORAGE — the
1029
+ * restore-and-verify pass a `verifier_receipt` attests (§7.3).
1030
+ *
1031
+ * `acceptCheckpoint` above proves a checkpoint the client just BUILT; this
1032
+ * proves one the client is about to make a claim about, from the stored bytes
1033
+ * alone. It reports the observed facts rather than throwing on a mismatch,
1034
+ * because "the checkpoint does not verify" is exactly the finding a receipt
1035
+ * must be able to carry as `false` — turning it into an exception would make
1036
+ * an honest negative attestation impossible to produce.
1037
+ */
1038
+ async verifyStoredCheckpoint(head, headSha256) {
1039
+ const block = head.checkpoint;
1040
+ if (!block)
1041
+ fail("CHECKPOINT_INCOMPLETE", `head ${head.generation} carries no checkpoint to verify`, "verifying a stored checkpoint", { generation: head.generation });
1042
+ const { genesis } = await this.genesis();
1043
+ const writerKey = genesis.creator_signing_pubkey;
1044
+ const claimBytes = await this.transport.getObject({ repo_id: this.repoId, path: gitvaultPaths.claimSet(block.claim_set.object_id) });
1045
+ if (!claimBytes || sha256Hex(claimBytes) !== block.claim_set.stored_bytes_sha256) {
1046
+ fail("CHECKPOINT_INCOMPLETE", `checkpoint claim set ${block.claim_set.object_id} is absent or does not match the head's receipt`, "verifying a stored checkpoint", { object_id: block.claim_set.object_id });
1047
+ }
1048
+ const claimSet = parseGitvaultStrict(new TextDecoder().decode(claimBytes));
1049
+ if (!verifyGitvaultObject(claimSet, writerKey))
1050
+ fail("CHECKPOINT_INCOMPLETE", "checkpoint claim set signature fails", "verifying a stored checkpoint", { object_id: claimSet.object_id });
1051
+ const manifest = await this.openCarrier("checkpoint_manifest", claimSet.manifest_receipt, gitvaultPaths.checkpointManifest(claimSet.manifest_receipt.object_id), writerKey);
1052
+ checkClaimSetEquality(claimSet, manifest, block.covers_through_generation);
1053
+ const refState = await this.openCarrier("ref_state", head.ref_state, gitvaultPaths.refState(head.ref_state.object_id), writerKey);
1054
+ const roots = await this.openCarrier("retention_roots", head.retention_roots, gitvaultPaths.retentionRoots(head.retention_roots.object_id), writerKey);
1055
+ const tips = GitvaultVault.coverageTips(refState.refs, roots.roots, refState.head_target);
1056
+ const scratch = mkdtempSync(join(tmpdir(), "run402-gitvault-attest-"));
1057
+ try {
1058
+ await hardenedGit(scratch, ["init", "-q", "--bare", "--object-format=sha1", "."]);
1059
+ for (const p of manifest.packs) {
1060
+ const frame = await this.transport.getObject({ repo_id: this.repoId, path: gitvaultPaths.checkpointPack(p.object_id) });
1061
+ if (!frame)
1062
+ fail("CHECKPOINT_INCOMPLETE", `checkpoint pack ${p.object_id} is absent from storage`, "verifying a stored checkpoint", { object_id: p.object_id });
1063
+ const plain = openFrame({ k_obj: deriveObjectKey(this.kRepo(), this.repoId, this.epoch(), "checkpoint_pack", p.object_id), repo_id: this.repoId, object_kind: "checkpoint_pack", object_id: p.object_id, epoch: this.epoch(), frame, expected_ciphertext_sha256: p.ciphertext_sha256 });
1064
+ if (sha256Hex(plain) !== p.plaintext_sha256 || String(plain.length) !== p.plaintext_size_bytes)
1065
+ fail("CHECKPOINT_INCOMPLETE", `checkpoint pack ${p.object_id} plaintext mismatch`, "verifying a stored checkpoint", { object_id: p.object_id });
1066
+ await hardenedGit(scratch, ["index-pack", "--stdin", "--strict"], { input: plain });
1067
+ }
1068
+ const missing = [];
1069
+ for (const t of tips)
1070
+ if (!(await hasObject(scratch, t)))
1071
+ missing.push(t);
1072
+ const fsck = tips.length === 0 ? { status: 0 } : await hardenedGit(scratch, ["fsck", "--no-dangling", "--connectivity-only", ...tips], { okStatuses: [1, 2] });
1073
+ const restored = tips.length === 0 || missing.length > 0 ? [] : await this.objectSetIn(scratch, tips);
1074
+ const restoredHmac = this.digest("objectset", { oids: restored });
1075
+ // Named locals, not inline ternaries: the no-removal checkpoint form has
1076
+ // no ticket at all, and both halves of that absence travel together.
1077
+ const cutoffTicketSha256 = block.cutoff ? block.cutoff.ticket.stored_bytes_sha256 : null;
1078
+ const cutoffAt = block.cutoff ? block.cutoff.cutoff_at : null;
1079
+ return {
1080
+ checkpoint_head_sha256: headSha256,
1081
+ checkpoint_generation: head.generation,
1082
+ claim_set_sha256: block.claim_set.stored_bytes_sha256,
1083
+ cutoff_ticket_sha256: cutoffTicketSha256,
1084
+ cutoff_at: cutoffAt,
1085
+ covered_tips: tips,
1086
+ missing_tips: missing,
1087
+ restored_object_set_hmac: restoredHmac,
1088
+ object_set_matches: missing.length === 0 && fsck.status === 0 && restoredHmac === manifest.object_set_hmac,
1089
+ ref_state_matches: this.digest("refmap", this.withoutSignature(refState)) === manifest.ref_state_hmac,
1090
+ retention_roots_matches: this.digest("rootset", this.withoutSignature(roots)) === manifest.retention_roots_hmac,
1091
+ retention_state_hmac: this.digest("rootset", this.withoutSignature(roots)),
1092
+ };
1093
+ }
1094
+ finally {
1095
+ rmSync(scratch, { recursive: true, force: true });
1096
+ }
1097
+ }
1098
+ /**
1099
+ * The whole verified chain, newest-first walk returned oldest-first, each
1100
+ * head paired with its checkpoint claim set (`null` when it bears none).
1101
+ *
1102
+ * The prune lane needs EVERY generation, not just the newest: a candidate is
1103
+ * an object some head once named and no surviving head still needs, and that
1104
+ * is only computable over the whole chain. Reuses {@link chainFrom}, so the
1105
+ * bytes are re-read and hash-checked against the verified chain rather than
1106
+ * trusted from a listing.
1107
+ */
1108
+ async chainEntries() {
1109
+ const newest = await this.verifyToNewest();
1110
+ if (!newest.head)
1111
+ return [];
1112
+ const chain = await this.chainFrom("0000000000000001", newest);
1113
+ const generations = [...chain.keys()].sort();
1114
+ const out = [];
1115
+ const { genesis } = await this.genesis();
1116
+ for (const gen of generations) {
1117
+ const entry = chain.get(gen);
1118
+ let claimSet = null;
1119
+ const block = entry.head.checkpoint;
1120
+ if (block) {
1121
+ // Plaintext-structured and stored-bytes-receipted: no decryption, but
1122
+ // the hash and the owner signature are still checked before a single
1123
+ // pack receipt inside it is believed.
1124
+ const bytes = await this.transport.getObject({ repo_id: this.repoId, path: gitvaultPaths.claimSet(block.claim_set.object_id) });
1125
+ if (!bytes || sha256Hex(bytes) !== block.claim_set.stored_bytes_sha256) {
1126
+ fail("CHECKPOINT_INCOMPLETE", `checkpoint claim set ${block.claim_set.object_id} (generation ${gen}) is absent or altered`, "walking the gitvault chain", { generation: gen, object_id: block.claim_set.object_id });
1127
+ }
1128
+ claimSet = parseGitvaultStrict(new TextDecoder().decode(bytes));
1129
+ if (!verifyGitvaultObject(claimSet, genesis.creator_signing_pubkey)) {
1130
+ fail("CHECKPOINT_INCOMPLETE", `checkpoint claim set ${claimSet.object_id} signature fails`, "walking the gitvault chain", { generation: gen });
1131
+ }
1132
+ }
1133
+ out.push({ head: entry.head, head_sha256: entry.sha256, claim_set: claimSet });
1134
+ }
1135
+ return out;
1136
+ }
1137
+ // ── upload with receipt-compare ──
1138
+ async uploadAll(objects) {
1139
+ const receipts = await this.transport.uploadObjects({ repo_id: this.repoId, objects });
1140
+ if (receipts.length !== objects.length)
1141
+ fail("GITVAULT_RECEIPT_MISMATCH", `${receipts.length} receipts for ${objects.length} objects`, "comparing finalization receipts");
1142
+ const mismatches = objects.filter((o, i) => receipts[i].path !== o.path || receipts[i].sha256 !== o.sha256 || receipts[i].size_bytes !== o.size_bytes);
1143
+ if (mismatches.length > 0) {
1144
+ fail("GITVAULT_RECEIPT_MISMATCH", `the server's finalization receipts do not match the expected manifest for ${mismatches.map((m) => m.path).join(", ")}; refusing to sign a head over them`, "comparing finalization receipts", { paths: mismatches.map((m) => m.path) });
1145
+ }
1146
+ }
1147
+ // ── §6.2 push ──
1148
+ /**
1149
+ * Build + upload + sign + admit ONE generation over `base`. Shared by the ref
1150
+ * transaction path (`push`) and the checkpoint-only path (`publishCheckpoint`); the
1151
+ * caller owns the conflict loop because only it knows how to re-derive the
1152
+ * next state from the winner.
1153
+ */
1154
+ async publishGeneration(input) {
1155
+ const { base } = input;
1156
+ const generation = nextGeneration(base.generation);
1157
+ // The ticket is obtained BEFORE the checkpoint is built (§4.5a: expiry is evaluated against a
1158
+ // server time that exists before signing, never a future storage-commit time).
1159
+ const ticket = input.cutoff ? await this.issueRetentionCutoff(base.head_sha256) : null;
1160
+ const roots = evolveRetentionRoots(base.roots, {
1161
+ generation,
1162
+ dropped: input.dropped,
1163
+ ...(ticket && input.cutoff ? { checkpoint_cutoff: { cutoff_at: ticket.ticket.cutoff_at, effectiveAdmittedAt: input.cutoff.effectiveAdmittedAt ?? (() => null) } } : {}),
1164
+ });
1165
+ const refState = this.buildRefState(generation, input.refs, input.head_target);
1166
+ const rootsObj = this.buildRetentionRoots(generation, roots, ticket ? { cutoff_ticket_sha256: ticket.receipt.stored_bytes_sha256, cutoff_at: ticket.ticket.cutoff_at } : null);
1167
+ const baseTips = GitvaultVault.coverageTips(base.refs, base.roots, base.head_target);
1168
+ const newTips = GitvaultVault.coverageTips(input.refs, roots, input.head_target);
1169
+ // Preflight the projected coverage against the V0 checkpoint maximum (client-side, plaintext projection).
1170
+ const walPacks = input.force_checkpoint ? [] : await this.buildPacks(newTips, baseTips);
1171
+ let form;
1172
+ const walEntries = [];
1173
+ let checkpoint = null;
1174
+ const objects = [refState.upload, rootsObj.upload];
1175
+ if (input.force_checkpoint || walPacks.length > GITVAULT_MAX_WAL_RECEIPTS_PER_HEAD) {
1176
+ form = "checkpoint";
1177
+ const built = await this.buildCheckpoint({ generation, ref_state: refState.object, retention_roots: rootsObj.object });
1178
+ objects.push(...built.objects);
1179
+ checkpoint = { claim_set: built.claim_set_receipt, covers_through_generation: generation, git_object_format: "sha1", cutoff: ticket ? { ticket: ticket.receipt, cutoff_at: ticket.ticket.cutoff_at } : null };
1180
+ }
1181
+ else {
1182
+ form = "wal";
1183
+ for (const pack of walPacks) {
1184
+ const id = newGitvaultId("wal");
1185
+ const upload = { ...this.seal("wal_pack", id, pack, gitvaultPaths.wal(id)), base_generation: base.generation };
1186
+ objects.push(upload);
1187
+ walEntries.push({ object_id: id, object_kind: "wal_pack", ciphertext_sha256: upload.sha256, size_bytes: upload.size_bytes, base_generation: base.generation });
1188
+ }
1189
+ }
1190
+ await this.uploadAll(objects);
1191
+ const binding = typeof input.capture_binding === "function" ? await input.capture_binding() : input.capture_binding ?? null;
1192
+ const head = this.signHead({
1193
+ generation, prev_sha256: base.head_sha256, wal_entries: walEntries,
1194
+ ref_state: { object_id: refState.object.object_id, object_kind: "ref_state", ciphertext_sha256: refState.upload.sha256, size_bytes: refState.upload.size_bytes },
1195
+ retention_roots: { object_id: rootsObj.object.object_id, object_kind: "retention_roots", ciphertext_sha256: rootsObj.upload.sha256, size_bytes: rootsObj.upload.size_bytes },
1196
+ checkpoint, checkpoint_purpose: checkpoint ? "ordinary_push" : null, capture_binding: binding, repair: null,
1197
+ });
1198
+ const admitted = await this.admit(head);
1199
+ if (admitted.outcome === "conflict")
1200
+ return { outcome: "conflict", generation, winner: admitted.winner };
1201
+ return { outcome: "admitted", generation, head, head_sha256: admitted.head_sha256, admission_record_sha256: admitted.admission_record_sha256, capture_receipt: admitted.capture_receipt, form, refs: input.refs };
1202
+ }
1203
+ /** The complete push: verify → materialize → evaluate → pack → upload → head → admit (409: re-apply to the winner, retry) → read back → advance pins. */
1204
+ async push(options) {
1205
+ let conflicts = 0;
1206
+ for (;;) {
1207
+ const base = await this.materialize();
1208
+ const evaluation = await evaluateRefTransaction(base.refs, options.transaction, { isAncestor: (a, d) => isAncestor(this.git(), a, d), protocol_refs: options.protocol_refs });
1209
+ const published = await this.publishGeneration({
1210
+ base, refs: evaluation.refs, dropped: evaluation.dropped, head_target: options.head_target ?? base.head_target,
1211
+ force_checkpoint: options.checkpoint === true, cutoff: options.cutoff ?? null, capture_binding: options.capture_binding,
1212
+ });
1213
+ if (published.outcome === "conflict") {
1214
+ conflicts += 1;
1215
+ if (conflicts > this.retries)
1216
+ fail("HEAD_CAS_CONFLICT", `admission lost ${conflicts} races at generation ${published.generation}; giving up`, "publishing gitvault head", { generation: published.generation, winner: published.winner }, [{ action: "verify the attached winner from storage, rebase, retry" }]);
1217
+ continue; // the loop re-verifies from storage (the winner), re-applies the transaction to the winner's map, retries
1218
+ }
1219
+ this.keystore.updateRepo(this.repoId, { last_ref_transaction: { generation: published.generation, transaction: options.transaction, at: formatGitvaultTimestamp(this.now()) } });
1220
+ return { generation: published.generation, head_sha256: published.head_sha256, head: published.head, admission_record_sha256: published.admission_record_sha256, capture_receipt: published.capture_receipt, form: published.form, conflicts_retried: conflicts, refs: published.refs };
1221
+ }
1222
+ }
1223
+ /**
1224
+ * Publish an `ordinary_push` checkpoint-bearing head that changes NO ref (the
1225
+ * canonical map and HEAD target are carried forward). With a cutoff the head
1226
+ * binds a fresh `retention_cutoff` ticket and roots past their ≥90-day lane
1227
+ * may leave the map; without one it is the no-removal form (§4.5a) and every
1228
+ * root is carried.
1229
+ *
1230
+ * Root expiry is PERMISSIVE: a root whose `effective_admitted_at` this client
1231
+ * cannot resolve is RETAINED. That is deliberate — `effective_admitted_at =
1232
+ * max(prepared_at, the admission record's storage creation time)`, and a client
1233
+ * reading only object bytes cannot see the second term. Resolving it from
1234
+ * `prepared_at` alone would shorten the lane, which the protocol's own
1235
+ * delayed-PUT vector calls out as the naive implementation.
1236
+ *
1237
+ * This is NOT `run402 gitvault compact`: the §7.2 maintenance CYCLE (purpose
1238
+ * `maintenance_cycle`, C1/C2 roles, stage claim sets, prune intents, `R2_cap`
1239
+ * accounting) is a separate protocol under compact authority, and gets its own
1240
+ * method when it ships.
1241
+ */
1242
+ async publishCheckpoint(options = {}) {
1243
+ let conflicts = 0;
1244
+ for (;;) {
1245
+ const base = await this.materialize();
1246
+ const published = await this.publishGeneration({
1247
+ base, refs: base.refs, dropped: [], head_target: base.head_target,
1248
+ force_checkpoint: true, cutoff: options.cutoff === false ? null : options.cutoff ?? {}, capture_binding: undefined,
1249
+ });
1250
+ if (published.outcome === "conflict") {
1251
+ conflicts += 1;
1252
+ if (conflicts > this.retries)
1253
+ fail("HEAD_CAS_CONFLICT", `the checkpoint lost ${conflicts} races at generation ${published.generation}; giving up`, "publishing gitvault checkpoint", { generation: published.generation, winner: published.winner }, [{ action: "verify the attached winner from storage, retry" }]);
1254
+ continue;
1255
+ }
1256
+ return { generation: published.generation, head_sha256: published.head_sha256, head: published.head, admission_record_sha256: published.admission_record_sha256, capture_receipt: published.capture_receipt, form: published.form, conflicts_retried: conflicts, refs: published.refs };
1257
+ }
1258
+ }
1259
+ /** Request a `retention_cutoff` ticket and check it binds THIS base head (and the service key, when one is pinned). */
1260
+ async issueRetentionCutoff(baseHeadSha256) {
1261
+ const issued = await this.transport.requestRetentionCutoff({ repo_id: this.repoId, base_head_sha256: baseHeadSha256 });
1262
+ const t = issued.ticket;
1263
+ if (t.object_kind !== "retention_cutoff" || t.repo_id !== this.repoId || t.base_head_sha256 !== baseHeadSha256) {
1264
+ fail("GITVAULT_CUTOFF_TICKET_INVALID", `the retention cutoff ticket does not bind this vault's base head ${baseHeadSha256}`, "requesting retention cutoff ticket", { object_id: t.object_id, base_head_sha256: t.base_head_sha256 });
1265
+ }
1266
+ if (Date.parse(t.expires_at) <= this.now().getTime())
1267
+ fail("GITVAULT_CUTOFF_TICKET_INVALID", `the retention cutoff ticket expired at ${t.expires_at}`, "requesting retention cutoff ticket", { object_id: t.object_id });
1268
+ if (this.servicePublicKey && !verifyGitvaultObject(t, this.servicePublicKey)) {
1269
+ fail("GITVAULT_CUTOFF_TICKET_INVALID", "the retention cutoff ticket is not signed by the pinned service key", "requesting retention cutoff ticket", { object_id: t.object_id, service_key_id: t.service_key_id });
1270
+ }
1271
+ return issued;
1272
+ }
1273
+ signHead(fields) {
1274
+ 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());
1275
+ }
1276
+ /** Admit a signed head; on success read it back from storage and compare BEFORE any pin advances. */
1277
+ async admit(head) {
1278
+ const bytes = storedBytes(head);
1279
+ const hash = sha256Hex(bytes);
1280
+ const result = await this.transport.admitHead({ repo_id: this.repoId, generation: head.generation, stored_bytes: bytes, stored_bytes_sha256: hash });
1281
+ if (result.outcome === "conflict")
1282
+ return result;
1283
+ const back = await this.transport.getObject({ repo_id: this.repoId, path: gitvaultPaths.head(head.generation) });
1284
+ if (!back || sha256Hex(back) !== hash) {
1285
+ fail("GITVAULT_HEAD_READBACK_MISMATCH", `the admitted head at generation ${head.generation} read back ${back ? sha256Hex(back) : "absent"} ≠ ${hash}; the push is NOT reported as landed and no pin advances`, "reading back admitted head", { generation: head.generation, expected: hash, observed: back ? sha256Hex(back) : null });
1286
+ }
1287
+ const pin = { generation: head.generation, head_sha256: hash, pinned_at: formatGitvaultTimestamp(this.now()) };
1288
+ this.keystore.updateRepo(this.repoId, { head_pin: pin, materialized_pin: pin, verified_prefix: null });
1289
+ return { outcome: "admitted", head_sha256: hash, admission_record_sha256: result.admission_record_sha256, capture_receipt: result.capture_receipt };
1290
+ }
1291
+ // ── repair (owner-only; mandatory fresh checkpoint; repair resource lane server-side) ──
1292
+ /**
1293
+ * Publish a repair head over `base_generation` (§4.3): superseded tips that
1294
+ * the repaired state no longer reaches enter the retention-root map with
1295
+ * `dropped_at_generation = the repair generation`; the head carries the
1296
+ * mandatory self-contained checkpoint. Coverage that cannot be built →
1297
+ * `REPAIR_TARGET_UNPRESERVABLE`. A repair never crosses an admitted transition.
1298
+ */
1299
+ async repair(input) {
1300
+ const newest = await this.verifyToNewest();
1301
+ const baseGen = generationToBigInt(input.base_generation);
1302
+ const newestGen = generationToBigInt(newest.generation);
1303
+ if (baseGen >= newestGen)
1304
+ fail("REPAIR_TARGET_UNPRESERVABLE", `base_generation ${input.base_generation} must be below the newest ${newest.generation}`, "preparing repair");
1305
+ if (baseGen === 0n)
1306
+ fail("REPAIR_TARGET_UNPRESERVABLE", "a repair cannot base on the genesis", "preparing repair");
1307
+ const writerKey = newest.genesis.creator_signing_pubkey;
1308
+ const chain = await this.chainFrom(input.base_generation, newest);
1309
+ const baseEntry = chain.get(input.base_generation);
1310
+ const baseRefState = await this.openCarrier("ref_state", baseEntry.head.ref_state, gitvaultPaths.refState(baseEntry.head.ref_state.object_id), writerKey);
1311
+ const baseRoots = await this.openCarrier("retention_roots", baseEntry.head.retention_roots, gitvaultPaths.retentionRoots(baseEntry.head.retention_roots.object_id), writerKey);
1312
+ const repairGen = nextGeneration(newest.generation);
1313
+ const repairedRefs = baseRefState.refs;
1314
+ const repairedTips = Object.values(repairedRefs);
1315
+ const added = [];
1316
+ for (let g = baseGen + 1n; g <= newestGen; g++) {
1317
+ const entry = chain.get(bigIntToGeneration(g));
1318
+ let state;
1319
+ try {
1320
+ state = await this.openCarrier("ref_state", entry.head.ref_state, gitvaultPaths.refState(entry.head.ref_state.object_id), writerKey);
1321
+ }
1322
+ catch {
1323
+ continue; // an unusable superseded ref_state preserves nothing it cannot name (reason unusable_ref_state)
1324
+ }
1325
+ for (const [ref, oid] of Object.entries(state.refs)) {
1326
+ let reachable = false;
1327
+ for (const t of repairedTips)
1328
+ if (await isAncestor(this.git(), oid, t)) {
1329
+ reachable = true;
1330
+ break;
1331
+ }
1332
+ if (!reachable) {
1333
+ if (!(await hasObject(this.git(), oid)))
1334
+ fail("REPAIR_TARGET_UNPRESERVABLE", `superseded tip ${ref}@${oid} (generation ${entry.head.generation}) is not present locally and cannot be preserved in the repair checkpoint`, "preparing repair", { ref, oid }, [{ action: "choose a repair base whose superseded tips can all be preserved" }]);
1335
+ added.push({ ref, oid });
1336
+ }
1337
+ }
1338
+ }
1339
+ if (added.length > GITVAULT_MAX_REPAIR_ADDED_ROOTS)
1340
+ fail("REF_STATE_LIMIT_EXCEEDED", `${added.length} repair-added roots exceed the ${GITVAULT_MAX_REPAIR_ADDED_ROOTS} bound`, "preparing repair");
1341
+ const roots = evolveRetentionRoots(baseRoots.roots, { generation: repairGen, dropped: added });
1342
+ const refState = this.buildRefState(repairGen, repairedRefs, baseRefState.head_target);
1343
+ const rootsObj = this.buildRetentionRoots(repairGen, roots, null);
1344
+ let built;
1345
+ try {
1346
+ built = await this.buildCheckpoint({ generation: repairGen, ref_state: refState.object, retention_roots: rootsObj.object });
1347
+ }
1348
+ catch (e) {
1349
+ if (isRun402Error(e) && e.code === "CHECKPOINT_INCOMPLETE")
1350
+ fail("REPAIR_TARGET_UNPRESERVABLE", `the repair checkpoint cannot be constructed: ${e.message}`, "preparing repair", undefined, [{ action: "choose a repair base whose superseded tips can all be preserved" }]);
1351
+ throw e;
1352
+ }
1353
+ await this.uploadAll([refState.upload, rootsObj.upload, ...built.objects]);
1354
+ const head = this.signHead({
1355
+ generation: repairGen, prev_sha256: newest.head_sha256, wal_entries: [],
1356
+ ref_state: { object_id: refState.object.object_id, object_kind: "ref_state", ciphertext_sha256: refState.upload.sha256, size_bytes: refState.upload.size_bytes },
1357
+ retention_roots: { object_id: rootsObj.object.object_id, object_kind: "retention_roots", ciphertext_sha256: rootsObj.upload.sha256, size_bytes: rootsObj.upload.size_bytes },
1358
+ checkpoint: { claim_set: built.claim_set_receipt, covers_through_generation: repairGen, git_object_format: "sha1", cutoff: null },
1359
+ checkpoint_purpose: "repair", capture_binding: null,
1360
+ repair: { base_generation: input.base_generation, base_head_sha256: baseEntry.sha256, supersedes_from: nextGeneration(input.base_generation), supersedes_through: newest.generation, reason: input.reason },
1361
+ });
1362
+ const admitted = await this.admit(head);
1363
+ if (admitted.outcome === "conflict")
1364
+ fail("HEAD_CAS_CONFLICT", "a different head was admitted while the repair was being prepared", "publishing repair head", { winner: admitted.winner }, [{ action: "verify the attached winner from storage, rebase, retry" }]);
1365
+ return { generation: repairGen, head_sha256: admitted.head_sha256, head, admission_record_sha256: admitted.admission_record_sha256, capture_receipt: null, form: "checkpoint", conflicts_retried: 0, refs: repairedRefs };
1366
+ }
1367
+ /** Heads `base..newest` (already chain-verified by `verifyToNewest`) re-read + hash-checked from storage. */
1368
+ async chainFrom(baseGeneration, newest) {
1369
+ const out = new Map();
1370
+ let g = generationToBigInt(newest.generation);
1371
+ let expected = newest.head_sha256;
1372
+ const stop = generationToBigInt(baseGeneration);
1373
+ while (g >= stop) {
1374
+ const gen = bigIntToGeneration(g);
1375
+ const bytes = await this.transport.getObject({ repo_id: this.repoId, path: gitvaultPaths.head(gen) });
1376
+ if (!bytes || sha256Hex(bytes) !== expected)
1377
+ fail("CHAIN_BROKEN", `head ${gen} no longer matches the verified chain`, "re-reading gitvault chain", { generation: gen });
1378
+ const head = parseGitvaultStrict(new TextDecoder().decode(bytes));
1379
+ if (head.transition !== null)
1380
+ fail("UPGRADE_REQUIRED", "a repair must not cross an admitted transition", "re-reading gitvault chain", { generation: gen });
1381
+ out.set(gen, { head, sha256: expected });
1382
+ expected = head.prev_sha256;
1383
+ g -= 1n;
1384
+ }
1385
+ return out;
1386
+ }
1387
+ // ── restore (clone-back / WAL replay into a local repository) ──
1388
+ /**
1389
+ * Pull the newest checkpoint (if any) and every later WAL pack into
1390
+ * `targetRepoDir` (an initialized repository), then verify every canonical
1391
+ * ref + the HEAD target resolves. Returns the materialized refs.
1392
+ */
1393
+ async restoreObjectsInto(targetRepoDir) {
1394
+ const newest = await this.materialize();
1395
+ if (!newest.head)
1396
+ return { refs: {}, head_target: newest.head_target, generation: newest.generation };
1397
+ const writerKey = newest.genesis.creator_signing_pubkey;
1398
+ // walk back to the newest checkpoint-bearing head
1399
+ const heads = [];
1400
+ let cur = newest.head;
1401
+ while (cur) {
1402
+ heads.unshift(cur);
1403
+ if (cur.checkpoint)
1404
+ break;
1405
+ if (cur.generation === "0000000000000001")
1406
+ break;
1407
+ const prevGen = bigIntToGeneration(generationToBigInt(cur.generation) - 1n);
1408
+ const bytes = await this.transport.getObject({ repo_id: this.repoId, path: gitvaultPaths.head(prevGen) });
1409
+ if (!bytes || sha256Hex(bytes) !== cur.prev_sha256)
1410
+ fail("CHAIN_BROKEN", `head ${prevGen} does not match the chain during restore`, "restoring gitvault objects");
1411
+ cur = parseGitvaultStrict(new TextDecoder().decode(bytes));
1412
+ }
1413
+ const first = heads[0];
1414
+ if (first.checkpoint) {
1415
+ const claimBytes = await this.transport.getObject({ repo_id: this.repoId, path: gitvaultPaths.claimSet(first.checkpoint.claim_set.object_id) });
1416
+ if (!claimBytes || sha256Hex(claimBytes) !== first.checkpoint.claim_set.stored_bytes_sha256)
1417
+ fail("CHECKPOINT_INCOMPLETE", "claim set absent or altered", "restoring gitvault objects");
1418
+ const claimSet = parseGitvaultStrict(new TextDecoder().decode(claimBytes));
1419
+ if (!verifyGitvaultObject(claimSet, writerKey))
1420
+ fail("CHECKPOINT_INCOMPLETE", "claim set signature fails", "restoring gitvault objects");
1421
+ const manifest = await this.openCarrier("checkpoint_manifest", claimSet.manifest_receipt, gitvaultPaths.checkpointManifest(claimSet.manifest_receipt.object_id), writerKey);
1422
+ checkClaimSetEquality(claimSet, manifest, first.checkpoint.covers_through_generation);
1423
+ for (const p of manifest.packs) {
1424
+ const frame = await this.transport.getObject({ repo_id: this.repoId, path: gitvaultPaths.checkpointPack(p.object_id) });
1425
+ if (!frame)
1426
+ fail("CHECKPOINT_INCOMPLETE", `checkpoint pack ${p.object_id} absent`, "restoring gitvault objects");
1427
+ const plain = openFrame({ k_obj: deriveObjectKey(this.kRepo(), this.repoId, this.epoch(), "checkpoint_pack", p.object_id), repo_id: this.repoId, object_kind: "checkpoint_pack", object_id: p.object_id, epoch: this.epoch(), frame, expected_ciphertext_sha256: p.ciphertext_sha256 });
1428
+ if (sha256Hex(plain) !== p.plaintext_sha256 || String(plain.length) !== p.plaintext_size_bytes)
1429
+ fail("CHECKPOINT_INCOMPLETE", `checkpoint pack ${p.object_id} plaintext mismatch`, "restoring gitvault objects");
1430
+ await hardenedGit(targetRepoDir, ["index-pack", "--stdin", "--strict"], { input: plain });
1431
+ }
1432
+ }
1433
+ for (const h of heads) {
1434
+ for (const w of h.wal_entries) {
1435
+ const frame = await this.transport.getObject({ repo_id: this.repoId, path: gitvaultPaths.wal(w.object_id) });
1436
+ if (!frame)
1437
+ fail("CHAIN_UNUSABLE", `WAL pack ${w.object_id} absent`, "restoring gitvault objects");
1438
+ const plain = openFrame({ k_obj: deriveObjectKey(this.kRepo(), this.repoId, this.epoch(), "wal_pack", w.object_id), repo_id: this.repoId, object_kind: "wal_pack", object_id: w.object_id, epoch: this.epoch(), frame, expected_ciphertext_sha256: w.ciphertext_sha256 });
1439
+ await hardenedGit(targetRepoDir, ["index-pack", "--stdin", "--strict"], { input: plain });
1440
+ }
1441
+ }
1442
+ // the §4.7 coverage set — canonical refs ∪ unexpired roots ∪ the HEAD target — must all resolve.
1443
+ for (const t of GitvaultVault.coverageTips(newest.refs, newest.roots, newest.head_target)) {
1444
+ if (!(await hasObject(targetRepoDir, t)))
1445
+ fail("CHAIN_UNUSABLE", `covered tip ${t} does not resolve after restore`, "restoring gitvault objects", { oid: t });
1446
+ }
1447
+ return { refs: newest.refs, head_target: newest.head_target, generation: newest.generation };
1448
+ }
1449
+ }
1450
+ /** §4.7 cross-field equality: covers_through agree; the claim set's ordered pack ids/hashes/sizes/total equal the manifest's (shared stored fields only). */
1451
+ export function checkClaimSetEquality(claimSet, manifest, headCoversThrough) {
1452
+ if (claimSet.covers_through_generation !== headCoversThrough || manifest.covers_through_generation !== headCoversThrough)
1453
+ fail("CHECKPOINT_INCOMPLETE", "covers_through_generation disagrees between head, claim set, and manifest", "checking checkpoint claim set");
1454
+ if (claimSet.ordered_pack_receipts.length !== manifest.packs.length)
1455
+ fail("CHECKPOINT_INCOMPLETE", "claim set and manifest list different pack counts", "checking checkpoint claim set");
1456
+ let total = BigInt(claimSet.manifest_receipt.size_bytes);
1457
+ for (let i = 0; i < manifest.packs.length; i++) {
1458
+ const c = claimSet.ordered_pack_receipts[i];
1459
+ const m = manifest.packs[i];
1460
+ if (c.object_id !== m.object_id || c.ciphertext_sha256 !== m.ciphertext_sha256 || c.size_bytes !== m.size_bytes)
1461
+ fail("CHECKPOINT_INCOMPLETE", `pack ${i}: claim set and manifest disagree on the shared stored fields`, "checking checkpoint claim set", { index: i });
1462
+ total += BigInt(c.size_bytes);
1463
+ }
1464
+ if (BigInt(claimSet.total_stored_size_bytes) !== total)
1465
+ fail("CHECKPOINT_INCOMPLETE", "total_stored_size_bytes ≠ manifest receipt + Σ pack sizes", "checking checkpoint claim set");
1466
+ if (claimSet.manifest_receipt.object_id !== manifest.object_id)
1467
+ fail("CHECKPOINT_INCOMPLETE", "claim set names a different manifest", "checking checkpoint claim set");
1468
+ }
1469
+ /** Convenience for the deploy lane: the §6.5 capture binding. */
1470
+ export function captureBinding(captureId, applyPlanSha256, snapshotOidHmac) {
1471
+ if (!/^[0-9a-f]{32}$/.test(captureId))
1472
+ fail("GITVAULT_BAD_ID", "capture_id must be 32 lowercase hex", "building capture binding");
1473
+ return { capture_id: captureId, apply_plan_sha256: applyPlanSha256, snapshot_oid_hmac: snapshotOidHmac };
1474
+ }
1475
+ //# sourceMappingURL=gitvault-publication.js.map