@open-agent-toolkit/cli 0.2.53 → 0.2.55

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 (81) hide show
  1. package/assets/agents/recon-worker.md +105 -0
  2. package/assets/bundle-metadata.json +1 -1
  3. package/assets/docs/cli-utilities/tool-packs.md +62 -8
  4. package/assets/docs/contributing/skills.md +26 -0
  5. package/assets/docs/workflows/skills/index.md +3 -0
  6. package/assets/docs/workflows/skills/recon.md +210 -0
  7. package/assets/public-package-versions.json +4 -4
  8. package/assets/skills/oat-dispatch-subagents/SKILL.md +1 -1
  9. package/assets/skills/recon/SKILL.md +298 -0
  10. package/assets/skills/recon/references/packet-contract.md +340 -0
  11. package/assets/skills/recon/references/profiles.md +58 -0
  12. package/assets/skills/recon/references/worker-contract.md +71 -0
  13. package/assets/skills/recon/scripts/create-review-brief.mjs +452 -0
  14. package/assets/skills/recon/scripts/lib/canonical-json.mjs +55 -0
  15. package/assets/skills/recon/scripts/lib/contracts.mjs +1969 -0
  16. package/assets/skills/recon/scripts/lib/safe-path.mjs +120 -0
  17. package/assets/skills/recon/scripts/lib/validated-run.mjs +80 -0
  18. package/assets/skills/recon/scripts/reconcile-ledger.mjs +360 -0
  19. package/assets/skills/recon/scripts/render-packet.mjs +379 -0
  20. package/assets/skills/recon/scripts/validate-artifact.mjs +96 -0
  21. package/assets/skills/recon/scripts/validate-packet.mjs +2192 -0
  22. package/dist/commands/doctor/index.js +5 -5
  23. package/dist/commands/init/index.d.ts.map +1 -1
  24. package/dist/commands/init/index.js +6 -7
  25. package/dist/commands/status/index.d.ts +2 -2
  26. package/dist/commands/status/index.d.ts.map +1 -1
  27. package/dist/commands/status/index.js +7 -4
  28. package/dist/commands/sync/index.d.ts.map +1 -1
  29. package/dist/commands/sync/index.js +24 -8
  30. package/dist/commands/sync/sync.types.d.ts +2 -2
  31. package/dist/commands/sync/sync.types.d.ts.map +1 -1
  32. package/dist/commands/tools/migrate/index.d.ts.map +1 -1
  33. package/dist/commands/tools/migrate/index.js +25 -1
  34. package/dist/commands/tools/migrate/migrate-pack.d.ts +5 -0
  35. package/dist/commands/tools/migrate/migrate-pack.d.ts.map +1 -1
  36. package/dist/commands/tools/migrate/migrate-pack.js +66 -7
  37. package/dist/commands/tools/remove/index.js +1 -1
  38. package/dist/commands/tools/remove/remove-tools.d.ts +5 -1
  39. package/dist/commands/tools/remove/remove-tools.d.ts.map +1 -1
  40. package/dist/commands/tools/remove/remove-tools.js +81 -1
  41. package/dist/commands/tools/shared/apply-pack-reconcile.d.ts +3 -0
  42. package/dist/commands/tools/shared/apply-pack-reconcile.d.ts.map +1 -1
  43. package/dist/commands/tools/shared/apply-pack-reconcile.js +40 -9
  44. package/dist/commands/tools/shared/format-pack-inventory.d.ts.map +1 -1
  45. package/dist/commands/tools/shared/format-pack-inventory.js +3 -0
  46. package/dist/commands/tools/shared/install-sync-context.d.ts +2 -1
  47. package/dist/commands/tools/shared/install-sync-context.d.ts.map +1 -1
  48. package/dist/commands/tools/shared/install-sync-context.js +14 -3
  49. package/dist/commands/tools/shared/pack-dependencies.d.ts +14 -0
  50. package/dist/commands/tools/shared/pack-dependencies.d.ts.map +1 -0
  51. package/dist/commands/tools/shared/pack-dependencies.js +54 -0
  52. package/dist/commands/tools/shared/pack-inventory.d.ts.map +1 -1
  53. package/dist/commands/tools/shared/pack-inventory.js +9 -9
  54. package/dist/commands/tools/shared/pack-lifecycle.d.ts +9 -1
  55. package/dist/commands/tools/shared/pack-lifecycle.d.ts.map +1 -1
  56. package/dist/commands/tools/shared/pack-lifecycle.js +102 -9
  57. package/dist/commands/tools/shared/pack-manifest.d.ts +2 -2
  58. package/dist/commands/tools/shared/pack-manifest.d.ts.map +1 -1
  59. package/dist/commands/tools/shared/pack-manifest.js +91 -2
  60. package/dist/commands/tools/shared/pack-reconcile.d.ts +15 -1
  61. package/dist/commands/tools/shared/pack-reconcile.d.ts.map +1 -1
  62. package/dist/commands/tools/shared/pack-reconcile.js +75 -13
  63. package/dist/commands/tools/shared/scoped-pack-intent.d.ts +8 -0
  64. package/dist/commands/tools/shared/scoped-pack-intent.d.ts.map +1 -1
  65. package/dist/commands/tools/shared/scoped-pack-intent.js +61 -15
  66. package/dist/commands/tools/shared/types.d.ts +7 -0
  67. package/dist/commands/tools/shared/types.d.ts.map +1 -1
  68. package/dist/commands/tools/update/update-tools.d.ts.map +1 -1
  69. package/dist/commands/tools/update/update-tools.js +7 -1
  70. package/dist/config/oat-config.d.ts +5 -1
  71. package/dist/config/oat-config.d.ts.map +1 -1
  72. package/dist/config/oat-config.js +19 -0
  73. package/dist/engine/index.d.ts +1 -1
  74. package/dist/engine/index.d.ts.map +1 -1
  75. package/dist/engine/scanner.d.ts +9 -1
  76. package/dist/engine/scanner.d.ts.map +1 -1
  77. package/dist/engine/scanner.js +61 -4
  78. package/dist/shared/types.d.ts +1 -0
  79. package/dist/shared/types.d.ts.map +1 -1
  80. package/dist/shared/types.js +3 -0
  81. package/package.json +2 -2
