harness-everything 0.36.2 → 0.36.4

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 (60) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/CHANGELOG.md +17 -0
  4. package/ci/fixtures/doc-obligations/read-boundary/boundary-controls.json +211 -0
  5. package/ci/fixtures/doc-obligations/read-boundary/boundary-policy.json +325 -0
  6. package/ci/fixtures/doc-obligations/read-boundary/cases.json +14058 -0
  7. package/ci/fixtures/doc-obligations/read-boundary/expected.json +1 -0
  8. package/ci/fixtures/doc-obligations/read-boundary/identity-cases.json +119 -0
  9. package/ci/fixtures/doc-obligations/read-boundary/identity-expected.json +1 -0
  10. package/ci/fixtures/doc-obligations/read-boundary/scope-cases.json +208 -0
  11. package/ci/fixtures/doc-obligations/read-boundary/scope-expected.json +1 -0
  12. package/ci/fixtures/doc-obligations/reconciliation-prep/cases.json +2751 -0
  13. package/ci/fixtures/doc-obligations/reconciliation-prep/flood-entrypoint.js +5 -0
  14. package/ci/fixtures/doc-obligations/reconciliation-prep/hang-entrypoint.js +5 -0
  15. package/ci/fixtures/doc-obligations/reconciliation-prep/pending-red.json +648 -0
  16. package/ci/fixtures/doc-obligations/reconciliation-prep/provider-contract.json +100 -0
  17. package/ci/fixtures/doc-obligations/reconciliation-prep/trust-contract.json +188 -0
  18. package/ci/lib/doc-evidence-boundary-check.js +1240 -0
  19. package/ci/lib/doc-evidence-fixtures.js +459 -0
  20. package/ci/lib/doc-evidence-trace.js +171 -0
  21. package/ci/lib/doc-reconciliation-prep-fixtures.js +502 -0
  22. package/ci/lib/snapshot-stdin.js +27 -0
  23. package/ci/mechanism-2aw-project-docs-config.test.js +450 -10
  24. package/ci/mechanism-325-3b-preparation.test.js +382 -0
  25. package/ci/mechanism-341-boundary-enforcement.test.js +660 -0
  26. package/ci/mechanism-341-capture-module.test.js +357 -0
  27. package/ci/mechanism-341-mutation-runner.test.js +413 -0
  28. package/ci/mechanism-341-read-boundary.test.js +388 -0
  29. package/ci/mechanism-341-snapshot-stdin.test.js +40 -0
  30. package/ci/mutation-check.js +805 -45
  31. package/ci/mutation-loader.js +544 -57
  32. package/ci/prep/doc-reconciliation-red.js +407 -0
  33. package/ci/stress-341-boundary-control.js +85 -0
  34. package/docs/doc-evidence-baseline-binding.md +105 -19
  35. package/docs/doc-evidence-boundary.md +135 -0
  36. package/docs/doc-workflow-reconciliation.md +224 -0
  37. package/docs/document-lifecycle.md +67 -8
  38. package/harness-everything/SKILL.md +1 -1
  39. package/harness-everything/scripts/doc-evidence-capture.js +331 -0
  40. package/harness-everything/scripts/doc-evidence.js +72 -223
  41. package/harness-everything/scripts/project-docs-resolver.js +30 -13
  42. package/multi-agent-workspace/SKILL.md +1 -1
  43. package/multi-agent-workspace/scripts/project-docs-resolver.js +30 -13
  44. package/opencode-plugin/plugin.json +1 -1
  45. package/package.json +5 -3
  46. package/plugins/harness-everything/.codex-plugin/plugin.json +1 -1
  47. package/plugins/harness-everything/plugin.json +1 -1
  48. package/plugins/harness-everything/skills/harness-everything/SKILL.md +1 -1
  49. package/plugins/harness-everything/skills/harness-everything/scripts/doc-evidence-capture.js +331 -0
  50. package/plugins/harness-everything/skills/harness-everything/scripts/doc-evidence.js +72 -223
  51. package/plugins/harness-everything/skills/harness-everything/scripts/project-docs-resolver.js +30 -13
  52. package/plugins/harness-everything/skills/multi-agent-workspace/SKILL.md +1 -1
  53. package/plugins/harness-everything/skills/multi-agent-workspace/scripts/project-docs-resolver.js +30 -13
  54. package/plugins/harness-everything/skills/to-spec/SKILL.md +1 -1
  55. package/plugins/harness-everything/skills/to-spec/scripts/check-project-docs.js +136 -4
  56. package/plugins/harness-everything/skills/to-spec/scripts/project-docs-resolver.js +30 -13
  57. package/sbom.cdx.json +43 -7
  58. package/to-spec/SKILL.md +1 -1
  59. package/to-spec/scripts/check-project-docs.js +136 -4
  60. package/to-spec/scripts/project-docs-resolver.js +30 -13
