@haposoft/cafekit 0.18.0 → 0.18.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (83) hide show
  1. package/README.md +1 -1
  2. package/bin/__tests__/codex-hooks.test.js +169 -236
  3. package/bin/__tests__/codex-native.test.js +54 -114
  4. package/bin/__tests__/develop-contract.test.js +101 -1361
  5. package/bin/__tests__/legacy-authority-decoupled.test.js +14 -19
  6. package/bin/__tests__/package-inventory.test.js +1 -395
  7. package/bin/__tests__/spec-narrowing.test.js +30 -134
  8. package/bin/__tests__/specs-v2-execution-closeout.test.js +0 -372
  9. package/bin/__tests__/test-runtime-dependency-closure.cjs +1 -2
  10. package/bin/__tests__/validator-grounding.test.js +17 -2929
  11. package/bin/lib/managed-writer.js +2 -3
  12. package/bin/phases/codex-runtime.js +6 -3
  13. package/bin/phases/copy-payload.js +13 -28
  14. package/bin/phases/omp-runtime.js +6 -3
  15. package/package.json +2 -3
  16. package/src/claude/agents/code-auditor.md +9 -6
  17. package/src/claude/agents/debugger.md +3 -9
  18. package/src/claude/agents/docs-keeper.md +4 -7
  19. package/src/claude/agents/git-ops.md +2 -4
  20. package/src/claude/agents/implementer.md +6 -7
  21. package/src/claude/agents/inspector.md +2 -0
  22. package/src/claude/agents/project-manager.md +2 -2
  23. package/src/claude/agents/spec-maker.md +4 -4
  24. package/src/claude/agents/test-runner.md +8 -11
  25. package/src/claude/agents/ui-ux-designer.md +0 -1
  26. package/src/claude/hooks/__tests__/spec-gate.test.js +170 -330
  27. package/src/claude/hooks/__tests__/spec-state-touched.test.js +26 -0
  28. package/src/claude/hooks/spec-gate.cjs +8 -63
  29. package/src/claude/hooks/spec-state.cjs +47 -38
  30. package/src/claude/migration-manifest.json +12 -7
  31. package/src/claude/output-styles/coding-level-0-eli5.md +9 -9
  32. package/src/claude/output-styles/coding-level-1-junior.md +3 -3
  33. package/src/claude/output-styles/coding-level-2-mid.md +3 -3
  34. package/src/claude/output-styles/coding-level-3-senior.md +2 -2
  35. package/src/claude/output-styles/coding-level-4-lead.md +5 -5
  36. package/src/claude/output-styles/coding-level-5-god.md +6 -6
  37. package/src/claude/rules/ai-dev-rules.md +1 -2
  38. package/src/claude/rules/workflow.md +1 -1
  39. package/src/claude/scripts/spec-receipt.cjs +0 -114
  40. package/src/claude/scripts/spec-resolver.cjs +41 -457
  41. package/src/claude/scripts/workflow-policy.cjs +3 -1801
  42. package/src/claude/skills/agent-browser/SKILL.md +1 -1
  43. package/src/claude/skills/chrome-devtools/SKILL.md +14 -31
  44. package/src/claude/skills/code-review/SKILL.md +25 -108
  45. package/src/claude/skills/code-review/references/spec-compliance-review.md +4 -7
  46. package/src/claude/skills/develop/SKILL.md +22 -70
  47. package/src/claude/skills/develop/references/subagent-patterns.md +0 -6
  48. package/src/claude/skills/research/SKILL.md +24 -55
  49. package/src/claude/skills/sync/SKILL.md +12 -34
  50. package/src/claude/skills/sync/references/rebind-and-audit.md +2 -2
  51. package/src/claude/skills/test/SKILL.md +39 -123
  52. package/src/claude/skills/test/references/execution-strategy.md +11 -22
  53. package/src/claude/skills/ui-ux/SKILL.md +1 -1
  54. package/src/claude/skills/ui-ux-pro-max/SKILL.md +24 -79
  55. package/src/claude/skills/web-testing/SKILL.md +2 -20
  56. package/src/codex/hooks/lib/spec-receipt.cjs +2 -30
  57. package/src/codex/hooks/lib/spec-utils.cjs +6 -45
  58. package/src/codex/hooks/spec-gate.cjs +11 -68
  59. package/src/codex/hooks/spec-state.cjs +37 -41
  60. package/src/common/AGENTS.md +4 -10
  61. package/bin/__tests__/change-firewall.semantic-firewall.test.js +0 -2133
  62. package/bin/__tests__/finalizer-pass-gate.semantic-firewall.test.js +0 -342
  63. package/bin/__tests__/release-preflight.semantic-firewall.test.js +0 -586
  64. package/bin/__tests__/review-receipt-c2.semantic-firewall.test.js +0 -378
  65. package/bin/__tests__/specs-v2-policy-and-scaffold.test.js +0 -665
  66. package/bin/__tests__/specs-v2-validator-grounder-contract.test.js +0 -1132
  67. package/src/claude/hooks/lib/context.cjs +0 -527
  68. package/src/claude/hooks/lib/detect.cjs +0 -474
  69. package/src/claude/scripts/change-firewall.cjs +0 -1748
  70. package/src/claude/scripts/spec-authoring-digest.cjs +0 -151
  71. package/src/claude/scripts/spec-authoring-validation.cjs +0 -126
  72. package/src/claude/scripts/spec-final-state.cjs +0 -290
  73. package/src/claude/scripts/spec-ground.cjs +0 -650
  74. package/src/claude/scripts/spec-readiness.cjs +0 -425
  75. package/src/claude/scripts/spec-scaffold.cjs +0 -2317
  76. package/src/claude/scripts/spec-semantic-model.cjs +0 -396
  77. package/src/claude/scripts/validate-spec-output.cjs +0 -3914
  78. package/src/claude/skills/code-review/references/verification-gate.md +0 -41
  79. package/src/claude/skills/specs/templates/design.md +0 -82
  80. package/src/claude/skills/specs/templates/requirements-init.md +0 -8
  81. package/src/claude/skills/specs/templates/requirements.md +0 -42
  82. package/src/claude/skills/specs/templates/spec-state.json +0 -56
  83. package/src/claude/skills/specs/templates/task.md +0 -47
