@sentropic/h2a-runtime 0.98.6 → 0.98.7-rc.2

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 (130) hide show
  1. package/dist/agent-launch-args.d.ts.map +1 -1
  2. package/dist/agent-launch-args.js +5 -5
  3. package/dist/agent-launch-args.js.map +1 -1
  4. package/dist/agy-claude.d.ts +2 -0
  5. package/dist/agy-claude.d.ts.map +1 -0
  6. package/dist/agy-claude.js +3 -0
  7. package/dist/agy-claude.js.map +1 -0
  8. package/dist/agy-headless.d.ts +11 -0
  9. package/dist/agy-headless.d.ts.map +1 -0
  10. package/dist/agy-headless.js +104 -0
  11. package/dist/agy-headless.js.map +1 -0
  12. package/dist/attach.d.ts +2 -0
  13. package/dist/attach.d.ts.map +1 -1
  14. package/dist/attach.js +73 -6
  15. package/dist/attach.js.map +1 -1
  16. package/dist/auth-login.d.ts +24 -1
  17. package/dist/auth-login.d.ts.map +1 -1
  18. package/dist/auth-login.js +132 -4
  19. package/dist/auth-login.js.map +1 -1
  20. package/dist/central-mcp.d.ts +2 -0
  21. package/dist/central-mcp.d.ts.map +1 -1
  22. package/dist/central-mcp.js +90 -3
  23. package/dist/central-mcp.js.map +1 -1
  24. package/dist/claude-code-cli-isolation.d.ts +53 -0
  25. package/dist/claude-code-cli-isolation.d.ts.map +1 -0
  26. package/dist/claude-code-cli-isolation.js +277 -0
  27. package/dist/claude-code-cli-isolation.js.map +1 -0
  28. package/dist/claude-code-cli-lifecycle.d.ts +80 -0
  29. package/dist/claude-code-cli-lifecycle.d.ts.map +1 -0
  30. package/dist/claude-code-cli-lifecycle.js +402 -0
  31. package/dist/claude-code-cli-lifecycle.js.map +1 -0
  32. package/dist/claude-code-cli-runner.d.ts +89 -0
  33. package/dist/claude-code-cli-runner.d.ts.map +1 -0
  34. package/dist/claude-code-cli-runner.js +659 -0
  35. package/dist/claude-code-cli-runner.js.map +1 -0
  36. package/dist/claude-seat-input.d.ts +47 -0
  37. package/dist/claude-seat-input.d.ts.map +1 -0
  38. package/dist/claude-seat-input.js +272 -0
  39. package/dist/claude-seat-input.js.map +1 -0
  40. package/dist/claude-seat-mesh-contract.d.ts +88 -0
  41. package/dist/claude-seat-mesh-contract.d.ts.map +1 -0
  42. package/dist/claude-seat-mesh-contract.js +13 -0
  43. package/dist/claude-seat-mesh-contract.js.map +1 -0
  44. package/dist/cli-help-groups.js +1 -1
  45. package/dist/cli-help-groups.js.map +1 -1
  46. package/dist/crash-log.d.ts +112 -0
  47. package/dist/crash-log.d.ts.map +1 -0
  48. package/dist/crash-log.js +560 -0
  49. package/dist/crash-log.js.map +1 -0
  50. package/dist/delegate.d.ts +10 -0
  51. package/dist/delegate.d.ts.map +1 -1
  52. package/dist/delegate.js +29 -0
  53. package/dist/delegate.js.map +1 -1
  54. package/dist/gateway-host/claude-seat-client.d.ts +32 -0
  55. package/dist/gateway-host/claude-seat-client.d.ts.map +1 -0
  56. package/dist/gateway-host/claude-seat-client.js +38 -0
  57. package/dist/gateway-host/claude-seat-client.js.map +1 -0
  58. package/dist/gateway-host/host.d.ts +6 -0
  59. package/dist/gateway-host/host.d.ts.map +1 -1
  60. package/dist/gateway-host/host.js +24 -3
  61. package/dist/gateway-host/host.js.map +1 -1
  62. package/dist/gateway-host/live-declarations.d.ts +58 -0
  63. package/dist/gateway-host/live-declarations.d.ts.map +1 -0
  64. package/dist/gateway-host/live-declarations.js +429 -0
  65. package/dist/gateway-host/live-declarations.js.map +1 -0
  66. package/dist/gateway-host/live-github.d.ts +11 -0
  67. package/dist/gateway-host/live-github.d.ts.map +1 -0
  68. package/dist/gateway-host/live-github.js +374 -0
  69. package/dist/gateway-host/live-github.js.map +1 -0
  70. package/dist/gateway-host/live-object-store.d.ts +35 -0
  71. package/dist/gateway-host/live-object-store.d.ts.map +1 -0
  72. package/dist/gateway-host/live-object-store.js +447 -0
  73. package/dist/gateway-host/live-object-store.js.map +1 -0
  74. package/dist/gateway-host/live-registration.d.ts +162 -0
  75. package/dist/gateway-host/live-registration.d.ts.map +1 -0
  76. package/dist/gateway-host/live-registration.js +455 -0
  77. package/dist/gateway-host/live-registration.js.map +1 -0
  78. package/dist/index.d.ts.map +1 -1
  79. package/dist/index.js +290 -23
  80. package/dist/index.js.map +1 -1
  81. package/dist/llm-mesh-accounts.d.ts +70 -4
  82. package/dist/llm-mesh-accounts.d.ts.map +1 -1
  83. package/dist/llm-mesh-accounts.js +296 -9
  84. package/dist/llm-mesh-accounts.js.map +1 -1
  85. package/dist/native-host.d.ts +1 -1
  86. package/dist/native-host.d.ts.map +1 -1
  87. package/dist/native-host.js +30 -17
  88. package/dist/native-host.js.map +1 -1
  89. package/dist/native-terminal/host.d.ts +6 -1
  90. package/dist/native-terminal/host.d.ts.map +1 -1
  91. package/dist/native-terminal/host.js +6 -25
  92. package/dist/native-terminal/host.js.map +1 -1
  93. package/dist/native-terminal/journal.d.ts +5 -0
  94. package/dist/native-terminal/journal.d.ts.map +1 -1
  95. package/dist/native-terminal/journal.js +28 -16
  96. package/dist/native-terminal/journal.js.map +1 -1
  97. package/dist/native-terminal/op.d.ts +13 -0
  98. package/dist/native-terminal/op.d.ts.map +1 -1
  99. package/dist/native-terminal/op.js +64 -5
  100. package/dist/native-terminal/op.js.map +1 -1
  101. package/dist/proc-identity.d.ts +31 -0
  102. package/dist/proc-identity.d.ts.map +1 -1
  103. package/dist/proc-identity.js +54 -0
  104. package/dist/proc-identity.js.map +1 -1
  105. package/dist/prompt-delivery.d.ts +3 -0
  106. package/dist/prompt-delivery.d.ts.map +1 -1
  107. package/dist/prompt-delivery.js +8 -3
  108. package/dist/prompt-delivery.js.map +1 -1
  109. package/dist/run.d.ts.map +1 -1
  110. package/dist/run.js +79 -16
  111. package/dist/run.js.map +1 -1
  112. package/dist/terminal-reset.d.ts +104 -0
  113. package/dist/terminal-reset.d.ts.map +1 -0
  114. package/dist/terminal-reset.js +229 -0
  115. package/dist/terminal-reset.js.map +1 -0
  116. package/dist/tmux.d.ts +26 -4
  117. package/dist/tmux.d.ts.map +1 -1
  118. package/dist/tmux.js +60 -25
  119. package/dist/tmux.js.map +1 -1
  120. package/dist/vendor/engram-live/contracts.d.ts +825 -0
  121. package/dist/vendor/engram-live/contracts.js +1734 -0
  122. package/dist/vendor/engram-live/integration.d.ts +113 -0
  123. package/dist/vendor/engram-live/integration.js +254 -0
  124. package/dist/vendor/engram-live/pr-sync.d.ts +144 -0
  125. package/dist/vendor/engram-live/pr-sync.js +411 -0
  126. package/dist/vendor/engram-live/registration.d.ts +138 -0
  127. package/dist/vendor/engram-live/registration.js +358 -0
  128. package/dist/vendor/engram-live/retention.d.ts +56 -0
  129. package/dist/vendor/engram-live/retention.js +179 -0
  130. package/package.json +2 -2