@@ -0,0 +1,407 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ // #325 Phase 3b preparation: strict and expected-RED replay over the real CLI, hook and Stop entry points.
5
+ //
6
+ // node ci/prep/doc-reconciliation-red.js --strict exits 1 on any desired-contract gap or control failure
7
+ // node ci/prep/doc-reconciliation-red.js --expect-red exits 0 only when every gap is qualified by the registry
8
+ // node ci/prep/doc-reconciliation-red.js --recapture-candidate <file> --baseline-sha <40-hex>
9
+ // writes a candidate registry for review; never the registry
10
+ //
11
+ // Optional: --case <id> (repeatable), --cases <file>, --registry <file>, --json (print the report), --log <file>.
12
+ // Exit 2 means infrastructure or registry error (replay crash, malformed registry, unreadable file, a spawn error,
13
+ // a signal, an exit outside the action's expectExit policy, or missing required output).
14
+ //
15
+ // This file is not discovered by filename. The preparation suite invokes --expect-red and parses the report.
16
+ // A qualified gap is an ON assertion that fails today and matches a registry entry exactly (case, assertion,
17
+ // distribution, actual and desired). Infrastructure faults are decided first and never qualify as gaps.
18
+ // Qualification is bound to the runtime fingerprint of the shared runtime files, not to the commit: a registry
19
+ // entry is valid only while those files are byte-identical to the capture.
20
+
21
+ const crypto = require('crypto');
22
+ const fs = require('fs');
23
+ const path = require('path');
24
+ const { spawnSync } = require('child_process');
25
+ const fx = require('../lib/doc-reconciliation-prep-fixtures');
26
+
27
+ const ROOT = path.resolve(__dirname, '..', '..');
28
+ const PREP_DIR = path.join(ROOT, 'ci', 'fixtures', 'doc-obligations', 'reconciliation-prep');
29
+ const DEFAULT_CASES = path.join(PREP_DIR, 'cases.json');
30
+ const DEFAULT_REGISTRY = path.join(PREP_DIR, 'pending-red.json');
31
+ const LEGS = ['source', 'packaged'];
32
+ const MODES = ['off', 'on'];
33
+ const BLOCKER = '#341 E0';
34
+ const ENTRY_KEYS = ['caseId', 'assertionId', 'distribution', 'baselineSha', 'actual', 'desired', 'blocker', 'reasonCode'];
35
+ // Shared runtime files whose bytes define what the registry describes. Every P01-P08 replay loads the first
36
+ // three (verified by a preload probe, see h3.md). doc-evidence.js is not loaded by any replay, so it is excluded:
37
+ // #341 0B refactors it, and including it would turn this suite red for an unrelated reason.
38
+ const FINGERPRINT_FILES = [
39
+ 'hooks/scripts/lib/workflow-obligations.js',
40
+ 'hooks/scripts/workflow-disposition.js',
41
+ 'hooks/scripts/workflow-stop-gate.js'
42
+ ];
43
+ const CANDIDATE_FILE = 'ci/fixtures/doc-obligations/reconciliation-prep/pending-red.candidate.json';
44
+ const UNREVIEWED_REASON = 'needs-reason';
45
+
46
+ class RegistryError extends Error {}
47
+
48
+ function fingerprint() {
49
+ const hash = crypto.createHash('sha256');
50
+ for (const rel of FINGERPRINT_FILES) {
51
+ hash.update(`${rel}\0`);
52
+ hash.update(fs.readFileSync(path.join(ROOT, rel)));
53
+ hash.update('\0');
54
+ }
55
+ return hash.digest('hex');
56
+ }
57
+
58
+ // Read-only: the commit named in the re-capture command. Falls back to a visible placeholder.
59
+ function currentCommit() {
60
+ const result = spawnSync('git', ['rev-parse', 'HEAD'], { cwd: ROOT, encoding: 'utf8' });
61
+ return result.status === 0 && /^[0-9a-f]{40}$/.test(result.stdout.trim()) ? result.stdout.trim() : '<commit>';
62
+ }
63
+
64
+ function recaptureCommand(commit) {
65
+ return `node ci/prep/doc-reconciliation-red.js --recapture-candidate ${CANDIDATE_FILE} --baseline-sha ${commit}`;
66
+ }
67
+
68
+ function parseArgs(argv) {
69
+ const args = {
70
+ mode: null, cases: [], casesFile: DEFAULT_CASES, registryFile: DEFAULT_REGISTRY,
71
+ json: false, log: null, candidateFile: null, baselineSha: null
72
+ };
73
+ for (let i = 0; i < argv.length; i += 1) {
74
+ const item = argv[i];
75
+ if (item === '--strict' || item === '--expect-red') args.mode = item.slice(2);
76
+ else if (item === '--json') args.json = true;
77
+ else if (item === '--case') args.cases.push(argv[++i]);
78
+ else if (item === '--cases') args.casesFile = argv[++i];
79
+ else if (item === '--registry') args.registryFile = argv[++i];
80
+ else if (item === '--log') args.log = argv[++i];
81
+ else if (item === '--recapture-candidate') { args.mode = 'recapture'; args.candidateFile = argv[++i]; }
82
+ else if (item === '--baseline-sha') args.baselineSha = argv[++i];
83
+ else throw new RegistryError(`unknown argument ${item}`);
84
+ }
85
+ if (!args.mode) throw new RegistryError('choose --strict, --expect-red, or --recapture-candidate <file>');
86
+ if (args.mode === 'recapture') {
87
+ if (!/^[0-9a-f]{40}$/.test(args.baselineSha || '')) throw new RegistryError('--recapture-candidate needs --baseline-sha <40-hex commit>');
88
+ if (!args.candidateFile) throw new RegistryError('--recapture-candidate needs a file path');
89
+ }
90
+ for (const value of [args.casesFile, args.registryFile]) {
91
+ if (value === undefined) throw new RegistryError('a file argument is missing its value');
92
+ }
93
+ return args;
94
+ }
95
+
96
+ // Structural validation of the registry against the corpus. Any violation is a registry error (exit 2).
97
+ function validateRegistry(registry, corpus) {
98
+ if (!registry || typeof registry !== 'object') throw new RegistryError('registry must be a JSON object');
99
+ if (registry.schemaVersion !== 1) throw new RegistryError('registry schemaVersion must be 1');
100
+ if (!/^[0-9a-f]{40}$/.test(registry.baselineSha || '')) throw new RegistryError('registry baselineSha must be a 40-hex commit');
101
+ if (!/^[0-9a-f]{64}$/.test(registry.runtimeFingerprint || '')) throw new RegistryError('registry runtimeFingerprint must be a sha256');
102
+ if (!Array.isArray(registry.entries)) throw new RegistryError('registry entries must be an array');
103
+ const cases = new Map(corpus.cases.map(item => [item.id, item]));
104
+ const seen = new Set();
105
+ for (const [index, entry] of registry.entries.entries()) {
106
+ const where = `entries[${index}]`;
107
+ if (!entry || typeof entry !== 'object') throw new RegistryError(`${where} must be an object`);
108
+ const keys = Object.keys(entry).sort();
109
+ if (JSON.stringify(keys) !== JSON.stringify([...ENTRY_KEYS].sort())) {
110
+ throw new RegistryError(`${where} must have exactly the keys ${ENTRY_KEYS.join(', ')}`);
111
+ }
112
+ const item = cases.get(entry.caseId);
113
+ if (!item) throw new RegistryError(`${where} names unknown case ${entry.caseId}`);
114
+ const onId = String(entry.assertionId || '').startsWith(`${entry.caseId}.on.`)
115
+ ? entry.assertionId.slice(`${entry.caseId}.on.`.length)
116
+ : null;
117
+ if (!onId || !item.expectations.on.some(assertion => assertion.id === onId)) {
118
+ throw new RegistryError(`${where} names ${entry.assertionId}, which is not an ON assertion of ${entry.caseId}`);
119
+ }
120
+ if (!LEGS.includes(entry.distribution)) throw new RegistryError(`${where} distribution must be source or packaged`);
121
+ if (entry.baselineSha !== registry.baselineSha) throw new RegistryError(`${where} baselineSha differs from the registry capture`);
122
+ if (entry.blocker !== BLOCKER) throw new RegistryError(`${where} blocker must be ${BLOCKER}`);
123
+ if (typeof entry.reasonCode !== 'string' || !entry.reasonCode.trim()) throw new RegistryError(`${where} reasonCode must be non-empty`);
124
+ if (entry.reasonCode === UNREVIEWED_REASON) throw new RegistryError(`${where} reasonCode is ${UNREVIEWED_REASON}; a reviewer must replace it`);
125
+ if (entry.actual === undefined || entry.desired === undefined) throw new RegistryError(`${where} needs actual and desired values`);
126
+ const identity = `${entry.caseId}|${entry.assertionId}|${entry.distribution}`;
127
+ if (seen.has(identity)) throw new RegistryError(`${where} duplicates ${identity}`);
128
+ seen.add(identity);
129
+ }
130
+ }
131
+
132
+ const sameJson = (left, right) => JSON.stringify(fx.normalize(left)) === JSON.stringify(fx.normalize(right));
133
+
134
+ function comparable(root) {
135
+ return Object.fromEntries(Object.entries(root.actions).map(([id, action]) => [id, {
136
+ exitCode: action.exitCode,
137
+ signal: action.signal,
138
+ stdout: action.json === null ? action.stdout : null,
139
+ json: fx.normalize(action.json),
140
+ stderr: action.stderr,
141
+ state: fx.normalize(action.state)
142
+ }]).concat([['$final', fx.normalize(root.state)]]));
143
+ }
144
+
145
+ // Classifies one case. `replays` holds the scrubbed roots: off/on for each leg.
146
+ function classifyCase(item, replays, registryEntries) {
147
+ // Infrastructure first: a faulted replay can never qualify as a gap, and parity is not meaningful for it. A replay
148
+ // that fail-fast did not run is absent from `replays`, so only the replays that ran are judged.
149
+ const found = [];
150
+ let actionsRun = 0;
151
+ for (const mode of MODES) {
152
+ for (const leg of LEGS) {
153
+ const root = replays[mode][leg];
154
+ if (!root) continue;
155
+ actionsRun += Object.keys(root.actions).length;
156
+ for (const { actionId, message } of fx.infrastructureFaults(item, root, mode)) {
157
+ found.push({ leg, mode, actionId, message, text: `${leg}.${mode}.${actionId}: ${message}` });
158
+ }
159
+ }
160
+ }
161
+ if (found.length) {
162
+ const first = found[0];
163
+ return {
164
+ caseId: item.id,
165
+ status: 'INFRASTRUCTURE',
166
+ qualifiedGaps: 0,
167
+ parity: 'not-evaluated',
168
+ reason: first.text,
169
+ offending: { leg: first.leg, mode: first.mode, actionId: first.actionId, fault: first.message },
170
+ infrastructure: found.map(entry => entry.text),
171
+ actionsRun,
172
+ assertions: []
173
+ };
174
+ }
175
+ const assertions = [];
176
+ let failed = false;
177
+ let qualifiedGaps = 0;
178
+ for (const leg of LEGS) {
179
+ for (const mode of ['off', 'on']) {
180
+ const root = replays[mode][leg];
181
+ for (const result of fx.evaluateAll(root, item.expectations[mode])) {
182
+ const assertionId = `${item.id}.${mode}.${result.id}`;
183
+ if (mode === 'off') {
184
+ if (!result.pass) failed = true;
185
+ assertions.push({ leg, mode, id: assertionId, status: result.pass ? 'pass' : 'control-fail', actual: result.actual });
186
+ continue;
187
+ }
188
+ const entry = registryEntries.find(candidate => candidate.assertionId === assertionId && candidate.distribution === leg);
189
+ if (result.pass) {
190
+ if (entry) { failed = true; assertions.push({ leg, mode, id: assertionId, status: 'unexpected-pass', actual: result.actual }); }
191
+ else assertions.push({ leg, mode, id: assertionId, status: 'pass', actual: result.actual });
192
+ continue;
193
+ }
194
+ if (!entry) {
195
+ failed = true;
196
+ assertions.push({ leg, mode, id: assertionId, status: 'gap-unqualified', actual: result.actual, desired: result.expected });
197
+ } else if (sameJson(entry.actual, result.actual) && sameJson(entry.desired, result.expected)) {
198
+ qualifiedGaps += 1;
199
+ assertions.push({ leg, mode, id: assertionId, status: 'gap-qualified', actual: result.actual, desired: result.expected });
200
+ } else {
201
+ failed = true;
202
+ assertions.push({ leg, mode, id: assertionId, status: 'gap-mismatch', actual: result.actual, desired: result.expected, registered: entry.actual });
203
+ }
204
+ }
205
+ }
206
+ }
207
+ // Parity: the two legs must agree on every normalized replay of this case.
208
+ let parity = 'match';
209
+ for (const mode of ['off', 'on']) {
210
+ if (!sameJson(comparable(replays[mode].source), comparable(replays[mode].packaged))) {
211
+ parity = 'mismatch';
212
+ failed = true;
213
+ }
214
+ }
215
+ const status = failed ? 'FAIL' : qualifiedGaps > 0 ? 'EXPECTED_RED' : 'CONTROL_PASS';
216
+ return { caseId: item.id, status, qualifiedGaps, parity, assertions };
217
+ }
218
+
219
+ // Both legs, both modes, for one case. Fresh replays every time; nothing is reused between cases or legs. The first
220
+ // replay with an infrastructure fault ends the case: the replays after it are not run and are absent from the result.
221
+ function replayAll(item) {
222
+ const replays = { off: {}, on: {} };
223
+ for (const mode of MODES) {
224
+ for (const leg of LEGS) {
225
+ replays[mode][leg] = fx.scrubReplay(fx.replayCase(item, leg, mode === 'on' ? { pilot: '1' } : undefined));
226
+ if (fx.infrastructureFaults(item, replays[mode][leg], mode).length) return replays;
227
+ }
228
+ }
229
+ return replays;
230
+ }
231
+
232
+ function countReplays(replays) {
233
+ return MODES.reduce((total, mode) => total + Object.keys(replays[mode]).length, 0);
234
+ }
235
+
236
+ // A case the corpus-level fail-fast did not reach. It is named, so the report never looks complete.
237
+ function notRun(item, infrastructureCaseId) {
238
+ return {
239
+ caseId: item.id,
240
+ status: 'NOT_RUN',
241
+ qualifiedGaps: 0,
242
+ parity: 'not-evaluated',
243
+ reason: `not run: ${infrastructureCaseId} is INFRASTRUCTURE`,
244
+ assertions: []
245
+ };
246
+ }
247
+
248
+ // The one exit-code rule. Infrastructure (replay faults, policy and output violations) is 2, ahead of RED results.
249
+ function exitCodeFor(report, mode) {
250
+ if (report.cases.some(item => item.status === 'INFRASTRUCTURE')) return 2;
251
+ const failed = report.cases.some(item => item.status === 'FAIL');
252
+ const gaps = report.cases.some(item => item.assertions.some(entry => entry.status.startsWith('gap')));
253
+ if (mode === 'strict') return failed || gaps ? 1 : 0;
254
+ return failed ? 1 : 0;
255
+ }
256
+
257
+ function loadRegistry(file) {
258
+ let registry;
259
+ try {
260
+ registry = JSON.parse(fs.readFileSync(file, 'utf8'));
261
+ } catch (error) {
262
+ throw new RegistryError(`registry unreadable: ${error.message.split('\n')[0]}`);
263
+ }
264
+ return registry;
265
+ }
266
+
267
+ function run(args) {
268
+ const corpus = fx.readCaseCorpus(args.casesFile);
269
+ const registry = loadRegistry(args.registryFile);
270
+ validateRegistry(registry, corpus);
271
+ const runtime = fingerprint();
272
+ const cases = corpus.cases.filter(item => args.cases.length === 0 || args.cases.includes(item.id));
273
+ if (args.cases.length && cases.length !== args.cases.length) throw new RegistryError('a --case names no corpus case');
274
+ const report = {
275
+ schemaVersion: 1,
276
+ mode: args.mode,
277
+ baselineSha: registry.baselineSha,
278
+ runtimeFingerprintMatches: registry.runtimeFingerprint === runtime,
279
+ pilotCredit: false,
280
+ replaysRun: 0,
281
+ cases: []
282
+ };
283
+ for (const [index, item] of cases.entries()) {
284
+ const replays = replayAll(item);
285
+ report.replaysRun += countReplays(replays);
286
+ const result = classifyCase(item, replays, registry.entries.filter(entry => entry.caseId === item.id));
287
+ if (registry.runtimeFingerprint !== runtime && result.status !== 'INFRASTRUCTURE') {
288
+ result.status = 'FAIL';
289
+ result.reason = 'runtime fingerprint differs from the registry capture';
290
+ }
291
+ report.cases.push(result);
292
+ if (result.status === 'INFRASTRUCTURE') {
293
+ // Fail fast across the corpus: once a case is infrastructure, the remaining cases are not replayed.
294
+ for (const skipped of cases.slice(index + 1)) report.cases.push(notRun(skipped, item.id));
295
+ break;
296
+ }
297
+ }
298
+ if (!report.runtimeFingerprintMatches) report.recaptureCommand = recaptureCommand(currentCommit());
299
+ return report;
300
+ }
301
+
302
+ // Candidate registry from the observed gaps on the current runtime. The committed registry is never written.
303
+ // Reason codes carry over by identity; a gap that is new to the registry gets the unreviewed marker.
304
+ function recapture(args) {
305
+ const corpus = fx.readCaseCorpus(args.casesFile);
306
+ const existing = fs.existsSync(args.registryFile) ? loadRegistry(args.registryFile).entries || [] : [];
307
+ const known = new Map(existing.map(entry => [`${entry.assertionId}|${entry.distribution}`, entry.reasonCode]));
308
+ const entries = [];
309
+ const problems = [];
310
+ const cases = corpus.cases.filter(item => args.cases.length === 0 || args.cases.includes(item.id));
311
+ for (const item of cases) {
312
+ const result = classifyCase(item, replayAll(item), []);
313
+ if (result.status === 'INFRASTRUCTURE') {
314
+ // Refuse, and stop: no candidate is written once a replay is infrastructure, so later cases are not replayed.
315
+ problems.push(`${item.id} infrastructure: ${result.reason}`);
316
+ break;
317
+ }
318
+ for (const assertion of result.assertions) {
319
+ if (assertion.status === 'control-fail') problems.push(`${item.id} control fails: ${assertion.id}`);
320
+ if (assertion.status === 'gap-unqualified') {
321
+ entries.push({
322
+ caseId: item.id,
323
+ assertionId: assertion.id,
324
+ distribution: assertion.leg,
325
+ baselineSha: args.baselineSha,
326
+ actual: assertion.actual,
327
+ desired: assertion.desired,
328
+ blocker: BLOCKER,
329
+ reasonCode: known.get(`${assertion.id}|${assertion.leg}`) || UNREVIEWED_REASON
330
+ });
331
+ }
332
+ }
333
+ if (result.parity === 'mismatch') problems.push(`${item.id} source and packaged legs disagree`);
334
+ }
335
+ if (problems.length) return { ok: false, problems };
336
+ return {
337
+ ok: true,
338
+ candidate: { schemaVersion: 1, phase: '325-3b-H2', baselineSha: args.baselineSha, runtimeFingerprint: fingerprint(), entries }
339
+ };
340
+ }
341
+
342
+ function summarize(report) {
343
+ const lines = report.cases.map(item => {
344
+ const gaps = item.assertions.filter(entry => entry.status.startsWith('gap')).length;
345
+ const reason = item.reason ? ` (${item.reason})` : '';
346
+ return `${item.caseId.padEnd(30)} ${item.status.padEnd(13)} gaps=${gaps} parity=${item.parity}${reason}`;
347
+ });
348
+ return lines.join('\n');
349
+ }
350
+
351
+ function main() {
352
+ let args;
353
+ try {
354
+ args = parseArgs(process.argv.slice(2));
355
+ } catch (error) {
356
+ console.error(`[doc-reconciliation-red] ${error.message}`);
357
+ process.exitCode = 2;
358
+ return;
359
+ }
360
+ if (args.mode === 'recapture') {
361
+ let outcome;
362
+ try {
363
+ outcome = recapture(args);
364
+ } catch (error) {
365
+ console.error(`[doc-reconciliation-red] infrastructure: ${error.message.split('\n')[0]}`);
366
+ process.exitCode = 2;
367
+ return;
368
+ }
369
+ if (!outcome.ok) {
370
+ console.error(`[doc-reconciliation-red] candidate not written:\n ${outcome.problems.join('\n ')}`);
371
+ process.exitCode = 1;
372
+ return;
373
+ }
374
+ const target = path.resolve(ROOT, args.candidateFile);
375
+ fs.writeFileSync(target, `${JSON.stringify(outcome.candidate, null, 2)}\n`, 'utf8');
376
+ const unreviewed = outcome.candidate.entries.filter(entry => entry.reasonCode === UNREVIEWED_REASON).length;
377
+ console.log(`candidate written: ${args.candidateFile} (${outcome.candidate.entries.length} entries, ${unreviewed} unreviewed)`);
378
+ console.log('review it against pending-red.json; replace the committed registry only after review, then delete the candidate.');
379
+ process.exitCode = 0;
380
+ return;
381
+ }
382
+ let report;
383
+ try {
384
+ report = run(args);
385
+ } catch (error) {
386
+ console.error(`[doc-reconciliation-red] infrastructure: ${error.message.split('\n')[0]}`);
387
+ process.exitCode = 2;
388
+ return;
389
+ }
390
+ if (args.log) fs.writeFileSync(args.log, `${JSON.stringify(report, null, 2)}\n`, 'utf8');
391
+ if (args.json) console.log(JSON.stringify(report, null, 2));
392
+ else console.log(summarize(report));
393
+ if (report.recaptureCommand) {
394
+ console.error(`[doc-reconciliation-red] runtime fingerprint changed since the registry capture. Re-capture deliberately; nothing is auto-accepted:\n` +
395
+ ` 1. ${report.recaptureCommand}\n` +
396
+ ` 2. review ${CANDIDATE_FILE} against ci/fixtures/doc-obligations/reconciliation-prep/pending-red.json (entries, reasonCode, actual, desired)\n` +
397
+ ' 3. replace pending-red.json with the reviewed candidate and delete the candidate file');
398
+ }
399
+ process.exitCode = exitCodeFor(report, args.mode);
400
+ }
401
+
402
+ if (require.main === module) main();
403
+
404
+ module.exports = {
405
+ fingerprint, validateRegistry, classifyCase, replayAll, run, recapture, exitCodeFor, parseArgs, recaptureCommand,
406
+ RegistryError, FINGERPRINT_FILES, CANDIDATE_FILE
407
+ };
@@ -0,0 +1,85 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ // Local/on-demand stress lane: four real C00 controls plus four bounded CPU workers.
5
+ // Kept on-demand rather than in every gate: CPU saturation multiplies CI load.
6
+ // The preload makes an unrelated sensitivity child unavailable deterministically;
7
+ // selecting N01d must not run that child, even when the selected assertion passed.
8
+ const assert = require('node:assert/strict');
9
+ const fs = require('node:fs');
10
+ const os = require('node:os');
11
+ const path = require('node:path');
12
+ const { spawn } = require('node:child_process');
13
+
14
+ const ROOT = path.resolve(__dirname, '..');
15
+
16
+ if (process.argv.includes('--worker')) {
17
+ const { runChecks } = require('./mutation-check');
18
+ const [result] = runChecks({ root: ROOT, timeoutMs: 60000, legacyAssertions: {}, checks: [{
19
+ label: 'parallel selected boundary control', target: 'ci/lib/doc-evidence-boundary-check.js',
20
+ mutantId: 'C00-noop-boundary', test: 'ci/mechanism-341-boundary-enforcement.test.js',
21
+ caseId: 'C1-N01d-canonical',
22
+ assertionId: 'C1-N01d-canonical.boundary.intended-code-CONTEXT_ALIAS_OR_COMPUTED_USE',
23
+ }] });
24
+ console.log(JSON.stringify(result));
25
+ assert.equal(result.verdict, 'SURVIVED', result.reason);
26
+ } else {
27
+ const temp = fs.mkdtempSync(path.join(os.tmpdir(), 'harness-341-control-stress-'));
28
+ const preload = path.join(temp, 'sensitivity-pressure.cjs');
29
+ fs.writeFileSync(preload, `
30
+ const cp = require('node:child_process');
31
+ const original = cp.spawnSync;
32
+ cp.spawnSync = function (exe, args, options) {
33
+ if (args && args.includes('--c1-isolation-child') && typeof options?.input === 'string') {
34
+ console.error('stress: pipe-fed isolation child reached');
35
+ return original(exe, ['-e', 'setTimeout(() => {}, 10000)'], { ...options, timeout: 25 });
36
+ }
37
+ if (args && /doc-evidence-boundary-check-baseline\\.js$/.test(args[0] || '')) {
38
+ // Simulate a starved, UNSELECTED child at the actual subprocess boundary.
39
+ console.error('stress: unselected sensitivity baseline reached');
40
+ return original(exe, ['-e', 'setTimeout(() => {}, 10000)'], { ...options, timeout: 25 });
41
+ }
42
+ return original(exe, args, options);
43
+ };
44
+ `);
45
+ const children = new Set();
46
+ const timer = setTimeout(() => {
47
+ for (const child of children) child.kill('SIGKILL');
48
+ }, 150000);
49
+ const env = { ...process.env, NODE_OPTIONS: `${process.env.NODE_OPTIONS || ''} --require=${JSON.stringify(preload)}`.trim() };
50
+ const run = args => new Promise((resolve, reject) => {
51
+ const child = spawn(process.execPath, args, { cwd: ROOT, env, stdio: ['ignore', 'pipe', 'pipe'] });
52
+ children.add(child);
53
+ let output = '';
54
+ child.stdout.on('data', chunk => { output += chunk; });
55
+ child.stderr.on('data', chunk => { output += chunk; });
56
+ child.on('error', reject);
57
+ child.on('close', (status, signal) => {
58
+ children.delete(child);
59
+ process.stdout.write(output);
60
+ resolve({ status, signal });
61
+ });
62
+ });
63
+ const burners = [];
64
+ for (let i = 0; i < 4; i++) {
65
+ const child = spawn(process.execPath, ['-e', 'const end = Date.now() + 140000; while (Date.now() < end) {}'], {
66
+ stdio: 'ignore',
67
+ });
68
+ burners.push(child);
69
+ children.add(child);
70
+ child.on('error', () => {});
71
+ child.on('close', () => children.delete(child));
72
+ }
73
+ Promise.all(Array.from({ length: 4 }, () => run([__filename, '--worker'])))
74
+ .then(results => {
75
+ assert.ok(results.every(result => result.status === 0 && !result.signal),
76
+ `parallel C00 controls must survive: ${JSON.stringify(results)}`);
77
+ console.log('PASS four parallel C00 controls under CPU load and input/selection isolation');
78
+ })
79
+ .catch(error => { console.error(error.message); process.exitCode = 1; })
80
+ .finally(() => {
81
+ clearTimeout(timer);
82
+ for (const child of children) child.kill('SIGKILL');
83
+ fs.rmSync(temp, { recursive: true, force: true });
84
+ });
85
+ }
@@ -5,6 +5,90 @@ the phase 3a `doc-change` / `requirement-sync` producer in
5
5
  [`document-lifecycle.md`](document-lifecycle.md). It does not reconcile evidence with workflow