@@ -1,1748 +0,0 @@
1
- 'use strict';
2
-
3
- // D1 change-firewall gate (owner R0-01). Exports the exact named surface D1
4
- // requires: loadBaselineAuthority, assertBaselineUsable, bootstrapBaseline,
5
- // advanceBaseline, deriveProtectedChangedPaths, computeProtectedTreeDigest,
6
- // computeProposalDigest, computeCandidateDigest, assertHotspotChangeAllowed,
7
- // createFreezeManifest, verifyFreezeManifest, invalidateFreeze,
8
- // NO_CHANGE_ATTESTATION_DIGEST, plus the C15 BenchmarkFailureLedger surface
9
- // (loadFailureLedger, assertFailureLedgerUsable, openFailure, classifyFailure,
10
- // reclassifyFailure, resolveFailure, computeFailureLedgerDigest,
11
- // deriveFailureState, NO_FAILURES_ATTESTATION_DIGEST) this same module owns.
12
- //
13
- // Production callers never get a per-call path override (I13/I19): the plain
14
- // top-level exports are bound to this checkout's own real project root
15
- // (resolved via git from this file's own location, never process.cwd()).
16
- // `createChangeFirewall({ root })` exists only so a test/harness can construct
17
- // an isolated instance bound to a different, once-verified trusted root.
18
-
19
- const crypto = require('node:crypto');
20
- const fs = require('node:fs');
21
- const path = require('node:path');
22
- const { spawnSync } = require('node:child_process');
23
-
24
- const DIGEST = require('./spec-authoring-digest.cjs');
25
- const { asciiOrdinalCompare, stableJson } = DIGEST;
26
-
27
- let VALIDATOR = null;
28
- function validatorModule() {
29
- if (!VALIDATOR) VALIDATOR = require('./validate-spec-output.cjs');
30
- return VALIDATOR;
31
- }
32
-
33
- const SCHEMA_VERSION = '1';
34
-
35
- const REL = {
36
- releaseBaseline: 'packages/spec/benchmarks/release-baseline.json',
37
- ledger: 'packages/spec/benchmarks/benchmark-failure-ledger.json',
38
- freeze: 'packages/spec/.cafekit-release/change-firewall-freeze.json',
39
- receipt: 'packages/spec/.cafekit-release/release-receipt.json',
40
- legacyBridge: 'specs/archive/cafekit-semantic-eval-firewall/reports/bootstrap-legacy-bridge-review.json',
41
- cafekitReleaseDir: 'packages/spec/.cafekit-release/',
42
- packageJson: 'packages/spec/package.json',
43
- };
44
-
45
- const PROTECTED_FILE_PATHS = new Set([
46
- 'packages/spec/bin/phases/copy-payload.js',
47
- 'packages/spec/package.json',
48
- 'packages/spec/.gitignore',
49
- ]);
50
-
51
- const PROTECTED_DIR_PREFIXES = [
52
- 'packages/spec/src/claude/scripts/',
53
- 'packages/spec/src/claude/skills/specs/',
54
- 'packages/spec/scripts/',
55
- 'packages/spec/benchmarks/',
56
- 'packages/spec/bin/__tests__/',
57
- ];
58
-
59
- const RESERVED_PATHS = new Set([REL.releaseBaseline, REL.ledger]);
60
-
61
- const FAILURE_CLASSES = [
62
- 'framework_regression', 'model_runtime_error', 'prompt_ambiguity',
63
- 'domain_research_failure', 'infrastructure', 'oracle_defect', 'mixed', 'unknown',
64
- ];
65
-
66
- const OWNER_LAYERS = [
67
- 'authoring_kernel', 'structural_compiler', 'semantic_reviewer',
68
- 'readiness_finalizer', 'benchmark_controller', 'independent_adjudicator', 'release_process',
69
- ];
70
-
71
- const CANDIDATE_DIGEST_FIELDS = [
72
- 'baselineAuthorityDigest', 'baselineGeneration', 'baselineCommit',
73
- 'failureLedgerDigest', 'failureLedgerGeneration', 'remediatedFailureIds',
74
- 'changedPaths', 'treeDigest', 'proposalDigest', 'packageVersion',
75
- ];
76
-
77
- const RELEASE_RECEIPT_FIELDS = [
78
- 'schema_version', 'receipt_id', 'status', 'baselineAuthorityDigest', 'baselineGeneration',
79
- 'freezeDigest', 'candidateDigest', 'treeDigest', 'packageVersion', 'releaseCommit',
80
- 'artifactPath', 'artifactDigest', 'resolvedFailureIds',
81
- ];
82
-
83
- const NO_CHANGE_ATTESTATION_DIGEST = `sha256:${crypto.createHash('sha256').update('cafekit:no-protected-change:v1').digest('hex')}`;
84
- const NO_FAILURES_ATTESTATION_DIGEST = `sha256:${crypto.createHash('sha256').update('cafekit:no-open-failures:v1').digest('hex')}`;
85
-
86
- class ChangeFirewallError extends Error {
87
- constructor(code, message) {
88
- super(message);
89
- this.name = 'ChangeFirewallError';
90
- this.code = code;
91
- }
92
- }
93
- function fail(code, message) { throw new ChangeFirewallError(code, message); }
94
-
95
- // ---------------------------------------------------------------------------
96
- // generic helpers
97
- // ---------------------------------------------------------------------------
98
-
99
- function sha256Hex(bytes) { return crypto.createHash('sha256').update(bytes).digest('hex'); }
100
- function sha256Tag(bytes) { return `sha256:${sha256Hex(bytes)}`; }
101
- function isSha256Tag(value) { return typeof value === 'string' && /^sha256:[0-9a-f]{64}$/.test(value); }
102
- function isCommitId(value) { return typeof value === 'string' && /^[0-9a-f]{40}$/.test(value); }
103
- function plain(value) { return Boolean(value && typeof value === 'object' && !Array.isArray(value)); }
104
-
105
- function exactKeys(value, keys) {
106
- return plain(value) && JSON.stringify(Object.keys(value).sort()) === JSON.stringify([...keys].sort());
107
- }
108
-
109
- function hasOnlyKeys(value, required, optional = []) {
110
- if (!plain(value)) return false;
111
- const keys = Object.keys(value);
112
- if (!required.every((key) => keys.includes(key))) return false;
113
- const allowed = new Set([...required, ...optional]);
114
- return keys.every((key) => allowed.has(key));
115
- }
116
-
117
- function sortedUnique(list) { return [...new Set(list)].sort(asciiOrdinalCompare); }
118
-
119
- function isSortedUniqueArray(value, { allowEmpty = false } = {}) {
120
- if (!Array.isArray(value)) return false;
121
- if (value.length === 0) return allowEmpty;
122
- if (value.some((entry) => typeof entry !== 'string' || entry === '')) return false;
123
- for (let index = 1; index < value.length; index += 1) {
124
- if (!(value[index - 1] < value[index])) return false;
125
- }
126
- return true;
127
- }
128
-
129
- function isNonEmptyStringArray(value) {
130
- return Array.isArray(value) && value.length > 0 && value.every((entry) => typeof entry === 'string' && entry !== '');
131
- }
132
-
133
- // ---------------------------------------------------------------------------
134
- // git helpers
135
- // ---------------------------------------------------------------------------
136
-
137
- function runGit(root, args) {
138
- const result = spawnSync('git', ['-C', root, ...args], { encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 });
139
- if (result.error || result.status !== 0) {
140
- const detail = result.error?.message || String(result.stderr || '').trim() || `exit ${result.status}`;
141
- fail('git_command_failed', `git ${args.join(' ')} failed: ${detail}`);
142
- }
143
- return result.stdout;
144
- }
145
-
146
- function runGitBytes(root, args) {
147
- const result = spawnSync('git', ['-C', root, ...args], { encoding: 'buffer', maxBuffer: 64 * 1024 * 1024 });
148
- if (result.error || result.status !== 0) {
149
- const detail = result.error?.message || (Buffer.isBuffer(result.stderr) ? result.stderr.toString('utf8') : '').trim() || `exit ${result.status}`;
150
- fail('git_command_failed', `git ${args.join(' ')} failed: ${detail}`);
151
- }
152
- return result.stdout;
153
- }
154
-
155
- function tryGitBytes(root, args) {
156
- const result = spawnSync('git', ['-C', root, ...args], { encoding: 'buffer', maxBuffer: 64 * 1024 * 1024 });
157
- if (result.error || result.status !== 0) return null;
158
- return result.stdout;
159
- }
160
-
161
- function splitNul(buffer) {
162
- if (buffer.length === 0) return [];
163
- const trimmed = buffer[buffer.length - 1] === 0 ? buffer.subarray(0, -1) : buffer;
164
- return trimmed.length === 0 ? [] : trimmed.toString('utf8').split('\0');
165
- }
166
-
167
- function gitHeadCommit(root) {
168
- const out = runGit(root, ['rev-parse', 'HEAD']).trim();
169
- if (!isCommitId(out)) fail('malformed_git_output', 'git rev-parse HEAD did not return a commit id');
170
- return out;
171
- }
172
-
173
- function isRealCommit(root, sha) {
174
- if (!isCommitId(sha)) return false;
175
- const result = spawnSync('git', ['-C', root, 'cat-file', '-e', `${sha}^{commit}`]);
176
- return result.status === 0;
177
- }
178
-
179
- function isAncestorOrEqual(root, ancestor, descendant) {
180
- if (!isRealCommit(root, descendant)) return false;
181
- if (ancestor === descendant) return isRealCommit(root, ancestor);
182
- if (!isRealCommit(root, ancestor)) return false;
183
- const result = spawnSync('git', ['-C', root, 'merge-base', '--is-ancestor', ancestor, descendant]);
184
- return result.status === 0;
185
- }
186
-
187
- function gitHeadFileBytes(root, relativePath) {
188
- return tryGitBytes(root, ['show', `HEAD:${relativePath}`]);
189
- }
190
-
191
- function tryParseJson(bytes) {
192
- try {
193
- return JSON.parse(bytes.toString('utf8'));
194
- } catch {
195
- return null;
196
- }
197
- }
198
-
199
- // true/false when git can answer definitively; null when the check itself is
200
- // ambiguous (spawn error, non-zero exit, or an unrecognized answer). Callers
201
- // must treat null exactly like "shallow" -- fail closed, never silently
202
- // assume a full clone just because the shallow probe itself misbehaved.
203
- function isShallowRepository(root) {
204
- const result = spawnSync('git', ['-C', root, 'rev-parse', '--is-shallow-repository'], { encoding: 'utf8' });
205
- if (result.error || result.status !== 0) return null;
206
- const value = result.stdout.trim();
207
- if (value === 'true') return true;
208
- if (value === 'false') return false;
209
- return null;
210
- }
211
-
212
- // Fail-closed reader for the ordered (oldest -> newest) committed snapshots
213
- // of relativePath -- the shared foundation both C10 authority and C15 ledger
214
- // provenance build on. The shallow/ambiguity probe runs *first*, unconditionally
215
- // -- before any "no history" trust can ever be granted, not merely once a
216
- // current blob happens to exist -- because a truncated or unanswerable clone
217
- // can hide real prior history for a path that currently looks absent just as
218
- // easily as it can hide the true genesis of a path that currently looks
219
- // present. { ok: true, snapshots: [] } (trusted, matching C10's own case (2),
220
- // "uncommitted... trusted... without requiring a git commit first") is
221
- // returned only when a full, non-shallow `git log` query itself *succeeds*
222
- // and independently proves zero commits ever touched this path, *and* HEAD
223
- // currently has no blob for it -- both facts must agree. If committed
224
- // history exists for this path but HEAD's tree currently has no blob for it,
225
- // that is a committed deletion: no named transition ever deletes or renames
226
- // this file, so a git-rm'd-then-recommitted (or renamed-away) reserved
227
- // control-plane path is itself an out-of-band edit, not a fresh "never
228
- // committed" genesis for whatever gets written in its place -- fail closed.
229
- // Any other ambiguity -- a failed or non-zero `git log`/`git show`, or a
230
- // commit whose blob cannot be read -- is reported as { ok: false } rather
231
- // than silently treated as "nothing committed" or "this is genesis". That
232
- // silent fallback is exactly how a shallow clone, a transient git failure, or
233
- // a delete-then-recreate could smuggle a tampered/laundered snapshot past
234
- // provenance as a false, unprovable trust root.
235
- function readCommittedSnapshots(root, relativePath) {
236
- if (isShallowRepository(root) !== false) return { ok: false }; // true or null (ambiguous): fail closed
237
- const logResult = spawnSync('git', ['-C', root, 'log', '--format=%H', '--', relativePath], { encoding: 'utf8' });
238
- if (logResult.error || logResult.status !== 0) return { ok: false };
239
- const commits = logResult.stdout.split('\n').map((line) => line.trim()).filter(Boolean).reverse(); // oldest first
240
- const headBytes = gitHeadFileBytes(root, relativePath);
241
- if (commits.length === 0) {
242
- if (headBytes !== null) return { ok: false }; // HEAD has a blob `git log` never saw: ambiguous, fail closed
243
- return { ok: true, snapshots: [] }; // full history independently proves this path never appeared: trusted
244
- }
245
- if (headBytes === null) return { ok: false }; // committed history exists but HEAD has no blob: committed deletion
246
- const snapshots = [];
247
- for (const hash of commits) {
248
- const bytes = tryGitBytes(root, ['show', `${hash}:${relativePath}`]);
249
- if (bytes === null) return { ok: false };
250
- snapshots.push({ hash, bytes });
251
- }
252
- return { ok: true, snapshots };
253
- }
254
-
255
- // Honest-agent-only provenance (C10/C15, not a defense against a privileged
256
- // history-rewriting insider). Validates the *entire* committed-then-current
257
- // chain for a control-plane record against one shared generation rule --
258
- // every adjacent pair, including every committed-to-committed pair, not
259
- // merely a last-commit-vs-current hop: a same-generation in-place rewrite
260
- // that itself got committed (and later buried under unrelated commits) is
261
- // caught here even though it is no longer the most recent commit, which a
262
- // check comparing only "current" against "the last commit" could never see
263
- // once that rewrite stopped being the most recent one.
264
- //
265
- // The exact rule (design.md C10, "an out-of-band edit": generation must equal
266
- // priorGeneration + 1, or the load rejects): once a committed predecessor
267
- // exists for this path, every content-changing pair -- committed-to-committed
268
- // or committed-to-current alike -- must advance generation by *exactly* one,
269
- // and the later record's own previousDigest must hash the immediately prior
270
- // bytes exactly. There is no generation-jump exemption: a named transition
271
- // always writes priorGeneration+1 relative to whatever it itself loaded as
272
- // current, so a real generation gap against the last *committed* snapshot can
273
- // only mean an intervening uncommitted transition was never independently
274
- // verified against that committed predecessor before a later one overwrote
275
- // it -- exactly the gap assertCandidateProvenance (called by every named
276
- // writer before it mutates anything) exists to close. The only trusted case
277
- // with no such constraint is case (2) of C10's own load rules: no committed
278
- // predecessor exists at all yet, so whatever is currently on disk -- however
279
- // many uncommitted transitions produced it -- is trusted as first-ever state.
280
- // parseSnapshot turns raw committed bytes into a validated record (or null
281
- // on a malformed historic snapshot, itself fail-closed); verifyPair, when
282
- // supplied, checks each caller-owned structural invariant across every
283
- // adjacent pair (C10 legal state order; C15 append-only event prefix).
284
- function verifyProvenanceChain(root, relativePath, {
285
- currentBytes, currentRecord, parseSnapshot, getGeneration, getPreviousDigest, verifyPair, label,
286
- }) {
287
- const read = readCommittedSnapshots(root, relativePath);
288
- if (!read.ok) {
289
- fail('out_of_band_edit', `${label} committed history could not be independently established (a shallow clone or an unreadable git history)`);
290
- }
291
- const chain = read.snapshots.map((snapshot) => {
292
- const record = parseSnapshot(snapshot.bytes);
293
- if (!record) fail('out_of_band_edit', `${label} has a committed snapshot that fails its exact schema`);
294
- return { bytes: snapshot.bytes, record };
295
- });
296
- chain.push({ bytes: currentBytes, record: currentRecord });
297
-
298
- for (let index = 1; index < chain.length; index += 1) {
299
- const prev = chain[index - 1];
300
- const curr = chain[index];
301
- if (prev.bytes.equals(curr.bytes)) continue; // unchanged at this hop: nothing to verify
302
- const prevGeneration = getGeneration(prev.record);
303
- const currGeneration = getGeneration(curr.record);
304
- if (currGeneration !== prevGeneration + 1) {
305
- fail('out_of_band_edit', `${label} generation does not advance by exactly one from its committed predecessor`);
306
- }
307
- if (getPreviousDigest(curr.record) !== sha256Tag(prev.bytes)) {
308
- fail('out_of_band_edit', `${label} previousDigest does not hash the immediately prior committed bytes`);
309
- }
310
- if (verifyPair) verifyPair(prev.record, curr.record);
311
- }
312
- }
313
-
314
- // ---------------------------------------------------------------------------
315
- // root resolution (I13/I19: production calls never take a per-call override)
316
- // ---------------------------------------------------------------------------
317
-
318
- function resolveTrustedRoot(requestedRoot) {
319
- const rawRoot = requestedRoot ? path.resolve(requestedRoot) : resolveGitRootFromHere();
320
- const canonical = fs.realpathSync(rawRoot);
321
- const stat = fs.lstatSync(canonical);
322
- if (!stat.isDirectory()) fail('malformed_input', 'trusted root must be a directory');
323
- return canonical;
324
- }
325
-
326
- function resolveGitRootFromHere() {
327
- const result = spawnSync('git', ['-C', __dirname, 'rev-parse', '--show-toplevel'], { encoding: 'utf8' });
328
- if (result.error || result.status !== 0) {
329
- fail('root_resolution_failed', 'cannot resolve the canonical project root via git from this source checkout');
330
- }
331
- return result.stdout.trim();
332
- }
333
-
334
- function absPath(root, relativePath) {
335
- return path.join(root, ...relativePath.split('/'));
336
- }
337
-
338
- // ---------------------------------------------------------------------------
339
- // D1 protected-path allowlist
340
- // ---------------------------------------------------------------------------
341
-
342
- function isExcludedPath(relativePath) {
343
- return relativePath === REL.cafekitReleaseDir.replace(/\/$/, '') || relativePath.startsWith(REL.cafekitReleaseDir);
344
- }
345
-
346
- function isReservedPath(relativePath) {
347
- return RESERVED_PATHS.has(relativePath);
348
- }
349
-
350
- function isProtectedPath(relativePath) {
351
- if (isExcludedPath(relativePath)) return false;
352
- if (isReservedPath(relativePath)) return false; // reserved-path exclusion precedes the prefix match (D1)
353
- if (PROTECTED_FILE_PATHS.has(relativePath)) return true;
354
- return PROTECTED_DIR_PREFIXES.some((prefix) => relativePath.startsWith(prefix));
355
- }
356
-
357
- // ---------------------------------------------------------------------------
358
- // atomic writes
359
- // ---------------------------------------------------------------------------
360
-
361
- function atomicWriteBytes(absoluteTarget, bytes) {
362
- fs.mkdirSync(path.dirname(absoluteTarget), { recursive: true });
363
- const tmp = path.join(
364
- path.dirname(absoluteTarget),
365
- `.${path.basename(absoluteTarget)}.tmp-${process.pid}-${Date.now()}-${crypto.randomBytes(4).toString('hex')}`,
366
- );
367
- const fd = fs.openSync(tmp, 'wx', 0o644);
368
- try {
369
- fs.writeFileSync(fd, bytes);
370
- fs.fsyncSync(fd);
371
- } finally {
372
- fs.closeSync(fd);
373
- }
374
- fs.renameSync(tmp, absoluteTarget);
375
- }
376
-
377
- // Sole canonical JSON-record byte serialization (D13-adjacent: one shared
378
- // primitive, never a per-writer reimplementation). Every C10/C15 named
379
- // writer computes its candidate's bytes through this exact function once,
380
- // then reuses those same bytes both to validate the candidate's provenance
381
- // and to perform the atomic write -- there is never a second, independently
382
- // re-serialized copy that could drift from what actually gets persisted.
383
- function jsonRecordBytes(value) {
384
- return Buffer.from(`${JSON.stringify(value, null, 2)}\n`, 'utf8');
385
- }
386
-
387
- function atomicWriteJson(absoluteTarget, value) {
388
- atomicWriteBytes(absoluteTarget, jsonRecordBytes(value));
389
- }
390
-
391
- function readJsonFileIfExists(root, relativePath) {
392
- const absolute = absPath(root, relativePath);
393
- let stat;
394
- try {
395
- stat = fs.lstatSync(absolute);
396
- } catch (error) {
397
- if (error.code === 'ENOENT') return null;
398
- throw error;
399
- }
400
- if (!stat.isFile() || stat.isSymbolicLink()) fail('malformed_input', `${relativePath} must be a regular non-symlink file`);
401
- const bytes = fs.readFileSync(absolute);
402
- let parsed;
403
- try {
404
- parsed = JSON.parse(bytes.toString('utf8'));
405
- } catch (error) {
406
- fail('malformed_json', `${relativePath} is not valid JSON (${error.message})`);
407
- }
408
- return { bytes, parsed };
409
- }
410
-
411
- // Tolerant variant for the one place a corrupt-or-missing file is itself an
412
- // expected, recoverable state rather than a fatal error: advanceBaseline's
413
- // idempotency check (I11 1a/1b) and resolveFailure's independent receipt read
414
- // (I13) must be able to distinguish "no usable canonical evidence" (missing,
415
- // unreadable, or malformed bytes) from a hard error, so they can apply their
416
- // own specific repair/refuse semantics instead of an uncaught crash.
417
- function tryReadJsonFile(root, relativePath) {
418
- try {
419
- return readJsonFileIfExists(root, relativePath);
420
- } catch {
421
- return null;
422
- }
423
- }
424
-
425
- // ---------------------------------------------------------------------------
426
- // deriveProtectedChangedPaths / computeProtectedTreeDigest (D1, R1.3, R1.5)
427
- // ---------------------------------------------------------------------------
428
-
429
- // No --diff-filter: an allowlist of change statuses (the prior
430
- // A(dded)/C(opied)/D(eleted)/M(odified)/R(enamed) set) is exactly the kind of
431
- // brittle enumeration that silently stops covering a real git status it never
432
- // anticipated -- it omitted T (type-changed, e.g. a protected regular file
433
- // swapped for a symlink to the same path) and U (unmerged), either of which
434
- // would let a protected-path change escape D1 detection entirely rather than
435
- // merely being classified oddly. Every change status git diff can report
436
- // must surface here; isProtectedPath below still gates which *paths* count.
437
- function gitChangedPathsRaw(root, baselineCommit, headCommit) {
438
- const diffArgs = headCommit
439
- ? ['diff', '--name-only', '-z', baselineCommit, headCommit]
440
- : ['diff', '--name-only', '-z', baselineCommit];
441
- const tracked = splitNul(runGitBytes(root, diffArgs));
442
- const untracked = splitNul(runGitBytes(root, ['ls-files', '-z', '--others', '--exclude-standard']));
443
- return [...tracked, ...untracked];
444
- }
445
-
446
- // Self-derives protected changed paths from a git diff (never a caller-supplied
447
- // changedPaths substitute): baselineCommit vs the working tree for a routine
448
- // freeze, or baselineCommit..headCommit for the one-time C14 bootstrap diff.
449
- function deriveProtectedChangedPaths(root, baselineCommit, options = {}) {
450
- if (!isCommitId(baselineCommit)) fail('malformed_input', 'baselineCommit must be a full git commit object id');
451
- const headCommit = options.headCommit;
452
- if (headCommit !== undefined && !isCommitId(headCommit)) fail('malformed_input', 'headCommit must be a full git commit object id');
453
- const raw = gitChangedPathsRaw(root, baselineCommit, headCommit);
454
- const protectedPaths = raw.filter((entry) => entry && isProtectedPath(entry));
455
- return sortedUnique(protectedPaths);
456
- }
457
-
458
- function readWorktreeEntry(root, relativePath) {
459
- const absolute = absPath(root, relativePath);
460
- let stat;
461
- try {
462
- stat = fs.lstatSync(absolute);
463
- } catch (error) {
464
- if (error.code === 'ENOENT') return { type: 'deleted', mode: '0000', content: Buffer.alloc(0) };
465
- throw error;
466
- }
467
- if (stat.isSymbolicLink()) return { type: 'symlink', mode: '0000', content: Buffer.from(fs.readlinkSync(absolute), 'utf8') };
468
- if (stat.isFile()) return { type: 'file', mode: (stat.mode & 0o7777).toString(8).padStart(4, '0'), content: fs.readFileSync(absolute) };
469
- if (stat.isDirectory()) return { type: 'directory', mode: '0000', content: Buffer.alloc(0) };
470
- return { type: 'other', mode: '0000', content: Buffer.alloc(0) };
471
- }
472
-
473
- function readGitTreeEntry(root, ref, relativePath) {
474
- const output = runGit(root, ['ls-tree', ref, '--', relativePath]);
475
- const line = output.split('\n').find(Boolean);
476
- if (!line) return { type: 'deleted', mode: '0000', content: Buffer.alloc(0) };
477
- const match = line.match(/^([0-7]+) (blob|tree|commit) ([0-9a-f]+)\t/);
478
- if (!match) fail('malformed_git_output', 'git ls-tree emitted an unrecognized entry');
479
- const [, gitMode, objectType, sha] = match;
480
- if (objectType !== 'blob') return { type: 'other', mode: '0000', content: Buffer.alloc(0) };
481
- const isSymlink = gitMode === '120000';
482
- const content = runGitBytes(root, ['cat-file', 'blob', sha]);
483
- return { type: isSymlink ? 'symlink' : 'file', mode: isSymlink ? '0000' : gitMode.slice(-4), content };
484
- }
485
-
486
- // Hashes sorted protected paths with type, mode, and content or a delete
487
- // sentinel (R1.5). With options.ref, reads each path from that git ref
488
- // (bootstrap-time, over a committed baseCommit); otherwise reads the current
489
- // working tree (routine freeze-time, over the proposed candidate state).
490
- function computeProtectedTreeDigest(root, protectedPaths, options = {}) {
491
- const sorted = sortedUnique(protectedPaths);
492
- const hash = crypto.createHash('sha256');
493
- hash.update('cafekit-protected-tree-v1\n');
494
- for (const relativePath of sorted) {
495
- const entry = options.ref ? readGitTreeEntry(root, options.ref, relativePath) : readWorktreeEntry(root, relativePath);
496
- hash.update(`${relativePath}\n${entry.type}\n${entry.mode}\n`);
497
- hash.update(entry.type === 'deleted' ? Buffer.from('DELETED') : entry.content);
498
- hash.update('\n');
499
- }
500
- return `sha256:${hash.digest('hex')}`;
501
- }
502
-
503
- function computeProposalDigest(proposal) {
504
- if (proposal === null || proposal === undefined) return NO_CHANGE_ATTESTATION_DIGEST;
505
- return sha256Tag(Buffer.from(stableJson(proposal), 'utf8'));
506
- }
507
-
508
- function computeCandidateDigest(fields) {
509
- if (!plain(fields)) fail('malformed_input', 'candidate digest fields must be an object');
510
- const missing = CANDIDATE_DIGEST_FIELDS.filter((key) => !Object.prototype.hasOwnProperty.call(fields, key));
511
- if (missing.length) fail('malformed_input', `candidate digest missing fields: ${missing.join(', ')}`);
512
- const extra = Object.keys(fields).filter((key) => !CANDIDATE_DIGEST_FIELDS.includes(key));
513
- if (extra.length) fail('malformed_input', `candidate digest received unexpected fields: ${extra.join(', ')}`);
514
- const bound = {};
515
- for (const key of CANDIDATE_DIGEST_FIELDS) bound[key] = fields[key];
516
- return sha256Tag(Buffer.from(stableJson(bound), 'utf8'));
517
- }
518
-
519
- // ---------------------------------------------------------------------------
520
- // C10 ReleaseBaselineAuthority
521
- // ---------------------------------------------------------------------------
522
-
523
- function validateBootstrapRecordShape(record) {
524
- if (!exactKeys(record, ['state', 'generation', 'baselineCommit', 'baselinePackageVersion', 'bootstrapReviewDigest', 'previousBaselineDigest'])) return false;
525
- if (record.state !== 'bootstrap') return false;
526
- if (record.generation !== 0) return false;
527
- if (!isCommitId(record.baselineCommit)) return false;
528
- if (typeof record.baselinePackageVersion !== 'string' || !record.baselinePackageVersion) return false;
529
- if (!isSha256Tag(record.bootstrapReviewDigest)) return false;
530
- if (record.previousBaselineDigest !== null) return false;
531
- return true;
532
- }
533
-
534
- function validateReleasedRecordShape(record) {
535
- if (!plain(record) || record.state !== 'released') return false;
536
- const hasCandidate = Object.prototype.hasOwnProperty.call(record, 'releasedCandidateDigest');
537
- const hasReceipt = Object.prototype.hasOwnProperty.call(record, 'releaseReceiptDigest');
538
- if (hasCandidate === hasReceipt) return false; // exactly one required
539
- const expected = [
540
- 'state', 'generation', 'baselineCommit', 'baselinePackageVersion', 'previousBaselineDigest',
541
- hasCandidate ? 'releasedCandidateDigest' : 'releaseReceiptDigest',
542
- ];
543
- if (!exactKeys(record, expected)) return false;
544
- if (!Number.isInteger(record.generation) || record.generation < 1) return false;
545
- if (!isCommitId(record.baselineCommit)) return false;
546
- if (typeof record.baselinePackageVersion !== 'string' || !record.baselinePackageVersion) return false;
547
- if (!isSha256Tag(record.previousBaselineDigest)) return false;
548
- if (hasCandidate && !isSha256Tag(record.releasedCandidateDigest)) return false;
549
- if (hasReceipt && !isSha256Tag(record.releaseReceiptDigest)) return false;
550
- return true;
551
- }
552
-
553
- function loadBaselineAuthority(root) {
554
- const loaded = readJsonFileIfExists(root, REL.releaseBaseline);
555
- if (!loaded) return { state: 'absent' };
556
- const { bytes, parsed } = loaded;
557
- if (!plain(parsed) || typeof parsed.state !== 'string') fail('malformed_authority', 'release-baseline.json is not a recognized C10 record');
558
- if (parsed.state === 'bootstrap') {
559
- if (!validateBootstrapRecordShape(parsed)) fail('malformed_authority', 'release-baseline.json bootstrap record fails the exact C10 schema');
560
- return { state: 'bootstrap', record: parsed, bytes, digest: sha256Tag(bytes) };
561
- }
562
- if (parsed.state === 'released') {
563
- if (!validateReleasedRecordShape(parsed)) fail('malformed_authority', 'release-baseline.json released record fails the exact C10 schema');
564
- return { state: 'released', record: parsed, bytes, digest: sha256Tag(bytes) };
565
- }
566
- fail('malformed_authority', 'release-baseline.json state must be bootstrap or released');
567
- }
568
-
569
- // Turns raw committed bytes into a validated C10 record (or null on a
570
- // malformed/unrecognized historic snapshot) -- reuses the exact same shape
571
- // validators loadBaselineAuthority applies to the live file, so a historic
572
- // commit is held to the identical schema, never a looser "unknown" reading.
573
- function parseBaselineSnapshot(bytes) {
574
- const parsed = tryParseJson(bytes);
575
- if (!plain(parsed) || typeof parsed.state !== 'string') return null;
576
- if (parsed.state === 'bootstrap') return validateBootstrapRecordShape(parsed) ? parsed : null;
577
- if (parsed.state === 'released') return validateReleasedRecordShape(parsed) ? parsed : null;
578
- return null;
579
- }
580
-
581
- // C10's own legal transition: bootstrap may become released (advanceBaseline,
582
- // once); released may only ever advance to another released generation.
583
- // released regressing back to bootstrap is never legitimate -- explicit here
584
- // even though the schema already makes it unreachable in practice (bootstrap
585
- // is always generation 0, so a released->bootstrap pair is already caught by
586
- // verifyProvenanceChain's own generation-must-advance rule first) -- keeping
587
- // the rule named and checked directly, not merely an emergent side effect.
588
- function verifyBaselineStateOrder(prevRecord, currRecord) {
589
- if (prevRecord.state === 'released' && currRecord.state === 'bootstrap') {
590
- fail('out_of_band_edit', 'release-baseline.json regresses from released back to bootstrap');
591
- }
592
- }
593
-
594
- // Shared per-call options for every release-baseline.json provenance check
595
- // (live-state validation and pre-write candidate validation alike) -- one
596
- // definition, so C10's parse/generation/digest/state-order rules can never
597
- // drift between the two call sites.
598
- const BASELINE_PROVENANCE_OPTIONS = {
599
- parseSnapshot: parseBaselineSnapshot,
600
- getGeneration: (record) => record.generation,
601
- getPreviousDigest: (record) => record.previousBaselineDigest,
602
- verifyPair: verifyBaselineStateOrder,
603
- label: 'release-baseline.json',
604
- };
605
-
606
- // Honest-agent-only provenance (C10, not a defense against a privileged
607
- // history-rewriting insider): the shared verifyProvenanceChain walks every
608
- // committed snapshot of release-baseline.json plus the live (possibly
609
- // uncommitted) current record, requiring each path-changing hop to move
610
- // generation by *exactly* one and, on that hop, to hash the immediately
611
- // prior committed bytes exactly -- catching a same-generation in-place
612
- // rewrite (bootstrap or released alike) even once it is no longer the most
613
- // recent commit. Case (2) is the only exemption: no committed predecessor
614
- // exists at all yet, so whatever is currently on disk is trusted as
615
- // first-ever state without requiring a git commit first.
616
- function verifyAuthorityProvenance(root, loaded) {
617
- verifyProvenanceChain(root, REL.releaseBaseline, {
618
- ...BASELINE_PROVENANCE_OPTIONS,
619
- currentBytes: loaded.bytes,
620
- currentRecord: loaded.record,
621
- });
622
- }
623
-
624
- // Validates a prospective (not-yet-written) C10 candidate against the exact
625
- // same rule, before any mutation happens: a second uncommitted named
626
- // transition relative to the same committed predecessor jumps generation by
627
- // more than one and is rejected *here*, leaving the file's bytes exactly as
628
- // the last valid transition left them, rather than writing a state that only
629
- // the next load would reject. Returns the candidate's serialized bytes (via
630
- // the sole canonical jsonRecordBytes) so the caller reuses them verbatim for
631
- // the atomic write -- no second, independent serialization.
632
- function verifyBaselineCandidate(root, candidateRecord) {
633
- const candidateBytes = jsonRecordBytes(candidateRecord);
634
- verifyProvenanceChain(root, REL.releaseBaseline, {
635
- ...BASELINE_PROVENANCE_OPTIONS,
636
- currentBytes: candidateBytes,
637
- currentRecord: candidateRecord,
638
- });
639
- return candidateBytes;
640
- }
641
-
642
- // R1.3 (design.md Errors and Recovery: "Caller base override | exit 2 | no
643
- // accept"): no function in this module ever accepts a baselineCommit/root
644
- // override from a caller (I13/I19), so the only remaining candidate surface
645
- // is environment configuration. This is a closed, exactly-enumerated
646
- // reserved-key boundary -- never an open-ended name-shape scan over
647
- // process.env. An environment variable this runtime never reads cannot
648
- // influence baseline authority and is therefore not an override attempt by
649
- // definition: guessing at arbitrary names via a BASE/BASELINE-shaped token
650
- // pattern is exactly the false-positive/false-negative tradeoff this
651
- // enumeration removes (a prior token-matching version false-positived on
652
- // ambient vars such as GITHUB_BASE_REF/GITHUB_BASE_SHA that GitHub Actions
653
- // sets on every pull_request run, and was never provably exhaustive against
654
- // a genuine override name it does not anticipate either). The six keys below
655
- // are the entire observable channel; presence is checked by exact key name
656
- // only (Object.prototype.hasOwnProperty on process.env), never by value --
657
- // a value is never read or logged. No other environment variable name --
658
- // however BASE/BASELINE-shaped, and regardless of any third-party or ambient
659
- // prefix (GITHUB_BASE_REF, OVERRIDE_BASE_COMMIT, FORCE_BASELINE_REF,
660
- // BASECOMMIT, RELEASE_BASE_URL, AUTHORITY_BASE_URL, DATABASE_URL,
661
- // RELEASE_NOTES_PATH, etc.) -- is ever a supported override channel: none of
662
- // them are read, and none influence authority, so a reviewer must not read
663
- // any of these names as a channel this boundary recognizes.
664
- const BASE_OVERRIDE_ENV_KEYS = Object.freeze(new Set([
665
- 'CAFEKIT_BASE_COMMIT',
666
- 'CAFEKIT_BASELINE_COMMIT',
667
- 'CAFEKIT_BASE_REF',
668
- 'CAFEKIT_BASELINE_REF',
669
- 'CAFEKIT_BASE_PATH',
670
- 'CAFEKIT_BASELINE_PATH',
671
- ]));
672
-
673
- function assertNoBaseOverrideEnv() {
674
- for (const name of BASE_OVERRIDE_ENV_KEYS) {
675
- if (Object.prototype.hasOwnProperty.call(process.env, name)) {
676
- fail('caller_base_override', `environment variable ${name} is a reserved baseline override channel; only tracked release-baseline.json is ever loaded (R1.3)`);
677
- }
678
- }
679
- }
680
-
681
- function assertBaselineUsable(root, options = {}) {
682
- assertNoBaseOverrideEnv();
683
- const loaded = loadBaselineAuthority(root);
684
- if (loaded.state === 'absent') {
685
- if (options.allowAbsent) return loaded;
686
- fail('authority_absent', 'release-baseline.json does not exist; only bootstrapBaseline may create it');
687
- }
688
- verifyAuthorityProvenance(root, loaded);
689
- return loaded;
690
- }
691
-
692
- // ---------------------------------------------------------------------------
693
- // D11 legacy-bridge artifact precondition (read-only; R0-01 never writes it)
694
- // ---------------------------------------------------------------------------
695
-
696
- const LEGACY_BRIDGE_KEYS = ['schema_version', 'verdict', 'blocking_count', 'reviewed_criteria', 'semantic_digest', 'bootWindowBaseCommit', 'written_at'];
697
-
698
- function readLegacyBridgeArtifact(root) {
699
- const loaded = readJsonFileIfExists(root, REL.legacyBridge);
700
- if (!loaded) fail('legacy_bridge_missing', 'bootstrap-legacy-bridge-review.json does not exist');
701
- const { parsed } = loaded;
702
- if (!exactKeys(parsed, LEGACY_BRIDGE_KEYS)) fail('legacy_bridge_malformed', 'bootstrap-legacy-bridge-review.json has an unexpected key set');
703
- if (parsed.schema_version !== '1') fail('legacy_bridge_malformed', 'bootstrap-legacy-bridge-review.json schema_version must be "1"');
704
- if (parsed.verdict !== 'PASS') fail('legacy_bridge_malformed', 'bootstrap-legacy-bridge-review.json verdict must be PASS');
705
- if (parsed.blocking_count !== 0) fail('legacy_bridge_malformed', 'bootstrap-legacy-bridge-review.json blocking_count must be 0');
706
- if (!Array.isArray(parsed.reviewed_criteria) || parsed.reviewed_criteria.length === 0) {
707
- fail('legacy_bridge_malformed', 'bootstrap-legacy-bridge-review.json reviewed_criteria must be a non-empty array');
708
- }
709
- if (!isSha256Tag(parsed.semantic_digest)) fail('legacy_bridge_malformed', 'bootstrap-legacy-bridge-review.json semantic_digest must be sha256');
710
- if (!isCommitId(parsed.bootWindowBaseCommit)) fail('legacy_bridge_malformed', 'bootstrap-legacy-bridge-review.json bootWindowBaseCommit must be a full commit id');
711
- if (typeof parsed.written_at !== 'string' || !parsed.written_at) fail('legacy_bridge_malformed', 'bootstrap-legacy-bridge-review.json written_at must be a non-empty string');
712
- return parsed;
713
- }
714
-
715
- // ---------------------------------------------------------------------------
716
- // C14 BootstrapAttestation / bootstrapBaseline
717
- // ---------------------------------------------------------------------------
718
-
719
- const REL_FEATURE_SPEC = 'specs/archive/cafekit-semantic-eval-firewall/spec.json';
720
-
721
- function readFeatureSpecJson(root) {
722
- const loaded = readJsonFileIfExists(root, REL_FEATURE_SPEC);
723
- if (!loaded) fail('spec_missing', 'this feature spec.json does not exist');
724
- return loaded.parsed;
725
- }
726
-
727
- function validateAttestationShape(attestation) {
728
- return exactKeys(attestation, ['baseCommit', 'headCommit', 'bootstrapReviewDigest', 'packageVersion'])
729
- && isCommitId(attestation.baseCommit)
730
- && isCommitId(attestation.headCommit)
731
- && isSha256Tag(attestation.bootstrapReviewDigest)
732
- && typeof attestation.packageVersion === 'string' && attestation.packageVersion !== '';
733
- }
734
-
735
- function readSeedPassEntry(spec) {
736
- const history = spec && spec.validation && spec.validation.semantic_review_history;
737
- if (!plain(history) || !Array.isArray(history.entries)) return null;
738
- return history.entries.find((entry) => entry && entry.sequence === 0 && entry.review_epoch === 0 && entry.verdict === 'PASS') || null;
739
- }
740
-
741
- // C14: one-time, caller-supplied input; never persisted verbatim. No
742
- // treeDigest field (baseCommit already pins the tree). Refuses to run before
743
- // the seed semantic_review_history entry (sequence 0/review_epoch 0) exists,
744
- // and verifies baseCommit against the legacy-bridge artifact's own durably
745
- // captured bootWindowBaseCommit field rather than a merge-base guess.
746
- function bootstrapBaseline(root, attestation) {
747
- if (!validateAttestationShape(attestation)) fail('malformed_input', 'C14 BootstrapAttestation fails its exact schema');
748
- const existing = loadBaselineAuthority(root);
749
- if (existing.state !== 'absent') fail('authority_exists', 'release-baseline.json already exists; bootstrapBaseline only creates the first record');
750
-
751
- const bridge = readLegacyBridgeArtifact(root);
752
- if (attestation.baseCommit !== bridge.bootWindowBaseCommit) {
753
- fail('base_commit_mismatch', 'C14 baseCommit does not equal the legacy-bridge artifact bootWindowBaseCommit');
754
- }
755
- if (!isRealCommit(root, attestation.baseCommit)) fail('malformed_input', 'C14 baseCommit is not a real git commit object');
756
- const headCommit = gitHeadCommit(root);
757
- if (attestation.headCommit !== headCommit) fail('stale_attestation', 'C14 headCommit does not equal the actual current HEAD');
758
- if (!isAncestorOrEqual(root, attestation.baseCommit, attestation.headCommit)) {
759
- fail('malformed_input', 'C14 baseCommit is not an ancestor of (or equal to) headCommit');
760
- }
761
-
762
- const spec = readFeatureSpecJson(root);
763
- const seedEntry = readSeedPassEntry(spec);
764
- if (!seedEntry) fail('seed_pass_missing', 'bootstrapBaseline cannot run before the seed semantic_review_history entry exists');
765
- if (attestation.bootstrapReviewDigest !== seedEntry.review_receipt_digest) {
766
- fail('stale_attestation', 'C14 bootstrapReviewDigest does not match the seed PASS entry review_receipt_digest');
767
- }
768
-
769
- const protectedPaths = deriveProtectedChangedPaths(root, attestation.baseCommit, { headCommit: attestation.headCommit });
770
- if (protectedPaths.length === 0) fail('empty_bootstrap_diff', 'bootstrap requires a real independent-PASS digest over a non-empty protected change set');
771
-
772
- const record = {
773
- state: 'bootstrap',
774
- generation: 0,
775
- baselineCommit: attestation.baseCommit,
776
- baselinePackageVersion: attestation.packageVersion,
777
- bootstrapReviewDigest: attestation.bootstrapReviewDigest,
778
- previousBaselineDigest: null,
779
- };
780
- const bytes = verifyBaselineCandidate(root, record);
781
- atomicWriteBytes(absPath(root, REL.releaseBaseline), bytes);
782
- return { record, protectedPaths };
783
- }
784
-
785
- // ---------------------------------------------------------------------------
786
- // C15 BenchmarkFailureLedger
787
- // ---------------------------------------------------------------------------
788
-
789
- function validateOpenedEvent(event) {
790
- return exactKeys(event, ['type', 'failure_id', 'paths', 'candidate_digest', 'evidence_digest', 'occurred_at'])
791
- && event.type === 'opened'
792
- && typeof event.failure_id === 'string' && event.failure_id !== ''
793
- && isSortedUniqueArray(event.paths)
794
- && isSha256Tag(event.candidate_digest)
795
- && isSha256Tag(event.evidence_digest)
796
- && typeof event.occurred_at === 'string' && event.occurred_at !== '';
797
- }
798
-
799
- function validateClassifiedEvent(event) {
800
- return exactKeys(event, ['type', 'failure_id', 'primary_failure_class', 'adjudication_digest', 'occurred_at'])
801
- && event.type === 'classified'
802
- && typeof event.failure_id === 'string' && event.failure_id !== ''
803
- && FAILURE_CLASSES.includes(event.primary_failure_class)
804
- && isSha256Tag(event.adjudication_digest)
805
- && typeof event.occurred_at === 'string' && event.occurred_at !== '';
806
- }
807
-
808
- function validateReclassifiedEvent(event) {
809
- return exactKeys(event, ['type', 'failure_id', 'previous_primary_failure_class', 'primary_failure_class', 'adjudication_digest', 'occurred_at'])
810
- && event.type === 'reclassified'
811
- && typeof event.failure_id === 'string' && event.failure_id !== ''
812
- && FAILURE_CLASSES.includes(event.previous_primary_failure_class)
813
- && FAILURE_CLASSES.includes(event.primary_failure_class)
814
- && isSha256Tag(event.adjudication_digest)
815
- && typeof event.occurred_at === 'string' && event.occurred_at !== '';
816
- }
817
-
818
- function validateResolvedEvent(event) {
819
- return exactKeys(event, ['type', 'failure_id', 'decision_digest', 'release_receipt_digest', 'occurred_at'])
820
- && event.type === 'resolved'
821
- && typeof event.failure_id === 'string' && event.failure_id !== ''
822
- && isSha256Tag(event.decision_digest)
823
- && isSha256Tag(event.release_receipt_digest)
824
- && typeof event.occurred_at === 'string' && event.occurred_at !== '';
825
- }
826
-
827
- function validateLedgerEvent(event) {
828
- if (!plain(event) || typeof event.type !== 'string') return false;
829
- if (event.type === 'opened') return validateOpenedEvent(event);
830
- if (event.type === 'classified') return validateClassifiedEvent(event);
831
- if (event.type === 'reclassified') return validateReclassifiedEvent(event);
832
- if (event.type === 'resolved') return validateResolvedEvent(event);
833
- return false;
834
- }
835
-
836
- // Pure shape validator (no throwing) so it can be reused both by the live
837
- // loader below and by parseLedgerSnapshot for a raw historic commit. Named
838
- // transitions (openFailure/classifyFailure/reclassifyFailure/resolveFailure)
839
- // each append exactly one event per generation (nextLedgerRecord), so
840
- // events.length === generation + 1 is a structural fact of every valid
841
- // record, current or historic -- not merely something to check pairwise.
842
- function validateLedgerRecordShape(parsed) {
843
- if (!exactKeys(parsed, ['schema_version', 'generation', 'previousLedgerDigest', 'events'])) return false;
844
- if (parsed.schema_version !== '1') return false;
845
- if (!Number.isInteger(parsed.generation) || parsed.generation < 0) return false;
846
- if (parsed.generation === 0 && parsed.previousLedgerDigest !== null) return false;
847
- if (parsed.generation > 0 && !isSha256Tag(parsed.previousLedgerDigest)) return false;
848
- if (!Array.isArray(parsed.events) || parsed.events.length !== parsed.generation + 1) return false;
849
- const seenOpened = new Set();
850
- for (const event of parsed.events) {
851
- if (!validateLedgerEvent(event)) return false;
852
- if (event.type === 'opened') {
853
- if (seenOpened.has(event.failure_id)) return false;
854
- seenOpened.add(event.failure_id);
855
- }
856
- }
857
- return true;
858
- }
859
-
860
- function loadFailureLedger(root) {
861
- const loaded = readJsonFileIfExists(root, REL.ledger);
862
- if (!loaded) return { state: 'absent' };
863
- const { bytes, parsed } = loaded;
864
- if (!validateLedgerRecordShape(parsed)) fail('malformed_ledger', 'benchmark-failure-ledger.json fails the exact C15 schema');
865
- return { state: 'present', record: parsed, bytes, digest: sha256Tag(bytes) };
866
- }
867
-
868
- // Turns raw committed bytes into a validated C15 record (or null on a
869
- // malformed/unrecognized historic snapshot), reusing the exact same shape
870
- // validator loadFailureLedger applies to the live file.
871
- function parseLedgerSnapshot(bytes) {
872
- const parsed = tryParseJson(bytes);
873
- return validateLedgerRecordShape(parsed) ? parsed : null;
874
- }
875
-
876
- // Append-only, generation-per-event invariant (C15): a committed transition
877
- // must strictly extend the events array, and every event the prior committed
878
- // snapshot already recorded must reappear byte-for-byte, in the same order,
879
- // in the later one -- never rewritten, reordered, or dropped. Since named
880
- // transitions each add exactly one event per generation and generation must
881
- // now advance by exactly one (no jump exemption), curr.events.length is
882
- // always exactly prev.events.length + 1 by construction; the explicit
883
- // length/prefix checks below stay as direct, named defense-in-depth rather
884
- // than an implicit side effect of the generation rule alone.
885
- function verifyLedgerEventPrefix(prevRecord, currRecord) {
886
- const prevEvents = prevRecord.events;
887
- const currEvents = currRecord.events;
888
- if (currEvents.length <= prevEvents.length) {
889
- fail('out_of_band_edit', 'benchmark-failure-ledger.json committed transition does not append new events');
890
- }
891
- for (let index = 0; index < prevEvents.length; index += 1) {
892
- if (JSON.stringify(prevEvents[index]) !== JSON.stringify(currEvents[index])) {
893
- fail('out_of_band_edit', 'benchmark-failure-ledger.json rewrites a previously committed event instead of only appending');
894
- }
895
- }
896
- }
897
-
898
- // Shared per-call options for every benchmark-failure-ledger.json provenance
899
- // check (live-state validation and pre-write candidate validation alike).
900
- const LEDGER_PROVENANCE_OPTIONS = {
901
- parseSnapshot: parseLedgerSnapshot,
902
- getGeneration: (record) => record.generation,
903
- getPreviousDigest: (record) => record.previousLedgerDigest,
904
- verifyPair: verifyLedgerEventPrefix,
905
- label: 'benchmark-failure-ledger.json',
906
- };
907
-
908
- // Same honest-agent-only chain-of-custody rule as C10 (verifyAuthorityProvenance),
909
- // via the shared verifyProvenanceChain: every committed snapshot plus the
910
- // live (possibly uncommitted) current record must move generation by exactly
911
- // one with the matching digest check, and each committed transition must
912
- // append-only extend the prior committed events array.
913
- function verifyLedgerProvenance(root, loaded) {
914
- verifyProvenanceChain(root, REL.ledger, {
915
- ...LEDGER_PROVENANCE_OPTIONS,
916
- currentBytes: loaded.bytes,
917
- currentRecord: loaded.record,
918
- });
919
- }
920
-
921
- // Validates a prospective (not-yet-written) C15 candidate against the exact
922
- // same rule before any mutation: a second uncommitted named transition
923
- // relative to the same committed predecessor is rejected here, before the
924
- // ledger's on-disk bytes are ever overwritten. Returns the candidate's
925
- // serialized bytes so the caller reuses them verbatim for the atomic write.
926
- function verifyLedgerCandidate(root, candidateRecord) {
927
- const candidateBytes = jsonRecordBytes(candidateRecord);
928
- verifyProvenanceChain(root, REL.ledger, {
929
- ...LEDGER_PROVENANCE_OPTIONS,
930
- currentBytes: candidateBytes,
931
- currentRecord: candidateRecord,
932
- });
933
- return candidateBytes;
934
- }
935
-
936
- function assertFailureLedgerUsable(root) {
937
- const loaded = loadFailureLedger(root);
938
- if (loaded.state === 'present') verifyLedgerProvenance(root, loaded);
939
- return loaded;
940
- }
941
-
942
- function computeFailureLedgerDigest(root) {
943
- const loaded = loadFailureLedger(root);
944
- return loaded.state === 'present' ? loaded.digest : NO_FAILURES_ATTESTATION_DIGEST;
945
- }
946
-
947
- // Folds a failure_id's events: the most recent classification-type event
948
- // (classified or reclassified) determines the current class (I18); blocking
949
- // while opened-only or classified framework_regression and unresolved.
950
- function deriveFailureState(ledgerLoaded, failureId) {
951
- const events = ledgerLoaded && ledgerLoaded.state === 'present' ? ledgerLoaded.record.events : [];
952
- const relevant = events.filter((event) => event.failure_id === failureId);
953
- const opened = relevant.find((event) => event.type === 'opened');
954
- if (!opened) return { exists: false };
955
- let currentClass = null;
956
- let resolved = false;
957
- let resolvedEvent = null;
958
- for (const event of relevant) {
959
- if (event.type === 'classified' || event.type === 'reclassified') currentClass = event.primary_failure_class;
960
- if (event.type === 'resolved') { resolved = true; resolvedEvent = event; }
961
- }
962
- const blocking = !resolved && (currentClass === null || currentClass === 'framework_regression');
963
- return { exists: true, openedPaths: opened.paths, currentClass, resolved, resolvedEvent, blocking };
964
- }
965
-
966
- function allOpenFailureIds(ledgerLoaded) {
967
- if (!ledgerLoaded || ledgerLoaded.state !== 'present') return [];
968
- const ids = new Set();
969
- for (const event of ledgerLoaded.record.events) if (event.type === 'opened') ids.add(event.failure_id);
970
- return [...ids];
971
- }
972
-
973
- // Builds the candidate for a named C15 transition and validates it against
974
- // the committed predecessor *before* returning -- callers write the returned
975
- // bytes verbatim; nothing here ever mutates release-baseline.json/the
976
- // ledger's own on-disk bytes itself before that validation passes.
977
- function nextLedgerRecord(root, buildEvent) {
978
- const loaded = assertFailureLedgerUsable(root);
979
- const events = loaded.state === 'present' ? [...loaded.record.events] : [];
980
- const generation = loaded.state === 'present' ? loaded.record.generation + 1 : 0;
981
- const previousLedgerDigest = loaded.state === 'present' ? loaded.digest : null;
982
- const newEvent = buildEvent(events, loaded);
983
- events.push(newEvent);
984
- const record = { schema_version: '1', generation, previousLedgerDigest, events };
985
- const bytes = verifyLedgerCandidate(root, record);
986
- return { record, bytes };
987
- }
988
-
989
- function openFailure(root, input) {
990
- if (!plain(input)) fail('malformed_input', 'openFailure input must be an object');
991
- const { failure_id: failureId, paths, candidate_digest: candidateDigest, evidence_digest: evidenceDigest, occurred_at: occurredAt } = input;
992
- if (typeof failureId !== 'string' || !failureId) fail('malformed_input', 'openFailure requires a non-empty failure_id');
993
- if (!isSortedUniqueArray(paths)) fail('malformed_input', 'openFailure requires sorted-unique non-empty paths');
994
- if (!isSha256Tag(candidateDigest)) fail('malformed_input', 'openFailure requires a valid candidate_digest');
995
- if (!isSha256Tag(evidenceDigest)) fail('malformed_input', 'openFailure requires a valid evidence_digest');
996
- if (typeof occurredAt !== 'string' || !occurredAt) fail('malformed_input', 'openFailure requires occurred_at');
997
- const { record, bytes } = nextLedgerRecord(root, (events) => {
998
- if (events.some((event) => event.type === 'opened' && event.failure_id === failureId)) {
999
- fail('duplicate_failure_id', `failure_id ${failureId} already opened`);
1000
- }
1001
- return { type: 'opened', failure_id: failureId, paths: [...paths], candidate_digest: candidateDigest, evidence_digest: evidenceDigest, occurred_at: occurredAt };
1002
- });
1003
- atomicWriteBytes(absPath(root, REL.ledger), bytes);
1004
- return record;
1005
- }
1006
-
1007
- function classifyFailure(root, input) {
1008
- if (!plain(input)) fail('malformed_input', 'classifyFailure input must be an object');
1009
- const { failure_id: failureId, primary_failure_class: primaryClass, adjudication_digest: adjudicationDigest, occurred_at: occurredAt } = input;
1010
- if (typeof failureId !== 'string' || !failureId) fail('malformed_input', 'classifyFailure requires failure_id');
1011
- if (!FAILURE_CLASSES.includes(primaryClass)) fail('malformed_input', 'classifyFailure requires a valid primary_failure_class');
1012
- if (!isSha256Tag(adjudicationDigest)) fail('malformed_input', 'classifyFailure requires a valid adjudication_digest');
1013
- if (typeof occurredAt !== 'string' || !occurredAt) fail('malformed_input', 'classifyFailure requires occurred_at');
1014
- const { record, bytes } = nextLedgerRecord(root, (events) => {
1015
- if (!events.some((event) => event.type === 'opened' && event.failure_id === failureId)) {
1016
- fail('unknown_failure_id', `classifyFailure requires an opened event for ${failureId}`);
1017
- }
1018
- if (events.some((event) => event.type === 'classified' && event.failure_id === failureId)) {
1019
- fail('already_classified', `failure_id ${failureId} already has a classified event`);
1020
- }
1021
- if (events.some((event) => (event.type === 'classified' || event.type === 'reclassified') && event.adjudication_digest === adjudicationDigest)) {
1022
- fail('reused_adjudication_digest', 'adjudication_digest has already been used for a classification event');
1023
- }
1024
- return { type: 'classified', failure_id: failureId, primary_failure_class: primaryClass, adjudication_digest: adjudicationDigest, occurred_at: occurredAt };
1025
- });
1026
- atomicWriteBytes(absPath(root, REL.ledger), bytes);
1027
- return record;
1028
- }
1029
-
1030
- function reclassifyFailure(root, input) {
1031
- if (!plain(input)) fail('malformed_input', 'reclassifyFailure input must be an object');
1032
- const {
1033
- failure_id: failureId, previous_primary_failure_class: previousClass,
1034
- primary_failure_class: primaryClass, adjudication_digest: adjudicationDigest, occurred_at: occurredAt,
1035
- } = input;
1036
- if (typeof failureId !== 'string' || !failureId) fail('malformed_input', 'reclassifyFailure requires failure_id');
1037
- if (!FAILURE_CLASSES.includes(previousClass)) fail('malformed_input', 'reclassifyFailure requires a valid previous_primary_failure_class');
1038
- if (!FAILURE_CLASSES.includes(primaryClass)) fail('malformed_input', 'reclassifyFailure requires a valid primary_failure_class');
1039
- if (!isSha256Tag(adjudicationDigest)) fail('malformed_input', 'reclassifyFailure requires a valid adjudication_digest');
1040
- if (typeof occurredAt !== 'string' || !occurredAt) fail('malformed_input', 'reclassifyFailure requires occurred_at');
1041
- const { record, bytes } = nextLedgerRecord(root, (events) => {
1042
- const state = deriveFailureState({ state: 'present', record: { events } }, failureId);
1043
- if (!state.exists || state.currentClass === null) fail('unknown_failure_id', `reclassifyFailure requires a prior classified event for ${failureId}`);
1044
- if (state.resolved) fail('already_resolved', `failure_id ${failureId} already has a resolved event`);
1045
- if (state.currentClass !== previousClass) fail('stale_previous_class', 'previous_primary_failure_class does not match the currently-derived class');
1046
- if (events.some((event) => (event.type === 'classified' || event.type === 'reclassified') && event.adjudication_digest === adjudicationDigest)) {
1047
- fail('reused_adjudication_digest', 'adjudication_digest has already been used for a classification event');
1048
- }
1049
- return {
1050
- type: 'reclassified', failure_id: failureId, previous_primary_failure_class: previousClass,
1051
- primary_failure_class: primaryClass, adjudication_digest: adjudicationDigest, occurred_at: occurredAt,
1052
- };
1053
- });
1054
- atomicWriteBytes(absPath(root, REL.ledger), bytes);
1055
- return record;
1056
- }
1057
-
1058
- // I13: never trusts a caller-supplied release_receipt_digest or a caller's
1059
- // claim that failure_id belongs to the release being resolved. Independently
1060
- // re-derives and validates the published receipt before appending.
1061
- function resolveFailure(root, input) {
1062
- if (!plain(input)) fail('malformed_input', 'resolveFailure input must be an object');
1063
- const { failure_id: failureId, decision_digest: decisionDigest } = input;
1064
- if (typeof failureId !== 'string' || !failureId) fail('malformed_input', 'resolveFailure requires failure_id');
1065
- if (!isSha256Tag(decisionDigest)) fail('malformed_input', 'resolveFailure requires a valid decision_digest');
1066
-
1067
- const authority = assertBaselineUsable(root);
1068
- if (authority.state !== 'released') fail('not_released', 'resolveFailure requires a released C10 authority');
1069
- const canonical = tryReadJsonFile(root, REL.receipt);
1070
- if (!canonical) fail('receipt_missing', 'canonical release receipt does not exist or is unreadable/malformed');
1071
- const recomputedDigest = sha256Tag(canonical.bytes);
1072
- if (recomputedDigest !== authority.record.releaseReceiptDigest) {
1073
- fail('receipt_digest_mismatch', 'canonical receipt bytes do not match the currently published releaseReceiptDigest');
1074
- }
1075
- if (!validateReceiptShape(canonical.parsed)) fail('malformed_receipt', 'canonical release receipt fails the exact C12 schema');
1076
- if (Object.prototype.hasOwnProperty.call(input, 'release_receipt_digest')
1077
- && input.release_receipt_digest !== undefined && input.release_receipt_digest !== recomputedDigest) {
1078
- fail('untrusted_claim_mismatch', 'caller-supplied release_receipt_digest does not match the independently recomputed digest');
1079
- }
1080
- const resolvedIds = sortedUnique(canonical.parsed.resolvedFailureIds);
1081
- if (!resolvedIds.includes(failureId)) fail('not_in_receipt', 'failure_id is not a member of the persisted receipt resolvedFailureIds');
1082
-
1083
- const ledgerLoaded = loadFailureLedger(root);
1084
- const state = deriveFailureState(ledgerLoaded, failureId);
1085
- if (!state.exists) fail('unknown_failure_id', `resolveFailure requires a known failure_id ${failureId}`);
1086
- if (state.resolved) {
1087
- if (state.resolvedEvent.release_receipt_digest === recomputedDigest) return { status: 'noop' };
1088
- fail('resolve_conflict', 'failure_id already resolved with a different release_receipt_digest');
1089
- }
1090
- if (state.currentClass !== 'framework_regression') fail('not_framework_regression', 'resolveFailure only resolves ids currently classified framework_regression');
1091
-
1092
- const { record, bytes } = nextLedgerRecord(root, () => ({
1093
- type: 'resolved', failure_id: failureId, decision_digest: decisionDigest,
1094
- release_receipt_digest: recomputedDigest, occurred_at: new Date().toISOString(),
1095
- }));
1096
- atomicWriteBytes(absPath(root, REL.ledger), bytes);
1097
- return { status: 'resolved', record };
1098
- }
1099
-
1100
- // ---------------------------------------------------------------------------
1101
- // C1 ChangeProposal shape validators
1102
- // ---------------------------------------------------------------------------
1103
-
1104
- function validateBenchmarkRemediationShape(proposal) {
1105
- const required = [
1106
- 'change_intent', 'proposal_id', 'invariant_id', 'hypothesis', 'owner_layer',
1107
- 'primary_failure_class', 'paths', 'failure_ids', 'evidence',
1108
- 'true_positive_family', 'negative_control_family', 'rollback_condition',
1109
- ];
1110
- if (!hasOnlyKeys(proposal, required, ['held_out_result', 'ceremony_delta'])) return false;
1111
- if (proposal.change_intent !== 'benchmark_remediation') return false;
1112
- if (typeof proposal.proposal_id !== 'string' || !proposal.proposal_id) return false;
1113
- if (typeof proposal.invariant_id !== 'string' || !proposal.invariant_id) return false;
1114
- if (typeof proposal.hypothesis !== 'string' || !proposal.hypothesis) return false;
1115
- if (!OWNER_LAYERS.includes(proposal.owner_layer)) return false;
1116
- if (!FAILURE_CLASSES.includes(proposal.primary_failure_class)) return false;
1117
- if (!isSortedUniqueArray(proposal.paths)) return false;
1118
- if (!Array.isArray(proposal.failure_ids) || proposal.failure_ids.length === 0) return false;
1119
- if (!proposal.failure_ids.every((entry) => typeof entry === 'string' && entry !== '')) return false;
1120
- if (new Set(proposal.failure_ids).size !== proposal.failure_ids.length) return false;
1121
- if (!isNonEmptyStringArray(proposal.evidence)) return false;
1122
- if (!isNonEmptyStringArray(proposal.true_positive_family)) return false;
1123
- if (!isNonEmptyStringArray(proposal.negative_control_family)) return false;
1124
- if (typeof proposal.rollback_condition !== 'string' || !proposal.rollback_condition) return false;
1125
- if ('held_out_result' in proposal && typeof proposal.held_out_result !== 'string') return false;
1126
- if ('ceremony_delta' in proposal && typeof proposal.ceremony_delta !== 'string') return false;
1127
- return true;
1128
- }
1129
-
1130
- function validateAuthorizedEvolutionShape(proposal) {
1131
- const required = [
1132
- 'change_intent', 'proposal_id', 'spec_ref', 'spec_semantic_digest',
1133
- 'independent_pass_receipt_digest', 'planned_write_set', 'rollback_condition', 'negative_control_family',
1134
- ];
1135
- if (!hasOnlyKeys(proposal, required)) return false;
1136
- if (proposal.change_intent !== 'authorized_evolution') return false;
1137
- if (typeof proposal.proposal_id !== 'string' || !proposal.proposal_id) return false;
1138
- if (typeof proposal.spec_ref !== 'string' || !proposal.spec_ref) return false;
1139
- if (!isSha256Tag(proposal.spec_semantic_digest)) return false;
1140
- if (!isSha256Tag(proposal.independent_pass_receipt_digest)) return false;
1141
- if (!isSortedUniqueArray(proposal.planned_write_set)) return false;
1142
- if (typeof proposal.rollback_condition !== 'string' || !proposal.rollback_condition) return false;
1143
- if (!isNonEmptyStringArray(proposal.negative_control_family)) return false;
1144
- return true;
1145
- }
1146
-
1147
- // Containment is checked twice: once lexically (before touching the
1148
- // filesystem) and once again against the fully realpath-resolved location,
1149
- // since an intermediate ancestor directory component can be a symlink that
1150
- // silently redirects a lexically-contained path outside the project root
1151
- // (lstat on the final component alone does not catch this).
1152
- function resolveSpecRefDir(root, specRef) {
1153
- if (typeof specRef !== 'string' || !specRef) fail('malformed_input', 'spec_ref must be a non-empty repo-relative path');
1154
- if (path.isAbsolute(specRef)) fail('path_escape', 'spec_ref must be repo-relative, not absolute');
1155
- const absolute = path.resolve(root, specRef);
1156
- const relative = path.relative(root, absolute);
1157
- if (relative === '..' || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) fail('path_escape', 'spec_ref escapes the project root');
1158
- let stat;
1159
- try {
1160
- stat = fs.lstatSync(absolute);
1161
- } catch {
1162
- fail('spec_ref_missing', 'spec_ref directory does not exist');
1163
- }
1164
- if (stat.isSymbolicLink()) fail('path_escape', 'spec_ref must not itself be a symlink');
1165
- if (!stat.isDirectory()) fail('spec_ref_missing', 'spec_ref must be a directory');
1166
- let realAbsolute;
1167
- let realRoot;
1168
- try {
1169
- realAbsolute = fs.realpathSync(absolute);
1170
- realRoot = fs.realpathSync(root);
1171
- } catch {
1172
- fail('spec_ref_missing', 'spec_ref cannot be resolved');
1173
- }
1174
- const realRelative = path.relative(realRoot, realAbsolute);
1175
- if (realRelative === '..' || realRelative.startsWith(`..${path.sep}`) || path.isAbsolute(realRelative)) {
1176
- fail('path_escape', 'spec_ref escapes the project root via a symlinked ancestor');
1177
- }
1178
- return absolute;
1179
- }
1180
-
1181
- // C12 artifactPath containment: rejects an absolute path, a lexical `..`
1182
- // escape (a raw string-prefix check alone is not enough -- a value like
1183
- // `packages/spec/.cafekit-release/../../../etc/passwd` still starts with the
1184
- // reserved prefix as text), anything outside the reserved release directory,
1185
- // a missing file, a symlink at the final component, and a symlinked ancestor
1186
- // directory that would otherwise silently redirect a lexically-contained
1187
- // path outside the project root.
1188
- function assertArtifactPath(root, relativeInput) {
1189
- if (typeof relativeInput !== 'string' || relativeInput === '') fail('malformed_input', 'artifactPath must be a non-empty string');
1190
- if (path.isAbsolute(relativeInput)) fail('artifact_path_escape', 'artifactPath must be repo-relative, not absolute');
1191
- const lexicalCandidate = path.resolve(root, relativeInput);
1192
- const lexicalRelative = path.relative(root, lexicalCandidate);
1193
- if (lexicalRelative === '..' || lexicalRelative.startsWith(`..${path.sep}`) || path.isAbsolute(lexicalRelative)) {
1194
- fail('artifact_path_escape', 'artifactPath escapes the project root');
1195
- }
1196
- const normalizedRelative = lexicalRelative.split(path.sep).join('/');
1197
- if (!normalizedRelative.startsWith(REL.cafekitReleaseDir)) {
1198
- fail('artifact_path_escape', 'artifactPath must be restricted to packages/spec/.cafekit-release/');
1199
- }
1200
- let stat;
1201
- try {
1202
- stat = fs.lstatSync(lexicalCandidate);
1203
- } catch {
1204
- fail('artifact_missing', 'release artifact does not exist at artifactPath');
1205
- }
1206
- if (stat.isSymbolicLink()) fail('artifact_invalid', 'release artifact must not be a symlink');
1207
- if (!stat.isFile()) fail('artifact_invalid', 'release artifact must be a regular file');
1208
- // No path component may be a symlink -- not the artifact's own ancestor
1209
- // directories, and not packages/spec/.cafekit-release/ itself. Comparing
1210
- // realpath-resolved endpoints only by their *relative* distance is not a
1211
- // sound check: if an ancestor symlink (including .cafekit-release/ itself)
1212
- // redirects the whole subtree elsewhere, both the candidate and the release
1213
- // directory resolve through that same redirection and still appear
1214
- // "contained" relative to each other; the same blind spot lets a symlink
1215
- // planted *inside* the release directory redirect to another real subdir
1216
- // that is still, itself, inside the release tree. The only check that
1217
- // catches every case uniformly is that realpath resolution changes nothing
1218
- // at all from the literal, lexically-resolved paths.
1219
- const lexicalReleaseDir = absPath(root, REL.cafekitReleaseDir.replace(/\/$/, ''));
1220
- let realCandidate;
1221
- let realReleaseDir;
1222
- try {
1223
- realCandidate = fs.realpathSync(lexicalCandidate);
1224
- realReleaseDir = fs.realpathSync(lexicalReleaseDir);
1225
- } catch {
1226
- fail('artifact_missing', 'release artifact cannot be resolved');
1227
- }
1228
- if (realReleaseDir !== lexicalReleaseDir) {
1229
- fail('artifact_invalid', 'packages/spec/.cafekit-release/ itself resolves through a symlink');
1230
- }
1231
- if (realCandidate !== lexicalCandidate) {
1232
- fail('artifact_invalid', 'release artifact path resolves through a symlinked component');
1233
- }
1234
- return lexicalCandidate;
1235
- }
1236
-
1237
- function readSpecJsonAt(specDir) {
1238
- const specFile = path.join(specDir, 'spec.json');
1239
- let stat;
1240
- try {
1241
- stat = fs.lstatSync(specFile);
1242
- } catch {
1243
- fail('spec_ref_missing', 'spec_ref does not contain spec.json');
1244
- }
1245
- if (!stat.isFile()) fail('spec_ref_missing', 'spec_ref spec.json must be a regular file');
1246
- let parsed;
1247
- try {
1248
- parsed = JSON.parse(fs.readFileSync(specFile, 'utf8'));
1249
- } catch (error) {
1250
- fail('malformed_input', `spec_ref spec.json is not valid JSON (${error.message})`);
1251
- }
1252
- return parsed;
1253
- }
1254
-
1255
- function computeSpecSemanticDigest(specDir, spec) {
1256
- const result = validatorModule().computeSemanticDigest21(specDir, spec);
1257
- if (result.errors && result.errors.length) fail('spec_digest_failed', `cannot compute spec_ref semantic digest (${result.errors.join('; ')})`);
1258
- return result.digest;
1259
- }
1260
-
1261
- // Shared C15 remediation ground-truth helper (single rule, no per-caller/
1262
- // per-fixture duplication): the exact set of currently open, blocking,
1263
- // path-intersecting ledger entries for a given changedPaths set, plus the
1264
- // union of their opened paths. Always re-derived from the live ledger --
1265
- // never trusts a caller- or freeze-recorded remediation claim. Used by
1266
- // assertBenchmarkRemediation, ledgerIntersectsPaths (I8), and
1267
- // verifyFreezeManifest's independent remediatedFailureIds re-check.
1268
- function deriveExpectedRemediation(ledgerLoaded, changedPaths) {
1269
- const intersecting = allOpenFailureIds(ledgerLoaded)
1270
- .map((failureId) => ({ failureId, state: deriveFailureState(ledgerLoaded, failureId) }))
1271
- .filter(({ state }) => state.blocking && state.openedPaths.some((entryPath) => changedPaths.includes(entryPath)));
1272
- return {
1273
- failureIds: sortedUnique(intersecting.map((entry) => entry.failureId)),
1274
- pathsUnion: sortedUnique(intersecting.flatMap((entry) => entry.state.openedPaths)),
1275
- };
1276
- }
1277
-
1278
- function ledgerIntersectsPaths(ledgerLoaded, changedPaths) {
1279
- return deriveExpectedRemediation(ledgerLoaded, changedPaths).failureIds.length > 0;
1280
- }
1281
-
1282
- function assertBenchmarkRemediation(proposal, changedPaths, ledgerLoaded) {
1283
- if (!validateBenchmarkRemediationShape(proposal)) fail('malformed_proposal', 'benchmark_remediation proposal fails the exact C1 schema');
1284
- if (JSON.stringify(sortedUnique(proposal.paths)) !== JSON.stringify(changedPaths)) {
1285
- fail('paths_mismatch', 'benchmark_remediation paths must exactly equal the derived protected changedPaths');
1286
- }
1287
- if (proposal.primary_failure_class !== 'framework_regression') {
1288
- fail('non_framework_regression', 'benchmark_remediation only accepts primary_failure_class = framework_regression');
1289
- }
1290
- const failureIds = sortedUnique(proposal.failure_ids);
1291
- const expected = deriveExpectedRemediation(ledgerLoaded, changedPaths);
1292
- if (JSON.stringify(expected.failureIds) !== JSON.stringify(failureIds)) {
1293
- fail('failure_ids_mismatch', 'failure_ids must exactly name every intersecting open ledger entry');
1294
- }
1295
- if (JSON.stringify(expected.pathsUnion) !== JSON.stringify(changedPaths)) {
1296
- fail('paths_union_mismatch', 'the combined opened.paths union of the named failure_ids must exactly equal the proposal paths');
1297
- }
1298
- return { changeKind: 'protected_change', remediatedFailureIds: failureIds };
1299
- }
1300
-
1301
- function assertAuthorizedEvolution(root, proposal, changedPaths, ledgerLoaded) {
1302
- if (!validateAuthorizedEvolutionShape(proposal)) fail('malformed_proposal', 'authorized_evolution proposal fails the exact C1 schema');
1303
- if (JSON.stringify(sortedUnique(proposal.planned_write_set)) !== JSON.stringify(changedPaths)) {
1304
- fail('paths_mismatch', 'authorized_evolution planned_write_set must exactly equal the derived protected changedPaths');
1305
- }
1306
- // I8: refuse unconditionally on any open-ledger intersection; no citation passes it through.
1307
- if (ledgerIntersectsPaths(ledgerLoaded, changedPaths)) {
1308
- fail('open_ledger_intersection', 'authorized_evolution is refused unconditionally when it intersects an open C15 ledger entry; use benchmark_remediation instead');
1309
- }
1310
-
1311
- const specDir = resolveSpecRefDir(root, proposal.spec_ref);
1312
- const spec = readSpecJsonAt(specDir);
1313
- const planningDepth = spec && spec.workflow_policy && spec.workflow_policy.planning_depth;
1314
- if (planningDepth !== 'Compact' && planningDepth !== 'Full') {
1315
- fail('planning_depth_invalid', 'spec_ref workflow_policy.planning_depth must be Compact or Full');
1316
- }
1317
- const freshness = DIGEST.assertAuthoringFresh({
1318
- specDir,
1319
- authoring: spec.authoring,
1320
- receipt: spec.validation && spec.validation.authoring_validation,
1321
- fields: ['requirements', 'design'],
1322
- });
1323
- if (!freshness.requirements.fresh || !freshness.design.fresh) {
1324
- fail('authoring_not_fresh', 'spec_ref authoring.requirements/design must both be validated and C16-digest-fresh (I20)');
1325
- }
1326
- const currentDigest = computeSpecSemanticDigest(specDir, spec);
1327
- if (proposal.spec_semantic_digest !== currentDigest) fail('stale_spec_digest', 'spec_semantic_digest does not equal the current semantic_model digest');
1328
-
1329
- const history = spec.validation && spec.validation.semantic_review_history;
1330
- const entries = plain(history) && Array.isArray(history.entries) ? history.entries : [];
1331
- const matching = entries.filter((entry) => entry && entry.verdict === 'PASS' && entry.semantic_digest === currentDigest);
1332
- if (matching.length === 0) fail('missing_independent_pass', 'no accepted PASS entry in this lineage matches the current spec digest');
1333
- const latest = matching.reduce((best, entry) => (entry.sequence > best.sequence ? entry : best), matching[0]);
1334
- if (proposal.independent_pass_receipt_digest !== latest.review_receipt_digest) {
1335
- fail('superseded_pass_citation', 'independent_pass_receipt_digest must cite the latest accepted PASS in the lineage (I14), not a superseded entry');
1336
- }
1337
- return { changeKind: 'protected_change', remediatedFailureIds: [] };
1338
- }
1339
-
1340
- // D1/I8: gates unproven or unauthorized change without freezing legitimate
1341
- // evolution. changedPaths must already be self-derived (deriveProtectedChangedPaths).
1342
- function assertHotspotChangeAllowed(root, { proposal, changedPaths, ledgerLoaded } = {}) {
1343
- if (!isSortedUniqueArray(changedPaths)) fail('malformed_input', 'changedPaths must be sorted-unique and non-empty');
1344
- const ledger = ledgerLoaded || assertFailureLedgerUsable(root);
1345
- if (!plain(proposal) || typeof proposal.change_intent !== 'string') fail('proposal_required', 'a tagged C1 ChangeProposal is required for a protected change');
1346
- if (proposal.change_intent === 'benchmark_remediation') return assertBenchmarkRemediation(proposal, changedPaths, ledger);
1347
- if (proposal.change_intent === 'authorized_evolution') return assertAuthorizedEvolution(root, proposal, changedPaths, ledger);
1348
- fail('malformed_input', 'change_intent must be benchmark_remediation or authorized_evolution');
1349
- }
1350
-
1351
- // ---------------------------------------------------------------------------
1352
- // C9 FreezeManifest
1353
- // ---------------------------------------------------------------------------
1354
-
1355
- function validateFreezeShape(freeze) {
1356
- const keys = [
1357
- 'baselineAuthorityDigest', 'baselineGeneration', 'baselineCommit',
1358
- 'failureLedgerDigest', 'failureLedgerGeneration', 'remediatedFailureIds',
1359
- 'changeKind', 'proposalDigest', 'treeDigest', 'changedPaths', 'packageVersion', 'candidateDigest',
1360
- ];
1361
- if (!exactKeys(freeze, keys)) return false;
1362
- if (!isSha256Tag(freeze.baselineAuthorityDigest)) return false;
1363
- if (!Number.isInteger(freeze.baselineGeneration) || freeze.baselineGeneration < 0) return false;
1364
- if (!isCommitId(freeze.baselineCommit)) return false;
1365
- if (freeze.failureLedgerDigest !== NO_FAILURES_ATTESTATION_DIGEST && !isSha256Tag(freeze.failureLedgerDigest)) return false;
1366
- if (freeze.failureLedgerGeneration !== null && !(Number.isInteger(freeze.failureLedgerGeneration) && freeze.failureLedgerGeneration >= 0)) return false;
1367
- if (!isSortedUniqueArray(freeze.remediatedFailureIds, { allowEmpty: true })) return false;
1368
- if (!['protected_change', 'no_protected_change'].includes(freeze.changeKind)) return false;
1369
- if (freeze.proposalDigest !== NO_CHANGE_ATTESTATION_DIGEST && !isSha256Tag(freeze.proposalDigest)) return false;
1370
- if (!isSha256Tag(freeze.treeDigest)) return false;
1371
- if (!isSortedUniqueArray(freeze.changedPaths, { allowEmpty: true })) return false;
1372
- if (freeze.changeKind === 'protected_change' && freeze.changedPaths.length === 0) return false;
1373
- if (freeze.changeKind === 'no_protected_change' && freeze.changedPaths.length !== 0) return false;
1374
- if (typeof freeze.packageVersion !== 'string' || !freeze.packageVersion) return false;
1375
- if (!isSha256Tag(freeze.candidateDigest)) return false;
1376
- return true;
1377
- }
1378
-
1379
- function readPackageVersion(root) {
1380
- const loaded = readJsonFileIfExists(root, REL.packageJson);
1381
- if (!loaded || typeof loaded.parsed.version !== 'string' || !loaded.parsed.version) {
1382
- fail('malformed_input', 'packages/spec/package.json version is missing');
1383
- }
1384
- return loaded.parsed.version;
1385
- }
1386
-
1387
- function createFreezeManifest(root, proposal) {
1388
- const authority = assertBaselineUsable(root);
1389
- if (!isAncestorOrEqual(root, authority.record.baselineCommit, gitHeadCommit(root))) {
1390
- fail('baseline_not_ancestor', 'authority baselineCommit is not an ancestor of (or equal to) current HEAD');
1391
- }
1392
- const ledgerLoaded = assertFailureLedgerUsable(root);
1393
- const failureLedgerDigest = ledgerLoaded.state === 'present' ? ledgerLoaded.digest : NO_FAILURES_ATTESTATION_DIGEST;
1394
- const failureLedgerGeneration = ledgerLoaded.state === 'present' ? ledgerLoaded.record.generation : null;
1395
-
1396
- const protectedPaths = deriveProtectedChangedPaths(root, authority.record.baselineCommit);
1397
- const packageVersion = readPackageVersion(root);
1398
-
1399
- let changeKind;
1400
- let proposalDigest;
1401
- let remediatedFailureIds = [];
1402
- let changedPaths = protectedPaths;
1403
-
1404
- if (protectedPaths.length === 0) {
1405
- if (authority.state !== 'released') fail('empty_paths_invalid', 'empty derived protected paths are never valid during bootstrap state');
1406
- if (proposal !== null && proposal !== undefined) fail('malformed_input', 'a no_protected_change freeze must not carry a C1 proposal');
1407
- changeKind = 'no_protected_change';
1408
- proposalDigest = NO_CHANGE_ATTESTATION_DIGEST;
1409
- changedPaths = [];
1410
- } else {
1411
- changeKind = 'protected_change';
1412
- const gate = assertHotspotChangeAllowed(root, { proposal, changedPaths: protectedPaths, ledgerLoaded });
1413
- proposalDigest = computeProposalDigest(proposal);
1414
- remediatedFailureIds = gate.remediatedFailureIds;
1415
- }
1416
-
1417
- const treeDigest = computeProtectedTreeDigest(root, changedPaths);
1418
- const candidateFields = {
1419
- baselineAuthorityDigest: authority.digest,
1420
- baselineGeneration: authority.record.generation,
1421
- baselineCommit: authority.record.baselineCommit,
1422
- failureLedgerDigest,
1423
- failureLedgerGeneration,
1424
- remediatedFailureIds: sortedUnique(remediatedFailureIds),
1425
- changedPaths,
1426
- treeDigest,
1427
- proposalDigest,
1428
- packageVersion,
1429
- };
1430
- const candidateDigest = computeCandidateDigest(candidateFields);
1431
-
1432
- const manifest = {
1433
- baselineAuthorityDigest: authority.digest,
1434
- baselineGeneration: authority.record.generation,
1435
- baselineCommit: authority.record.baselineCommit,
1436
- failureLedgerDigest,
1437
- failureLedgerGeneration,
1438
- remediatedFailureIds: sortedUnique(remediatedFailureIds),
1439
- changeKind,
1440
- proposalDigest,
1441
- treeDigest,
1442
- changedPaths,
1443
- packageVersion,
1444
- candidateDigest,
1445
- };
1446
- atomicWriteJson(absPath(root, REL.freeze), manifest);
1447
- return manifest;
1448
- }
1449
-
1450
- function verifyFreezeManifest(root) {
1451
- const loaded = readJsonFileIfExists(root, REL.freeze);
1452
- if (!loaded) fail('freeze_missing', 'change-firewall-freeze.json does not exist; freeze creation must run first');
1453
- const freeze = loaded.parsed;
1454
- if (!validateFreezeShape(freeze)) fail('freeze_malformed', 'change-firewall-freeze.json fails the exact C9 schema');
1455
-
1456
- const authority = assertBaselineUsable(root);
1457
- if (freeze.baselineAuthorityDigest !== authority.digest
1458
- || freeze.baselineGeneration !== authority.record.generation
1459
- || freeze.baselineCommit !== authority.record.baselineCommit) {
1460
- fail('freeze_stale', 'freeze is stale against the current C10 authority');
1461
- }
1462
- if (!isAncestorOrEqual(root, authority.record.baselineCommit, gitHeadCommit(root))) {
1463
- fail('baseline_not_ancestor', 'authority baselineCommit is not an ancestor of (or equal to) current HEAD');
1464
- }
1465
- const ledgerLoaded = assertFailureLedgerUsable(root);
1466
- const currentLedgerDigest = ledgerLoaded.state === 'present' ? ledgerLoaded.digest : NO_FAILURES_ATTESTATION_DIGEST;
1467
- const currentLedgerGeneration = ledgerLoaded.state === 'present' ? ledgerLoaded.record.generation : null;
1468
- if (freeze.failureLedgerDigest !== currentLedgerDigest || freeze.failureLedgerGeneration !== currentLedgerGeneration) {
1469
- fail('freeze_stale', 'freeze is stale against the current C15 ledger');
1470
- }
1471
- if (freeze.changeKind === 'no_protected_change' && authority.state !== 'released') {
1472
- fail('freeze_malformed', 'no_protected_change is only valid when authority state is released');
1473
- }
1474
- const currentPackageVersion = readPackageVersion(root);
1475
- if (freeze.packageVersion !== currentPackageVersion) {
1476
- fail('freeze_stale', 'freeze packageVersion no longer matches the current package.json version');
1477
- }
1478
- // Re-derive the protected changed-path set fresh from the authority baseline
1479
- // and the current working tree -- never trust the freeze's own recorded
1480
- // list. A path added or removed since freeze creation changes this set
1481
- // even when every already-listed path's own content digest still matches,
1482
- // so this exact-set comparison is required in addition to the treeDigest
1483
- // recompute below (which only re-hashes the paths the freeze already lists).
1484
- const currentChangedPaths = deriveProtectedChangedPaths(root, authority.record.baselineCommit);
1485
- if (JSON.stringify(currentChangedPaths) !== JSON.stringify(freeze.changedPaths)) {
1486
- fail('freeze_stale', 'derived protected changed paths no longer match the frozen set');
1487
- }
1488
- const recomputedTreeDigest = computeProtectedTreeDigest(root, freeze.changedPaths);
1489
- if (recomputedTreeDigest !== freeze.treeDigest) fail('freeze_stale', 'freeze treeDigest no longer matches the current working tree');
1490
- // Re-derive the expected remediation from the live C15 ledger + the current
1491
- // changedPaths -- never trust freeze.remediatedFailureIds itself. The
1492
- // candidateDigest recompute below only proves the freeze's own recorded
1493
- // fields are mutually self-consistent, not that they still reflect ground
1494
- // truth; a hand-edited freeze that narrows remediatedFailureIds and
1495
- // self-recomputes a matching candidateDigest must still fail here.
1496
- const expectedRemediation = deriveExpectedRemediation(ledgerLoaded, freeze.changedPaths);
1497
- if (JSON.stringify(expectedRemediation.failureIds) !== JSON.stringify(freeze.remediatedFailureIds)) {
1498
- fail('freeze_stale', 'freeze remediatedFailureIds no longer names exactly the current intersecting open C15 ledger entries');
1499
- }
1500
- // Same path-union invariant assertBenchmarkRemediation enforces at proposal
1501
- // time: when remediatedFailureIds is non-empty, changedPaths must equal
1502
- // exactly the combined opened.paths union of those named entries -- never a
1503
- // superset that smuggles an extra, unrelated protected-path change through
1504
- // under an otherwise-correct failure_ids/remediatedFailureIds match. Skipped
1505
- // when remediatedFailureIds is empty (authorized_evolution/no_protected_change,
1506
- // where changedPaths is never required to be covered by any ledger entry).
1507
- if (freeze.remediatedFailureIds.length > 0
1508
- && JSON.stringify(expectedRemediation.pathsUnion) !== JSON.stringify(freeze.changedPaths)) {
1509
- fail('freeze_stale', 'freeze changedPaths is not exactly the opened.paths union of its own remediatedFailureIds');
1510
- }
1511
- const recomputedCandidate = computeCandidateDigest({
1512
- baselineAuthorityDigest: freeze.baselineAuthorityDigest,
1513
- baselineGeneration: freeze.baselineGeneration,
1514
- baselineCommit: freeze.baselineCommit,
1515
- failureLedgerDigest: freeze.failureLedgerDigest,
1516
- failureLedgerGeneration: freeze.failureLedgerGeneration,
1517
- remediatedFailureIds: freeze.remediatedFailureIds,
1518
- changedPaths: freeze.changedPaths,
1519
- treeDigest: freeze.treeDigest,
1520
- proposalDigest: freeze.proposalDigest,
1521
- packageVersion: freeze.packageVersion,
1522
- });
1523
- if (recomputedCandidate !== freeze.candidateDigest) fail('freeze_malformed', "freeze candidateDigest does not match its own bound fields");
1524
- return freeze;
1525
- }
1526
-
1527
- function invalidateFreeze(root) {
1528
- const absolute = absPath(root, REL.freeze);
1529
- try {
1530
- fs.unlinkSync(absolute);
1531
- } catch (error) {
1532
- if (error.code !== 'ENOENT') throw error;
1533
- }
1534
- }
1535
-
1536
- // ---------------------------------------------------------------------------
1537
- // C12 ReleaseReceipt / advanceBaseline (I11/I13)
1538
- // ---------------------------------------------------------------------------
1539
-
1540
- function validateReceiptShape(receipt) {
1541
- if (!exactKeys(receipt, RELEASE_RECEIPT_FIELDS)) return false;
1542
- if (receipt.schema_version !== '1') return false;
1543
- if (typeof receipt.receipt_id !== 'string' || !receipt.receipt_id) return false;
1544
- if (receipt.status !== 'published') return false;
1545
- if (!isSha256Tag(receipt.baselineAuthorityDigest)) return false;
1546
- if (!Number.isInteger(receipt.baselineGeneration) || receipt.baselineGeneration < 0) return false;
1547
- if (!isSha256Tag(receipt.freezeDigest)) return false;
1548
- if (!isSha256Tag(receipt.candidateDigest)) return false;
1549
- if (!isSha256Tag(receipt.treeDigest)) return false;
1550
- if (typeof receipt.packageVersion !== 'string' || !receipt.packageVersion) return false;
1551
- if (!isCommitId(receipt.releaseCommit)) return false;
1552
- if (typeof receipt.artifactPath !== 'string' || !receipt.artifactPath) return false;
1553
- if (!isSha256Tag(receipt.artifactDigest)) return false;
1554
- if (!isSortedUniqueArray(receipt.resolvedFailureIds, { allowEmpty: true })) return false;
1555
- return true;
1556
- }
1557
-
1558
- // Publishing a release is exactly three independent single-file-atomic events
1559
- // (I11): (A) persist the canonical receipt, (B) rotate C10 authority (owned
1560
- // here, as two sequential sub-steps of this one call); (C) resolveFailure per
1561
- // id is invoked separately by the caller, strictly after this returns success.
1562
- function advanceBaseline(root, receiptBytes) {
1563
- if (!Buffer.isBuffer(receiptBytes)) fail('malformed_input', 'advanceBaseline requires the exact receipt bytes as a Buffer');
1564
- let receipt;
1565
- try {
1566
- receipt = JSON.parse(receiptBytes.toString('utf8'));
1567
- } catch (error) {
1568
- fail('malformed_input', `supplied receipt bytes are not valid JSON (${error.message})`);
1569
- }
1570
- const suppliedDigest = sha256Tag(receiptBytes);
1571
- const suppliedShapeOk = validateReceiptShape(receipt);
1572
-
1573
- const authority = assertBaselineUsable(root);
1574
- const publishedDigest = authority.state === 'released' ? authority.record.releaseReceiptDigest : null;
1575
-
1576
- // (1) idempotency check, before any other read or write.
1577
- if (publishedDigest && suppliedDigest === publishedDigest) {
1578
- const canonical = tryReadJsonFile(root, REL.receipt);
1579
- const canonicalOk = Boolean(canonical) && sha256Tag(canonical.bytes) === publishedDigest && validateReceiptShape(canonical.parsed);
1580
- if (canonicalOk) return { status: 'already_published', digest: publishedDigest };
1581
- // (1b) repair from supplied bytes -- only if the supplied bytes themselves
1582
- // independently verify (their digest already matches the published one).
1583
- if (!suppliedShapeOk) fail('receipt_unverifiable', 'canonical receipt evidence is missing/corrupt and the supplied bytes fail the exact C12 schema');
1584
- atomicWriteBytes(absPath(root, REL.receipt), receiptBytes);
1585
- // Re-read the canonical bytes this write actually produced and reuse the
1586
- // same digest/schema authority as (1a) to prove the repair landed -- never
1587
- // trust the write call alone (design.md C12 1b: "re-verify the write").
1588
- const repairedRead = tryReadJsonFile(root, REL.receipt);
1589
- const repairedOk = Boolean(repairedRead) && sha256Tag(repairedRead.bytes) === publishedDigest && validateReceiptShape(repairedRead.parsed);
1590
- if (!repairedOk) fail('receipt_unverifiable', 'idempotent repair write did not durably persist verifiable canonical receipt evidence (re-read failed digest/schema verification)');
1591
- return { status: 'repaired', digest: publishedDigest };
1592
- }
1593
-
1594
- // (2) a genuinely new release candidate. Independently re-verify the current
1595
- // on-disk freeze itself before this receipt is allowed to bind against it --
1596
- // verifyFreezeManifest re-derives authority/ledger/tree/remediation ground
1597
- // truth fresh and throws on any staleness or tampering. Trusting the freeze's
1598
- // own recorded bytes/fields (via only a candidateDigest/treeDigest match
1599
- // below) is not enough: a hand-edited freeze can recompute its own
1600
- // candidateDigest to stay internally self-consistent, so this call must run
1601
- // before any of that freeze's fields are relied on.
1602
- if (!suppliedShapeOk) fail('malformed_receipt', 'supplied receipt fails the exact C12 schema');
1603
- if (receipt.baselineAuthorityDigest !== authority.digest) fail('authority_mismatch', 'receipt baselineAuthorityDigest does not match the current authority');
1604
- if (receipt.baselineGeneration !== authority.record.generation) fail('authority_mismatch', 'receipt baselineGeneration does not match the current authority generation');
1605
-
1606
- verifyFreezeManifest(root);
1607
- const freezeLoaded = readJsonFileIfExists(root, REL.freeze);
1608
- if (!freezeLoaded) fail('freeze_missing', 'advanceBaseline requires a valid current freeze');
1609
- if (receipt.freezeDigest !== sha256Tag(freezeLoaded.bytes)) fail('freeze_mismatch', 'receipt freezeDigest does not match the current freeze bytes');
1610
- const freeze = freezeLoaded.parsed;
1611
- if (receipt.candidateDigest !== freeze.candidateDigest) fail('candidate_mismatch', 'receipt candidateDigest does not match the current freeze');
1612
- if (receipt.treeDigest !== freeze.treeDigest) fail('candidate_mismatch', 'receipt treeDigest does not match the current freeze');
1613
- if (receipt.packageVersion !== freeze.packageVersion) fail('candidate_mismatch', 'receipt packageVersion does not match the current freeze');
1614
-
1615
- const currentRemediated = sortedUnique(freeze.remediatedFailureIds);
1616
- const receiptResolved = sortedUnique(receipt.resolvedFailureIds);
1617
- if (JSON.stringify(currentRemediated) !== JSON.stringify(receiptResolved)) {
1618
- fail('resolved_failure_ids_mismatch', 'receipt resolvedFailureIds diverges from the freeze remediatedFailureIds (I12)');
1619
- }
1620
-
1621
- if (!isAncestorOrEqual(root, authority.record.baselineCommit, receipt.releaseCommit)) {
1622
- fail('release_commit_unreachable', 'receipt releaseCommit is not reachable from the current baseline');
1623
- }
1624
-
1625
- const artifactAbs = assertArtifactPath(root, receipt.artifactPath);
1626
- const artifactDigest = sha256Tag(fs.readFileSync(artifactAbs));
1627
- if (artifactDigest !== receipt.artifactDigest) fail('artifact_digest_mismatch', 'artifactDigest does not match the actual packaged bytes');
1628
-
1629
- // Prepare-then-validate-all, strictly before the first write: Event (A)'s
1630
- // bytes (receiptBytes) are already fixed and schema/match-validated above;
1631
- // Event (B)'s candidate is built and independently verified against
1632
- // committed C10 provenance *here*, before either file is touched. Building
1633
- // this candidate can throw (e.g. an out-of-band-edit/second-uncommitted-
1634
- // advance rejection) exactly as easily as any check already run above --
1635
- // running it only after Event (A) had already mutated release-receipt.json
1636
- // would leave that file rewritten while release-baseline.json stayed
1637
- // untouched on the very failure path this whole event sequence exists to
1638
- // avoid. Neither file may be touched until both candidates are known-good.
1639
- const previousBytes = authority.bytes;
1640
- const newRecord = {
1641
- state: 'released',
1642
- generation: authority.record.generation + 1,
1643
- baselineCommit: receipt.releaseCommit,
1644
- baselinePackageVersion: receipt.packageVersion,
1645
- releaseReceiptDigest: suppliedDigest,
1646
- previousBaselineDigest: sha256Tag(previousBytes),
1647
- };
1648
- const newBytes = verifyBaselineCandidate(root, newRecord);
1649
-
1650
- // Event (A): persist the canonical receipt bytes verbatim, strictly before authority mutation.
1651
- atomicWriteBytes(absPath(root, REL.receipt), receiptBytes);
1652
-
1653
- // Read-after-write verification (R1.11/I11), symmetric with the (1b) repair
1654
- // branch above: re-read the exact bytes this write actually produced and
1655
- // reuse the same digest/schema authority to prove event (A) landed --
1656
- // never trust atomicWriteBytes alone. A physically corrupted write, a
1657
- // concurrent clobber, or a filesystem fault throws fail-closed here,
1658
- // strictly before event (B) ever runs, so authority is never rotated and
1659
- // this call never returns `published`.
1660
- const persistedRead = tryReadJsonFile(root, REL.receipt);
1661
- const persistedOk = Boolean(persistedRead) && sha256Tag(persistedRead.bytes) === suppliedDigest && validateReceiptShape(persistedRead.parsed);
1662
- if (!persistedOk) fail('receipt_unverifiable', 'event (A) receipt write did not durably persist verifiable canonical receipt evidence (re-read failed digest/schema verification)');
1663
-
1664
- // Event (B): rotate authority, the sole publication checkpoint, then invalidate the freeze.
1665
- atomicWriteBytes(absPath(root, REL.releaseBaseline), newBytes);
1666
- invalidateFreeze(root);
1667
-
1668
- return { status: 'published', digest: suppliedDigest, record: newRecord };
1669
- }
1670
-
1671
- // ---------------------------------------------------------------------------
1672
- // Public factory + production (root-bound) surface
1673
- // ---------------------------------------------------------------------------
1674
-
1675
- // Test/harness-only entry point: binds every D1 export to an explicit,
1676
- // once-verified trusted root. Production code must never call this with a
1677
- // caller-supplied path per call (I13/I19) -- use the plain top-level exports
1678
- // below instead, which are always bound to this checkout's real project root.
1679
- function createChangeFirewall(options = {}) {
1680
- const root = resolveTrustedRoot(options.root);
1681
- return {
1682
- root,
1683
- loadBaselineAuthority: () => loadBaselineAuthority(root),
1684
- assertBaselineUsable: (opts) => assertBaselineUsable(root, opts),
1685
- bootstrapBaseline: (attestation) => bootstrapBaseline(root, attestation),
1686
- advanceBaseline: (receiptBytes) => advanceBaseline(root, receiptBytes),
1687
- deriveProtectedChangedPaths: (baselineCommit, opts) => deriveProtectedChangedPaths(root, baselineCommit, opts),
1688
- computeProtectedTreeDigest: (paths, opts) => computeProtectedTreeDigest(root, paths, opts),
1689
- computeProposalDigest,
1690
- computeCandidateDigest,
1691
- assertHotspotChangeAllowed: (opts) => assertHotspotChangeAllowed(root, opts),
1692
- createFreezeManifest: (proposal) => createFreezeManifest(root, proposal),
1693
- verifyFreezeManifest: () => verifyFreezeManifest(root),
1694
- invalidateFreeze: () => invalidateFreeze(root),
1695
- loadFailureLedger: () => loadFailureLedger(root),
1696
- assertFailureLedgerUsable: () => assertFailureLedgerUsable(root),
1697
- openFailure: (input) => openFailure(root, input),
1698
- classifyFailure: (input) => classifyFailure(root, input),
1699
- reclassifyFailure: (input) => reclassifyFailure(root, input),
1700
- resolveFailure: (input) => resolveFailure(root, input),
1701
- computeFailureLedgerDigest: () => computeFailureLedgerDigest(root),
1702
- deriveFailureState: (failureId) => deriveFailureState(loadFailureLedger(root), failureId),
1703
- assertLegacyBridgeArtifact: () => readLegacyBridgeArtifact(root),
1704
- NO_CHANGE_ATTESTATION_DIGEST,
1705
- NO_FAILURES_ATTESTATION_DIGEST,
1706
- };
1707
- }
1708
-
1709
- let cachedDefaultRoot = null;
1710
- function defaultRoot() {
1711
- if (!cachedDefaultRoot) cachedDefaultRoot = resolveTrustedRoot();
1712
- return cachedDefaultRoot;
1713
- }
1714
-
1715
- module.exports = {
1716
- SCHEMA_VERSION,
1717
- REL,
1718
- ChangeFirewallError,
1719
- NO_CHANGE_ATTESTATION_DIGEST,
1720
- NO_FAILURES_ATTESTATION_DIGEST,
1721
- isProtectedPath,
1722
- sortedUnique,
1723
- sha256Tag,
1724
- createChangeFirewall,
1725
-
1726
- loadBaselineAuthority: () => loadBaselineAuthority(defaultRoot()),
1727
- assertBaselineUsable: (opts) => assertBaselineUsable(defaultRoot(), opts),
1728
- bootstrapBaseline: (attestation) => bootstrapBaseline(defaultRoot(), attestation),
1729
- advanceBaseline: (receiptBytes) => advanceBaseline(defaultRoot(), receiptBytes),
1730
- deriveProtectedChangedPaths: (baselineCommit, opts) => deriveProtectedChangedPaths(defaultRoot(), baselineCommit, opts),
1731
- computeProtectedTreeDigest: (paths, opts) => computeProtectedTreeDigest(defaultRoot(), paths, opts),
1732
- computeProposalDigest,
1733
- computeCandidateDigest,
1734
- assertHotspotChangeAllowed: (opts) => assertHotspotChangeAllowed(defaultRoot(), opts),
1735
- createFreezeManifest: (proposal) => createFreezeManifest(defaultRoot(), proposal),
1736
- verifyFreezeManifest: () => verifyFreezeManifest(defaultRoot()),
1737
- invalidateFreeze: () => invalidateFreeze(defaultRoot()),
1738
-
1739
- loadFailureLedger: () => loadFailureLedger(defaultRoot()),
1740
- assertFailureLedgerUsable: () => assertFailureLedgerUsable(defaultRoot()),
1741
- openFailure: (input) => openFailure(defaultRoot(), input),
1742
- classifyFailure: (input) => classifyFailure(defaultRoot(), input),
1743
- reclassifyFailure: (input) => reclassifyFailure(defaultRoot(), input),
1744
- resolveFailure: (input) => resolveFailure(defaultRoot(), input),
1745
- computeFailureLedgerDigest: () => computeFailureLedgerDigest(defaultRoot()),
1746
- deriveFailureState: (failureId) => deriveFailureState(loadFailureLedger(defaultRoot()), failureId),
1747
- assertLegacyBridgeArtifact: () => readLegacyBridgeArtifact(defaultRoot()),
1748
- };