@@ -0,0 +1,1734 @@
1
+ // Vendored from rhanka/engram@545a57b8911ab108e84fa1778e0f268fe11bf1de: engram-live/contracts.ts.
2
+ // Compiled with TypeScript 5.9.3 (ES2022/NodeNext, declarations); do not hand-edit.
3
+ // @sentropic/engram-live/contracts — built, data-pure contract export
4
+ // (spec/SPEC_LIVE_GIT_INDEXING.md §3, §11; plan/09 L1b).
5
+ //
6
+ // This module is data-pure: DTOs, constants and validators only. It takes no
7
+ // imports at all — neither node: builtins, host types, model modules nor
8
+ // storage drivers. The executable factory lives in ./integration.js; hosts
9
+ // import only this contract at the adapter boundary and lazily load the
10
+ // factory when they compose live (§3).
11
+ export const CONTRACT_NAME = "@sentropic/engram-live/contracts";
12
+ export const CONTRACT_VERSION = 1;
13
+ export const PRODUCER_NAME = "engram-live";
14
+ export const POLICY_VERSION = "live-git-indexing/1";
15
+ export const CAPABILITY_IDS = [
16
+ "refs",
17
+ "history-dag",
18
+ "github-pr",
19
+ "index-snapshot",
20
+ "working-tree",
21
+ "diffs",
22
+ "code-analysis",
23
+ "retention-requests",
24
+ "reconciliation",
25
+ "publication",
26
+ ];
27
+ /** The lot that implements each capability; part of the advertised contract. */
28
+ export const CAPABILITY_PLANNED_LOTS = {
29
+ "refs": "L2a",
30
+ "history-dag": "L2b",
31
+ "github-pr": "P1",
32
+ "index-snapshot": "L4a",
33
+ "working-tree": "L4a",
34
+ "diffs": "L4b",
35
+ "code-analysis": "L5",
36
+ "retention-requests": "R1",
37
+ "reconciliation": "L6b",
38
+ "publication": "L6b",
39
+ };
40
+ export const NOT_IMPLEMENTED_REASON = "engram-live L1b supplies the package/factory seams and grammar loading only; this capability is explicitly unavailable until its lot lands";
41
+ export function describeCapabilities() {
42
+ return CAPABILITY_IDS.map((capability) => ({
43
+ capability,
44
+ state: "unavailable",
45
+ reason: NOT_IMPLEMENTED_REASON,
46
+ plannedLot: CAPABILITY_PLANNED_LOTS[capability],
47
+ }));
48
+ }
49
+ export const COVERAGE_AXES = [
50
+ "refs",
51
+ "dag",
52
+ "pr-metadata",
53
+ "object-availability",
54
+ "pr-attribution",
55
+ "ast",
56
+ "index",
57
+ "working-tree",
58
+ "remote-publication",
59
+ ];
60
+ /** Languages with a pinned, packaged tree-sitter WASM grammar (L1b matrix). */
61
+ export const ADVERTISED_LANGUAGES = [
62
+ "c",
63
+ "cpp",
64
+ "go",
65
+ "java",
66
+ "javascript",
67
+ "php",
68
+ "python",
69
+ "ruby",
70
+ "rust",
71
+ "typescript",
72
+ "tsx",
73
+ ];
74
+ export const GRAMMAR_UNAVAILABLE_REASON = "no packaged grammar is pinned for this language; AST coverage is explicitly unavailable";
75
+ function isRecord(value) {
76
+ return typeof value === "object" && value !== null && !Array.isArray(value);
77
+ }
78
+ function isNonEmptyString(value) {
79
+ return typeof value === "string" && value.trim().length > 0;
80
+ }
81
+ function isPositiveInteger(value) {
82
+ return typeof value === "number" && Number.isSafeInteger(value) && value > 0;
83
+ }
84
+ function hasNul(value) {
85
+ return typeof value === "string" && value.includes("\0");
86
+ }
87
+ /** Opaque scoped id as minted by the canonical derivation (prefixed, NUL-free). */
88
+ function isOpaqueId(value, prefix) {
89
+ return isNonEmptyString(value) && !hasNul(value) && value.startsWith(prefix);
90
+ }
91
+ function isUncertaintyList(value) {
92
+ return Array.isArray(value) && value.every((state) => isIdentityUncertaintyState(state));
93
+ }
94
+ // Exact-key enforcement (L6a): every DTO validator rejects unknown keys so
95
+ // a misspelled or smuggled field can never pass as a valid declaration,
96
+ // clone, worktree, batch or revocation.
97
+ function rejectUnknownKeys(value, allowed, path, errors) {
98
+ // Opaque refusal: unknown keys are caller-supplied data and may carry
99
+ // private text, so the key itself is never interpolated into diagnostics.
100
+ for (const key of Object.keys(value)) {
101
+ if (!allowed.includes(key)) {
102
+ void key;
103
+ errors.push(`${path} carries an unknown key: refused`);
104
+ return;
105
+ }
106
+ }
107
+ }
108
+ const LIVE_SCOPE_KEYS = ["tenant", "clone", "worktree"];
109
+ /**
110
+ * Strict opaque scope identifiers (L4b M1: no free text toward the sink).
111
+ * Lowercase alphanumerics plus `.`, `_`, `:` and `-` only: private code with
112
+ * spaces, parentheses or braces never validates as an identifier.
113
+ */
114
+ const STRICT_SCOPE_ID = /^[a-z0-9][a-z0-9._:-]{0,127}$/;
115
+ function isStrictScopeId(value) {
116
+ return typeof value === "string" && STRICT_SCOPE_ID.test(value);
117
+ }
118
+ /** Strict opaque batch/generation/key identifiers: prefixed, charset-closed. */
119
+ export function isStrictBatchId(value, prefix) {
120
+ return isStrictScopeId(value) && value.startsWith(prefix) && value.length > prefix.length;
121
+ }
122
+ /**
123
+ * Fixed-width generation identifiers (L4b-2 m9): `gen:` plus exactly
124
+ * GENERATION_ID_WIDTH zero-padded decimal digits. Lexicographic order is
125
+ * numeric order (`gen:0000000010` > `gen:0000000009`), so a newer delivery
126
+ * is never durably rejected as stale. Producers mint monotone ids per
127
+ * target; the host keeps only the highest accepted generation per target.
128
+ */
129
+ export const GENERATION_ID_WIDTH = 10;
130
+ export function isStrictGenerationId(value) {
131
+ return typeof value === "string" && new RegExp(`^gen:[0-9]{${GENERATION_ID_WIDTH}}$`).test(value);
132
+ }
133
+ export function validateLiveScope(scope) {
134
+ if (!isRecord(scope))
135
+ return { ok: false, errors: ["scope must be an object"] };
136
+ const errors = [];
137
+ rejectUnknownKeys(scope, LIVE_SCOPE_KEYS, "scope", errors);
138
+ if (!isStrictScopeId(scope.tenant))
139
+ errors.push("scope.tenant must be a strict opaque identifier");
140
+ if (!isStrictScopeId(scope.clone))
141
+ errors.push("scope.clone must be a strict opaque identifier");
142
+ if (scope.worktree !== undefined && !isStrictScopeId(scope.worktree)) {
143
+ errors.push("scope.worktree, when present, must be a strict opaque identifier");
144
+ }
145
+ return { ok: errors.length === 0, errors };
146
+ }
147
+ const WORKSPACE_DECLARATION_KEYS = [
148
+ "tenant",
149
+ "principal",
150
+ "hostFingerprint",
151
+ "identityUncertainty",
152
+ "declarationId",
153
+ "cloneId",
154
+ "worktreeId",
155
+ "authorizedRoots",
156
+ "liveCapability",
157
+ "policyVersion",
158
+ "declarationRevision",
159
+ "serverWorkspaceId",
160
+ "serverIssuer",
161
+ "bindingRevision",
162
+ ];
163
+ export function validateWorkspaceDeclaration(declaration) {
164
+ if (!isRecord(declaration))
165
+ return { ok: false, errors: ["declaration must be an object"] };
166
+ const errors = [];
167
+ rejectUnknownKeys(declaration, WORKSPACE_DECLARATION_KEYS, "declaration", errors);
168
+ if (!isNonEmptyString(declaration.tenant) || hasNul(declaration.tenant)) {
169
+ errors.push("declaration.tenant must be a non-empty string without NUL");
170
+ }
171
+ if (!isNonEmptyString(declaration.principal) || hasNul(declaration.principal)) {
172
+ errors.push("declaration.principal must be a non-empty string without NUL");
173
+ }
174
+ if (!isUncertaintyList(declaration.identityUncertainty)) {
175
+ errors.push(`declaration.identityUncertainty must be an array of uncertainty states (${IDENTITY_UNCERTAINTY_STATES.join(", ")})`);
176
+ }
177
+ if (declaration.hostFingerprint !== undefined) {
178
+ if (!isNonEmptyString(declaration.hostFingerprint) || hasNul(declaration.hostFingerprint)) {
179
+ errors.push("declaration.hostFingerprint, when present, must be a non-empty string without NUL");
180
+ }
181
+ }
182
+ else if (!isUncertaintyList(declaration.identityUncertainty) ||
183
+ !declaration.identityUncertainty.includes("missing_history")) {
184
+ errors.push('declaration.hostFingerprint is absent: identityUncertainty must explicitly contain "missing_history" (LIVE §12)');
185
+ }
186
+ if (!isOpaqueId(declaration.declarationId, ID_PREFIXES.declaration)) {
187
+ errors.push(`declaration.declarationId must be a non-empty opaque id starting with "${ID_PREFIXES.declaration}"`);
188
+ }
189
+ if (!isOpaqueId(declaration.cloneId, ID_PREFIXES.clone)) {
190
+ errors.push(`declaration.cloneId must be a non-empty opaque id starting with "${ID_PREFIXES.clone}"`);
191
+ }
192
+ if (!isOpaqueId(declaration.worktreeId, ID_PREFIXES.worktree)) {
193
+ errors.push(`declaration.worktreeId must be a non-empty opaque id starting with "${ID_PREFIXES.worktree}"`);
194
+ }
195
+ if (!Array.isArray(declaration.authorizedRoots) || declaration.authorizedRoots.length === 0) {
196
+ errors.push("declaration.authorizedRoots must be a non-empty array");
197
+ }
198
+ else if (declaration.authorizedRoots.some((path) => !isNonEmptyString(path) || hasNul(path))) {
199
+ errors.push("declaration.authorizedRoots entries must be non-empty strings without NUL");
200
+ }
201
+ if (declaration.liveCapability !== "live") {
202
+ errors.push('declaration.liveCapability must be the explicit string "live" (bind/upload is not activation)');
203
+ }
204
+ if (declaration.policyVersion !== POLICY_VERSION) {
205
+ errors.push(`declaration.policyVersion must be the fixed live policy version "${POLICY_VERSION}"`);
206
+ }
207
+ if (!isPositiveInteger(declaration.declarationRevision) ||
208
+ declaration.declarationRevision < INITIAL_DECLARATION_REVISION) {
209
+ errors.push(`declaration.declarationRevision must be an integer >= ${INITIAL_DECLARATION_REVISION}`);
210
+ }
211
+ const hasServerWorkspace = declaration.serverWorkspaceId !== undefined;
212
+ const hasServerIssuer = declaration.serverIssuer !== undefined;
213
+ const hasBindingRevision = declaration.bindingRevision !== undefined;
214
+ if (hasServerWorkspace && (!isNonEmptyString(declaration.serverWorkspaceId) || hasNul(declaration.serverWorkspaceId))) {
215
+ errors.push("declaration.serverWorkspaceId, when present, must be a non-empty string without NUL");
216
+ }
217
+ if (hasServerIssuer && (!isNonEmptyString(declaration.serverIssuer) || hasNul(declaration.serverIssuer))) {
218
+ errors.push("declaration.serverIssuer, when present, must be a non-empty string without NUL");
219
+ }
220
+ if (hasBindingRevision && (!isPositiveInteger(declaration.bindingRevision) ||
221
+ declaration.bindingRevision < INITIAL_BINDING_REVISION)) {
222
+ errors.push(`declaration.bindingRevision, when present, must be an integer >= ${INITIAL_BINDING_REVISION}`);
223
+ }
224
+ // Product live intent binds the complete server/issuer/revision triplet
225
+ // (spec §12): a workspace without its issuer or binding revision, an
226
+ // issuer or revision without the workspace, is not a valid product
227
+ // binding. Absence of all three is a valid local-only declaration
228
+ // requiring no enrollment.
229
+ if ((hasServerWorkspace || hasServerIssuer || hasBindingRevision) &&
230
+ !(hasServerWorkspace && hasServerIssuer && hasBindingRevision)) {
231
+ errors.push("the product binding must be the complete serverWorkspaceId + serverIssuer + bindingRevision triplet: a partial binding is not a valid product intent");
232
+ }
233
+ return { ok: errors.length === 0, errors };
234
+ }
235
+ const FACT_BATCH_KEYS = [
236
+ "scope",
237
+ "producerName",
238
+ "contractVersion",
239
+ "policyVersion",
240
+ "generationId",
241
+ "batchId",
242
+ "batchDigest",
243
+ "facts",
244
+ ];
245
+ function validateBatchFact(fact, index) {
246
+ const path = `batch.facts[${index}]`;
247
+ if (!isRecord(fact))
248
+ return { ok: false, errors: [`${path} must be an object`] };
249
+ // Sink-bound fact control: every L4b-shaped fact is re-validated at the
250
+ // batch boundary, so a diff carrying transient clair can never ride into
251
+ // a sink inside an otherwise valid envelope. Facts of unknown shape
252
+ // belong to other lots and pass through structurally.
253
+ if ("files" in fact) {
254
+ const checked = validateLocalDiffShape(fact, true);
255
+ if (!checked.ok)
256
+ return { ok: false, errors: checked.errors.map((error) => `${path}: ${error}`) };
257
+ return { ok: true, errors: [] };
258
+ }
259
+ if ("snapshotId" in fact) {
260
+ const checked = validateLocalSnapshot(fact);
261
+ if (!checked.ok)
262
+ return { ok: false, errors: checked.errors.map((error) => `${path}: ${error}`) };
263
+ return { ok: true, errors: [] };
264
+ }
265
+ if ("axis" in fact && "state" in fact && "reason" in fact) {
266
+ const checked = validateLocalCoverage(fact);
267
+ if (!checked.ok)
268
+ return { ok: false, errors: checked.errors.map((error) => `${path}: ${error}`) };
269
+ return { ok: true, errors: [] };
270
+ }
271
+ return { ok: true, errors: [] };
272
+ }
273
+ export function validateFactBatch(batch) {
274
+ if (!isRecord(batch))
275
+ return { ok: false, errors: ["batch must be an object"] };
276
+ const errors = [];
277
+ rejectUnknownKeys(batch, FACT_BATCH_KEYS, "batch", errors);
278
+ const scope = validateLiveScope(batch.scope);
279
+ if (!scope.ok)
280
+ errors.push(...scope.errors.map((error) => `batch.scope: ${error}`));
281
+ if (batch.producerName !== PRODUCER_NAME)
282
+ errors.push(`batch.producerName must be "${PRODUCER_NAME}"`);
283
+ if (batch.contractVersion !== CONTRACT_VERSION) {
284
+ errors.push(`batch.contractVersion must be ${CONTRACT_VERSION}`);
285
+ }
286
+ if (batch.policyVersion !== CONTENT_POLICY_VERSION) {
287
+ errors.push(`batch.policyVersion must be "${CONTENT_POLICY_VERSION}"`);
288
+ }
289
+ if (!isStrictGenerationId(batch.generationId))
290
+ errors.push(`batch.generationId must be a fixed-width generation id ("gen:" plus ${GENERATION_ID_WIDTH} zero-padded digits)`);
291
+ if (!isStrictBatchId(batch.batchId, "batch:"))
292
+ errors.push("batch.batchId must be a strict opaque batch id");
293
+ if (typeof batch.batchDigest !== "string" || !/^[0-9a-f]{64}$/.test(batch.batchDigest)) {
294
+ errors.push("batch.batchDigest must be a 64 character lowercase hex digest");
295
+ }
296
+ if (!Array.isArray(batch.facts)) {
297
+ errors.push("batch.facts must be an array");
298
+ }
299
+ else {
300
+ batch.facts.forEach((fact, index) => {
301
+ const checked = validateBatchFact(fact, index);
302
+ if (!checked.ok)
303
+ errors.push(...checked.errors);
304
+ });
305
+ }
306
+ return { ok: errors.length === 0, errors };
307
+ }
308
+ // Same identity, same bytes is idempotent; same identity, different bytes is
309
+ // refused (spec §3). Pure decision over validated batches.
310
+ export function classifyDuplicateBatch(previous, next) {
311
+ if (previous.batchId !== next.batchId) {
312
+ throw new Error("classifyDuplicateBatch compares the same batch identity only");
313
+ }
314
+ return previous.batchDigest === next.batchDigest ? "idempotent" : "refused";
315
+ }
316
+ // — L6a: explicit host declarations and clone identity
317
+ // (spec/SPEC_LIVE_GIT_INDEXING.md §§2–3, 10, 12; plan/09 L6a; owner Q4 A).
318
+ //
319
+ // Consumer contract for the h2a (H) and cluster-mesh/sentropic (S) seats.
320
+ // H owns durable local declarations plus clone/worktree resolution
321
+ // (live-registration.ts, live-declarations.ts, data-pure DTOs); S owns the
322
+ // registration persistence, product declarations and migrations; engram
323
+ // owns only this data-pure contract and the pure functions in
324
+ // ./registration.js. Read this block before implementing either host side.
325
+ //
326
+ // - Explicit opt-in, once per workspace, through h2a/cluster-mesh. Only
327
+ // liveCapability "live" (EXPLICIT_LIVE_CAPABILITY) activates collection.
328
+ // Bind, upload, code-workspace creation, session opening or session
329
+ // ending never activate or remove a declaration
330
+ // (NON_ACTIVATING_LIFECYCLE_KINDS). Replay of a stored declaration after
331
+ // projection loss resumes work without renewed activation.
332
+ // - Engram neutrality: this module takes no imports of any kind (no h2a,
333
+ // no sentropic, no storage driver, no network); registration.ts adds pure
334
+ // functions with no host state, no storage and no network. Durable state
335
+ // belongs to the hosts — local h2a state outside source roots plus
336
+ // app-operated product persistence — independent of the live projection.
337
+ // - Opaque identity triangle: every declaration carries a declarationId
338
+ // (unique per declaration, stable across revisions) attached to its
339
+ // cloneId and worktreeId. A revocation names exactly one declaration;
340
+ // withdrawing a whole clone means revoking each of its declarations,
341
+ // each revocation naming its exact declarationId.
342
+ // - Workspace fingerprint vs clone identity: the public h2a `ws:`
343
+ // fingerprint stays on the declaration (hostFingerprint) and is never
344
+ // the sole clone key. The clone key is the (hostScope, common-dir
345
+ // realpath, common-dir project fingerprint) triple. Two clones with an
346
+ // equal project fingerprint remain distinct; linked worktrees sharing one
347
+ // common-dir share one clone with isolated worktree identities. The
348
+ // common-dir realpath is host-local and never published to sentropic:
349
+ // share only the opaque cloneId.
350
+ // - Identity uncertainty (LIVE §12): a repo without history (unborn HEAD)
351
+ // or with grafted (shallow) history declares explicit uncertainty
352
+ // states; a fingerprint may be absent only under "missing_history".
353
+ // Uncertainty never merges clones: the hostScope + common-dir realpath
354
+ // key components keep them distinct.
355
+ // - Revisions increment by exactly one, preserve tenant/principal/
356
+ // hostFingerprint, and preserve the product binding unless the binding
357
+ // itself changes: a revision outside the binding (roots, policy version)
358
+ // keeps it, while a changed serverWorkspaceId or serverIssuer — or an
359
+ // explicit revalidation — requires a strictly increasing
360
+ // bindingRevision (the first binding carries exactly 1). The host must
361
+ // additionally refuse to redirect in-flight collection/publication on
362
+ // rebind.
363
+ // - Revocation is a private-purge request: the host stops work, rejects
364
+ // delayed jobs, and purges private snapshots, derived indexes and
365
+ // decryptability, while preserving authorized committed history. Purging
366
+ // never deletes committed facts.
367
+ //
368
+ // FROZEN L6a CONVENTIONS — both seats align on these values:
369
+ // - POLICY_VERSION ("live-git-indexing/1") is the one policy name stamped
370
+ // on every live declaration; h2a's provisional "live-git-1" retires.
371
+ // - INITIAL_DECLARATION_REVISION (1): the explicit activation itself is
372
+ // the first revision; there is no revision-0 declaration.
373
+ // - INITIAL_BINDING_REVISION (1): the first product binding carries 1,
374
+ // and every rebind/revalidation carries a strictly greater
375
+ // bindingRevision.
376
+ // - Identity uncertainty vocabulary: "missing_history", "shallow_history"
377
+ // (the labels H already produces under LIVE §12).
378
+ // - Opaque ids are minted ONLY through the canonical derivation below
379
+ // (canonicalIdKey + the sha256 minting in ./registration.js): the same
380
+ // inputs give H, S and E byte-identical cloneId/worktreeId/
381
+ // declarationId. MIGRATION (H): identifiers already persisted by h2a
382
+ // were hashed from newline-joined "hostScope\nrealpath\nfingerprint"
383
+ // (clone), "hostScope\ngitDir" (worktree) and
384
+ // "hostScope\ncommonDirRealpath\nws:" (declaration); that scheme is
385
+ // ambiguous and diverges from this contract. Re-derive every persisted
386
+ // id with the canonical derivation and rewrite the local h2a store; no
387
+ // old id has crossed to sentropic (L6a is not consumed yet), so the
388
+ // migration is a purely local rewrite with no product-side replay.
389
+ /** The only capability value that activates live collection (Q4 A). */
390
+ export const EXPLICIT_LIVE_CAPABILITY = "live";
391
+ /**
392
+ * Lifecycle kinds that never activate or remove a live declaration:
393
+ * enrollment bind, transcript upload, code-workspace creation and agent
394
+ * session open/end are distinct capabilities (spec §12).
395
+ */
396
+ export const NON_ACTIVATING_LIFECYCLE_KINDS = [
397
+ "bind",
398
+ "upload",
399
+ "code-workspace-create",
400
+ "session-open",
401
+ "session-end",
402
+ ];
403
+ export function isNonActivatingLifecycleKind(value) {
404
+ return (typeof value === "string" &&
405
+ NON_ACTIVATING_LIFECYCLE_KINDS.includes(value));
406
+ }
407
+ export const OID_ALGORITHMS = ["sha1", "sha256"];
408
+ export const REVOCATION_REASONS = ["workspace-remove", "owner-request", "policy-withdrawn"];
409
+ export function isRevocationReason(value) {
410
+ return (typeof value === "string" &&
411
+ REVOCATION_REASONS.includes(value));
412
+ }
413
+ /**
414
+ * Explicit identity-coverage uncertainty states (LIVE §12). A repo with no
415
+ * history (unborn HEAD) declares "missing_history"; a grafted (shallow)
416
+ * history declares "shallow_history" while keeping its fingerprints, whose
417
+ * coverage stays uncertain. An absent fingerprint is valid only under
418
+ * "missing_history"; uncertainty never merges clones.
419
+ */
420
+ export const IDENTITY_UNCERTAINTY_STATES = ["missing_history", "shallow_history"];
421
+ export function isIdentityUncertaintyState(value) {
422
+ return (typeof value === "string" &&
423
+ IDENTITY_UNCERTAINTY_STATES.includes(value));
424
+ }
425
+ /** First declaration revision: the explicit activation is revision 1. */
426
+ export const INITIAL_DECLARATION_REVISION = 1;
427
+ /** First product binding revision; every rebind carries a strictly greater one. */
428
+ export const INITIAL_BINDING_REVISION = 1;
429
+ /** The three opaque id kinds minted by the canonical derivation. */
430
+ export const ID_KINDS = ["clone", "worktree", "declaration"];
431
+ /** Opaque id prefixes; a valid id of a kind starts with its prefix. */
432
+ export const ID_PREFIXES = {
433
+ clone: "clone:",
434
+ worktree: "wt:",
435
+ declaration: "decl:",
436
+ };
437
+ export function isIdKind(value) {
438
+ return (typeof value === "string" &&
439
+ ID_KINDS.includes(value));
440
+ }
441
+ /**
442
+ * THE canonical identity serialization shared by every live opaque id
443
+ * (cloneId, worktreeId, declarationId). Both seats and engram mint ids
444
+ * from this exact function, so the same identity components always give
445
+ * the same id on every side. The serialization is unambiguous: a JSON
446
+ * array of the kind plus the components in order, so no component value
447
+ * can shift a boundary. NUL is refused outright in every component, so the
448
+ * ambiguous couples ("a\0b", "c") vs ("a", "b\0c") can never be
449
+ * constructed. Pure and total over valid inputs; throws on malformed
450
+ * components so no silent normalization can produce a colliding key.
451
+ */
452
+ export function canonicalIdKey(kind, components) {
453
+ if (!isIdKind(kind)) {
454
+ throw new Error(`canonicalIdKey requires a known id kind (${ID_KINDS.join(", ")})`);
455
+ }
456
+ for (const component of components) {
457
+ if (typeof component !== "string") {
458
+ throw new Error("canonicalIdKey requires string components");
459
+ }
460
+ if (component.includes("\0")) {
461
+ throw new Error("canonicalIdKey refuses NUL in identity components");
462
+ }
463
+ }
464
+ return JSON.stringify([kind, ...components]);
465
+ }
466
+ const CLONE_IDENTITY_KEYS = [
467
+ "hostScope",
468
+ "commonDirRealpath",
469
+ "commonDirProjectFingerprint",
470
+ "cloneId",
471
+ "oidAlgorithm",
472
+ "identityUncertainty",
473
+ ];
474
+ const WORKTREE_IDENTITY_KEYS = [
475
+ "cloneId",
476
+ "worktreeId",
477
+ "worktreePath",
478
+ "gitDir",
479
+ "worktreeFingerprint",
480
+ "identityUncertainty",
481
+ ];
482
+ // — L2b: roots, retained DAG and OID completion
483
+ // (spec/SPEC_LIVE_GIT_INDEXING.md §§3–4, 6; plan/09 L2b). Data-pure DTOs,
484
+ // constants and validators only; the engine lives in ./history.js and the
485
+ // ref/repository models in ./refs.js and ./repository.js. Every fact and
486
+ // lookup below is scoped by clone and qualified by OID algorithm.
487
+ /**
488
+ * The follow-up window for branch activity (spec §4). It selects followed
489
+ * branches only; it never limits commit count, commit age, retained DAG
490
+ * facts or already acquired PR history, and it never prunes anything.
491
+ */
492
+ export const ACTIVITY_WINDOW_DAYS = 30;
493
+ /** Hex length of an OID under one algorithm. */
494
+ export function oidHexLength(algorithm) {
495
+ return algorithm === "sha1" ? 40 : 64;
496
+ }
497
+ /** Strict algorithm-qualified OID check: lowercase hex of the algorithm's exact length. */
498
+ export function isValidQualifiedOid(value) {
499
+ return ((value.algorithm === "sha1" || value.algorithm === "sha256") &&
500
+ new RegExp(`^[0-9a-f]{${oidHexLength(value.algorithm)}}$`).test(value.oid));
501
+ }
502
+ export function validateQualifiedOid(value) {
503
+ if (!isRecord(value))
504
+ return { ok: false, errors: ["qualifiedOid must be an object"] };
505
+ const errors = [];
506
+ if (value.algorithm !== "sha1" && value.algorithm !== "sha256") {
507
+ errors.push('qualifiedOid.algorithm must be "sha1" or "sha256"');
508
+ }
509
+ if (typeof value.oid !== "string" || !/^[0-9a-f]{40}$|^[0-9a-f]{64}$/.test(value.oid)) {
510
+ errors.push("qualifiedOid.oid must be a 40 or 64 character lowercase hex string");
511
+ }
512
+ else if ((value.algorithm === "sha1" || value.algorithm === "sha256") &&
513
+ value.oid.length !== oidHexLength(value.algorithm)) {
514
+ errors.push(`qualifiedOid.oid must have exactly ${oidHexLength(value.algorithm)} hex characters under ${value.algorithm}`);
515
+ }
516
+ return { ok: errors.length === 0, errors };
517
+ }
518
+ const RAW_REF_OBSERVATION_KEYS = [
519
+ "refName", "oid", "observedAt", "absent", "tipCommitterUnixSeconds",
520
+ ];
521
+ export function validateRawRefObservation(value) {
522
+ if (!isRecord(value))
523
+ return { ok: false, errors: ["ref observation must be an object"] };
524
+ const errors = [];
525
+ rejectUnknownKeys(value, RAW_REF_OBSERVATION_KEYS, "ref observation", errors);
526
+ if (!isNonEmptyString(value.refName) || hasNul(value.refName) || !value.refName.startsWith("refs/")) {
527
+ errors.push("ref observation.refName must be a non-empty full ref name starting with \"refs/\" and without NUL");
528
+ }
529
+ if (typeof value.oid !== "string" || !/^[0-9a-f]{0,64}$/.test(value.oid)) {
530
+ errors.push("ref observation.oid must be a lowercase hex string");
531
+ }
532
+ if (typeof value.observedAt !== "number" || !Number.isSafeInteger(value.observedAt) || value.observedAt <= 0) {
533
+ errors.push("ref observation.observedAt must be a positive safe integer (milliseconds)");
534
+ }
535
+ if (value.absent !== undefined && typeof value.absent !== "boolean") {
536
+ errors.push("ref observation.absent, when present, must be a boolean");
537
+ }
538
+ if (value.absent === true && value.oid !== "") {
539
+ errors.push("ref observation.oid must be empty on an absent (deletion) observation");
540
+ }
541
+ if (value.tipCommitterUnixSeconds !== undefined &&
542
+ (typeof value.tipCommitterUnixSeconds !== "number" || !Number.isSafeInteger(value.tipCommitterUnixSeconds))) {
543
+ errors.push("ref observation.tipCommitterUnixSeconds, when present, must be an integer (unix seconds)");
544
+ }
545
+ return { ok: errors.length === 0, errors };
546
+ }
547
+ const PRIVATE_PURGE_REQUEST_KEYS = [
548
+ "purgePrivateSnapshots",
549
+ "purgeDerivedIndexes",
550
+ "revokeDecryptability",
551
+ "preserveCommittedHistory",
552
+ ];
553
+ const DECLARATION_REVOCATION_KEYS = [
554
+ "declarationId",
555
+ "tenant",
556
+ "cloneId",
557
+ "worktreeId",
558
+ "declarationRevision",
559
+ "reason",
560
+ "requestPrivatePurge",
561
+ ];
562
+ /**
563
+ * Lexical normalization applied to the host-resolved common-dir realpath
564
+ * before key derivation: trailing slashes are stripped (the filesystem
565
+ * root alone is kept). The host resolves symlinks to the realpath before
566
+ * calling; this function performs no I/O.
567
+ */
568
+ export function normalizeCommonDirRealpath(realpath) {
569
+ if (realpath.length > 1) {
570
+ const stripped = realpath.replace(/\/+$/, "");
571
+ return stripped.length === 0 ? "/" : stripped;
572
+ }
573
+ return realpath;
574
+ }
575
+ export function validateCloneIdentity(identity) {
576
+ if (!isRecord(identity))
577
+ return { ok: false, errors: ["cloneIdentity must be an object"] };
578
+ const errors = [];
579
+ rejectUnknownKeys(identity, CLONE_IDENTITY_KEYS, "cloneIdentity", errors);
580
+ if (!isNonEmptyString(identity.hostScope) || hasNul(identity.hostScope)) {
581
+ errors.push("cloneIdentity.hostScope must be a non-empty string without NUL");
582
+ }
583
+ if (!isNonEmptyString(identity.commonDirRealpath) || hasNul(identity.commonDirRealpath)) {
584
+ errors.push("cloneIdentity.commonDirRealpath must be a non-empty string without NUL");
585
+ }
586
+ if (identity.commonDirProjectFingerprint !== undefined &&
587
+ (!isNonEmptyString(identity.commonDirProjectFingerprint) || hasNul(identity.commonDirProjectFingerprint))) {
588
+ errors.push("cloneIdentity.commonDirProjectFingerprint, when present, must be a non-empty string without NUL");
589
+ }
590
+ else if (identity.commonDirProjectFingerprint === undefined &&
591
+ (!isUncertaintyList(identity.identityUncertainty) ||
592
+ !identity.identityUncertainty.includes("missing_history"))) {
593
+ errors.push('cloneIdentity.commonDirProjectFingerprint is absent: identityUncertainty must explicitly contain "missing_history" (LIVE §12)');
594
+ }
595
+ if (!isOpaqueId(identity.cloneId, ID_PREFIXES.clone)) {
596
+ errors.push(`cloneIdentity.cloneId must be a non-empty opaque id starting with "${ID_PREFIXES.clone}"`);
597
+ }
598
+ if (identity.oidAlgorithm !== "sha1" && identity.oidAlgorithm !== "sha256") {
599
+ errors.push('cloneIdentity.oidAlgorithm must be "sha1" or "sha256"');
600
+ }
601
+ if (!isUncertaintyList(identity.identityUncertainty)) {
602
+ errors.push(`cloneIdentity.identityUncertainty must be an array of uncertainty states (${IDENTITY_UNCERTAINTY_STATES.join(", ")})`);
603
+ }
604
+ return { ok: errors.length === 0, errors };
605
+ }
606
+ export function validateWorktreeIdentity(identity) {
607
+ if (!isRecord(identity))
608
+ return { ok: false, errors: ["worktreeIdentity must be an object"] };
609
+ const errors = [];
610
+ rejectUnknownKeys(identity, WORKTREE_IDENTITY_KEYS, "worktreeIdentity", errors);
611
+ if (!isOpaqueId(identity.cloneId, ID_PREFIXES.clone)) {
612
+ errors.push(`worktreeIdentity.cloneId must be a non-empty opaque id starting with "${ID_PREFIXES.clone}"`);
613
+ }
614
+ if (!isOpaqueId(identity.worktreeId, ID_PREFIXES.worktree)) {
615
+ errors.push(`worktreeIdentity.worktreeId must be a non-empty opaque id starting with "${ID_PREFIXES.worktree}"`);
616
+ }
617
+ if (!isNonEmptyString(identity.worktreePath) || hasNul(identity.worktreePath)) {
618
+ errors.push("worktreeIdentity.worktreePath must be a non-empty string without NUL");
619
+ }
620
+ if (!isNonEmptyString(identity.gitDir) || hasNul(identity.gitDir)) {
621
+ errors.push("worktreeIdentity.gitDir must be a non-empty string without NUL");
622
+ }
623
+ if (identity.worktreeFingerprint !== undefined &&
624
+ (!isNonEmptyString(identity.worktreeFingerprint) || hasNul(identity.worktreeFingerprint))) {
625
+ errors.push("worktreeIdentity.worktreeFingerprint, when present, must be a non-empty string without NUL");
626
+ }
627
+ else if (identity.worktreeFingerprint === undefined &&
628
+ (!isUncertaintyList(identity.identityUncertainty) ||
629
+ !identity.identityUncertainty.includes("missing_history"))) {
630
+ errors.push('worktreeIdentity.worktreeFingerprint is absent: identityUncertainty must explicitly contain "missing_history" (LIVE §12)');
631
+ }
632
+ if (!isUncertaintyList(identity.identityUncertainty)) {
633
+ errors.push(`worktreeIdentity.identityUncertainty must be an array of uncertainty states (${IDENTITY_UNCERTAINTY_STATES.join(", ")})`);
634
+ }
635
+ return { ok: errors.length === 0, errors };
636
+ }
637
+ export function validatePrivatePurgeRequest(request) {
638
+ if (!isRecord(request))
639
+ return { ok: false, errors: ["requestPrivatePurge must be an object"] };
640
+ const errors = [];
641
+ rejectUnknownKeys(request, PRIVATE_PURGE_REQUEST_KEYS, "requestPrivatePurge", errors);
642
+ if (request.purgePrivateSnapshots !== true) {
643
+ errors.push("requestPrivatePurge.purgePrivateSnapshots must be true (revocation always purges private snapshots)");
644
+ }
645
+ if (request.purgeDerivedIndexes !== true) {
646
+ errors.push("requestPrivatePurge.purgeDerivedIndexes must be true (revocation always purges derived indexes)");
647
+ }
648
+ if (request.revokeDecryptability !== true) {
649
+ errors.push("requestPrivatePurge.revokeDecryptability must be true (revocation always revokes decryptability)");
650
+ }
651
+ if (request.preserveCommittedHistory !== true) {
652
+ errors.push("requestPrivatePurge.preserveCommittedHistory must be true (revocation never deletes committed history)");
653
+ }
654
+ return { ok: errors.length === 0, errors };
655
+ }
656
+ // — R1: private retention of every ingested root
657
+ // (spec/SPEC_LIVE_GIT_INDEXING.md §4, A02/A04; plan/09 R1). Data-pure DTOs,
658
+ // constants and validators only; the engine lives in ./retention.js.
659
+ //
660
+ // Consumer contract for the h2a (H) seat. H owns the private Git object
661
+ // store (live-object-store.ts) and the retention handoff (delegate.ts);
662
+ // engram owns only this data-pure contract plus the pure derivation in
663
+ // ./retention.js. Read this block before implementing the host side.
664
+ //
665
+ // - Private retention: every ingested root (default branch even when old,
666
+ // active local/remote branches, PR heads of all states, detached worktree
667
+ // HEADs, previously ingested roots) requires one scoped durable receipt.
668
+ // No commit-count or commit-age limit exists; nothing is ever erased here.
669
+ // - Requests carry ONLY the algorithm-qualified OID plus its scope and
670
+ // provenance signal: no file content, no plaintext, no patch, no derived
671
+ // text. Uncommitted plaintext is never added to the private store.
672
+ // - Receipts are scoped by tenant + clone (never by worktree, never global)
673
+ // and durable: the host records them through the injected port before any
674
+ // reconstructible-coverage claim. A missing receipt is an explicit
675
+ // object-availability gap, never a silent current claim.
676
+ // - Acquisition (H) uses pinned OIDs and private refs, without writing into
677
+ // the observed clone, without borrowing an alternates store that may be
678
+ // GC'd, and without invoking repository-controlled programs. If removal
679
+ // wins the acquisition race, H reports the gap instead of inventing
680
+ // retention. Quotas pause further work without deleting protected roots.
681
+ // - Receipts expose an opaque coordinated-backup reference (backupRef) for
682
+ // L8 backup coordination; P1 reuses this facility for PR roots. No raw
683
+ // private pack is published to Postgres through this contract.
684
+ /** The coverage axis retention reports on: object availability, never DAG currency. */
685
+ export const RETENTION_COVERAGE_AXIS = "object-availability";
686
+ const RETENTION_REQUEST_KEYS = [
687
+ "scope",
688
+ "algorithm",
689
+ "oid",
690
+ "firstSignal",
691
+ "refName",
692
+ "requestedAt",
693
+ ];
694
+ const RETENTION_RECEIPT_KEYS = [
695
+ "scope",
696
+ "algorithm",
697
+ "oid",
698
+ "receivedAt",
699
+ "backupRef",
700
+ ];
701
+ const RETENTION_SIGNALS = [
702
+ "default-branch",
703
+ "active-window",
704
+ "pr-head",
705
+ "worktree-head",
706
+ "previously-ingested",
707
+ ];
708
+ function isRetentionScope(scope) {
709
+ return validateLiveScope(scope).ok;
710
+ }
711
+ export function validateRetentionRequest(request) {
712
+ if (!isRecord(request))
713
+ return { ok: false, errors: ["retention request must be an object"] };
714
+ const errors = [];
715
+ rejectUnknownKeys(request, RETENTION_REQUEST_KEYS, "retention request", errors);
716
+ const scope = validateLiveScope(request.scope);
717
+ if (!scope.ok)
718
+ errors.push(...scope.errors.map((error) => `retention request.scope: ${error}`));
719
+ else if (isRetentionScope(request.scope) && request.scope.worktree !== undefined) {
720
+ errors.push("retention request.scope must be clone-scoped: worktree must be absent (retention is per tenant + clone)");
721
+ }
722
+ if (request.algorithm !== "sha1" && request.algorithm !== "sha256") {
723
+ errors.push('retention request.algorithm must be "sha1" or "sha256"');
724
+ }
725
+ if (typeof request.oid !== "string" || !/^[0-9a-f]{40}$|^[0-9a-f]{64}$/.test(request.oid)) {
726
+ errors.push("retention request.oid must be a 40 or 64 character lowercase hex string");
727
+ }
728
+ else if ((request.algorithm === "sha1" || request.algorithm === "sha256") &&
729
+ request.oid.length !== oidHexLength(request.algorithm)) {
730
+ errors.push(`retention request.oid must have exactly ${oidHexLength(request.algorithm)} hex characters under ${request.algorithm}`);
731
+ }
732
+ if (typeof request.firstSignal !== "string" || !RETENTION_SIGNALS.includes(request.firstSignal)) {
733
+ errors.push(`retention request.firstSignal must be one of ${RETENTION_SIGNALS.join(", ")}`);
734
+ }
735
+ if (request.refName !== null &&
736
+ (!isNonEmptyString(request.refName) || hasNul(request.refName) || !request.refName.startsWith("refs/"))) {
737
+ errors.push('retention request.refName must be null or a non-empty full ref name starting with "refs/" without NUL');
738
+ }
739
+ if (typeof request.requestedAt !== "number" || !Number.isSafeInteger(request.requestedAt) || request.requestedAt <= 0) {
740
+ errors.push("retention request.requestedAt must be a positive safe integer (milliseconds)");
741
+ }
742
+ return { ok: errors.length === 0, errors };
743
+ }
744
+ export function validateRetentionReceipt(receipt) {
745
+ if (!isRecord(receipt))
746
+ return { ok: false, errors: ["retention receipt must be an object"] };
747
+ const errors = [];
748
+ rejectUnknownKeys(receipt, RETENTION_RECEIPT_KEYS, "retention receipt", errors);
749
+ const scope = validateLiveScope(receipt.scope);
750
+ if (!scope.ok)
751
+ errors.push(...scope.errors.map((error) => `retention receipt.scope: ${error}`));
752
+ else if (isRetentionScope(receipt.scope) && receipt.scope.worktree !== undefined) {
753
+ errors.push("retention receipt.scope must be clone-scoped: worktree must be absent (retention is per tenant + clone)");
754
+ }
755
+ if (receipt.algorithm !== "sha1" && receipt.algorithm !== "sha256") {
756
+ errors.push('retention receipt.algorithm must be "sha1" or "sha256"');
757
+ }
758
+ if (typeof receipt.oid !== "string" || !/^[0-9a-f]{40}$|^[0-9a-f]{64}$/.test(receipt.oid)) {
759
+ errors.push("retention receipt.oid must be a 40 or 64 character lowercase hex string");
760
+ }
761
+ else if ((receipt.algorithm === "sha1" || receipt.algorithm === "sha256") &&
762
+ receipt.oid.length !== oidHexLength(receipt.algorithm)) {
763
+ errors.push(`retention receipt.oid must have exactly ${oidHexLength(receipt.algorithm)} hex characters under ${receipt.algorithm}`);
764
+ }
765
+ if (typeof receipt.receivedAt !== "number" || !Number.isSafeInteger(receipt.receivedAt) || receipt.receivedAt <= 0) {
766
+ errors.push("retention receipt.receivedAt must be a positive safe integer (milliseconds)");
767
+ }
768
+ if (!isNonEmptyString(receipt.backupRef) || hasNul(receipt.backupRef)) {
769
+ errors.push("retention receipt.backupRef must be a non-empty string without NUL (opaque L8 coordination reference)");
770
+ }
771
+ return { ok: errors.length === 0, errors };
772
+ }
773
+ export function validateDeclarationRevocation(revocation) {
774
+ if (!isRecord(revocation))
775
+ return { ok: false, errors: ["revocation must be an object"] };
776
+ const errors = [];
777
+ rejectUnknownKeys(revocation, DECLARATION_REVOCATION_KEYS, "revocation", errors);
778
+ if (!isOpaqueId(revocation.declarationId, ID_PREFIXES.declaration)) {
779
+ errors.push(`revocation.declarationId must be a non-empty opaque id starting with "${ID_PREFIXES.declaration}"`);
780
+ }
781
+ if (!isNonEmptyString(revocation.tenant) || hasNul(revocation.tenant)) {
782
+ errors.push("revocation.tenant must be a non-empty string without NUL");
783
+ }
784
+ if (!isOpaqueId(revocation.cloneId, ID_PREFIXES.clone)) {
785
+ errors.push(`revocation.cloneId must be a non-empty opaque id starting with "${ID_PREFIXES.clone}"`);
786
+ }
787
+ if (!isOpaqueId(revocation.worktreeId, ID_PREFIXES.worktree)) {
788
+ errors.push(`revocation.worktreeId must be a non-empty opaque id starting with "${ID_PREFIXES.worktree}"`);
789
+ }
790
+ if (!isPositiveInteger(revocation.declarationRevision) ||
791
+ revocation.declarationRevision < INITIAL_DECLARATION_REVISION) {
792
+ errors.push(`revocation.declarationRevision must be an integer >= ${INITIAL_DECLARATION_REVISION}`);
793
+ }
794
+ if (!isRevocationReason(revocation.reason)) {
795
+ errors.push(`revocation.reason must be one of ${REVOCATION_REASONS.join(", ")}`);
796
+ }
797
+ const purge = validatePrivatePurgeRequest(revocation.requestPrivatePurge);
798
+ if (!purge.ok)
799
+ errors.push(...purge.errors.map((error) => `revocation.${error}`));
800
+ return { ok: errors.length === 0, errors };
801
+ }
802
+ // — P1: all-state GitHub PR synchronization (spec/SPEC_LIVE_GIT_INDEXING.md
803
+ // §§3–5; plan/09 P1). Data-pure DTOs, constants and validators only; the
804
+ // engines live in ./pr-sync.js (pagination/overlap/sweeps, neutral PR/object
805
+ // jobs) and ./pr-attribution.js (pinned historical attribution).
806
+ //
807
+ // Consumer contracts for the H: (h2a transport) and S: (cluster-mesh mapping)
808
+ // seats. H owns the approved installed `gh`/Git executor, host-controlled
809
+ // credentials, private object acquisition into service-owned refs and
810
+ // retention; S owns adapter mappings/receipts when the L3 contract needs
811
+ // PR-specific typed fields. Read this block before implementing either side.
812
+ //
813
+ // - PRs of every state (open, closed, merged) plus drafts (open + isDraft)
814
+ // are indexed, with no count or age limit and nothing erased: a state
815
+ // change, a force-push or a merge strategy change is a new pinned
816
+ // observation, never a substitution or deletion of the previous one.
817
+ // - The GitHub transport is INJECTED (GithubPrTransportPort is implemented by
818
+ // the host test fake here and by the H executor in production): engram
819
+ // performs no network call, persists no token, and never invokes a
820
+ // repository-selected credential helper or executable. Tokens never enter
821
+ // facts, logs or rows: PR_FACT_FORBIDDEN_KEYS are rejected by validation.
822
+ // - No end of list without proof: a page with hasMore false must carry
823
+ // endProof "end-of-list"; a page with hasMore true must carry a non-empty
824
+ // nextCursor. A truncated or unproven sweep fails the pass with an
825
+ // explicit gap instead of completing.
826
+ // - Metadata and objects advance independently: a metadata cursor success
827
+ // never completes a failed object job, and metadata receipts never
828
+ // acknowledge failed private acquisition (canAcknowledgePrObjects in
829
+ // ./pr-sync.js, consumed by the S mapping).
830
+ // - Attribution is pinned history, not ancestry: pinPrAttribution in
831
+ // ./pr-attribution.js requires observed head/base bounds plus merge-base
832
+ // evidence for "complete"; unknown bounds stay "partial" while the
833
+ // acquired ancestry remains available. No REST commit array proves
834
+ // exhaustiveness; merge, squash and rebase stay distinguished.
835
+ // - Private acquisition contract for H: acquire only the observed head OID
836
+ // into PRIVATE_PR_REF_PREFIX service refs inside the private store, verify
837
+ // the acquired target against the observed OID, and report a moving head
838
+ // as another observation. Never move refs or touch the user clone index.
839
+ /** Every PR state indexed by live (drafts are open + isDraft true). */
840
+ export const PULL_REQUEST_STATES = ["open", "closed", "merged"];
841
+ export function isPullRequestState(value) {
842
+ return (typeof value === "string" &&
843
+ PULL_REQUEST_STATES.includes(value));
844
+ }
845
+ /** Merge strategies stay distinguished; unknown means not yet evidenced. */
846
+ export const PULL_REQUEST_MERGE_STRATEGIES = ["merge", "squash", "rebase", "unknown"];
847
+ export function isPullRequestMergeStrategy(value) {
848
+ return (typeof value === "string" &&
849
+ PULL_REQUEST_MERGE_STRATEGIES.includes(value));
850
+ }
851
+ /** Transport failure kinds: each preserves last success with stale coverage. */
852
+ export const PR_SYNC_ERROR_KINDS = ["offline", "auth_required", "rate_limited", "unavailable", "protocol"];
853
+ export function isPrSyncErrorKind(value) {
854
+ return (typeof value === "string" &&
855
+ PR_SYNC_ERROR_KINDS.includes(value));
856
+ }
857
+ /** Neutral object-job lifecycle, independent of the metadata cursor. */
858
+ export const PR_OBJECT_JOB_STATES = ["pending", "acquired", "failed"];
859
+ export function isPrObjectJobState(value) {
860
+ return (typeof value === "string" &&
861
+ PR_OBJECT_JOB_STATES.includes(value));
862
+ }
863
+ /** PR coverage axes (spec §12): metadata, objects and attribution separate. */
864
+ export const PR_COVERAGE_AXES = ["pr-metadata", "object-availability", "pr-attribution"];
865
+ /** Explicit end-of-list proof: the only accepted complete-sweep terminator. */
866
+ export const PR_END_PROOF = "end-of-list";
867
+ /** Service-owned refs for private PR acquisition (H executor, never the clone). */
868
+ export const PRIVATE_PR_REF_PREFIX = "refs/live-private/pr/";
869
+ /** Keys that must never enter a PR fact (tokens stay out of rows/logs). */
870
+ export const PR_FACT_FORBIDDEN_KEYS = ["token", "credential", "secret", "authorization"];
871
+ const GITHUB_REPOSITORY_KEYS = ["owner", "repo"];
872
+ export function validateGithubRepository(value) {
873
+ if (!isRecord(value))
874
+ return { ok: false, errors: ["github repository must be an object"] };
875
+ const errors = [];
876
+ rejectUnknownKeys(value, GITHUB_REPOSITORY_KEYS, "github repository", errors);
877
+ if (!isNonEmptyString(value.owner) || hasNul(value.owner)) {
878
+ errors.push("github repository.owner must be a non-empty string without NUL");
879
+ }
880
+ if (!isNonEmptyString(value.repo) || hasNul(value.repo)) {
881
+ errors.push("github repository.repo must be a non-empty string without NUL");
882
+ }
883
+ return { ok: errors.length === 0, errors };
884
+ }
885
+ const PULL_REQUEST_FACT_KEYS = [
886
+ "repositoryOwner",
887
+ "repositoryRepo",
888
+ "number",
889
+ "state",
890
+ "isDraft",
891
+ "updatedAt",
892
+ "headOid",
893
+ "headRef",
894
+ "baseOid",
895
+ "baseRef",
896
+ "mergeOid",
897
+ "mergeStrategy",
898
+ ];
899
+ export function validatePullRequestFact(value) {
900
+ if (!isRecord(value))
901
+ return { ok: false, errors: ["PR fact must be an object"] };
902
+ const errors = [];
903
+ rejectUnknownKeys(value, PULL_REQUEST_FACT_KEYS, "PR fact", errors);
904
+ for (const forbidden of PR_FACT_FORBIDDEN_KEYS) {
905
+ if (forbidden in value)
906
+ errors.push(`PR fact must never carry "${forbidden}" (tokens stay out of facts/logs/rows)`);
907
+ }
908
+ if (!isNonEmptyString(value.repositoryOwner) || hasNul(value.repositoryOwner)) {
909
+ errors.push("PR fact.repositoryOwner must be a non-empty string without NUL");
910
+ }
911
+ if (!isNonEmptyString(value.repositoryRepo) || hasNul(value.repositoryRepo)) {
912
+ errors.push("PR fact.repositoryRepo must be a non-empty string without NUL");
913
+ }
914
+ if (typeof value.number !== "number" || !Number.isSafeInteger(value.number) || value.number <= 0) {
915
+ errors.push("PR fact.number must be a positive safe integer");
916
+ }
917
+ if (!isPullRequestState(value.state)) {
918
+ errors.push(`PR fact.state must be one of ${PULL_REQUEST_STATES.join(", ")}`);
919
+ }
920
+ if (typeof value.isDraft !== "boolean") {
921
+ errors.push("PR fact.isDraft must be a boolean (drafts are open + isDraft true)");
922
+ }
923
+ if (typeof value.updatedAt !== "number" || !Number.isSafeInteger(value.updatedAt) || value.updatedAt <= 0) {
924
+ errors.push("PR fact.updatedAt must be a positive safe integer (milliseconds)");
925
+ }
926
+ const head = validateQualifiedOid(value.headOid);
927
+ if (!head.ok)
928
+ errors.push(...head.errors.map((error) => `PR fact.headOid: ${error}`));
929
+ if (!isNonEmptyString(value.headRef) || hasNul(value.headRef)) {
930
+ errors.push("PR fact.headRef must be a non-empty string without NUL");
931
+ }
932
+ const base = validateQualifiedOid(value.baseOid);
933
+ if (!base.ok)
934
+ errors.push(...base.errors.map((error) => `PR fact.baseOid: ${error}`));
935
+ if (!isNonEmptyString(value.baseRef) || hasNul(value.baseRef)) {
936
+ errors.push("PR fact.baseRef must be a non-empty string without NUL");
937
+ }
938
+ if (value.mergeOid !== undefined && value.mergeOid !== null) {
939
+ const merge = validateQualifiedOid(value.mergeOid);
940
+ if (!merge.ok)
941
+ errors.push(...merge.errors.map((error) => `PR fact.mergeOid: ${error}`));
942
+ }
943
+ if (value.mergeStrategy !== undefined && !isPullRequestMergeStrategy(value.mergeStrategy)) {
944
+ errors.push(`PR fact.mergeStrategy, when present, must be one of ${PULL_REQUEST_MERGE_STRATEGIES.join(", ")}`);
945
+ }
946
+ return { ok: errors.length === 0, errors };
947
+ }
948
+ const PR_PAGE_KEYS = ["items", "nextCursor", "hasMore", "endProof", "etag"];
949
+ export function validatePrPage(value) {
950
+ if (!isRecord(value))
951
+ return { ok: false, errors: ["PR page must be an object"] };
952
+ const errors = [];
953
+ rejectUnknownKeys(value, PR_PAGE_KEYS, "PR page", errors);
954
+ if (!Array.isArray(value.items)) {
955
+ errors.push("PR page.items must be an array");
956
+ }
957
+ else {
958
+ value.items.forEach((item, index) => {
959
+ const checked = validatePullRequestFact(item);
960
+ if (!checked.ok)
961
+ errors.push(...checked.errors.map((error) => `PR page.items[${index}]: ${error}`));
962
+ });
963
+ }
964
+ if (value.nextCursor !== null && !isNonEmptyString(value.nextCursor)) {
965
+ errors.push("PR page.nextCursor must be a non-empty string or null");
966
+ }
967
+ if (typeof value.hasMore !== "boolean") {
968
+ errors.push("PR page.hasMore must be a boolean");
969
+ }
970
+ // No end of list without proof, and no contradictory terminator: a page
971
+ // is complete only with hasMore false, the explicit end-of-list proof and
972
+ // a null nextCursor. A continuation cursor beside an end proof, or an end
973
+ // proof on a continuing page, is a protocol failure, never a completion.
974
+ if (value.hasMore === false && value.endProof !== PR_END_PROOF) {
975
+ errors.push(`PR page with hasMore false must carry endProof "${PR_END_PROOF}" (no silent end of list)`);
976
+ }
977
+ if (value.hasMore === false && value.nextCursor !== null) {
978
+ errors.push("PR page with hasMore false must carry nextCursor null (a continuation cursor beside an end proof is contradictory)");
979
+ }
980
+ if (value.hasMore === true && !isNonEmptyString(value.nextCursor)) {
981
+ errors.push("PR page with hasMore true must carry a non-empty nextCursor");
982
+ }
983
+ if (value.hasMore === true && value.endProof !== undefined) {
984
+ errors.push("PR page with hasMore true must not carry an endProof (a continuation excludes the end-of-list proof)");
985
+ }
986
+ if (value.etag !== undefined && value.etag !== null && !isNonEmptyString(value.etag)) {
987
+ errors.push("PR page.etag, when present, must be a non-empty string or null");
988
+ }
989
+ return { ok: errors.length === 0, errors };
990
+ }
991
+ const PR_SYNC_CURSOR_KEYS = [
992
+ "repositoryOwner",
993
+ "repositoryRepo",
994
+ "updatedAtCursor",
995
+ "lastSuccessAt",
996
+ "lastEtag",
997
+ "etagsByCursor",
998
+ "sweepKind",
999
+ "pendingRetry",
1000
+ ];
1001
+ export function validatePrSyncCursor(value) {
1002
+ if (!isRecord(value))
1003
+ return { ok: false, errors: ["PR cursor must be an object"] };
1004
+ const errors = [];
1005
+ rejectUnknownKeys(value, PR_SYNC_CURSOR_KEYS, "PR cursor", errors);
1006
+ if (!isNonEmptyString(value.repositoryOwner) || hasNul(value.repositoryOwner)) {
1007
+ errors.push("PR cursor.repositoryOwner must be a non-empty string without NUL");
1008
+ }
1009
+ if (!isNonEmptyString(value.repositoryRepo) || hasNul(value.repositoryRepo)) {
1010
+ errors.push("PR cursor.repositoryRepo must be a non-empty string without NUL");
1011
+ }
1012
+ if (value.updatedAtCursor !== null &&
1013
+ (typeof value.updatedAtCursor !== "number" || !Number.isSafeInteger(value.updatedAtCursor) || value.updatedAtCursor <= 0)) {
1014
+ errors.push("PR cursor.updatedAtCursor must be a positive safe integer or null");
1015
+ }
1016
+ if (value.lastSuccessAt !== null &&
1017
+ (typeof value.lastSuccessAt !== "number" || !Number.isSafeInteger(value.lastSuccessAt) || value.lastSuccessAt <= 0)) {
1018
+ errors.push("PR cursor.lastSuccessAt must be a positive safe integer or null");
1019
+ }
1020
+ if (value.lastEtag !== null && value.lastEtag !== undefined && !isNonEmptyString(value.lastEtag)) {
1021
+ errors.push("PR cursor.lastEtag must be a non-empty string or null");
1022
+ }
1023
+ if (value.etagsByCursor !== undefined && value.etagsByCursor !== null) {
1024
+ if (!isRecord(value.etagsByCursor)) {
1025
+ errors.push("PR cursor.etagsByCursor, when present, must be a record of non-empty ETag strings or null");
1026
+ }
1027
+ else {
1028
+ for (const [key, etag] of Object.entries(value.etagsByCursor)) {
1029
+ if (!isNonEmptyString(etag) || hasNul(etag)) {
1030
+ errors.push(`PR cursor.etagsByCursor[${key}] must be a non-empty string without NUL`);
1031
+ }
1032
+ }
1033
+ }
1034
+ }
1035
+ if (value.sweepKind !== "incremental" && value.sweepKind !== "full") {
1036
+ errors.push('PR cursor.sweepKind must be "incremental" or "full"');
1037
+ }
1038
+ if (value.pendingRetry !== undefined && value.pendingRetry !== null) {
1039
+ if (!isRecord(value.pendingRetry)) {
1040
+ errors.push("PR cursor.pendingRetry, when present, must be an object or null");
1041
+ }
1042
+ else {
1043
+ if (!isPrSyncErrorKind(value.pendingRetry.kind)) {
1044
+ errors.push(`PR cursor.pendingRetry.kind must be one of ${PR_SYNC_ERROR_KINDS.join(", ")}`);
1045
+ }
1046
+ if (typeof value.pendingRetry.attempts !== "number" || !Number.isSafeInteger(value.pendingRetry.attempts) || value.pendingRetry.attempts <= 0) {
1047
+ errors.push("PR cursor.pendingRetry.attempts must be a positive safe integer");
1048
+ }
1049
+ if (typeof value.pendingRetry.nextAfter !== "number" || !Number.isSafeInteger(value.pendingRetry.nextAfter)) {
1050
+ errors.push("PR cursor.pendingRetry.nextAfter must be a safe integer");
1051
+ }
1052
+ }
1053
+ }
1054
+ return { ok: errors.length === 0, errors };
1055
+ }
1056
+ const PR_OBJECT_JOB_KEYS = [
1057
+ "repositoryOwner",
1058
+ "repositoryRepo",
1059
+ "prNumber",
1060
+ "headOid",
1061
+ "state",
1062
+ "attempts",
1063
+ "lastError",
1064
+ "acquiredOid",
1065
+ "totalCommits",
1066
+ "acquiredCommits",
1067
+ ];
1068
+ export function validatePrObjectJob(value) {
1069
+ if (!isRecord(value))
1070
+ return { ok: false, errors: ["PR object job must be an object"] };
1071
+ const errors = [];
1072
+ rejectUnknownKeys(value, PR_OBJECT_JOB_KEYS, "PR object job", errors);
1073
+ if (!isNonEmptyString(value.repositoryOwner) || hasNul(value.repositoryOwner)) {
1074
+ errors.push("PR object job.repositoryOwner must be a non-empty string without NUL");
1075
+ }
1076
+ if (!isNonEmptyString(value.repositoryRepo) || hasNul(value.repositoryRepo)) {
1077
+ errors.push("PR object job.repositoryRepo must be a non-empty string without NUL");
1078
+ }
1079
+ if (typeof value.prNumber !== "number" || !Number.isSafeInteger(value.prNumber) || value.prNumber <= 0) {
1080
+ errors.push("PR object job.prNumber must be a positive safe integer");
1081
+ }
1082
+ const head = validateQualifiedOid(value.headOid);
1083
+ if (!head.ok)
1084
+ errors.push(...head.errors.map((error) => `PR object job.headOid: ${error}`));
1085
+ if (!isPrObjectJobState(value.state)) {
1086
+ errors.push(`PR object job.state must be one of ${PR_OBJECT_JOB_STATES.join(", ")}`);
1087
+ }
1088
+ if (typeof value.attempts !== "number" || !Number.isSafeInteger(value.attempts) || value.attempts < 0) {
1089
+ errors.push("PR object job.attempts must be a non-negative safe integer");
1090
+ }
1091
+ if (value.lastError !== undefined && value.lastError !== null && !isPrSyncErrorKind(value.lastError)) {
1092
+ errors.push(`PR object job.lastError, when present, must be one of ${PR_SYNC_ERROR_KINDS.join(", ")}`);
1093
+ }
1094
+ if (value.acquiredOid !== undefined && value.acquiredOid !== null) {
1095
+ const acquired = validateQualifiedOid(value.acquiredOid);
1096
+ if (!acquired.ok)
1097
+ errors.push(...acquired.errors.map((error) => `PR object job.acquiredOid: ${error}`));
1098
+ }
1099
+ for (const field of ["totalCommits", "acquiredCommits"]) {
1100
+ const count = value[field];
1101
+ if (count !== undefined && count !== null &&
1102
+ (typeof count !== "number" || !Number.isSafeInteger(count) || count < 0)) {
1103
+ errors.push(`PR object job.${field}, when present, must be a non-negative safe integer (no cap, exact count)`);
1104
+ }
1105
+ }
1106
+ return { ok: errors.length === 0, errors };
1107
+ }
1108
+ const PR_ATTRIBUTION_KEYS = [
1109
+ "repositoryOwner",
1110
+ "repositoryRepo",
1111
+ "prNumber",
1112
+ "baseOid",
1113
+ "headOid",
1114
+ "mergeBaseOids",
1115
+ "mergeOid",
1116
+ "mergeStrategy",
1117
+ "status",
1118
+ "reason",
1119
+ ];
1120
+ export function validatePrAttribution(value) {
1121
+ if (!isRecord(value))
1122
+ return { ok: false, errors: ["PR attribution must be an object"] };
1123
+ const errors = [];
1124
+ rejectUnknownKeys(value, PR_ATTRIBUTION_KEYS, "PR attribution", errors);
1125
+ if (!isNonEmptyString(value.repositoryOwner) || hasNul(value.repositoryOwner)) {
1126
+ errors.push("PR attribution.repositoryOwner must be a non-empty string without NUL");
1127
+ }
1128
+ if (!isNonEmptyString(value.repositoryRepo) || hasNul(value.repositoryRepo)) {
1129
+ errors.push("PR attribution.repositoryRepo must be a non-empty string without NUL");
1130
+ }
1131
+ if (typeof value.prNumber !== "number" || !Number.isSafeInteger(value.prNumber) || value.prNumber <= 0) {
1132
+ errors.push("PR attribution.prNumber must be a positive safe integer");
1133
+ }
1134
+ // An unknown base stays an explicit null and is always partial; only an
1135
+ // observed base bound can support a complete attribution.
1136
+ if (value.baseOid === null) {
1137
+ if (value.status === "complete") {
1138
+ errors.push('PR attribution with baseOid null must stay "partial" (an unknown bound is never complete)');
1139
+ }
1140
+ }
1141
+ else {
1142
+ const base = validateQualifiedOid(value.baseOid);
1143
+ if (!base.ok)
1144
+ errors.push(...base.errors.map((error) => `PR attribution.baseOid: ${error}`));
1145
+ }
1146
+ const head = validateQualifiedOid(value.headOid);
1147
+ if (!head.ok)
1148
+ errors.push(...head.errors.map((error) => `PR attribution.headOid: ${error}`));
1149
+ if (!Array.isArray(value.mergeBaseOids)) {
1150
+ errors.push("PR attribution.mergeBaseOids must be an array");
1151
+ }
1152
+ else {
1153
+ value.mergeBaseOids.forEach((oid, index) => {
1154
+ const checked = validateQualifiedOid(oid);
1155
+ if (!checked.ok)
1156
+ errors.push(...checked.errors.map((error) => `PR attribution.mergeBaseOids[${index}]: ${error}`));
1157
+ });
1158
+ if (value.status === "complete" && value.mergeBaseOids.length === 0) {
1159
+ errors.push('PR attribution with status "complete" requires at least one merge-base OID (no proof-less completeness)');
1160
+ }
1161
+ }
1162
+ if (value.mergeOid !== undefined && value.mergeOid !== null) {
1163
+ const merge = validateQualifiedOid(value.mergeOid);
1164
+ if (!merge.ok)
1165
+ errors.push(...merge.errors.map((error) => `PR attribution.mergeOid: ${error}`));
1166
+ }
1167
+ if (!isPullRequestMergeStrategy(value.mergeStrategy)) {
1168
+ errors.push(`PR attribution.mergeStrategy must be one of ${PULL_REQUEST_MERGE_STRATEGIES.join(", ")}`);
1169
+ }
1170
+ if (value.status !== "complete" && value.status !== "partial") {
1171
+ errors.push('PR attribution.status must be "complete" or "partial"');
1172
+ }
1173
+ if (!isNonEmptyString(value.reason)) {
1174
+ errors.push("PR attribution.reason must be a non-empty string");
1175
+ }
1176
+ return { ok: errors.length === 0, errors };
1177
+ }
1178
+ const PRIVATE_ACQUISITION_KEYS = [
1179
+ "repositoryOwner",
1180
+ "repositoryRepo",
1181
+ "prNumber",
1182
+ "headOid",
1183
+ "serviceRef",
1184
+ ];
1185
+ export function validatePrivateObjectAcquisitionRequest(value) {
1186
+ if (!isRecord(value))
1187
+ return { ok: false, errors: ["private acquisition request must be an object"] };
1188
+ const errors = [];
1189
+ rejectUnknownKeys(value, PRIVATE_ACQUISITION_KEYS, "private acquisition request", errors);
1190
+ if (!isNonEmptyString(value.repositoryOwner) || hasNul(value.repositoryOwner)) {
1191
+ errors.push("private acquisition request.repositoryOwner must be a non-empty string without NUL");
1192
+ }
1193
+ if (!isNonEmptyString(value.repositoryRepo) || hasNul(value.repositoryRepo)) {
1194
+ errors.push("private acquisition request.repositoryRepo must be a non-empty string without NUL");
1195
+ }
1196
+ if (typeof value.prNumber !== "number" || !Number.isSafeInteger(value.prNumber) || value.prNumber <= 0) {
1197
+ errors.push("private acquisition request.prNumber must be a positive safe integer");
1198
+ }
1199
+ const head = validateQualifiedOid(value.headOid);
1200
+ if (!head.ok)
1201
+ errors.push(...head.errors.map((error) => `private acquisition request.headOid: ${error}`));
1202
+ if (!isNonEmptyString(value.serviceRef) || hasNul(value.serviceRef)) {
1203
+ errors.push("private acquisition request.serviceRef must be a non-empty string without NUL");
1204
+ }
1205
+ else if (!value.serviceRef.startsWith(PRIVATE_PR_REF_PREFIX)) {
1206
+ errors.push(`private acquisition request.serviceRef must stay under "${PRIVATE_PR_REF_PREFIX}" (private store only, never the user clone)`);
1207
+ }
1208
+ return { ok: errors.length === 0, errors };
1209
+ }
1210
+ // — L4b: diffs and encrypted latest local content
1211
+ // (spec/SPEC_LIVE_GIT_INDEXING.md §§8, 10, A08/A09; plan/09 L4b; owner Q2 B).
1212
+ // Data-pure DTOs, constants and validators only; the engines live in
1213
+ // ./diffs.js (neutral derivation), ./content-policy.js (deterministic
1214
+ // pre-persistence secret/path policy) and ./snapshots.js (latest-only
1215
+ // staging through the injected protection/store/sink ports).
1216
+ //
1217
+ // Consumer contract for the S seat (cluster-mesh protected-payload/sqlite)
1218
+ // and the H seat (h2a live-key-provider). S and H implement the durable and
1219
+ // cryptographic sides; engram owns only this data-pure contract plus the
1220
+ // pure derivation. Read this block before implementing either host side.
1221
+ //
1222
+ // - Neutral snapshots/diff/hunk/range facts with versioned normalization
1223
+ // (LOCAL_DIFF_NORMALIZER_VERSION): staged, unstaged and conflict-stage
1224
+ // diffs are distinct kinds with immutable recorded endpoints
1225
+ // (staged = HEAD → index, unstaged = index → worktree, conflict =
1226
+ // index → index with separate stage 1/2/3 OIDs). An optional cumulative
1227
+ // HEAD → worktree view is never produced here: only the three L4b kinds
1228
+ // validate.
1229
+ // - Deterministic pre-persistence secret/path policy (CONTENT_POLICY_VERSION),
1230
+ // applied to EVERY surface — patch text, symbol names, citation quotes,
1231
+ // search snippets, context excerpts — before any batch is assembled or
1232
+ // stored. Persist-then-mask is structurally refused: the sink-bound
1233
+ // validator (validateSealedLocalDiff) rejects transient open hunk lines.
1234
+ // - Latest authorized encrypted state only (Q2 B): replacement is indexed
1235
+ // by target and generation at the sink (L4b M2) — the sink keeps only the
1236
+ // highest accepted generation per target, durably rejects any delivery at
1237
+ // or below the held generation, and retires every older generation when it
1238
+ // accepts a newer one; no list/history accessor exists on the port by
1239
+ // construction. Snapshots carry no predecessor pointer. The target
1240
+ // includes the diff kind (L4b-2): staged, unstaged and conflict snapshots
1241
+ // hold distinct targets, and generation ids are fixed-width numeric
1242
+ // (`gen:` plus GENERATION_ID_WIDTH digits) so lexicographic order is
1243
+ // numeric order.
1244
+ // - Without a protection key: allowed metadata/ranges only, explicit
1245
+ // content_unavailable (CONTENT_UNAVAILABLE) coverage, and the revocation
1246
+ // private-purge request (L6a DeclarationRevocationDto) purges private
1247
+ // snapshots, derived indexes and decryptability while preserving
1248
+ // authorized committed history.
1249
+ // - .gitignore is never a secrecy guarantee: no gitignore input exists
1250
+ // anywhere in this contract or in the engines; a negation cannot unblock
1251
+ // a protected path.
1252
+ // - Binaries, oversized files, filtered paths, protected paths, symlinks,
1253
+ // gitlinks, deletions and unstable captures are explicit exclusions or
1254
+ // coverage, never invented text hunks.
1255
+ /** Versioned local-diff normalization evidence (spec §8: rename detection and thresholds are versioned evidence). */
1256
+ export const LOCAL_DIFF_NORMALIZER_VERSION = "local-diff-normalizer/1";
1257
+ /** Versioned deterministic content-policy rules stamped on every L4b fact. */
1258
+ export const CONTENT_POLICY_VERSION = "live-content-policy/1";
1259
+ /** Explicit coverage marker when no protection key authorizes local content. */
1260
+ export const CONTENT_UNAVAILABLE = "content_unavailable";
1261
+ /** Files larger than this are excluded before any content read (matches the L4a capture bound). */
1262
+ export const LOCAL_MAX_FILE_BYTES = 1_048_576;
1263
+ /** The three distinct local comparisons (spec §8; GitNexus staged/unstaged/reference distinction). */
1264
+ export const LOCAL_DIFF_KINDS = ["staged", "unstaged", "conflict"];
1265
+ export function isLocalDiffKind(value) {
1266
+ return (typeof value === "string" &&
1267
+ LOCAL_DIFF_KINDS.includes(value));
1268
+ }
1269
+ /** Immutable recorded endpoint of one local comparison. */
1270
+ export const LOCAL_DIFF_ENDPOINT_LABELS = ["HEAD", "index", "worktree", "empty-tree", "oid"];
1271
+ /** Why a path carries metadata only, never an invented text hunk. */
1272
+ export const LOCAL_EXCLUSION_REASONS = [
1273
+ "binary",
1274
+ "oversized",
1275
+ "unknown-size",
1276
+ "filtered",
1277
+ "protected",
1278
+ "symlink",
1279
+ "gitlink",
1280
+ "unstable-capture",
1281
+ "deleted",
1282
+ ];
1283
+ /**
1284
+ * Closed withheld reasons (L4b M1: no free text toward the sink).
1285
+ * Secret-rule values name the matched deterministic rule only; the secret
1286
+ * itself never appears. `withheld-no-protection-key` is the no-key
1287
+ * downgrade of a single hunk.
1288
+ */
1289
+ export const LOCAL_WITHHELD_REASONS = [
1290
+ "withheld-secret-aws-access-key",
1291
+ "withheld-secret-github-token",
1292
+ "withheld-secret-private-key-block",
1293
+ "withheld-secret-credential-assignment",
1294
+ "withheld-secret-credentialed-url",
1295
+ "withheld-secret-unknown",
1296
+ "withheld-no-protection-key",
1297
+ ];
1298
+ export function isLocalWithheldReason(value) {
1299
+ return typeof value === "string" && LOCAL_WITHHELD_REASONS.includes(value);
1300
+ }
1301
+ /** Closed withheld reason for a matched deterministic secret rule (the secret itself never appears). */
1302
+ export function withheldReasonForSecretRule(rule) {
1303
+ const candidate = `withheld-secret-${rule ?? "unknown"}`;
1304
+ return isLocalWithheldReason(candidate) ? candidate : "withheld-secret-unknown";
1305
+ }
1306
+ /** Closed seal algorithm allowlist (L4b M1: the S seat extends this list by contract amendment). */
1307
+ export const LOCAL_SEAL_ALGORITHMS = ["fake-seal/1"];
1308
+ export function isLocalSealAlgorithm(value) {
1309
+ return typeof value === "string" && LOCAL_SEAL_ALGORITHMS.includes(value);
1310
+ }
1311
+ /**
1312
+ * Strict opaque key reference (L4b M1: host key custody identifier, never
1313
+ * free text). Charset-closed; the H seat mints values within this charset.
1314
+ */
1315
+ export function isStrictKeyRef(value) {
1316
+ return isStrictScopeId(value);
1317
+ }
1318
+ /**
1319
+ * Closed coverage reasons (L4b M1: no free sentence toward the sink).
1320
+ * Values carrying `content_unavailable` mark the explicit no-content
1321
+ * downgrade (CONTENT_UNAVAILABLE).
1322
+ */
1323
+ export const LOCAL_COVERAGE_REASONS = [
1324
+ "content_unavailable:no-key",
1325
+ "content_unavailable:unstable-capture",
1326
+ "content_unavailable:unknown-size",
1327
+ "content_unavailable:conflict-inventory-mismatch",
1328
+ "content_unavailable:policy-withheld",
1329
+ "partial:no-snapshot-payload",
1330
+ "current:sealed-latest",
1331
+ ];
1332
+ export function isLocalCoverageReason(value) {
1333
+ return typeof value === "string" && LOCAL_COVERAGE_REASONS.includes(value);
1334
+ }
1335
+ /** Closed snapshot metadata-only reasons (L4b M1: the no-key downgrade reason; L4b-2: the secret-exclusion reason). */
1336
+ export const LOCAL_SNAPSHOT_METADATA_REASONS = [
1337
+ "metadata:unstable-capture",
1338
+ "metadata:no-key",
1339
+ "metadata:no-payload",
1340
+ "metadata:policy-withheld",
1341
+ ];
1342
+ export function isLocalSnapshotMetadataReason(value) {
1343
+ return (typeof value === "string" && LOCAL_SNAPSHOT_METADATA_REASONS.includes(value));
1344
+ }
1345
+ /** Strict snapshot identity: `lsnap:` plus the 64 hex characters minted at staging. */
1346
+ export function isStrictSnapshotId(value) {
1347
+ return typeof value === "string" && /^lsnap:[0-9a-f]{64}$/.test(value);
1348
+ }
1349
+ export const LOCAL_FILE_STATUSES = ["added", "modified", "deleted", "conflict"];
1350
+ /** Explicit index/working-tree coverage (spec §12; A04/A09). */
1351
+ export const LOCAL_COVERAGE_CAUSES = ["unstable-capture", "missing-key", "policy-withheld"];
1352
+ const LOCAL_DIFF_ENDPOINT_KEYS = ["label", "oid"];
1353
+ const LOCAL_HUNK_KEYS = [
1354
+ "startBefore",
1355
+ "countBefore",
1356
+ "startAfter",
1357
+ "countAfter",
1358
+ "payload",
1359
+ ];
1360
+ const OPEN_LINES_PAYLOAD_KEYS = ["state", "lines"];
1361
+ const SEALED_HUNK_PAYLOAD_KEYS = ["state", "algorithm", "keyRef", "ciphertext"];
1362
+ const WITHHELD_PAYLOAD_KEYS = ["state", "reason"];
1363
+ const LOCAL_FILE_CHANGE_KEYS = [
1364
+ "path",
1365
+ "previousPath",
1366
+ "status",
1367
+ "oldMode",
1368
+ "newMode",
1369
+ "oldOid",
1370
+ "newOid",
1371
+ "added",
1372
+ "deleted",
1373
+ "exclusion",
1374
+ "stageOids",
1375
+ "hunks",
1376
+ ];
1377
+ const LOCAL_DIFF_KEYS = [
1378
+ "scope",
1379
+ "worktreeId",
1380
+ "kind",
1381
+ "base",
1382
+ "tip",
1383
+ "comparedParent",
1384
+ "mergeBase",
1385
+ "normalizerVersion",
1386
+ "policyVersion",
1387
+ "files",
1388
+ "coverage",
1389
+ ];
1390
+ const LOCAL_COVERAGE_KEYS = ["axis", "state", "reason", "cause"];
1391
+ const LOCAL_SNAPSHOT_KEYS = [
1392
+ "scope",
1393
+ "worktreeId",
1394
+ "snapshotId",
1395
+ "kind",
1396
+ "diffKind",
1397
+ "capturedAt",
1398
+ "diffDigest",
1399
+ "sealed",
1400
+ "policyVersion",
1401
+ ];
1402
+ const SEALED_SNAPSHOT_PAYLOAD_KEYS = ["state", "algorithm", "keyRef", "ciphertext"];
1403
+ const METADATA_SNAPSHOT_PAYLOAD_KEYS = ["state", "reason"];
1404
+ function isHexOid(value) {
1405
+ return typeof value === "string" && (/^[0-9a-f]{40}$/.test(value) || /^[0-9a-f]{64}$/.test(value));
1406
+ }
1407
+ function isPathBytes(value) {
1408
+ return value instanceof Uint8Array;
1409
+ }
1410
+ export function validateLocalDiffEndpoint(endpoint) {
1411
+ if (!isRecord(endpoint))
1412
+ return { ok: false, errors: ["diff endpoint must be an object"] };
1413
+ const errors = [];
1414
+ rejectUnknownKeys(endpoint, LOCAL_DIFF_ENDPOINT_KEYS, "diff endpoint", errors);
1415
+ if (typeof endpoint.label !== "string" ||
1416
+ !LOCAL_DIFF_ENDPOINT_LABELS.includes(endpoint.label)) {
1417
+ errors.push(`diff endpoint.label must be one of ${LOCAL_DIFF_ENDPOINT_LABELS.join(", ")}`);
1418
+ }
1419
+ if (endpoint.label === "worktree" || endpoint.label === "empty-tree") {
1420
+ if (endpoint.oid !== null) {
1421
+ errors.push("diff endpoint.oid must be null for the worktree and empty-tree endpoints");
1422
+ }
1423
+ }
1424
+ else if (!isHexOid(endpoint.oid)) {
1425
+ errors.push("diff endpoint.oid must be a 40 or 64 character lowercase hex string");
1426
+ }
1427
+ return { ok: errors.length === 0, errors };
1428
+ }
1429
+ function validateLocalHunkPayload(payload, sealed) {
1430
+ if (!isRecord(payload))
1431
+ return { ok: false, errors: ["hunk payload must be an object"] };
1432
+ const errors = [];
1433
+ // Per-variant exact keys (sink-bound proof): a "sealed" payload carrying
1434
+ // a foreign "lines" key is clair under seal and never validates, in either
1435
+ // mode. Shared key sets across variants are what let clair pass.
1436
+ if (payload.state === "open-lines") {
1437
+ rejectUnknownKeys(payload, OPEN_LINES_PAYLOAD_KEYS, "hunk payload", errors);
1438
+ if (sealed) {
1439
+ errors.push("hunk payload \"open-lines\" is transient pre-persistence evidence and never reaches a sink or a store");
1440
+ }
1441
+ else if (!Array.isArray(payload.lines) ||
1442
+ payload.lines.some((line) => typeof line !== "string" || line.includes("\0"))) {
1443
+ errors.push("hunk payload.lines must be an array of NUL-free strings");
1444
+ }
1445
+ }
1446
+ else if (payload.state === "sealed") {
1447
+ rejectUnknownKeys(payload, SEALED_HUNK_PAYLOAD_KEYS, "hunk payload", errors);
1448
+ if (!isLocalSealAlgorithm(payload.algorithm)) {
1449
+ errors.push(`hunk payload.algorithm must be one of ${LOCAL_SEAL_ALGORITHMS.join(", ")}`);
1450
+ }
1451
+ if (!isStrictKeyRef(payload.keyRef)) {
1452
+ errors.push("hunk payload.keyRef must be a strict opaque key reference");
1453
+ }
1454
+ if (!(payload.ciphertext instanceof Uint8Array) || payload.ciphertext.length === 0) {
1455
+ errors.push("hunk payload.ciphertext must be a non-empty byte array");
1456
+ }
1457
+ if ("lines" in payload) {
1458
+ errors.push("hunk payload.lines is clair and never coexists with a sealed state");
1459
+ }
1460
+ }
1461
+ else if (payload.state === "withheld") {
1462
+ rejectUnknownKeys(payload, WITHHELD_PAYLOAD_KEYS, "hunk payload", errors);
1463
+ if (!isLocalWithheldReason(payload.reason)) {
1464
+ errors.push(`hunk payload.reason must be one of ${LOCAL_WITHHELD_REASONS.join(", ")}`);
1465
+ }
1466
+ }
1467
+ else {
1468
+ errors.push('hunk payload.state must be "open-lines", "sealed" or "withheld"');
1469
+ }
1470
+ return { ok: errors.length === 0, errors };
1471
+ }
1472
+ function validateLocalHunk(hunk, path, sealed) {
1473
+ if (!isRecord(hunk))
1474
+ return { ok: false, errors: [`${path} must be an object`] };
1475
+ const errors = [];
1476
+ rejectUnknownKeys(hunk, LOCAL_HUNK_KEYS, path, errors);
1477
+ for (const bound of ["startBefore", "countBefore", "startAfter", "countAfter"]) {
1478
+ const value = hunk[bound];
1479
+ if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 0) {
1480
+ errors.push(`${path}.${bound} must be a non-negative safe integer (immutable range bound)`);
1481
+ }
1482
+ }
1483
+ const payload = validateLocalHunkPayload(hunk.payload, sealed);
1484
+ if (!payload.ok)
1485
+ errors.push(...payload.errors.map((error) => `${path}.${error}`));
1486
+ return { ok: errors.length === 0, errors };
1487
+ }
1488
+ function validateLocalFileChange(file, path, sealed) {
1489
+ if (!isRecord(file))
1490
+ return { ok: false, errors: [`${path} must be an object`] };
1491
+ const errors = [];
1492
+ rejectUnknownKeys(file, LOCAL_FILE_CHANGE_KEYS, path, errors);
1493
+ if (!isPathBytes(file.path) || file.path.length === 0) {
1494
+ errors.push(`${path}.path must be non-empty raw path bytes`);
1495
+ }
1496
+ if (file.previousPath !== null && (!isPathBytes(file.previousPath) || file.previousPath.length === 0)) {
1497
+ errors.push(`${path}.previousPath must be null or non-empty raw path bytes`);
1498
+ }
1499
+ if (typeof file.status !== "string" ||
1500
+ !LOCAL_FILE_STATUSES.includes(file.status)) {
1501
+ errors.push(`${path}.status must be one of ${LOCAL_FILE_STATUSES.join(", ")}`);
1502
+ }
1503
+ for (const mode of ["oldMode", "newMode"]) {
1504
+ if (typeof file[mode] !== "string" || !/^[0-7]{6}$/.test(file[mode])) {
1505
+ errors.push(`${path}.${mode} must be a six-digit octal file mode`);
1506
+ }
1507
+ }
1508
+ for (const oid of ["oldOid", "newOid"]) {
1509
+ if (!isHexOid(file[oid]) && file[oid] !== "0".repeat(40) && file[oid] !== "0".repeat(64)) {
1510
+ errors.push(`${path}.${oid} must be a hex OID or the zero OID`);
1511
+ }
1512
+ }
1513
+ if (file.added !== null && (typeof file.added !== "number" || !Number.isSafeInteger(file.added) || file.added < 0)) {
1514
+ errors.push(`${path}.added must be null or a non-negative safe integer (null for binary/non-textual content)`);
1515
+ }
1516
+ if (file.deleted !== null && (typeof file.deleted !== "number" || !Number.isSafeInteger(file.deleted) || file.deleted < 0)) {
1517
+ errors.push(`${path}.deleted must be null or a non-negative safe integer (null for binary/non-textual content)`);
1518
+ }
1519
+ if (file.exclusion !== undefined) {
1520
+ if (typeof file.exclusion !== "string" ||
1521
+ !LOCAL_EXCLUSION_REASONS.includes(file.exclusion)) {
1522
+ errors.push(`${path}.exclusion must be one of ${LOCAL_EXCLUSION_REASONS.join(", ")}`);
1523
+ }
1524
+ if (Array.isArray(file.hunks) && file.hunks.length > 0) {
1525
+ errors.push(`${path} is excluded: excluded files carry metadata only, never text hunks`);
1526
+ }
1527
+ if (file.added !== null || file.deleted !== null) {
1528
+ errors.push(`${path} is excluded: excluded files carry null numstat counts, never invented counts`);
1529
+ }
1530
+ }
1531
+ if (file.stageOids !== undefined) {
1532
+ if (!isRecord(file.stageOids)) {
1533
+ errors.push(`${path}.stageOids must be an object keyed by conflict stage`);
1534
+ }
1535
+ else {
1536
+ // Opaque refusal: the rejected stage key is caller-supplied data and
1537
+ // may itself carry private text, so it is never interpolated.
1538
+ for (const stage of Object.keys(file.stageOids)) {
1539
+ if (stage !== "1" && stage !== "2" && stage !== "3") {
1540
+ errors.push(`${path}.stageOids carries an unknown conflict stage: only conflict stages 1/2/3 exist`);
1541
+ }
1542
+ else if (!isHexOid(file.stageOids[stage])) {
1543
+ errors.push(`${path}.stageOids holds a non-OID value: every present stage must be a hex OID`);
1544
+ }
1545
+ }
1546
+ }
1547
+ }
1548
+ if (!Array.isArray(file.hunks)) {
1549
+ errors.push(`${path}.hunks must be an array`);
1550
+ }
1551
+ else {
1552
+ file.hunks.forEach((hunk, index) => {
1553
+ const checked = validateLocalHunk(hunk, `${path}.hunks[${index}]`, sealed);
1554
+ if (!checked.ok)
1555
+ errors.push(...checked.errors);
1556
+ });
1557
+ }
1558
+ return { ok: errors.length === 0, errors };
1559
+ }
1560
+ export function validateLocalCoverage(coverage) {
1561
+ if (!isRecord(coverage))
1562
+ return { ok: false, errors: ["diff coverage must be an object"] };
1563
+ const errors = [];
1564
+ rejectUnknownKeys(coverage, LOCAL_COVERAGE_KEYS, "diff coverage", errors);
1565
+ if (coverage.axis !== "index" && coverage.axis !== "working-tree") {
1566
+ errors.push('diff coverage.axis must be "index" or "working-tree"');
1567
+ }
1568
+ if (typeof coverage.state !== "string" ||
1569
+ !["current", "pending", "partial", "stale", "unavailable"].includes(coverage.state)) {
1570
+ errors.push("diff coverage.state must be an explicit coverage state, never an empty success");
1571
+ }
1572
+ if (!isLocalCoverageReason(coverage.reason)) {
1573
+ errors.push(`diff coverage.reason must be one of ${LOCAL_COVERAGE_REASONS.join(", ")}`);
1574
+ }
1575
+ if (coverage.cause !== undefined &&
1576
+ (typeof coverage.cause !== "string" ||
1577
+ !LOCAL_COVERAGE_CAUSES.includes(coverage.cause))) {
1578
+ errors.push(`diff coverage.cause must be one of ${LOCAL_COVERAGE_CAUSES.join(", ")}`);
1579
+ }
1580
+ return { ok: errors.length === 0, errors };
1581
+ }
1582
+ function checkLocalDiffEndpoints(kind, base, tip) {
1583
+ if (kind === "staged" && (base.label !== "HEAD" || tip.label !== "index")) {
1584
+ return ['a "staged" diff is HEAD → index only: staged, unstaged and conflict stages never share endpoints'];
1585
+ }
1586
+ if (kind === "unstaged" && (base.label !== "index" || tip.label !== "worktree")) {
1587
+ return ['an "unstaged" diff is index → worktree only: staged, unstaged and conflict stages never share endpoints'];
1588
+ }
1589
+ if (kind === "conflict" && (base.label !== "index" || tip.label !== "index")) {
1590
+ return ['a "conflict" diff is index → index with stage 1/2/3 OIDs only'];
1591
+ }
1592
+ return [];
1593
+ }
1594
+ function validateLocalDiffShape(diff, sealed) {
1595
+ if (!isRecord(diff))
1596
+ return { ok: false, errors: ["local diff must be an object"] };
1597
+ const errors = [];
1598
+ rejectUnknownKeys(diff, LOCAL_DIFF_KEYS, "local diff", errors);
1599
+ const scope = validateLiveScope(diff.scope);
1600
+ if (!scope.ok)
1601
+ errors.push(...scope.errors.map((error) => `local diff.scope: ${error}`));
1602
+ if (!isStrictBatchId(diff.worktreeId, ID_PREFIXES.worktree)) {
1603
+ errors.push(`local diff.worktreeId must be a strict opaque id starting with "${ID_PREFIXES.worktree}"`);
1604
+ }
1605
+ const kindOk = isLocalDiffKind(diff.kind);
1606
+ if (!kindOk) {
1607
+ errors.push(`local diff.kind must be one of ${LOCAL_DIFF_KINDS.join(", ")} (no cumulative HEAD → worktree view validates here)`);
1608
+ }
1609
+ const base = validateLocalDiffEndpoint(diff.base);
1610
+ if (!base.ok)
1611
+ errors.push(...base.errors.map((error) => `local diff.base: ${error}`));
1612
+ const tip = validateLocalDiffEndpoint(diff.tip);
1613
+ if (!tip.ok)
1614
+ errors.push(...tip.errors.map((error) => `local diff.tip: ${error}`));
1615
+ if (kindOk && base.ok && tip.ok) {
1616
+ errors.push(...checkLocalDiffEndpoints(diff.kind, diff.base, diff.tip));
1617
+ }
1618
+ if (diff.comparedParent !== undefined && diff.comparedParent !== null &&
1619
+ (typeof diff.comparedParent !== "number" || !Number.isSafeInteger(diff.comparedParent) || diff.comparedParent < 0)) {
1620
+ errors.push("local diff.comparedParent, when present, must be null or a non-negative safe integer");
1621
+ }
1622
+ if (diff.mergeBase !== undefined && diff.mergeBase !== null && !isHexOid(diff.mergeBase)) {
1623
+ errors.push("local diff.mergeBase, when present, must be null or a hex OID");
1624
+ }
1625
+ if (diff.normalizerVersion !== LOCAL_DIFF_NORMALIZER_VERSION) {
1626
+ errors.push(`local diff.normalizerVersion must be "${LOCAL_DIFF_NORMALIZER_VERSION}" (normalization is versioned evidence)`);
1627
+ }
1628
+ if (diff.policyVersion !== CONTENT_POLICY_VERSION) {
1629
+ errors.push(`local diff.policyVersion must be "${CONTENT_POLICY_VERSION}"`);
1630
+ }
1631
+ if (!Array.isArray(diff.files)) {
1632
+ errors.push("local diff.files must be an array");
1633
+ }
1634
+ else {
1635
+ diff.files.forEach((file, index) => {
1636
+ const checked = validateLocalFileChange(file, `local diff.files[${index}]`, sealed);
1637
+ if (!checked.ok)
1638
+ errors.push(...checked.errors);
1639
+ });
1640
+ }
1641
+ if (diff.coverage !== undefined) {
1642
+ const coverage = validateLocalCoverage(diff.coverage);
1643
+ if (!coverage.ok)
1644
+ errors.push(...coverage.errors.map((error) => `local diff.${error}`));
1645
+ }
1646
+ return { ok: errors.length === 0, errors };
1647
+ }
1648
+ /** Structural validation of a derived (possibly transient) local diff. */
1649
+ export function validateLocalDiff(diff) {
1650
+ return validateLocalDiffShape(diff, false);
1651
+ }
1652
+ /**
1653
+ * Sink/store-bound validation: transient "open-lines" hunk payloads are
1654
+ * refused, so persist-then-mask cannot validate. Seal through the injected
1655
+ * host protection port first, or withhold explicitly.
1656
+ */
1657
+ export function validateSealedLocalDiff(diff) {
1658
+ return validateLocalDiffShape(diff, true);
1659
+ }
1660
+ function validateSealedSnapshotPayload(payload) {
1661
+ if (!isRecord(payload))
1662
+ return { ok: false, errors: ["snapshot sealed payload must be an object"] };
1663
+ const errors = [];
1664
+ if (payload.state === "sealed") {
1665
+ rejectUnknownKeys(payload, SEALED_SNAPSHOT_PAYLOAD_KEYS, "snapshot sealed payload", errors);
1666
+ if (!isLocalSealAlgorithm(payload.algorithm)) {
1667
+ errors.push(`snapshot sealed payload.algorithm must be one of ${LOCAL_SEAL_ALGORITHMS.join(", ")}`);
1668
+ }
1669
+ if (!isStrictKeyRef(payload.keyRef)) {
1670
+ errors.push("snapshot sealed payload.keyRef must be a strict opaque key reference (host key custody, never in the database)");
1671
+ }
1672
+ if (!(payload.ciphertext instanceof Uint8Array) || payload.ciphertext.length === 0) {
1673
+ errors.push("snapshot sealed payload.ciphertext must be a non-empty byte array");
1674
+ }
1675
+ if ("lines" in payload || "reason" in payload) {
1676
+ errors.push("a sealed snapshot payload carries ciphertext only, never clair or downgrade reasons");
1677
+ }
1678
+ }
1679
+ else if (payload.state === "metadata-only") {
1680
+ rejectUnknownKeys(payload, METADATA_SNAPSHOT_PAYLOAD_KEYS, "snapshot sealed payload", errors);
1681
+ if (!isLocalSnapshotMetadataReason(payload.reason)) {
1682
+ errors.push(`snapshot sealed payload.reason must be one of ${LOCAL_SNAPSHOT_METADATA_REASONS.join(", ")}`);
1683
+ }
1684
+ }
1685
+ else {
1686
+ errors.push('snapshot sealed payload.state must be "sealed" or "metadata-only"');
1687
+ }
1688
+ return { ok: errors.length === 0, errors };
1689
+ }
1690
+ /** Structural validation of the latest authorized local state (Q2 B). */
1691
+ export function validateLocalSnapshot(snapshot) {
1692
+ if (!isRecord(snapshot))
1693
+ return { ok: false, errors: ["local snapshot must be an object"] };
1694
+ const errors = [];
1695
+ rejectUnknownKeys(snapshot, LOCAL_SNAPSHOT_KEYS, "local snapshot", errors);
1696
+ const scope = validateLiveScope(snapshot.scope);
1697
+ if (!scope.ok)
1698
+ errors.push(...scope.errors.map((error) => `local snapshot.scope: ${error}`));
1699
+ if (!isStrictBatchId(snapshot.worktreeId, ID_PREFIXES.worktree)) {
1700
+ errors.push(`local snapshot.worktreeId must be a strict opaque id starting with "${ID_PREFIXES.worktree}"`);
1701
+ }
1702
+ if (!isStrictSnapshotId(snapshot.snapshotId)) {
1703
+ errors.push("local snapshot.snapshotId must be a strict lsnap hex snapshot id");
1704
+ }
1705
+ if (snapshot.kind !== "index" && snapshot.kind !== "working-tree") {
1706
+ errors.push('local snapshot.kind must be "index" or "working-tree"');
1707
+ }
1708
+ if (!isLocalDiffKind(snapshot.diffKind)) {
1709
+ errors.push('local snapshot.diffKind must be one of "staged", "unstaged", "conflict" (staged and conflict hold distinct index targets)');
1710
+ }
1711
+ // MINEUR 2 (Opus tour 2): kind/diffKind pairing — `unstaged` goes with
1712
+ // `working-tree` only; `staged`/`conflict` go with `index` only.
1713
+ if ((snapshot.kind === "index" || snapshot.kind === "working-tree") &&
1714
+ isLocalDiffKind(snapshot.diffKind)) {
1715
+ const coherent = (snapshot.diffKind === "unstaged" && snapshot.kind === "working-tree") ||
1716
+ (snapshot.diffKind !== "unstaged" && snapshot.kind === "index");
1717
+ if (!coherent) {
1718
+ errors.push('local snapshot.kind/diffKind mismatch: "unstaged" pairs with "working-tree" only, "staged"/"conflict" pair with "index" only');
1719
+ }
1720
+ }
1721
+ if (typeof snapshot.capturedAt !== "number" || !Number.isSafeInteger(snapshot.capturedAt) || snapshot.capturedAt <= 0) {
1722
+ errors.push("local snapshot.capturedAt must be a positive safe integer (milliseconds)");
1723
+ }
1724
+ if (typeof snapshot.diffDigest !== "string" || !/^[0-9a-f]{64}$/.test(snapshot.diffDigest)) {
1725
+ errors.push("local snapshot.diffDigest must be a 64 character lowercase hex digest of the screened diff");
1726
+ }
1727
+ const sealed = validateSealedSnapshotPayload(snapshot.sealed);
1728
+ if (!sealed.ok)
1729
+ errors.push(...sealed.errors.map((error) => `local snapshot.${error}`));
1730
+ if (snapshot.policyVersion !== CONTENT_POLICY_VERSION) {
1731
+ errors.push(`local snapshot.policyVersion must be "${CONTENT_POLICY_VERSION}"`);
1732
+ }
1733
+ return { ok: errors.length === 0, errors };
1734
+ }