6
6
  disposition (#325 phase 3b/3c), add a lock, or add a router/provider path.
7
7
 
8
+ ## Phase 0 read-boundary contract (#341)
9
+
10
+ Phase 0 preserves the public `checkDocObligations()` result and the current reads. A read role
11
+ captures facts at most once **per its existing role-specific identity and scope**; consumers of
12
+ that same role (validation, binding, stale diagnostics, and audit where applicable) reuse the
13
+ capture. Consumers are not roles, so separating consumers does not permit another caller-object
14
+ read. Detached immutable internal views are a Phase 0B implementation requirement; they must not
15
+ be obtained by freezing or retaining mutable caller-owned objects. An internally consistent
16
+ snapshot proves agreement with the supplied values, not their external truth or provenance.
17
+
18
+ The private `ReadResult<T>` contract for the later capture layer distinguishes:
19
+
20
+ ```ts
21
+ type ReadResult<T> =
22
+ | { kind: 'present'; value: T }
23
+ | { kind: 'absent' }
24
+ | { kind: 'inherited' }
25
+ | { kind: 'unknown'; reason: string };
26
+ ```
27
+
28
+ `present` includes an own property whose value is `undefined`; field validation decides whether
29
+ that value is valid, and it must not be collapsed into `absent`. `absent` means no own key exists.
30
+ `inherited` records that a value is reachable only through the prototype chain and cannot satisfy
31
+ an own-key evidence requirement. `unknown` covers throwing or otherwise uninspectable input. An
32
+ unknown read is not a completed obligation and cannot produce a pass; mandatory baseline evidence
33
+ that is invalid or unknown fails closed with a structured diagnostic. This representation does
34
+ not change existing field policy or the public output schema.
35
+
36
+ The read roles and compatibility traces are fixed to their existing identities:
37
+
38
+ | ID | Identity and read rule | Consumers / compatibility behavior |
39
+ | --- | --- | --- |
40
+ | R1 — whole-baseline digest | Invocation. Read once before the obligation loop only if `baseline` passes the plain-object check; otherwise zero reads. | Reused across obligations. This does not claim that the caller's baseline is externally attested. |
41
+ | R2 — candidate-inventory classification | Invocation, lazy. At most one prototype inspection, only when an applicable baseline-declaring obligation needs it. | The same classification is shared across obligations as today. Inapplicable and non-baseline lanes do not trigger it. |
42
+ | R3 — rename-destination validation | Obligation × declaring entry. One own-digest read for each declaring entry that reaches it; accepted entries must agree. | A rejected destination is poisoned: never reread, later declarations fail closed, and the failed snapshot takes precedence in audit over an earlier success. Distinct declaring entries remain separate observations. |
43
+ | R4 — baseline-entry metadata and source own-key absence | Obligation × entry. Capture `path`, `reason`, own `renameTo` presence/value, and source absence once for reuse. | The tested candidate descriptor budget combines R3 and R4: two inspections for a rename (destination presence and source absence), one for a deletion. |
44
+ | R5 — per-path baseline digest | Obligation × path. Capture once during metadata validation. | Audit mirror, stale diagnostics, and baseline binding reuse that validated value. |
45
+ | R6 — ordinary candidate capture | Obligation × path, applicable baseline-annotated lane. | Preserve today's `artifact()` access trace (up to three digest-property reads), seed from a successful R3 value, and reuse the capture for binding, audit, and stale diagnostics. |
46
+ | C1 — legacy audit lane | Obligation × path for N/A, unknown, or non-baseline cases. | Preserve today's `artifact()` reads; this is a compatibility trace, not a once-only guarantee. N/A and unknown do not consume R3. |
47
+ | C2 — evidence-record consumption | Obligation × record identity × field. | Preserve today's decision and provenance field read counts, including `paths`, `baseDigest`, `artifactDigests`, and `baselineArtifacts`; a single record must still bind both rename endpoints. |
48
+ | C3 — invocation-level ingestion | Invocation × record / obligation, before evaluation, including when there are no obligations. | Preserve `isRecord()` reads of `id` / `obligationId` (up to two each in the current guard/short-circuit order, twice each for an admitted stable record), `knownIds` reads of obligation `id` / `capability`, and the waiver scan reads of review-record `kind` / `obligationId`. |
49
+ | C4 — stale-diagnostic record paths | Obligation × record path. | Preserve iteration over `record.paths`; a path without a scoped capture falls back to the legacy `artifact()` read, including for applicable baseline-bound obligations. |
50
+ | C5 — requirement-sync PRD-set enumeration | Conditional obligation check. | Preserve own-key enumeration and `artifact()` reads for each `docs/prd/` path, including out-of-scope PRDs, only when envelope verification reaches that check. |
51
+
52
+ **Closure rule.** Any caller-object access not listed in R1–R6 remains a compatibility trace by
53
+ default: preserve its existing timing, count, and decision effect until a separately approved
54
+ change says otherwise. This does not permit a new access. A caller-object access absent from the
55
+ current implementation has a budget of zero in Phase 0. Phase 0A records ordered `get`, `ownKeys`,
56
+ `getOwnPropertyDescriptor`, and `getPrototypeOf` events across every factory-known caller object
57
+ and pins the complete trace per observed case and distribution, including symbol properties,
58
+ result category/identity, and thrown markers. Tracing must be transparent: the traced and unwrapped
59
+ controls must agree on outputs, exceptions, identity relationships, semantic read counts, and
60
+ caller-input integrity. The tracer must respect Proxy invariants for frozen objects and nested
61
+ non-configurable properties. A trace proves completeness for its observed case; it does not
62
+ establish branch coverage or replace Phase 0C's structural/API boundary and negative controls. Any
63
+ observed unclassified access is added to the contract before Phase 0B. The applicability gate
64
+ protects R2, R3, and scoped candidate capture; it does not gate every baseline, metadata, or
65
+ pre-evaluation ingestion read (R1 and C3 retain their stated timing). C2, C4, C5, and C3's
66
+ pre-evaluation ingestion are explicit compatibility lanes; their bounded reads must remain
67
+ isolated or be documented as narrow exceptions to the Phase 0C boundary.
68
+
69
+ ### Phase limits
70
+
71
+ Phase 0A defines this contract and builds the RED replay corpus, exact read traces, and qualified
72
+ mutation controls. Each corpus case compares the full public result (`observation`, `outcome`,
73
+ diagnostics, `artifactDigest`, `baseDigest`, `baselineArtifacts`, provenance, and `recordRefs`),
74
+ role-specific counts/traces, and input integrity against canonical and packaged implementations.
75
+ Phase 0A does not change evaluator behavior. Phase 0B introduces the smallest private
76
+ capture layer as a pure refactor, preserving outputs, decision semantics, audit identity, read
77
+ order/counts, input integrity, and canonical/package parity. Phase 0C enforces the normalized
78
+ applicable baseline-bound boundary and qualifies mutation gates, including negative controls.
79
+ Those phases and relevant CI on the final merged SHA are required for Gate E0. Phase 0A alone is
80
+ not E0 and does not authorize runtime Evidence Reconciliation. Snapshot consistency is not external
81
+ VCS/provenance attestation; that remains in #325 Phase 3b/3c.
82
+
83
+ The C1 isolation trial rejected 9 of 11 weakened-boundary controls per distribution, while N05 and
84
+ N06 survived with complete coverage and no diagnostics. The C2 AST/source supplement now rejects
85
+ all 11 frozen controls in both distributions, including N05/N06 under `COMP-C2-BOUND`; its pinned
86
+ whole-module and named-target fingerprints fail closed on unreviewed structural changes. This is
87
+ deterministic mechanism evidence, not E0 qualification. C3 mutation/compatibility qualification and
88
+ the final merged-SHA gates remain required. See
89
+ [`doc-evidence-boundary.md`](doc-evidence-boundary.md) for the C1/C2 policy, trial results, and
90
+ evidence boundary.
91
+
8
92
  ## Data shape
9
93
 
10
94
  The trusted baseline keeps its existing whole-tree identity and may add per-path SHA256 values:
@@ -39,9 +123,9 @@ scope = {
39
123
  - `reason: 'rename-from'` describes the old endpoint of an identified content-identical move.
40
124
  The destination is named by `renameTo` and must itself be a member of the same
41
125
  `scope.paths`. A `renameTo` outside the scope is malformed and fails the obligation closed.
42
- - Every `baselinePaths` entry's fields (`path`, `reason`, and the own-key `renameTo` presence) are
43
- snapshotted through one guarded inspection, and each field result is carried exactly once. An
44
- entry whose field getter or descriptor trap throws is a metadata error (`baseline-path-invalid`)
126
+ - Under R4, each `baselinePaths` entry's fields (`path`, `reason`, and the own-key `renameTo`
127
+ presence) are snapshotted through one guarded inspection per obligation/entry, then reused by
128
+ consumers. An entry whose field getter or descriptor trap throws is a metadata error (`baseline-path-invalid`)
45
129
  and fails the entry closed instead of escaping the checker; a field accessor that throws only on
46
130
  a later read cannot abort the audit mirror; the mirror is built from the metadata-validated
47
131
  snapshot (validated path and captured digest) and is never a second read of the raw entry
@@ -74,7 +158,8 @@ A deleted path is satisfied only when one observed record:
74
158
  4. carries **no** `artifactDigests` entry for that path, and the candidate artifact map has **no
75
159
  own-key entry** for that path.
76
160
 
77
- The trusted per-path value is validated once during metadata validation and then **captured**.
161
+ The trusted per-path value is validated and captured once for its R5 obligation/path identity
162
+ during metadata validation.
78
163
  The audit mirror, the stale-baseline diagnostics, and baseline binding compare against that
79
164
  captured value and never re-read the caller-owned `baseline.artifacts` map during evidence
80
165
  evaluation: a stateful own getter cannot satisfy a record with a value it started returning only
@@ -84,14 +169,14 @@ different or unusable value later is factored out entirely. A baseline per-path
84
169
  throws during validation is a `baseline-artifact-invalid` metadata error and fails the path
85
170
  closed instead of escaping the checker. The audit mirror is likewise keyed by the **validated**
86
171
  path snapshot rather than a re-read of `baselinePaths[].path`, so a stateful `path` accessor is
87
- read exactly once and a passing result always describes the baseline identity that validated and
88
- bound the evidence.
172
+ read once within its R4 obligation/entry snapshot and a passing result always describes the
173
+ baseline identity that validated and bound the evidence.
89
174
 
90
- The whole-baseline identity `baseline.digest` follows the same discipline: it is read exactly
91
- once and that same scalar is validated, so a stateful value cannot be inspected as A while every
92
- evidence comparison uses B. The rename destination's own digest is also read exactly once during
93
- its validated snapshot, and the captured value drives the metadata cache, binding, the stale
94
- diagnostics, and the audit identity.
175
+ The whole-baseline identity `baseline.digest` follows R1: the invocation-level read, when eligible,
176
+ is the scalar validated and reused by every obligation. A rename destination's own digest follows
177
+ R3: each declaring entry that reaches validation has its own read, with shared-destination
178
+ agreement and poisoned-snapshot precedence. Consumers of each accepted snapshot reuse its captured
179
+ value for metadata, binding, stale diagnostics, and audit identity.
95
180
 
96
181
  Per-path evidence must be carried explicitly. Inherited properties in `baseline.artifacts` or a
97
182
  record's `baselineArtifacts` are not own-key evidence and never satisfy a path; the trusted
@@ -116,11 +201,11 @@ candidate digest — the same one used by ordinary candidate binding, the audit
116
201
  stale diagnostics. The destination is therefore never read again through the candidate map during
117
202
  capture or binding: a nested `digest` that throws or changes after validation cannot make a stale
118
203
  destination pair bind while the audit/stale verdicts still describe the captured value. The same
119
- read-once guarantee extends to rejected destination snapshots: when the destination's own digest
120
- read throws or returns an unusable value, that failed snapshot ('') is recorded and all later
121
- reads for that destination path are suppressed as well — the entry's single metadata read remains
122
- its only digest read, and the failed path contributes `path=missing` to `artifactDigest` instead
123
- of a later, different value. The same
204
+ R3 also governs rejected destination snapshots: when a declaring entry's own-digest read throws or
205
+ returns an unusable value, that failed snapshot ('') is recorded and later declarations in the
206
+ same obligation's metadata loop do not reread the poisoned destination — the rejected entry's
207
+ metadata read remains its only digest read, and the failed path contributes `path=missing` to
208
+ `artifactDigest` instead of a later, different value. The same
124
209
  record can cover both endpoints. Destination-only evidence, source-only tombstones, split endpoint
125
210
  records, and stale destination digests do not satisfy the source. A deletion tombstone therefore
126
211
  cannot stand in for a known rename. This destination validation and the snapshot it produces are
@@ -129,9 +214,10 @@ evidence, so they do not consume the destination digest getter during metadata v
129
214
  audit identity keeps the un-gated legacy candidate read, and a stateful accessor therefore reports
130
215
  the same first legacy value to them that it would report without baseline binding.
131
216
 
132
- Metadata validation is the single candidate snapshot for a baseline-only path. Once a declared
133
- baseline path has been confirmed to have **no** own-key candidate entry, that validated absence is
134
- carried on the entry; binding and the `artifactDigest` audit identity reuse it instead of
217
+ For an applicable baseline-only path, R4 metadata validation captures candidate own-key absence
218
+ once for that obligation/entry. Once a declared baseline path has been confirmed to have **no**
219
+ own-key candidate entry, that validated absence is carried on the entry; binding and the
220
+ `artifactDigest` audit identity reuse it instead of
135
221
  re-inspecting the candidate map, so a `Proxy` that answers own-property or `get` inspection
136
222
  consistently during validation cannot later abort the check or change the audit key. A
137
223
  baseline-only path therefore contributes `path=missing` to `artifactDigest` from the validated