@@ -0,0 +1,2192 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { readFile, rm } from 'node:fs/promises';
4
+ import { join, resolve } from 'node:path';
5
+ import { pathToFileURL } from 'node:url';
6
+
7
+ import { hashCanonicalJson, hashFile, sha256 } from './lib/canonical-json.mjs';
8
+ import {
9
+ approvalFingerprintInput,
10
+ isDigest,
11
+ isObject,
12
+ issue,
13
+ profiles,
14
+ validateArtifactShape,
15
+ } from './lib/contracts.mjs';
16
+ import {
17
+ assertCanonicalRoot,
18
+ assertSafeExistingPath,
19
+ assertUnchangedRoot,
20
+ isContainedPath,
21
+ } from './lib/safe-path.mjs';
22
+ import { createValidatedRun } from './lib/validated-run.mjs';
23
+
24
+ const requiredPasses = {
25
+ quick: ['map', 'gather'],
26
+ standard: [
27
+ 'map',
28
+ 'gather',
29
+ 'semantic-verification',
30
+ 'adversarial',
31
+ 'coverage',
32
+ 'reconciliation',
33
+ ],
34
+ thorough: [
35
+ 'map',
36
+ 'gather',
37
+ 'semantic-verification',
38
+ 'adversarial',
39
+ 'coverage',
40
+ 'reconciliation',
41
+ 'redundant-gather',
42
+ 'redundant-verification',
43
+ 'contradiction-resolution',
44
+ ],
45
+ };
46
+
47
+ function packetPath(packetRoot, path) {
48
+ if (typeof path !== 'string' || path.length === 0) return null;
49
+ const candidate = resolve(packetRoot, path);
50
+ return isContainedPath(packetRoot, candidate) ? candidate : null;
51
+ }
52
+
53
+ async function retainFilesystemIdentity(identities, path) {
54
+ const identity = await assertCanonicalRoot(path);
55
+ identities.set(identity.path, identity);
56
+ return identity;
57
+ }
58
+
59
+ async function retainDeclaredSourceIdentities(
60
+ packetRoot,
61
+ source,
62
+ filesystemIdentities,
63
+ ) {
64
+ const paths = [];
65
+ if (source.kind === 'repository' && typeof source.root === 'string') {
66
+ paths.push(source.root);
67
+ } else if (source.kind === 'file' && typeof source.path === 'string') {
68
+ paths.push(source.path);
69
+ } else if (source.kind === 'url') {
70
+ const capturePath =
71
+ source.capturePath ?? source.validatorState?.capturePath;
72
+ if (typeof capturePath === 'string') {
73
+ const path = packetPath(packetRoot, capturePath);
74
+ if (!path) {
75
+ throw Object.assign(new Error('Capture path escapes the packet root'), {
76
+ code: 'PATH_ESCAPE',
77
+ });
78
+ }
79
+ paths.push(path);
80
+ }
81
+ } else if (source.kind === 'command-output') {
82
+ if (typeof source.cwd === 'string') paths.push(source.cwd);
83
+ if (typeof source.outputPath === 'string') {
84
+ const path = packetPath(packetRoot, source.outputPath);
85
+ if (!path) {
86
+ throw Object.assign(
87
+ new Error('Command output path escapes the packet root'),
88
+ { code: 'PATH_ESCAPE' },
89
+ );
90
+ }
91
+ paths.push(path);
92
+ }
93
+ } else if (
94
+ source.kind === 'connected-resource' &&
95
+ typeof source.capturePath === 'string'
96
+ ) {
97
+ const path = packetPath(packetRoot, source.capturePath);
98
+ if (!path) {
99
+ throw Object.assign(new Error('Capture path escapes the packet root'), {
100
+ code: 'PATH_ESCAPE',
101
+ });
102
+ }
103
+ paths.push(path);
104
+ }
105
+ for (const path of paths) {
106
+ await retainFilesystemIdentity(filesystemIdentities, path);
107
+ }
108
+ }
109
+
110
+ async function readManagedJson(
111
+ root,
112
+ path,
113
+ code,
114
+ errors,
115
+ canonicalByteDigests,
116
+ relativePath,
117
+ ) {
118
+ try {
119
+ await assertSafeExistingPath(root, path);
120
+ } catch (error) {
121
+ errors.push(
122
+ issue(
123
+ error?.code === 'SYMLINK_ESCAPE' ? 'SYMLINK_ESCAPE' : code,
124
+ error?.code === 'SYMLINK_ESCAPE'
125
+ ? 'Canonical packet path contains a symlink'
126
+ : `Unable to read ${path}`,
127
+ path,
128
+ ),
129
+ );
130
+ return null;
131
+ }
132
+ try {
133
+ const bytes = await readFile(path);
134
+ const value = JSON.parse(bytes.toString('utf8'));
135
+ canonicalByteDigests.set(relativePath, sha256(bytes));
136
+ return value;
137
+ } catch (error) {
138
+ errors.push(
139
+ issue(
140
+ code,
141
+ error instanceof Error ? error.message : `Unable to read ${path}`,
142
+ path,
143
+ ),
144
+ );
145
+ return null;
146
+ }
147
+ }
148
+
149
+ function collectReferences(manifest, ledger) {
150
+ const references = [];
151
+ for (const reference of manifest?.artifacts ?? []) references.push(reference);
152
+ for (const reference of ledger?.inputArtifacts ?? [])
153
+ references.push(reference);
154
+ for (const evidence of ledger?.evidence ?? []) {
155
+ if (evidence.provenance) references.push(evidence.provenance);
156
+ }
157
+ for (const claim of ledger?.claims ?? []) {
158
+ for (const reference of claim.derivedFrom ?? []) references.push(reference);
159
+ }
160
+ return references;
161
+ }
162
+
163
+ async function validateReferences(packetRoot, references, errors) {
164
+ const checked = new Set();
165
+ const artifactsByPath = new Map();
166
+ const artifactsById = new Map();
167
+ const validatedByteDigests = new Map();
168
+ for (const reference of references) {
169
+ if (!isObject(reference) || typeof reference.path !== 'string') continue;
170
+ const key = `${reference.path}:${reference.digest}`;
171
+ if (checked.has(key)) continue;
172
+ checked.add(key);
173
+ const path = packetPath(packetRoot, reference.path);
174
+ if (!path) {
175
+ errors.push(
176
+ issue(
177
+ 'PATH_ESCAPE',
178
+ 'Artifact path escapes the packet directory',
179
+ reference.path,
180
+ ),
181
+ );
182
+ continue;
183
+ }
184
+ try {
185
+ await assertSafeExistingPath(packetRoot, path);
186
+ const bytes = await readFile(path);
187
+ const actual = sha256(bytes);
188
+ if (isDigest(reference.digest) && actual !== reference.digest) {
189
+ errors.push(
190
+ issue(
191
+ 'ARTIFACT_DIGEST_MISMATCH',
192
+ `Artifact digest does not match ${reference.path}`,
193
+ reference.path,
194
+ ),
195
+ );
196
+ }
197
+ validatedByteDigests.set(reference.path, actual);
198
+ if (reference.path.endsWith('.json')) {
199
+ const value = JSON.parse(bytes.toString('utf8'));
200
+ const validation = validateArtifactShape(value);
201
+ errors.push(
202
+ ...validation.errors.map((error) => ({
203
+ ...error,
204
+ path: `${reference.path}:${error.path}`,
205
+ })),
206
+ );
207
+ artifactsByPath.set(reference.path, { reference, value, path });
208
+ if (typeof value.id === 'string') {
209
+ if (artifactsById.has(value.id)) {
210
+ errors.push(
211
+ issue(
212
+ 'DUPLICATE_ARTIFACT_ID',
213
+ `Duplicate artifact identifier ${value.id}`,
214
+ reference.path,
215
+ ),
216
+ );
217
+ }
218
+ artifactsById.set(value.id, { reference, value, path });
219
+ }
220
+ }
221
+ } catch (error) {
222
+ errors.push(
223
+ issue(
224
+ error?.code === 'SYMLINK_ESCAPE'
225
+ ? 'SYMLINK_ESCAPE'
226
+ : 'MISSING_ARTIFACT',
227
+ error?.code === 'SYMLINK_ESCAPE'
228
+ ? `Artifact path contains a symlink: ${reference.path}`
229
+ : `Missing artifact ${reference.path}`,
230
+ reference.path,
231
+ ),
232
+ );
233
+ }
234
+ }
235
+ return { artifactsByPath, artifactsById, validatedByteDigests };
236
+ }
237
+
238
+ function lineExcerpt(content, start, end) {
239
+ if (start === undefined && end === undefined) return content;
240
+ if (
241
+ !Number.isInteger(start) ||
242
+ !Number.isInteger(end) ||
243
+ start < 1 ||
244
+ end < start
245
+ ) {
246
+ return null;
247
+ }
248
+ const lines = content.replace(/\r\n/g, '\n').split('\n');
249
+ if (end > lines.length) return null;
250
+ return lines.slice(start - 1, end).join('\n');
251
+ }
252
+
253
+ function redactSecrets(value) {
254
+ return value
255
+ .replace(
256
+ /\b(token|password|secret|api[_-]?key)\s*[:=]\s*[^\s,;]+/gi,
257
+ (_match, key) => `${key}=[REDACTED]`,
258
+ )
259
+ .replace(/\b(?:sk|ghp|xox[baprs])[-_][A-Za-z0-9_-]{8,}\b/g, '[REDACTED]');
260
+ }
261
+
262
+ function containsSecret(value) {
263
+ if (typeof value === 'string') return redactSecrets(value) !== value;
264
+ if (Array.isArray(value)) return value.some(containsSecret);
265
+ if (isObject(value)) return Object.values(value).some(containsSecret);
266
+ return false;
267
+ }
268
+
269
+ function validatePersistedSecretSafety(values, errors) {
270
+ for (const [path, value] of values) {
271
+ if (containsSecret(value)) {
272
+ errors.push(
273
+ issue(
274
+ 'UNREDACTED_SECRET',
275
+ 'Persisted packet data contains a secret-like span',
276
+ path,
277
+ ),
278
+ );
279
+ }
280
+ }
281
+ }
282
+
283
+ function sourceHasMinimumProvenance(source) {
284
+ if (!isObject(source)) return false;
285
+ if (source.kind === 'repository') {
286
+ return (
287
+ typeof source.root === 'string' &&
288
+ typeof source.revision === 'string' &&
289
+ isObject(source.contentHashes)
290
+ );
291
+ }
292
+ if (source.kind === 'file') {
293
+ return typeof source.path === 'string' && isDigest(source.contentHash);
294
+ }
295
+ if (source.kind === 'url') {
296
+ const validator = source.validatorState;
297
+ return (
298
+ typeof source.url === 'string' &&
299
+ ((typeof source.capturePath === 'string' &&
300
+ isDigest(source.captureDigest)) ||
301
+ (isObject(validator) &&
302
+ typeof validator.capturePath === 'string' &&
303
+ isDigest(validator.captureDigest) &&
304
+ (typeof validator.etag === 'string' ||
305
+ typeof validator.lastModified === 'string')))
306
+ );
307
+ }
308
+ if (source.kind === 'command-output') {
309
+ return (
310
+ Array.isArray(source.argv) &&
311
+ typeof source.cwd === 'string' &&
312
+ Number.isInteger(source.exitStatus) &&
313
+ typeof source.outputPath === 'string' &&
314
+ isDigest(source.outputDigest) &&
315
+ Array.isArray(source.environmentNames)
316
+ );
317
+ }
318
+ if (source.kind === 'connected-resource') {
319
+ return (
320
+ typeof source.system === 'string' &&
321
+ typeof source.resourceId === 'string' &&
322
+ (typeof source.resourceVersion === 'string' ||
323
+ typeof source.retrievalToken === 'string') &&
324
+ typeof source.capturePath === 'string' &&
325
+ isDigest(source.captureDigest)
326
+ );
327
+ }
328
+ return false;
329
+ }
330
+
331
+ async function reopenEvidence(
332
+ packetRoot,
333
+ source,
334
+ evidence,
335
+ errors,
336
+ filesystemIdentities,
337
+ allowIneligibleAudit = false,
338
+ ) {
339
+ if (source?.available !== true || source?.validationState !== 'pinned') {
340
+ if (allowIneligibleAudit) return false;
341
+ errors.push(
342
+ issue(
343
+ 'INELIGIBLE_SOURCE_STATE',
344
+ `Source ${source?.id ?? 'unknown'} is not available and pinned`,
345
+ `source:${source?.id ?? 'unknown'}`,
346
+ ),
347
+ );
348
+ return false;
349
+ }
350
+ if (!sourceHasMinimumProvenance(source)) {
351
+ errors.push(
352
+ issue(
353
+ 'INSUFFICIENT_PROVENANCE',
354
+ `Source ${source?.id ?? 'unknown'} lacks reopenable provenance`,
355
+ `source:${source?.id ?? 'unknown'}`,
356
+ ),
357
+ );
358
+ return false;
359
+ }
360
+ const locator = evidence.locator;
361
+ if (!isObject(locator) || locator.kind !== source.kind) {
362
+ errors.push(
363
+ issue(
364
+ 'INVALID_LOCATOR',
365
+ 'Locator kind does not match source kind',
366
+ evidence.id,
367
+ ),
368
+ );
369
+ return false;
370
+ }
371
+ if (
372
+ !['exact', 'redacted-exact'].includes(evidence.locatorValidation?.status)
373
+ ) {
374
+ return false;
375
+ }
376
+ if (evidence.observedAt !== source.observedAt) {
377
+ errors.push(
378
+ issue(
379
+ 'SOURCE_VERSION_MISMATCH',
380
+ 'Evidence observation time does not match the pinned source',
381
+ evidence.id,
382
+ ),
383
+ );
384
+ return false;
385
+ }
386
+
387
+ let content;
388
+ let expectedDigest;
389
+ let path;
390
+ try {
391
+ if (source.kind === 'repository') {
392
+ await retainFilesystemIdentity(filesystemIdentities, source.root);
393
+ path = resolve(source.root, locator.path);
394
+ if (!isContainedPath(resolve(source.root), path)) {
395
+ errors.push(
396
+ issue(
397
+ 'PATH_ESCAPE',
398
+ 'Repository locator escapes source root',
399
+ evidence.id,
400
+ ),
401
+ );
402
+ return false;
403
+ }
404
+ await assertSafeExistingPath(resolve(source.root), path);
405
+ await retainFilesystemIdentity(filesystemIdentities, path);
406
+ if (locator.revision !== source.revision) {
407
+ errors.push(
408
+ issue(
409
+ 'SOURCE_VERSION_MISMATCH',
410
+ 'Repository revision changed',
411
+ evidence.id,
412
+ ),
413
+ );
414
+ return false;
415
+ }
416
+ expectedDigest = source.contentHashes[locator.path];
417
+ content = await readFile(path, 'utf8');
418
+ } else if (source.kind === 'file') {
419
+ if (resolve(locator.path) !== resolve(source.path)) {
420
+ errors.push(
421
+ issue(
422
+ 'SOURCE_IDENTITY_MISMATCH',
423
+ 'File locator path does not match its source identity',
424
+ evidence.id,
425
+ ),
426
+ );
427
+ return false;
428
+ }
429
+ path = resolve(source.path);
430
+ await retainFilesystemIdentity(filesystemIdentities, path);
431
+ expectedDigest = source.contentHash;
432
+ content = await readFile(path, 'utf8');
433
+ } else if (source.kind === 'url') {
434
+ if (locator.url !== source.url) {
435
+ errors.push(
436
+ issue('SOURCE_VERSION_MISMATCH', 'URL identity changed', evidence.id),
437
+ );
438
+ return false;
439
+ }
440
+ if (locator.retrievedAt !== source.observedAt) {
441
+ errors.push(
442
+ issue(
443
+ 'SOURCE_VERSION_MISMATCH',
444
+ 'URL observation timestamp changed',
445
+ evidence.id,
446
+ ),
447
+ );
448
+ return false;
449
+ }
450
+ const validator = source.validatorState;
451
+ if (validator) {
452
+ const token = hashCanonicalJson({
453
+ etag: validator.etag ?? null,
454
+ lastModified: validator.lastModified ?? null,
455
+ });
456
+ if (locator.validatorToken !== token) {
457
+ errors.push(
458
+ issue(
459
+ 'SOURCE_VERSION_MISMATCH',
460
+ 'URL validator token changed',
461
+ evidence.id,
462
+ ),
463
+ );
464
+ return false;
465
+ }
466
+ }
467
+ const capturePath = source.capturePath ?? validator?.capturePath;
468
+ path = packetPath(packetRoot, capturePath);
469
+ if (path) await assertSafeExistingPath(packetRoot, path);
470
+ if (path) await retainFilesystemIdentity(filesystemIdentities, path);
471
+ expectedDigest = source.captureDigest ?? validator?.captureDigest;
472
+ content = await readFile(path, 'utf8');
473
+ } else if (source.kind === 'command-output') {
474
+ if (locator.artifactPath !== source.outputPath) {
475
+ errors.push(
476
+ issue(
477
+ 'SOURCE_VERSION_MISMATCH',
478
+ 'Command artifact changed',
479
+ evidence.id,
480
+ ),
481
+ );
482
+ return false;
483
+ }
484
+ if (locator.commandDigest !== hashCanonicalJson(source.argv)) {
485
+ errors.push(
486
+ issue(
487
+ 'SOURCE_VERSION_MISMATCH',
488
+ 'Command identity changed',
489
+ evidence.id,
490
+ ),
491
+ );
492
+ return false;
493
+ }
494
+ path = packetPath(packetRoot, source.outputPath);
495
+ if (path) await assertSafeExistingPath(packetRoot, path);
496
+ if (path) await retainFilesystemIdentity(filesystemIdentities, path);
497
+ expectedDigest = source.outputDigest;
498
+ content = await readFile(path, 'utf8');
499
+ } else if (source.kind === 'connected-resource') {
500
+ if (
501
+ locator.system !== source.system ||
502
+ locator.resourceId !== source.resourceId
503
+ ) {
504
+ errors.push(
505
+ issue(
506
+ 'SOURCE_VERSION_MISMATCH',
507
+ 'Connected resource identity changed',
508
+ evidence.id,
509
+ ),
510
+ );
511
+ return false;
512
+ }
513
+ if (
514
+ source.resourceVersion &&
515
+ locator.resourceVersion !== source.resourceVersion
516
+ ) {
517
+ errors.push(
518
+ issue(
519
+ 'SOURCE_VERSION_MISMATCH',
520
+ 'Connected resource version changed',
521
+ evidence.id,
522
+ ),
523
+ );
524
+ return false;
525
+ }
526
+ if (
527
+ source.retrievalToken &&
528
+ locator.retrievalToken !== source.retrievalToken
529
+ ) {
530
+ errors.push(
531
+ issue(
532
+ 'SOURCE_VERSION_MISMATCH',
533
+ 'Connected resource retrieval token changed',
534
+ evidence.id,
535
+ ),
536
+ );
537
+ return false;
538
+ }
539
+ if (locator.retrievedAt !== source.observedAt) {
540
+ errors.push(
541
+ issue(
542
+ 'SOURCE_VERSION_MISMATCH',
543
+ 'Connected resource observation timestamp changed',
544
+ evidence.id,
545
+ ),
546
+ );
547
+ return false;
548
+ }
549
+ path = packetPath(packetRoot, source.capturePath);
550
+ if (path) await assertSafeExistingPath(packetRoot, path);
551
+ if (path) await retainFilesystemIdentity(filesystemIdentities, path);
552
+ expectedDigest = source.captureDigest;
553
+ content = await readFile(path, 'utf8');
554
+ }
555
+ } catch (error) {
556
+ errors.push(
557
+ issue(
558
+ error?.code === 'SYMLINK_ESCAPE'
559
+ ? 'SYMLINK_ESCAPE'
560
+ : 'SOURCE_UNAVAILABLE',
561
+ error?.code === 'SYMLINK_ESCAPE'
562
+ ? 'Pinned source path contains a symlink'
563
+ : 'Pinned source could not be reopened',
564
+ evidence.id,
565
+ ),
566
+ );
567
+ return false;
568
+ }
569
+
570
+ if (!path || (await hashFile(path)) !== expectedDigest) {
571
+ errors.push(
572
+ issue('SOURCE_DRIFT', 'Pinned source digest changed', evidence.id),
573
+ );
574
+ return false;
575
+ }
576
+
577
+ let observed = content;
578
+ if (
579
+ source.kind === 'repository' ||
580
+ source.kind === 'file' ||
581
+ source.kind === 'command-output'
582
+ ) {
583
+ observed = lineExcerpt(content, locator.lineStart, locator.lineEnd);
584
+ if (observed === null) {
585
+ errors.push(
586
+ issue(
587
+ 'INVALID_LOCATOR_RANGE',
588
+ 'Locator line range is invalid',
589
+ evidence.id,
590
+ ),
591
+ );
592
+ return false;
593
+ }
594
+ } else if (source.kind === 'connected-resource' && locator.fieldOrSection) {
595
+ try {
596
+ const parsed = JSON.parse(content);
597
+ observed = String(parsed[locator.fieldOrSection] ?? '');
598
+ } catch {
599
+ observed = content;
600
+ }
601
+ }
602
+
603
+ if (evidence.locatorValidation?.status === 'redacted-exact') {
604
+ if (
605
+ evidence.redaction?.applied !== true ||
606
+ evidence.redaction?.originalPersisted !== false ||
607
+ evidence.contentHash !== undefined ||
608
+ redactSecrets(observed).trim() !== evidence.displayExcerpt.trim()
609
+ ) {
610
+ errors.push(
611
+ issue(
612
+ 'INVALID_REDACTED_EVIDENCE',
613
+ 'Redacted-exact evidence is not safely reproducible',
614
+ evidence.id,
615
+ ),
616
+ );
617
+ return false;
618
+ }
619
+ return true;
620
+ }
621
+
622
+ if (redactSecrets(observed) !== observed) {
623
+ errors.push(
624
+ issue(
625
+ 'UNREDACTED_SECRET',
626
+ 'Evidence contains a secret-like span and must use redacted-exact persistence',
627
+ evidence.id,
628
+ ),
629
+ );
630
+ return false;
631
+ }
632
+
633
+ if (!observed.includes(evidence.displayExcerpt)) {
634
+ errors.push(
635
+ issue(
636
+ 'LOCATOR_EXCERPT_MISMATCH',
637
+ 'Display excerpt does not match the resolved source',
638
+ evidence.id,
639
+ ),
640
+ );
641
+ return false;
642
+ }
643
+ if (
644
+ evidence.contentHash &&
645
+ evidence.contentHash !== hashCanonicalJson(evidence.displayExcerpt)
646
+ ) {
647
+ errors.push(
648
+ issue(
649
+ 'EVIDENCE_DIGEST_MISMATCH',
650
+ 'Evidence excerpt digest changed',
651
+ evidence.id,
652
+ ),
653
+ );
654
+ return false;
655
+ }
656
+ return true;
657
+ }
658
+
659
+ const passContracts = {
660
+ map: { kind: 'recon.raw-dossier', mode: 'map' },
661
+ gather: { kind: 'recon.raw-dossier', mode: 'gather' },
662
+ 'semantic-verification': {
663
+ kind: 'recon.review-result',
664
+ reviewKind: 'semantic',
665
+ },
666
+ adversarial: { kind: 'recon.review-result', reviewKind: 'adversarial' },
667
+ coverage: { kind: 'recon.review-result', reviewKind: 'coverage' },
668
+ reconciliation: {
669
+ kind: 'recon.review-result',
670
+ reviewKind: 'reconciliation',
671
+ },
672
+ 'redundant-verification': {
673
+ kind: 'recon.review-result',
674
+ reviewKind: 'redundant-verification',
675
+ },
676
+ 'contradiction-resolution': {
677
+ kind: 'recon.review-result',
678
+ reviewKind: 'contradiction-resolution',
679
+ },
680
+ };
681
+
682
+ const assuranceReviewKinds = new Set([
683
+ 'semantic',
684
+ 'adversarial',
685
+ 'coverage',
686
+ 'redundant-verification',
687
+ 'contradiction-resolution',
688
+ ]);
689
+
690
+ // A gap names a pass only when the mode appears as a whole token; `gather`
691
+ // must not be satisfied by a `redundant-gather` gap.
692
+ function gapNamesMode(gap, mode) {
693
+ return (
694
+ (gap.code === 'PASS_FAILED' || gap.code === 'PASS_OMITTED') &&
695
+ gap.material === true &&
696
+ typeof gap.message === 'string' &&
697
+ new RegExp(`(?:^|[^a-z-])${mode}(?:$|[^a-z-])`).test(gap.message)
698
+ );
699
+ }
700
+
701
+ function artifactIsComplete(artifact) {
702
+ return (
703
+ (!('status' in artifact) || artifact.status === 'complete') &&
704
+ (!('outcome' in artifact) || artifact.outcome === 'complete')
705
+ );
706
+ }
707
+
708
+ const reviewWaveMode = {
709
+ semantic: 'semantic-verification',
710
+ adversarial: 'adversarial',
711
+ coverage: 'coverage',
712
+ reconciliation: 'reconciliation',
713
+ 'redundant-verification': 'redundant-verification',
714
+ 'contradiction-resolution': 'contradiction-resolution',
715
+ };
716
+
717
+ function laneWaveMatches(wave, value) {
718
+ if (value.kind === 'recon.raw-dossier') {
719
+ return wave.mode === 'redundant-gather'
720
+ ? value.mode === 'gather'
721
+ : wave.mode === value.mode;
722
+ }
723
+ return reviewWaveMode[value.reviewKind] === wave.mode;
724
+ }
725
+
726
+ function validateApprovedLanes(manifest, artifactsById, errors) {
727
+ const lanes = new Map();
728
+ for (const wave of manifest.execution?.waves ?? []) {
729
+ for (const lane of wave.lanes ?? []) {
730
+ lanes.set(lane.laneId, { wave, lane });
731
+ }
732
+ }
733
+ const written = new Set();
734
+ for (const [id, { reference, value }] of artifactsById) {
735
+ if (value.runId !== manifest.run.id) continue;
736
+ const laneId =
737
+ value.kind === 'recon.raw-dossier'
738
+ ? value.laneId
739
+ : value.kind === 'recon.review-result'
740
+ ? value.reviewerLane
741
+ : null;
742
+ if (laneId === null) continue;
743
+ const approved = lanes.get(laneId);
744
+ if (
745
+ !approved ||
746
+ (value.kind === 'recon.raw-dossier' &&
747
+ approved.wave.waveId !== value.waveId) ||
748
+ !laneWaveMatches(approved.wave, value)
749
+ ) {
750
+ errors.push(
751
+ issue(
752
+ 'UNAPPROVED_LANE',
753
+ `Artifact ${id} was not written by an approved lane of a matching wave`,
754
+ id,
755
+ ),
756
+ );
757
+ continue;
758
+ }
759
+ const root = approved.lane.writeRoot;
760
+ if (reference.path !== root && !reference.path.startsWith(`${root}/`)) {
761
+ errors.push(
762
+ issue(
763
+ 'LANE_WRITE_PATH_VIOLATION',
764
+ `Artifact ${id} is outside its approved lane write root ${root}`,
765
+ id,
766
+ ),
767
+ );
768
+ }
769
+ // Only a complete artifact settles a lane; a failed or partial artifact
770
+ // still needs a material outcome gap.
771
+ if (artifactIsComplete(value)) written.add(laneId);
772
+ }
773
+ for (const [laneId, { wave }] of lanes) {
774
+ // The canonical ledger is the compile outcome; it carries no lane identity.
775
+ if (wave.conditional || wave.mode === 'compile' || written.has(laneId)) {
776
+ continue;
777
+ }
778
+ const hasOutcomeEvidence = (manifest.gaps ?? []).some((gap) =>
779
+ gapNamesMode(gap, wave.mode),
780
+ );
781
+ if (!hasOutcomeEvidence) {
782
+ errors.push(
783
+ issue(
784
+ 'MISSING_LANE_OUTCOME',
785
+ `Approved lane ${laneId} has neither a result nor a material ${wave.mode} outcome gap`,
786
+ `lane:${laneId}`,
787
+ ),
788
+ );
789
+ }
790
+ }
791
+ }
792
+
793
+ function collectCompletePasses(artifactsById, runId) {
794
+ const passes = new Map();
795
+ for (const [id, { value }] of artifactsById) {
796
+ if (value.runId !== runId || !artifactIsComplete(value)) continue;
797
+ for (const [mode, contract] of Object.entries(passContracts)) {
798
+ if (
799
+ value.kind === contract.kind &&
800
+ (contract.mode ? value.mode === contract.mode : true) &&
801
+ (contract.reviewKind ? value.reviewKind === contract.reviewKind : true)
802
+ ) {
803
+ const ids = passes.get(mode) ?? [];
804
+ ids.push(id);
805
+ passes.set(mode, ids);
806
+ }
807
+ }
808
+ }
809
+ const gatherLanes = new Set(
810
+ (passes.get('gather') ?? []).map(
811
+ (id) => artifactsById.get(id).value.laneId,
812
+ ),
813
+ );
814
+ if (gatherLanes.size >= 2)
815
+ passes.set('redundant-gather', passes.get('gather'));
816
+ for (const ids of passes.values()) ids.sort();
817
+ return passes;
818
+ }
819
+
820
+ function deriveAchievedProfile(passes) {
821
+ let achieved = null;
822
+ for (const profile of profiles) {
823
+ if (requiredPasses[profile].every((mode) => passes.has(mode))) {
824
+ achieved = profile;
825
+ }
826
+ }
827
+ return achieved;
828
+ }
829
+
830
+ function validatePassOutcomes(manifest, passes, errors) {
831
+ for (const mode of requiredPasses[manifest.run.requestedProfile] ?? []) {
832
+ if (passes.has(mode)) continue;
833
+ const hasOutcomeEvidence = (manifest.gaps ?? []).some((gap) =>
834
+ gapNamesMode(gap, mode),
835
+ );
836
+ if (!hasOutcomeEvidence) {
837
+ errors.push(
838
+ issue(
839
+ 'MISSING_PASS_OUTCOME_EVIDENCE',
840
+ `Requested ${manifest.run.requestedProfile} profile lacks a complete ${mode} result and a material PASS_FAILED or PASS_OMITTED gap naming it`,
841
+ `pass:${mode}`,
842
+ ),
843
+ );
844
+ }
845
+ }
846
+ }
847
+
848
+ function collectAssuranceReviewIds(artifactsById, runId) {
849
+ const ids = new Set();
850
+ for (const [id, { value }] of artifactsById) {
851
+ if (
852
+ value.kind === 'recon.review-result' &&
853
+ value.runId === runId &&
854
+ assuranceReviewKinds.has(value.reviewKind) &&
855
+ artifactIsComplete(value)
856
+ ) {
857
+ ids.add(id);
858
+ }
859
+ }
860
+ return ids;
861
+ }
862
+
863
+ function sameReference(left, right) {
864
+ return left?.path === right?.path && left?.digest === right?.digest;
865
+ }
866
+
867
+ function resolveTerminalReconciliation(
868
+ artifactsById,
869
+ artifactsByPath,
870
+ passes,
871
+ reconciliationRequired,
872
+ errors,
873
+ ) {
874
+ const terminalId = passes.get('reconciliation')?.[0];
875
+ const reconciliationResults = [...artifactsById.values()].filter(
876
+ ({ value }) =>
877
+ value.kind === 'recon.review-result' &&
878
+ value.reviewKind === 'reconciliation',
879
+ );
880
+ const expectedCount = reconciliationRequired ? 1 : 0;
881
+ if (
882
+ reconciliationResults.length !== expectedCount ||
883
+ (reconciliationRequired &&
884
+ reconciliationResults[0]?.value.id !== terminalId)
885
+ ) {
886
+ errors.push(
887
+ issue(
888
+ 'SHADOW_RECONCILIATION',
889
+ 'The packet must contain exactly its one complete terminal reconciliation',
890
+ terminalId ?? '$.artifacts',
891
+ ),
892
+ );
893
+ }
894
+ const reconciliation = artifactsById.get(terminalId)?.value ?? null;
895
+ const priorLedger = reconciliation
896
+ ? ([...artifactsByPath.values()].find(
897
+ ({ reference, value }) =>
898
+ sameReference(reference, reconciliation.inputLedger) &&
899
+ value.kind === 'recon.claim-ledger',
900
+ )?.value ?? null)
901
+ : null;
902
+ return { reconciliation, priorLedger };
903
+ }
904
+
905
+ function reviewBriefBindsClaim(brief, reviewKind, claim, ledger, manifest) {
906
+ const projected =
907
+ reviewKind === 'semantic' || reviewKind === 'redundant-verification'
908
+ ? brief?.claims?.find((item) => item.id === claim.id)
909
+ : reviewKind === 'adversarial' ||
910
+ reviewKind === 'contradiction-resolution'
911
+ ? brief?.provisionalStatements?.find((item) => item.id === claim.id)
912
+ : brief?.claims?.find((item) => item.id === claim.id);
913
+ if (!projected || projected.statement !== claim.statement) return false;
914
+ if (reviewKind !== 'semantic' && reviewKind !== 'redundant-verification') {
915
+ return Object.keys(projected).sort().join(',') === 'id,statement';
916
+ }
917
+ const evidenceById = new Map(ledger.evidence.map((item) => [item.id, item]));
918
+ const expectedEvidence = claim.evidence.map((link) => {
919
+ const evidence = evidenceById.get(link.evidenceId);
920
+ return evidence
921
+ ? {
922
+ id: evidence.id,
923
+ sourceId: evidence.sourceId,
924
+ displayExcerpt: evidence.displayExcerpt,
925
+ locator: evidence.locator,
926
+ }
927
+ : null;
928
+ });
929
+ if (
930
+ expectedEvidence.some((item) => !item) ||
931
+ hashCanonicalJson(projected.evidence) !==
932
+ hashCanonicalJson(expectedEvidence)
933
+ ) {
934
+ return false;
935
+ }
936
+ const sourceIds = new Set(expectedEvidence.map((item) => item.sourceId));
937
+ const expectedSources = manifest.sources.filter((source) =>
938
+ sourceIds.has(source.id),
939
+ );
940
+ return (
941
+ hashCanonicalJson(brief.sources) === hashCanonicalJson(expectedSources)
942
+ );
943
+ }
944
+
945
+ function validateReviewBindings(
946
+ manifest,
947
+ ledger,
948
+ artifactsById,
949
+ artifactsByPath,
950
+ reconciliationContext,
951
+ errors,
952
+ ) {
953
+ const claims = new Map(ledger.claims.map((claim) => [claim.id, claim]));
954
+ const priorLedger = reconciliationContext.priorLedger;
955
+ const priorClaims = new Map(
956
+ (priorLedger?.claims ?? []).map((claim) => [claim.id, claim]),
957
+ );
958
+ for (const { value: result } of artifactsById.values()) {
959
+ if (
960
+ result.kind !== 'recon.review-result' ||
961
+ result.reviewKind === 'reconciliation'
962
+ ) {
963
+ continue;
964
+ }
965
+ const brief = [...artifactsByPath.values()].find(
966
+ ({ reference, value }) =>
967
+ sameReference(reference, result.brief) &&
968
+ value.kind === 'recon.review-brief',
969
+ )?.value;
970
+ const expectedMode =
971
+ result.reviewKind === 'semantic' ||
972
+ result.reviewKind === 'redundant-verification'
973
+ ? 'verify'
974
+ : result.reviewKind === 'adversarial' ||
975
+ result.reviewKind === 'contradiction-resolution'
976
+ ? 'adversary'
977
+ : 'coverage';
978
+ if (!brief || brief.mode !== expectedMode || brief.runId !== ledger.runId) {
979
+ errors.push(
980
+ issue(
981
+ 'REVIEW_BRIEF_MISMATCH',
982
+ `Review ${result.id} does not resolve its exact typed brief`,
983
+ result.id,
984
+ ),
985
+ );
986
+ continue;
987
+ }
988
+ const seen = new Set();
989
+ for (const disposition of result.dispositions) {
990
+ const currentClaim = claims.get(disposition.claimId);
991
+ const priorClaim = priorClaims.get(disposition.claimId);
992
+ const claim = priorClaim ?? currentClaim;
993
+ const bindingLedger = priorClaim ? priorLedger : ledger;
994
+ if (
995
+ !claim ||
996
+ seen.has(disposition.claimId) ||
997
+ !reviewBriefBindsClaim(
998
+ brief,
999
+ result.reviewKind,
1000
+ claim,
1001
+ bindingLedger,
1002
+ manifest,
1003
+ )
1004
+ ) {
1005
+ errors.push(
1006
+ issue(
1007
+ 'REVIEW_BRIEF_MISMATCH',
1008
+ `Review ${result.id} dispositions must exactly match claim-bearing brief projections`,
1009
+ result.id,
1010
+ ),
1011
+ );
1012
+ }
1013
+ seen.add(disposition.claimId);
1014
+ }
1015
+ const suppliedEvidenceIds = new Set(
1016
+ result.newEvidence.map((evidence) => evidence.id),
1017
+ );
1018
+ const dispositionClaimIds = new Set(
1019
+ result.dispositions.map((disposition) => disposition.claimId),
1020
+ );
1021
+ const associationKeys = new Set();
1022
+ const associatedEvidenceIds = new Set();
1023
+ for (const association of result.evidenceAssociations ?? []) {
1024
+ const key = hashCanonicalJson(association);
1025
+ if (
1026
+ associationKeys.has(key) ||
1027
+ !suppliedEvidenceIds.has(association.evidenceId) ||
1028
+ !dispositionClaimIds.has(association.claimId) ||
1029
+ (!priorClaims.has(association.claimId) &&
1030
+ !claims.has(association.claimId))
1031
+ ) {
1032
+ errors.push(
1033
+ issue(
1034
+ 'REVIEW_EVIDENCE_ASSOCIATION_MISMATCH',
1035
+ `Review ${result.id} evidence associations must bind exact supplied evidence to disposition claims`,
1036
+ result.id,
1037
+ ),
1038
+ );
1039
+ }
1040
+ associationKeys.add(key);
1041
+ associatedEvidenceIds.add(association.evidenceId);
1042
+ }
1043
+ if (
1044
+ suppliedEvidenceIds.size !== associatedEvidenceIds.size ||
1045
+ [...suppliedEvidenceIds].some((id) => !associatedEvidenceIds.has(id))
1046
+ ) {
1047
+ errors.push(
1048
+ issue(
1049
+ 'REVIEW_EVIDENCE_ASSOCIATION_MISMATCH',
1050
+ `Review ${result.id} must associate every new evidence record`,
1051
+ result.id,
1052
+ ),
1053
+ );
1054
+ }
1055
+ if (result.reviewKind === 'contradiction-resolution') {
1056
+ const challengeOwners = new Map();
1057
+ for (const claim of ledger.claims) {
1058
+ for (const challenge of claim.challenges ?? []) {
1059
+ challengeOwners.set(challenge.id, claim.id);
1060
+ }
1061
+ }
1062
+ for (const disposition of result.contradictionDispositions ?? []) {
1063
+ const owner = challengeOwners.get(disposition.contradictionId);
1064
+ if (
1065
+ !owner ||
1066
+ !disposition.claimIds.includes(owner) ||
1067
+ !disposition.claimIds.every((claimId) => claims.has(claimId))
1068
+ ) {
1069
+ errors.push(
1070
+ issue(
1071
+ 'CONTRADICTION_BINDING_MISMATCH',
1072
+ `Contradiction ${disposition.contradictionId} is not bound to its affected claims`,
1073
+ result.id,
1074
+ ),
1075
+ );
1076
+ }
1077
+ }
1078
+ }
1079
+ }
1080
+ }
1081
+
1082
+ function validateReconciliation(
1083
+ manifest,
1084
+ ledger,
1085
+ artifactsById,
1086
+ reconciliationContext,
1087
+ passes,
1088
+ assuranceReviewIds,
1089
+ exactEvidence,
1090
+ reconciliationRequired,
1091
+ errors,
1092
+ ) {
1093
+ const { reconciliation, priorLedger: prior } = reconciliationContext;
1094
+ if (ledger.revision <= 1) {
1095
+ if (reconciliationRequired) {
1096
+ errors.push(
1097
+ issue(
1098
+ 'RECONCILIATION_REVISION_MISMATCH',
1099
+ 'Standard and thorough canonical ledgers must be the next revision of the bound prior ledger',
1100
+ '$.revision',
1101
+ ),
1102
+ );
1103
+ }
1104
+ return;
1105
+ }
1106
+ if (!reconciliation || reconciliation.status !== 'complete') {
1107
+ errors.push(
1108
+ issue(
1109
+ 'MISSING_RECONCILIATION_RESULT',
1110
+ 'A revised ledger requires a complete reconciliation result',
1111
+ '$.revision',
1112
+ ),
1113
+ );
1114
+ return;
1115
+ }
1116
+ if (reconciliation.runId !== ledger.runId) {
1117
+ errors.push(
1118
+ issue(
1119
+ 'REVIEW_RUN_MISMATCH',
1120
+ 'Reconciliation result belongs to another run',
1121
+ reconciliation.id,
1122
+ ),
1123
+ );
1124
+ }
1125
+ if (
1126
+ !prior ||
1127
+ prior.revision !== reconciliation.inputLedger?.revision ||
1128
+ reconciliation.outputRevision !== ledger.revision ||
1129
+ prior.revision + 1 !== ledger.revision
1130
+ ) {
1131
+ errors.push(
1132
+ issue(
1133
+ 'RECONCILIATION_REVISION_MISMATCH',
1134
+ 'Reconciliation must bind the previous and current ledger revisions',
1135
+ reconciliation.id,
1136
+ ),
1137
+ );
1138
+ }
1139
+ if (!prior) return;
1140
+ const priorClaims = new Map(prior.claims.map((claim) => [claim.id, claim]));
1141
+ const currentClaims = new Map(
1142
+ ledger.claims.map((claim) => [claim.id, claim]),
1143
+ );
1144
+ const expectedAdditions = [...currentClaims.keys()].filter(
1145
+ (id) => !priorClaims.has(id),
1146
+ );
1147
+ const expectedRemovals = [...priorClaims.keys()].filter(
1148
+ (id) => !currentClaims.has(id),
1149
+ );
1150
+ if (
1151
+ JSON.stringify(reconciliation.additions) !==
1152
+ JSON.stringify(expectedAdditions) ||
1153
+ JSON.stringify(reconciliation.removals) !== JSON.stringify(expectedRemovals)
1154
+ ) {
1155
+ errors.push(
1156
+ issue(
1157
+ 'RECONCILIATION_MEMBERSHIP_MISMATCH',
1158
+ 'Reconciliation additions and removals must exactly match ledger membership changes',
1159
+ reconciliation.id,
1160
+ ),
1161
+ );
1162
+ }
1163
+ const removalDispositions = new Map(
1164
+ (reconciliation.removalDispositions ?? []).map((item) => [
1165
+ item.claimId,
1166
+ item,
1167
+ ]),
1168
+ );
1169
+ for (const claimId of expectedRemovals) {
1170
+ const authorization = removalDispositions.get(claimId);
1171
+ const review = artifactsById.get(authorization?.reviewId)?.value;
1172
+ const disposition = review?.dispositions?.find(
1173
+ (item) => item.claimId === claimId,
1174
+ );
1175
+ if (
1176
+ !authorization ||
1177
+ authorization.disposition !== 'rejected' ||
1178
+ !assuranceReviewIds.has(authorization.reviewId) ||
1179
+ !reconciliation.incorporatedReviewIds.includes(authorization.reviewId) ||
1180
+ disposition?.disposition !== 'rejected'
1181
+ ) {
1182
+ errors.push(
1183
+ issue(
1184
+ 'UNAUTHORIZED_CLAIM_REMOVAL',
1185
+ `Claim ${claimId} lacks a complete typed review disposition authorizing removal`,
1186
+ claimId,
1187
+ ),
1188
+ );
1189
+ }
1190
+ }
1191
+ if (removalDispositions.size !== expectedRemovals.length) {
1192
+ errors.push(
1193
+ issue(
1194
+ 'UNAUTHORIZED_CLAIM_REMOVAL',
1195
+ 'Removal dispositions must exactly match removed prior claims',
1196
+ reconciliation.id,
1197
+ ),
1198
+ );
1199
+ }
1200
+ const transitions = reconciliation.transitions ?? [];
1201
+ const transitionCounts = new Map();
1202
+ for (const transition of transitions) {
1203
+ transitionCounts.set(
1204
+ transition.claimId,
1205
+ (transitionCounts.get(transition.claimId) ?? 0) + 1,
1206
+ );
1207
+ }
1208
+ const incorporatedReviews = (reconciliation.incorporatedReviewIds ?? [])
1209
+ .map((id) => artifactsById.get(id)?.value)
1210
+ .filter(Boolean);
1211
+ const expectedNewEvidence = [];
1212
+ const allowedNewEvidence = new Map();
1213
+ const expectedAssociationsByClaim = new Map();
1214
+ const associationKeys = new Set();
1215
+ for (const review of incorporatedReviews) {
1216
+ const reviewEvidenceIds = new Set(
1217
+ (review.newEvidence ?? []).map((evidence) => evidence.id),
1218
+ );
1219
+ const dispositionClaimIds = new Set(
1220
+ (review.dispositions ?? []).map((disposition) => disposition.claimId),
1221
+ );
1222
+ for (const evidence of review.newEvidence ?? []) {
1223
+ if (allowedNewEvidence.has(evidence.id)) {
1224
+ errors.push(
1225
+ issue(
1226
+ 'RECONCILIATION_EVIDENCE_INVENTION',
1227
+ `Evidence ${evidence.id} was supplied more than once`,
1228
+ evidence.id,
1229
+ ),
1230
+ );
1231
+ continue;
1232
+ }
1233
+ allowedNewEvidence.set(evidence.id, evidence);
1234
+ expectedNewEvidence.push(evidence);
1235
+ }
1236
+ for (const association of review.evidenceAssociations ?? []) {
1237
+ const key = hashCanonicalJson(association);
1238
+ if (
1239
+ associationKeys.has(key) ||
1240
+ !reviewEvidenceIds.has(association.evidenceId) ||
1241
+ !dispositionClaimIds.has(association.claimId)
1242
+ ) {
1243
+ errors.push(
1244
+ issue(
1245
+ 'RECONCILIATION_EVIDENCE_ASSOCIATION_MISMATCH',
1246
+ `Review ${review.id} contains an invalid incorporated evidence association`,
1247
+ review.id,
1248
+ ),
1249
+ );
1250
+ }
1251
+ associationKeys.add(key);
1252
+ const links = expectedAssociationsByClaim.get(association.claimId) ?? [];
1253
+ links.push({
1254
+ evidenceId: association.evidenceId,
1255
+ relation: association.relation,
1256
+ });
1257
+ expectedAssociationsByClaim.set(association.claimId, links);
1258
+ }
1259
+ }
1260
+ for (const [claimId, priorClaim] of priorClaims) {
1261
+ const currentClaim = currentClaims.get(claimId);
1262
+ if (!currentClaim) continue;
1263
+ for (const field of ['statement', 'qualifications']) {
1264
+ if (
1265
+ hashCanonicalJson(priorClaim[field]) !==
1266
+ hashCanonicalJson(currentClaim[field])
1267
+ ) {
1268
+ errors.push(
1269
+ issue(
1270
+ 'RECONCILIATION_CONTINUITY_MISMATCH',
1271
+ `Claim ${claimId} changed ${field} across reconciliation`,
1272
+ claimId,
1273
+ ),
1274
+ );
1275
+ }
1276
+ }
1277
+ const expectedEvidenceLinks = [
1278
+ ...priorClaim.evidence,
1279
+ ...(expectedAssociationsByClaim.get(claimId) ?? []),
1280
+ ];
1281
+ if (
1282
+ hashCanonicalJson(expectedEvidenceLinks) !==
1283
+ hashCanonicalJson(currentClaim.evidence)
1284
+ ) {
1285
+ errors.push(
1286
+ issue(
1287
+ 'RECONCILIATION_CONTINUITY_MISMATCH',
1288
+ `Claim ${claimId} evidence links do not preserve prior bytes plus exact incorporated associations`,
1289
+ claimId,
1290
+ ),
1291
+ );
1292
+ }
1293
+ for (const field of ['derivedFrom', 'challenges']) {
1294
+ if (
1295
+ hashCanonicalJson(priorClaim[field]) !==
1296
+ hashCanonicalJson(currentClaim[field])
1297
+ ) {
1298
+ errors.push(
1299
+ issue(
1300
+ 'RECONCILIATION_CONTINUITY_MISMATCH',
1301
+ `Claim ${claimId} changed ${field} without a typed reconciliation input`,
1302
+ claimId,
1303
+ ),
1304
+ );
1305
+ }
1306
+ }
1307
+ const expectedReviewIds = [
1308
+ ...new Set([
1309
+ ...priorClaim.reviewIds,
1310
+ ...[...artifactsById.values()]
1311
+ .filter(
1312
+ ({ value }) =>
1313
+ reconciliation.incorporatedReviewIds.includes(value.id) &&
1314
+ value.dispositions?.some((item) => item.claimId === claimId),
1315
+ )
1316
+ .map(({ value }) => value.id),
1317
+ ]),
1318
+ ];
1319
+ if (
1320
+ expectedReviewIds.length !== new Set(currentClaim.reviewIds).size ||
1321
+ expectedReviewIds.some((id) => !currentClaim.reviewIds.includes(id))
1322
+ ) {
1323
+ errors.push(
1324
+ issue(
1325
+ 'RECONCILIATION_REVIEW_MISMATCH',
1326
+ `Claim ${claimId} review membership is not derived from incorporated results`,
1327
+ claimId,
1328
+ ),
1329
+ );
1330
+ }
1331
+ const expectedCount = priorClaim.status === currentClaim.status ? 0 : 1;
1332
+ const transition = transitions.find((item) => item.claimId === claimId);
1333
+ if (
1334
+ (transitionCounts.get(claimId) ?? 0) !== expectedCount ||
1335
+ (expectedCount === 1 &&
1336
+ (transition.from !== priorClaim.status ||
1337
+ transition.to !== currentClaim.status))
1338
+ ) {
1339
+ errors.push(
1340
+ issue(
1341
+ 'RECONCILIATION_TRANSITION_BINDING_MISMATCH',
1342
+ `Claim ${claimId} is not bound to exactly its prior/current state transition`,
1343
+ claimId,
1344
+ ),
1345
+ );
1346
+ }
1347
+ }
1348
+ const priorEvidence = new Map(prior.evidence.map((item) => [item.id, item]));
1349
+ for (const evidence of ledger.evidence) {
1350
+ const previous = priorEvidence.get(evidence.id);
1351
+ if (
1352
+ previous &&
1353
+ hashCanonicalJson(previous) !== hashCanonicalJson(evidence)
1354
+ ) {
1355
+ errors.push(
1356
+ issue(
1357
+ 'RECONCILIATION_EVIDENCE_DRIFT',
1358
+ `Evidence ${evidence.id} changed`,
1359
+ evidence.id,
1360
+ ),
1361
+ );
1362
+ }
1363
+ const allowed = allowedNewEvidence.get(evidence.id);
1364
+ if (
1365
+ !previous &&
1366
+ (!allowed || hashCanonicalJson(allowed) !== hashCanonicalJson(evidence))
1367
+ ) {
1368
+ errors.push(
1369
+ issue(
1370
+ 'RECONCILIATION_EVIDENCE_INVENTION',
1371
+ `Evidence ${evidence.id} was not supplied by an incorporated review`,
1372
+ evidence.id,
1373
+ ),
1374
+ );
1375
+ }
1376
+ }
1377
+ if (
1378
+ hashCanonicalJson([...prior.evidence, ...expectedNewEvidence]) !==
1379
+ hashCanonicalJson(ledger.evidence)
1380
+ ) {
1381
+ errors.push(
1382
+ issue(
1383
+ 'RECONCILIATION_EVIDENCE_INVENTION',
1384
+ 'Canonical evidence must preserve prior bytes and append exactly incorporated review evidence',
1385
+ reconciliation.id,
1386
+ ),
1387
+ );
1388
+ }
1389
+ if (
1390
+ JSON.stringify(reconciliation.transitions) !==
1391
+ JSON.stringify(ledger.transitions)
1392
+ ) {
1393
+ errors.push(
1394
+ issue(
1395
+ 'RECONCILIATION_TRANSITION_MISMATCH',
1396
+ 'Reconciliation transitions differ from the canonical ledger',
1397
+ reconciliation.id,
1398
+ ),
1399
+ );
1400
+ }
1401
+ const incorporated = new Set(reconciliation.incorporatedReviewIds ?? []);
1402
+ if (
1403
+ assuranceReviewIds.size !== incorporated.size ||
1404
+ [...assuranceReviewIds].some((id) => !incorporated.has(id))
1405
+ ) {
1406
+ errors.push(
1407
+ issue(
1408
+ 'RECONCILIATION_REVIEW_MISMATCH',
1409
+ 'Reconciliation does not incorporate the exact independent review set',
1410
+ reconciliation.id,
1411
+ ),
1412
+ );
1413
+ }
1414
+
1415
+ const coverageResults = (passes.get('coverage') ?? [])
1416
+ .map((id) => artifactsById.get(id)?.value)
1417
+ .filter(Boolean);
1418
+ const coverageDispositions = new Map(
1419
+ (reconciliation.coverageDispositions ?? []).map((item) => [
1420
+ item.findingId,
1421
+ item,
1422
+ ]),
1423
+ );
1424
+ for (const coverage of coverageResults) {
1425
+ for (const finding of coverage.coverageFindings ?? []) {
1426
+ const gap = manifest.gaps.find((item) => item.id === finding.gapId);
1427
+ const claimsExist = finding.claimIds.every((id) => currentClaims.has(id));
1428
+ if (
1429
+ !gap ||
1430
+ gap.material !== finding.material ||
1431
+ gap.code !== finding.code ||
1432
+ gap.message !== finding.message ||
1433
+ !gap.coverageFindingIds?.includes(finding.id) ||
1434
+ !finding.claimIds.every((id) => gap.claimIds?.includes(id)) ||
1435
+ !claimsExist
1436
+ ) {
1437
+ errors.push(
1438
+ issue(
1439
+ 'COVERAGE_GAP_MISMATCH',
1440
+ `Coverage finding ${finding.id} is not bound to an exact manifest gap and affected claims`,
1441
+ finding.id,
1442
+ ),
1443
+ );
1444
+ }
1445
+ const disposition = coverageDispositions.get(finding.id);
1446
+ if (!disposition || disposition.gapId !== finding.gapId) {
1447
+ errors.push(
1448
+ issue(
1449
+ 'COVERAGE_RECONCILIATION_MISMATCH',
1450
+ `Coverage finding ${finding.id} lacks an exact reconciliation disposition`,
1451
+ finding.id,
1452
+ ),
1453
+ );
1454
+ }
1455
+ if (disposition?.disposition === 'accepted-gap') {
1456
+ for (const claimId of finding.claimIds) {
1457
+ const claim = currentClaims.get(claimId);
1458
+ const coverageDisposition = coverage.dispositions.find(
1459
+ (item) => item.claimId === claimId,
1460
+ );
1461
+ if (
1462
+ finding.material === true &&
1463
+ (claim?.status === 'verified' ||
1464
+ coverageDisposition?.disposition !== 'gap')
1465
+ ) {
1466
+ errors.push(
1467
+ issue(
1468
+ 'MATERIAL_COVERAGE_ASSURANCE_EXCEEDED',
1469
+ `Material unresolved coverage finding ${finding.id} requires claim ${claimId} to be downgraded`,
1470
+ claimId,
1471
+ ),
1472
+ );
1473
+ }
1474
+ }
1475
+ } else if (disposition?.disposition === 'resolved') {
1476
+ if (
1477
+ !disposition.evidenceIds?.every(
1478
+ (id) =>
1479
+ exactEvidence.has(id) &&
1480
+ ledger.evidence.some((evidence) => evidence.id === id),
1481
+ )
1482
+ ) {
1483
+ errors.push(
1484
+ issue(
1485
+ 'COVERAGE_RESOLUTION_EVIDENCE_INVALID',
1486
+ `Coverage finding ${finding.id} is not resolved by exact typed evidence`,
1487
+ finding.id,
1488
+ ),
1489
+ );
1490
+ }
1491
+ }
1492
+ }
1493
+ }
1494
+ }
1495
+
1496
+ function validateDerivedSourceGaps(manifest, ledger, errors) {
1497
+ const evidenceById = new Map(
1498
+ (ledger.evidence ?? []).map((item) => [item.id, item]),
1499
+ );
1500
+ const claimsBySource = new Map();
1501
+ for (const claim of ledger.claims ?? []) {
1502
+ for (const link of claim.evidence ?? []) {
1503
+ const sourceId = evidenceById.get(link.evidenceId)?.sourceId;
1504
+ if (!sourceId) continue;
1505
+ const ids = claimsBySource.get(sourceId) ?? new Set();
1506
+ ids.add(claim.id);
1507
+ claimsBySource.set(sourceId, ids);
1508
+ }
1509
+ }
1510
+ for (const source of manifest.sources ?? []) {
1511
+ if (source.available === true && source.validationState === 'pinned')
1512
+ continue;
1513
+ const affectedClaims = claimsBySource.get(source.id) ?? new Set();
1514
+ const gap = (manifest.gaps ?? []).find(
1515
+ (item) =>
1516
+ item.sourceIds?.includes(source.id) &&
1517
+ [...affectedClaims].every((claimId) =>
1518
+ item.claimIds?.includes(claimId),
1519
+ ),
1520
+ );
1521
+ if (!gap) {
1522
+ errors.push(
1523
+ issue(
1524
+ 'MISSING_SOURCE_GAP',
1525
+ `Ineligible source ${source.id} requires an explicit affected-claim gap`,
1526
+ `source:${source.id}`,
1527
+ ),
1528
+ );
1529
+ } else if (gap.material !== true || manifest.run.status !== 'partial') {
1530
+ errors.push(
1531
+ issue(
1532
+ 'SOURCE_GAP_REQUIRES_MATERIAL_PARTIAL',
1533
+ `Ineligible source ${source.id} requires a material gap and partial publication`,
1534
+ `source:${source.id}`,
1535
+ ),
1536
+ );
1537
+ }
1538
+ if (
1539
+ [...affectedClaims].some((claimId) =>
1540
+ ['supported', 'verified'].includes(
1541
+ ledger.claims.find((claim) => claim.id === claimId)?.status,
1542
+ ),
1543
+ )
1544
+ ) {
1545
+ errors.push(
1546
+ issue(
1547
+ 'CLAIM_ASSURANCE_INVALID',
1548
+ `Ineligible source ${source.id} cannot support assured claims`,
1549
+ `source:${source.id}`,
1550
+ ),
1551
+ );
1552
+ }
1553
+ }
1554
+ }
1555
+
1556
+ function validateAssurance(validatedRun, errors) {
1557
+ const { manifest, ledger, achievedProfile } = validatedRun;
1558
+ const exactEvidence = new Set(validatedRun.exactEvidenceIds);
1559
+ const assuranceReviewIds = new Set(validatedRun.assuranceReviewIds);
1560
+ const artifactsById = new Map(
1561
+ validatedRun.artifacts.map((artifact) => [artifact.id, artifact]),
1562
+ );
1563
+ const evidenceById = new Map(
1564
+ (ledger.evidence ?? []).map((item) => [item.id, item]),
1565
+ );
1566
+ const priorClaims = new Map(
1567
+ (validatedRun.priorLedger?.claims ?? []).map((claim) => [claim.id, claim]),
1568
+ );
1569
+ const claimIds = new Set((ledger.claims ?? []).map((claim) => claim.id));
1570
+ for (const claimId of ledger.synthesis?.keyClaimIds ?? []) {
1571
+ if (!claimIds.has(claimId)) {
1572
+ errors.push(
1573
+ issue(
1574
+ 'SYNTHESIS_REFERENCE_MISSING',
1575
+ `Synthesis key claim ${claimId} does not resolve to an existing claim`,
1576
+ '$.synthesis.keyClaimIds',
1577
+ ),
1578
+ );
1579
+ }
1580
+ }
1581
+ const questionIds = new Set(
1582
+ (ledger.unresolvedQuestions ?? []).map((q) => q.id),
1583
+ );
1584
+ for (const questionId of ledger.synthesis?.unresolvedQuestionIds ?? []) {
1585
+ if (!questionIds.has(questionId)) {
1586
+ errors.push(
1587
+ issue(
1588
+ 'SYNTHESIS_REFERENCE_MISSING',
1589
+ `Synthesis unresolved question ${questionId} does not resolve to an existing question`,
1590
+ '$.synthesis.unresolvedQuestionIds',
1591
+ ),
1592
+ );
1593
+ }
1594
+ }
1595
+ for (const claim of ledger.claims ?? []) {
1596
+ if (
1597
+ (claim.status === 'supported' || claim.status === 'verified') &&
1598
+ achievedProfile === null
1599
+ ) {
1600
+ errors.push(
1601
+ issue(
1602
+ 'PROFILE_ASSURANCE_EXCEEDED',
1603
+ 'Supported assurance requires the quick profile to be achieved',
1604
+ claim.id,
1605
+ ),
1606
+ );
1607
+ }
1608
+ if (claim.status === 'verified' && achievedProfile === 'quick') {
1609
+ errors.push(
1610
+ issue(
1611
+ 'PROFILE_ASSURANCE_EXCEEDED',
1612
+ 'A quick packet cannot contain verified claims',
1613
+ claim.id,
1614
+ ),
1615
+ );
1616
+ }
1617
+ if (claim.status === 'verified') {
1618
+ const unresolved = (claim.challenges ?? []).some(
1619
+ (challenge) =>
1620
+ challenge.material === true && challenge.status === 'unresolved',
1621
+ );
1622
+ if (unresolved) {
1623
+ errors.push(
1624
+ issue(
1625
+ 'UNRESOLVED_VERIFICATION_CHALLENGE',
1626
+ 'A verified claim has an unresolved material challenge',
1627
+ claim.id,
1628
+ ),
1629
+ );
1630
+ }
1631
+ if (
1632
+ !Array.isArray(claim.reviewIds) ||
1633
+ new Set(claim.reviewIds).size !== claim.reviewIds.length
1634
+ ) {
1635
+ errors.push(
1636
+ issue(
1637
+ 'DUPLICATE_REVIEW_ID',
1638
+ 'Verified claim review identifiers must be unique',
1639
+ claim.id,
1640
+ ),
1641
+ );
1642
+ }
1643
+ const required = new Map([
1644
+ ['semantic', 'affirmed'],
1645
+ ['adversarial', 'unchallenged'],
1646
+ ['coverage', 'covered'],
1647
+ ]);
1648
+ if (achievedProfile === 'thorough') {
1649
+ required.set('redundant-verification', 'affirmed');
1650
+ }
1651
+ const satisfied = new Set();
1652
+ const reviewerLanes = new Set();
1653
+ for (const reviewId of new Set(claim.reviewIds ?? [])) {
1654
+ const artifact = artifactsById.get(reviewId)?.value;
1655
+ if (!artifact || artifact.kind !== 'recon.review-result') {
1656
+ errors.push(
1657
+ issue(
1658
+ 'MISSING_INDEPENDENT_REVIEW',
1659
+ `Review ${reviewId} does not resolve to a hashed result`,
1660
+ claim.id,
1661
+ ),
1662
+ );
1663
+ continue;
1664
+ }
1665
+ if (!assuranceReviewIds.has(reviewId)) {
1666
+ errors.push(
1667
+ issue(
1668
+ 'INCOMPLETE_INDEPENDENT_REVIEW',
1669
+ `Review ${reviewId} is not a complete same-run assurance result`,
1670
+ claim.id,
1671
+ ),
1672
+ );
1673
+ }
1674
+ if (
1675
+ !required.has(artifact.reviewKind) &&
1676
+ artifact.reviewKind !== 'contradiction-resolution'
1677
+ ) {
1678
+ errors.push(
1679
+ issue(
1680
+ 'REVIEW_KIND_MISMATCH',
1681
+ `Review ${reviewId} has an ineligible kind`,
1682
+ claim.id,
1683
+ ),
1684
+ );
1685
+ continue;
1686
+ }
1687
+ if (artifact.runId !== ledger.runId) {
1688
+ errors.push(
1689
+ issue(
1690
+ 'REVIEW_RUN_MISMATCH',
1691
+ `Review ${reviewId} belongs to another run`,
1692
+ claim.id,
1693
+ ),
1694
+ );
1695
+ }
1696
+ if (artifact.status !== 'complete') {
1697
+ errors.push(
1698
+ issue(
1699
+ 'INCOMPLETE_INDEPENDENT_REVIEW',
1700
+ `Review ${reviewId} is not complete`,
1701
+ claim.id,
1702
+ ),
1703
+ );
1704
+ }
1705
+ if (reviewerLanes.has(artifact.reviewerLane)) {
1706
+ errors.push(
1707
+ issue(
1708
+ 'MISSING_INDEPENDENT_REVIEW',
1709
+ `Review lane ${artifact.reviewerLane} is reused`,
1710
+ claim.id,
1711
+ ),
1712
+ );
1713
+ }
1714
+ reviewerLanes.add(artifact.reviewerLane);
1715
+ const brief = [...artifactsById.values()].find(
1716
+ ({ reference, value }) =>
1717
+ sameReference(reference, artifact.brief) &&
1718
+ value.kind === 'recon.review-brief',
1719
+ )?.value;
1720
+ const expectedMode =
1721
+ artifact.reviewKind === 'semantic' ||
1722
+ artifact.reviewKind === 'redundant-verification'
1723
+ ? 'verify'
1724
+ : artifact.reviewKind === 'adversarial' ||
1725
+ artifact.reviewKind === 'contradiction-resolution'
1726
+ ? 'adversary'
1727
+ : 'coverage';
1728
+ if (
1729
+ !brief ||
1730
+ brief.runId !== ledger.runId ||
1731
+ brief.mode !== expectedMode ||
1732
+ !reviewBriefBindsClaim(
1733
+ brief,
1734
+ artifact.reviewKind,
1735
+ priorClaims.get(claim.id) ?? claim,
1736
+ priorClaims.has(claim.id) ? validatedRun.priorLedger : ledger,
1737
+ manifest,
1738
+ ) ||
1739
+ !artifact.permittedInputs?.some((reference) =>
1740
+ sameReference(reference, artifact.brief),
1741
+ )
1742
+ ) {
1743
+ errors.push(
1744
+ issue(
1745
+ 'REVIEW_BRIEF_MISMATCH',
1746
+ `Review ${reviewId} is not bound to the expected immutable brief`,
1747
+ claim.id,
1748
+ ),
1749
+ );
1750
+ }
1751
+ const dispositions = (artifact.dispositions ?? []).filter(
1752
+ (item) => item.claimId === claim.id,
1753
+ );
1754
+ const expectedDisposition =
1755
+ artifact.reviewKind === 'contradiction-resolution'
1756
+ ? 'resolved'
1757
+ : required.get(artifact.reviewKind);
1758
+ if (
1759
+ dispositions.length !== 1 ||
1760
+ dispositions[0].disposition !== expectedDisposition
1761
+ ) {
1762
+ errors.push(
1763
+ issue(
1764
+ 'REVIEW_DISPOSITION_MISMATCH',
1765
+ `Review ${reviewId} does not support verified disposition`,
1766
+ claim.id,
1767
+ ),
1768
+ );
1769
+ }
1770
+ if ((artifact.unresolvedIssues ?? []).length > 0) {
1771
+ errors.push(
1772
+ issue(
1773
+ 'REVIEW_DISPOSITION_MISMATCH',
1774
+ `Review ${reviewId} retains unresolved issues`,
1775
+ claim.id,
1776
+ ),
1777
+ );
1778
+ }
1779
+ if (required.has(artifact.reviewKind)) {
1780
+ satisfied.add(artifact.reviewKind);
1781
+ }
1782
+ }
1783
+ const missingReviewKinds = [...required.keys()].filter(
1784
+ (kind) => !satisfied.has(kind),
1785
+ );
1786
+ if (missingReviewKinds.length > 0) {
1787
+ errors.push(
1788
+ issue(
1789
+ 'MISSING_INDEPENDENT_REVIEW',
1790
+ `Verified claims require ${missingReviewKinds.join(', ')} reviews`,
1791
+ claim.id,
1792
+ ),
1793
+ );
1794
+ }
1795
+ }
1796
+ if (claim.status === 'supported' || claim.status === 'verified') {
1797
+ const supports = (claim.evidence ?? []).some((link) => {
1798
+ const evidence = evidenceById.get(link.evidenceId);
1799
+ return (
1800
+ link.relation === 'supports' &&
1801
+ evidence &&
1802
+ exactEvidence.has(evidence.id)
1803
+ );
1804
+ });
1805
+ if (!supports) {
1806
+ errors.push(
1807
+ issue(
1808
+ 'CLAIM_ASSURANCE_INVALID',
1809
+ 'Supported claims require exact valid evidence',
1810
+ claim.id,
1811
+ ),
1812
+ );
1813
+ }
1814
+ }
1815
+ }
1816
+
1817
+ if (manifest.run.achievedProfile !== achievedProfile) {
1818
+ errors.push(
1819
+ issue(
1820
+ 'ACHIEVED_PROFILE_MISMATCH',
1821
+ `Manifest claims ${manifest.run.achievedProfile}, derived ${achievedProfile}`,
1822
+ '$.run.achievedProfile',
1823
+ ),
1824
+ );
1825
+ }
1826
+ if (
1827
+ manifest.run.status === 'complete' &&
1828
+ achievedProfile !== manifest.run.requestedProfile
1829
+ ) {
1830
+ errors.push(
1831
+ issue(
1832
+ 'INCOMPLETE_PROFILE',
1833
+ 'Complete status requires the requested profile to be achieved',
1834
+ '$.run.status',
1835
+ ),
1836
+ );
1837
+ }
1838
+ const hasMaterialGap = (manifest.gaps ?? []).some(
1839
+ (gap) => gap.material === true,
1840
+ );
1841
+ if (manifest.run.status === 'complete' && hasMaterialGap) {
1842
+ errors.push(
1843
+ issue(
1844
+ 'MATERIAL_GAP_REQUIRES_PARTIAL',
1845
+ 'Complete publication cannot contain material gaps',
1846
+ '$.run.status',
1847
+ ),
1848
+ );
1849
+ }
1850
+ const requestedIndex = profiles.indexOf(manifest.run.requestedProfile);
1851
+ const achievedIndex = profiles.indexOf(achievedProfile);
1852
+ if (
1853
+ manifest.run.status === 'partial' &&
1854
+ achievedIndex >= requestedIndex &&
1855
+ !hasMaterialGap
1856
+ ) {
1857
+ errors.push(
1858
+ issue(
1859
+ 'INVALID_PARTIAL_STATUS',
1860
+ 'Partial status requires a lower achieved profile',
1861
+ '$.run.status',
1862
+ ),
1863
+ );
1864
+ }
1865
+ }
1866
+
1867
+ export async function compileValidatedRun(packetDirectory) {
1868
+ const packetRoot = resolve(packetDirectory);
1869
+ const errors = [];
1870
+ const warnings = [];
1871
+ const filesystemIdentities = new Map();
1872
+ const canonicalByteDigests = new Map();
1873
+ let packetRootIdentity = null;
1874
+ try {
1875
+ packetRootIdentity = await assertCanonicalRoot(packetRoot);
1876
+ filesystemIdentities.set(packetRootIdentity.path, packetRootIdentity);
1877
+ } catch (error) {
1878
+ errors.push(
1879
+ issue(
1880
+ error?.code === 'SYMLINK_ESCAPE'
1881
+ ? 'SYMLINK_ESCAPE'
1882
+ : 'NON_CANONICAL_ROOT',
1883
+ 'Packet root must be a stable canonical realpath',
1884
+ packetRoot,
1885
+ ),
1886
+ );
1887
+ }
1888
+ const manifest = await readManagedJson(
1889
+ packetRoot,
1890
+ resolve(packetRoot, 'manifest.json'),
1891
+ 'INVALID_MANIFEST_JSON',
1892
+ errors,
1893
+ canonicalByteDigests,
1894
+ 'manifest.json',
1895
+ );
1896
+ const ledger = await readManagedJson(
1897
+ packetRoot,
1898
+ resolve(packetRoot, 'claims.json'),
1899
+ 'INVALID_LEDGER_JSON',
1900
+ errors,
1901
+ canonicalByteDigests,
1902
+ 'claims.json',
1903
+ );
1904
+
1905
+ if (manifest) errors.push(...validateArtifactShape(manifest).errors);
1906
+ if (ledger) errors.push(...validateArtifactShape(ledger).errors);
1907
+
1908
+ if (manifest && manifest.request?.outputPath !== packetRoot) {
1909
+ errors.push(
1910
+ issue(
1911
+ 'OUTPUT_ROOT_MISMATCH',
1912
+ 'Declared output path must equal the canonical packet root',
1913
+ '$.request.outputPath',
1914
+ ),
1915
+ );
1916
+ }
1917
+
1918
+ if (manifest && ledger && manifest.run?.id !== ledger.runId) {
1919
+ errors.push(issue('RUN_ID_MISMATCH', 'Manifest and ledger run IDs differ'));
1920
+ }
1921
+
1922
+ if (isObject(manifest?.execution?.approval)) {
1923
+ const expected = hashCanonicalJson(
1924
+ approvalFingerprintInput(manifest.execution),
1925
+ );
1926
+ if (manifest.execution.approval.fingerprint !== expected) {
1927
+ errors.push(
1928
+ issue(
1929
+ 'APPROVAL_FINGERPRINT_MISMATCH',
1930
+ 'Approved execution envelope no longer matches its approval fingerprint',
1931
+ '$.execution.approval.fingerprint',
1932
+ ),
1933
+ );
1934
+ }
1935
+ } else if (manifest) {
1936
+ errors.push(
1937
+ issue(
1938
+ 'MISSING_APPROVAL_ENVELOPE',
1939
+ 'Manifest lacks approval-bound execution evidence',
1940
+ ),
1941
+ );
1942
+ }
1943
+
1944
+ const { artifactsByPath, artifactsById, validatedByteDigests } =
1945
+ await validateReferences(
1946
+ packetRoot,
1947
+ collectReferences(manifest, ledger),
1948
+ errors,
1949
+ );
1950
+ for (const [path, digest] of validatedByteDigests) {
1951
+ const retained = canonicalByteDigests.get(path);
1952
+ if (retained && retained !== digest) {
1953
+ errors.push(
1954
+ issue(
1955
+ 'ARTIFACT_DIGEST_MISMATCH',
1956
+ `Canonical artifact bytes changed during validation: ${path}`,
1957
+ path,
1958
+ ),
1959
+ );
1960
+ }
1961
+ canonicalByteDigests.set(path, digest);
1962
+ }
1963
+ if (manifest && !artifactsByPath.has('claims.json')) {
1964
+ errors.push(
1965
+ issue(
1966
+ 'MISSING_CANONICAL_LEDGER_REFERENCE',
1967
+ 'Manifest must bind claims.json by exact digest',
1968
+ '$.artifacts',
1969
+ ),
1970
+ );
1971
+ }
1972
+ validatePersistedSecretSafety(
1973
+ [
1974
+ ['manifest.json', manifest],
1975
+ ['claims.json', ledger],
1976
+ ...[...artifactsByPath.entries()].map(([path, artifact]) => [
1977
+ path,
1978
+ artifact.value,
1979
+ ]),
1980
+ ],
1981
+ errors,
1982
+ );
1983
+
1984
+ const exactEvidence = new Set();
1985
+ if (manifest && ledger) {
1986
+ validateDerivedSourceGaps(manifest, ledger, errors);
1987
+ const sources = new Map(
1988
+ manifest.sources.map((source) => [source.id, source]),
1989
+ );
1990
+ for (const source of sources.values()) {
1991
+ try {
1992
+ await retainDeclaredSourceIdentities(
1993
+ packetRoot,
1994
+ source,
1995
+ filesystemIdentities,
1996
+ );
1997
+ } catch (error) {
1998
+ const code = [
1999
+ 'SYMLINK_ESCAPE',
2000
+ 'PATH_ESCAPE',
2001
+ 'NON_CANONICAL_ROOT',
2002
+ ].includes(error?.code)
2003
+ ? error.code
2004
+ : 'SOURCE_UNAVAILABLE';
2005
+ errors.push(
2006
+ issue(
2007
+ code,
2008
+ 'Declared source filesystem identity is not stable and canonical',
2009
+ `source:${source.id}`,
2010
+ ),
2011
+ );
2012
+ }
2013
+ }
2014
+ for (const evidence of ledger.evidence ?? []) {
2015
+ const source = sources.get(evidence.sourceId);
2016
+ if (!source) {
2017
+ errors.push(
2018
+ issue(
2019
+ 'MISSING_SOURCE',
2020
+ `Unknown source ${evidence.sourceId}`,
2021
+ evidence.id,
2022
+ ),
2023
+ );
2024
+ continue;
2025
+ }
2026
+ const affectedClaims = (ledger.claims ?? []).filter((claim) =>
2027
+ (claim.evidence ?? []).some((link) => link.evidenceId === evidence.id),
2028
+ );
2029
+ const ineligibleGap = (manifest.gaps ?? []).find(
2030
+ (gap) =>
2031
+ gap.sourceIds?.includes(source.id) &&
2032
+ affectedClaims.every((claim) => gap.claimIds?.includes(claim.id)) &&
2033
+ affectedClaims.every(
2034
+ (claim) => !['supported', 'verified'].includes(claim.status),
2035
+ ),
2036
+ );
2037
+ if (
2038
+ await reopenEvidence(
2039
+ packetRoot,
2040
+ source,
2041
+ evidence,
2042
+ errors,
2043
+ filesystemIdentities,
2044
+ Boolean(ineligibleGap),
2045
+ )
2046
+ ) {
2047
+ exactEvidence.add(evidence.id);
2048
+ }
2049
+ }
2050
+ validateApprovedLanes(manifest, artifactsById, errors);
2051
+ const passes = collectCompletePasses(artifactsById, manifest.run.id);
2052
+ const achievedProfile = deriveAchievedProfile(passes);
2053
+ validatePassOutcomes(manifest, passes, errors);
2054
+ const assuranceReviewIds = collectAssuranceReviewIds(
2055
+ artifactsById,
2056
+ manifest.run.id,
2057
+ );
2058
+ const reconciliationRequired =
2059
+ achievedProfile === 'standard' || achievedProfile === 'thorough';
2060
+ const reconciliationContext = resolveTerminalReconciliation(
2061
+ artifactsById,
2062
+ artifactsByPath,
2063
+ passes,
2064
+ reconciliationRequired,
2065
+ errors,
2066
+ );
2067
+ validateReviewBindings(
2068
+ manifest,
2069
+ ledger,
2070
+ artifactsById,
2071
+ artifactsByPath,
2072
+ reconciliationContext,
2073
+ errors,
2074
+ );
2075
+ validateReconciliation(
2076
+ manifest,
2077
+ ledger,
2078
+ artifactsById,
2079
+ reconciliationContext,
2080
+ passes,
2081
+ assuranceReviewIds,
2082
+ exactEvidence,
2083
+ reconciliationRequired,
2084
+ errors,
2085
+ );
2086
+ for (const identity of filesystemIdentities.values()) {
2087
+ try {
2088
+ await assertUnchangedRoot(identity);
2089
+ } catch {
2090
+ errors.push(
2091
+ issue(
2092
+ 'ROOT_IDENTITY_CHANGED',
2093
+ 'Filesystem identity changed during validation',
2094
+ identity.path,
2095
+ ),
2096
+ );
2097
+ }
2098
+ }
2099
+ let validatedRun = null;
2100
+ if (
2101
+ packetRootIdentity &&
2102
+ isObject(manifest.run) &&
2103
+ Array.isArray(ledger.claims) &&
2104
+ Array.isArray(ledger.evidence)
2105
+ ) {
2106
+ validatedRun = createValidatedRun({
2107
+ packetRoot,
2108
+ filesystemIdentities,
2109
+ canonicalByteDigests,
2110
+ manifest,
2111
+ ledger,
2112
+ artifactsById,
2113
+ exactEvidence,
2114
+ passes,
2115
+ achievedProfile,
2116
+ assuranceReviewIds,
2117
+ reconciliationContext,
2118
+ });
2119
+ validateAssurance(validatedRun, errors);
2120
+ }
2121
+
2122
+ const valid = errors.length === 0;
2123
+ const publishable =
2124
+ valid &&
2125
+ (manifest.run.status === 'complete' || manifest.run.status === 'partial');
2126
+ if (valid && !publishable) {
2127
+ errors.push(
2128
+ issue(
2129
+ 'PACKET_NOT_PUBLISHABLE',
2130
+ `Packet status ${manifest.run.status} is not publishable`,
2131
+ '$.run.status',
2132
+ ),
2133
+ );
2134
+ }
2135
+ if (!publishable && packetRootIdentity) {
2136
+ await assertUnchangedRoot(packetRootIdentity);
2137
+ await rm(join(packetRoot, 'packet.md'), { force: true });
2138
+ }
2139
+ return {
2140
+ valid,
2141
+ publishable,
2142
+ status: manifest.run.status,
2143
+ requestedProfile: manifest.run.requestedProfile,
2144
+ achievedProfile,
2145
+ packetRoot,
2146
+ errors,
2147
+ warnings,
2148
+ validatedRun: publishable ? validatedRun : undefined,
2149
+ };
2150
+ }
2151
+
2152
+ if (packetRootIdentity) {
2153
+ await assertUnchangedRoot(packetRootIdentity);
2154
+ await rm(join(packetRoot, 'packet.md'), { force: true });
2155
+ }
2156
+ return {
2157
+ valid: false,
2158
+ publishable: false,
2159
+ status: 'failed',
2160
+ requestedProfile: null,
2161
+ achievedProfile: null,
2162
+ packetRoot,
2163
+ errors,
2164
+ warnings,
2165
+ };
2166
+ }
2167
+
2168
+ export async function validatePacket(packetDirectory) {
2169
+ const { validatedRun: _validatedRun, ...result } =
2170
+ await compileValidatedRun(packetDirectory);
2171
+ return result;
2172
+ }
2173
+
2174
+ async function main(argv) {
2175
+ const [packetDirectory] = argv;
2176
+ if (!packetDirectory) {
2177
+ throw new Error('Usage: validate-packet.mjs <packet-directory>');
2178
+ }
2179
+ const result = await validatePacket(packetDirectory);
2180
+ process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
2181
+ process.exitCode = result.publishable ? 0 : 1;
2182
+ }
2183
+
2184
+ if (
2185
+ process.argv[1] &&
2186
+ import.meta.url === pathToFileURL(process.argv[1]).href
2187
+ ) {
2188
+ main(process.argv.slice(2)).catch((error) => {
2189
+ process.stderr.write(`${error instanceof Error ? error.message : error}\n`);
2190
+ process.exitCode = 2;
2191
+ });
2192
+ }