full-reverse 0.0.0-stage → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +115 -2
  3. package/THIRD_PARTY_NOTICES.md +63 -0
  4. package/VERIFICATION.md +156 -0
  5. package/bin/full-reverse.mjs +253 -0
  6. package/package.json +30 -4
  7. package/skills/full-reverse/LICENSE +21 -0
  8. package/skills/full-reverse/README.md +116 -0
  9. package/skills/full-reverse/SKILL.md +173 -0
  10. package/skills/full-reverse/THIRD_PARTY_NOTICES.md +63 -0
  11. package/skills/full-reverse/VERIFICATION.md +156 -0
  12. package/skills/full-reverse/agents/openai.yaml +3 -0
  13. package/skills/full-reverse/package-lock.json +75 -0
  14. package/skills/full-reverse/package.json +21 -0
  15. package/skills/full-reverse/references/investigation-contract.md +171 -0
  16. package/skills/full-reverse/references/workflow-example.md +148 -0
  17. package/skills/full-reverse/references/workspace-layout.md +83 -0
  18. package/skills/full-reverse/schemas/claims.schema.json +123 -0
  19. package/skills/full-reverse/schemas/common.schema.json +305 -0
  20. package/skills/full-reverse/schemas/evidence-index.schema.json +135 -0
  21. package/skills/full-reverse/schemas/examples/claims.example.json +355 -0
  22. package/skills/full-reverse/schemas/examples/evidence-index.example.json +254 -0
  23. package/skills/full-reverse/schemas/examples/external-evidence.example.json +33 -0
  24. package/skills/full-reverse/schemas/examples/inventory.example.json +106 -0
  25. package/skills/full-reverse/schemas/examples/investigation.example.json +159 -0
  26. package/skills/full-reverse/schemas/examples/review.example.json +75 -0
  27. package/skills/full-reverse/schemas/external-evidence.schema.json +68 -0
  28. package/skills/full-reverse/schemas/inventory.schema.json +91 -0
  29. package/skills/full-reverse/schemas/investigation.schema.json +142 -0
  30. package/skills/full-reverse/schemas/review.schema.json +78 -0
  31. package/skills/full-reverse/scripts/lib/cli.mjs +254 -0
  32. package/skills/full-reverse/scripts/lib/jsonscan.mjs +285 -0
  33. package/skills/full-reverse/scripts/lib/rea.mjs +496 -0
  34. package/skills/full-reverse/scripts/lib/schemas.mjs +79 -0
  35. package/skills/full-reverse/scripts/lib/scope.mjs +59 -0
  36. package/skills/full-reverse/scripts/lib/selectors.mjs +256 -0
  37. package/skills/full-reverse/scripts/lib/util.mjs +333 -0
  38. package/skills/full-reverse/scripts/lib/validate.mjs +1133 -0
  39. package/skills/full-reverse/scripts/lib/workspace.mjs +861 -0
  40. package/skills/full-reverse/scripts/validate-investigation.mjs +28 -0
  41. package/skills/full-reverse-inspect/SKILL.md +119 -0
  42. package/skills/full-reverse-inspect/agents/openai.yaml +5 -0
  43. package/skills/full-reverse-inspect/references/android.md +84 -0
  44. package/skills/full-reverse-inspect/references/evidence-workflows.md +170 -0
  45. package/skills/full-reverse-inspect/references/javascript-electron.md +146 -0
  46. package/skills/full-reverse-inspect/references/managed.md +59 -0
  47. package/skills/full-reverse-inspect/references/native.md +290 -0
  48. package/skills/full-reverse-inspect/references/packages-and-specialized-artifacts.md +93 -0
  49. package/skills/full-reverse-inspect/references/recorded-evidence.md +118 -0
  50. package/skills/full-reverse-inventory/SKILL.md +134 -0
  51. package/skills/full-reverse-inventory/agents/openai.yaml +5 -0
  52. package/skills/full-reverse-model/SKILL.md +148 -0
  53. package/skills/full-reverse-model/agents/openai.yaml +5 -0
  54. package/skills/full-reverse-model/references/claim-classification.md +85 -0
  55. package/skills/full-reverse-model/references/specification-template.md +199 -0
  56. package/skills/full-reverse-observe/SKILL.md +97 -0
  57. package/skills/full-reverse-observe/agents/openai.yaml +5 -0
  58. package/skills/full-reverse-observe/references/runtime-scope.md +301 -0
  59. package/skills/full-reverse-verify/SKILL.md +111 -0
  60. package/skills/full-reverse-verify/agents/openai.yaml +5 -0
  61. package/skills/full-reverse-verify/references/review-checklist.md +99 -0
@@ -0,0 +1,1133 @@
1
+ // Deterministic workspace validation. Checks report verified | failed | unverified.
2
+ // This proves structure, integrity and reference resolution, never that evidence
3
+ // semantically supports a statement (that is the verify specialist's review).
4
+ import fsp from "node:fs/promises";
5
+ import path from "node:path";
6
+ import { DEFAULT_MAX_PARSE_BYTES, inspectFile } from "./rea.mjs";
7
+ import { validateSchema, loadValidators, PINNED_AJV } from "./schemas.mjs";
8
+ import { scopeDigest } from "./scope.mjs";
9
+ import { ExternalFacts, PointerStore, judgeExternal, judgePointer } from "./selectors.mjs";
10
+ import { EVIDENCE_DIRS, INDEX_PATH, kindDir, matchBuild, semanticDigest, walkFiles } from "./workspace.mjs";
11
+ import { hashFile, normalizeRelPath, openWorkspace, resolveInWorkspace, sha256Buffer } from "./util.mjs";
12
+
13
+ const RANK = { observed: 3, derived: 2, inferred: 1, unknown: 0, unavailable: 0 };
14
+ const ASSERTING = new Set(["observed", "derived", "inferred"]);
15
+ const CANONICAL_KINDS = new Set(["canonical-record", "canonical-bundle", "derived-view"]);
16
+ const MAX_DOC_BYTES = 256 * 1024 * 1024;
17
+ const MAX_LISTED_CHECKS = 400;
18
+
19
+ class Report {
20
+ constructor(mode) {
21
+ this.mode = mode;
22
+ this.checks = [];
23
+ this.counts = {};
24
+ this.totals = { verified: 0, failed: 0, unverified: 0 };
25
+ this.errorFailed = 0;
26
+ this.errorUnverified = 0;
27
+ this.resumeBlockers = [];
28
+ this.omitted = 0;
29
+ this.verbose = false;
30
+ }
31
+
32
+ add(rule, status, subject, message, { reason, severity = "error", modes, details } = {}) {
33
+ // modes: modes in which this check is severity "error"; elsewhere it is a warning.
34
+ const effective = modes && !modes.includes(this.mode) ? "warning" : severity;
35
+ this.totals[status] += 1;
36
+ const bucket = (this.counts[rule] ??= { verified: 0, failed: 0, unverified: 0 });
37
+ bucket[status] += 1;
38
+ if (effective === "error") {
39
+ if (status === "failed") this.errorFailed += 1;
40
+ if (status === "unverified") this.errorUnverified += 1;
41
+ }
42
+ if (modes?.includes("resume") && status !== "verified" && effective === "error") this.resumeBlockers.push(`${rule}:${subject}`);
43
+ if (status === "verified" && !this.verbose) return;
44
+ if (this.checks.length >= MAX_LISTED_CHECKS && !this.verbose) {
45
+ this.omitted += 1;
46
+ return;
47
+ }
48
+ this.checks.push({ id: `${rule}:${subject}`, rule, status, severity: effective, subject, message, ...(reason ? { reason } : {}), ...(details ? { details } : {}) });
49
+ }
50
+
51
+ ok(rule, subject, message, opts) {
52
+ this.add(rule, "verified", subject, message, opts);
53
+ }
54
+
55
+ fail(rule, subject, message, opts) {
56
+ this.add(rule, "failed", subject, message, opts);
57
+ }
58
+
59
+ unv(rule, subject, message, opts) {
60
+ this.add(rule, "unverified", subject, message, opts);
61
+ }
62
+ }
63
+
64
+ const CLOSURE = ["closure"];
65
+ const NARRATIVE_HEADING = "Narrative (non-factual)";
66
+
67
+ /**
68
+ * Line classifier for fenced code blocks (``` or ~~~, closed by the same character with at
69
+ * least the opening length). Returns true for fence delimiters and lines inside a fence, so
70
+ * a longer outer fence may contain shorter example fences.
71
+ */
72
+ function makeFenceTracker() {
73
+ let open = null;
74
+ return (line) => {
75
+ const m = /^\s*(`{3,}|~{3,})(.*)$/.exec(line);
76
+ if (open) {
77
+ if (m && m[1][0] === open.char && m[1].length >= open.length && m[2].trim() === "") open = null;
78
+ return true;
79
+ }
80
+ if (m && !(m[1][0] === "`" && m[2].includes("`"))) {
81
+ open = { char: m[1][0], length: m[1].length };
82
+ return true;
83
+ }
84
+ return false;
85
+ };
86
+ }
87
+
88
+ /** Inline markdown link targets outside code fences and inline code spans (images excluded). */
89
+ function markdownLinkTargets(body) {
90
+ const inFenceLine = makeFenceTracker();
91
+ const targets = [];
92
+ for (const line of body.split(/\r?\n/)) {
93
+ if (inFenceLine(line)) continue;
94
+ const stripped = line.replace(/`+[^`]*`+/g, "");
95
+ for (const m of stripped.matchAll(/(!?)\[[^\]]*\]\(\s*(?:<([^>]*)>|([^)\s]*))(?:\s+(?:"[^"]*"|'[^']*'))?\s*\)/g)) {
96
+ if (m[1]) continue;
97
+ targets.push(m[2] ?? m[3] ?? "");
98
+ }
99
+ }
100
+ return targets;
101
+ }
102
+
103
+ function forEachRef(node, fn) {
104
+ if (Array.isArray(node)) {
105
+ for (const item of node) forEachRef(item, fn);
106
+ } else if (node && typeof node === "object") {
107
+ if ((node.kind === "canonical" || node.kind === "external") && typeof node.file === "string" && typeof node.sha256 === "string") fn(node);
108
+ for (const value of Object.values(node)) if (value && typeof value === "object") forEachRef(value, fn);
109
+ }
110
+ }
111
+
112
+ const refKey = (ref) => `${ref.kind}|${ref.file}|${ref.sha256}|${ref.evidence_id ?? ref.ext_id}`;
113
+ const describeErrors = (errors) => errors.slice(0, 5).map((e) => `${e.path} ${e.message}`).join("; ");
114
+
115
+ export async function validateWorkspace({ workspace, mode = "progress", maxParseBytes = DEFAULT_MAX_PARSE_BYTES, verbose = false, checkTargets = false }) {
116
+ const ws = await openWorkspace(workspace);
117
+ const r = new Report(mode);
118
+ r.verbose = verbose;
119
+ const { ajvVersion } = await loadValidators();
120
+ const closure = mode === "closure";
121
+ const targets = checkTargets || mode === "resume";
122
+
123
+ // ---- load documents -------------------------------------------------------------
124
+ const docs = {};
125
+ const loadDoc = async (rel, schema, { required }) => {
126
+ const resolved = await resolveInWorkspace(ws.abs, rel).catch((error) => ({ error }));
127
+ if (resolved.error) {
128
+ r.fail("workspace.path", rel, resolved.error.message);
129
+ return { exists: false, valid: false };
130
+ }
131
+ if (!resolved.exists) {
132
+ if (required) r.fail("workspace.file_missing", rel, `${rel} is missing`);
133
+ return { exists: false, valid: false };
134
+ }
135
+ if (!resolved.stat.isFile()) {
136
+ r.fail("workspace.path", rel, `${rel} is not a regular file`);
137
+ return { exists: true, valid: false };
138
+ }
139
+ if (resolved.stat.size > MAX_DOC_BYTES) {
140
+ r.unv(`schema.${schema}`, rel, `${rel} is ${resolved.stat.size} bytes; exceeds the ${MAX_DOC_BYTES} byte document limit`, { reason: "document_size_limit" });
141
+ return { exists: true, valid: false };
142
+ }
143
+ const buffer = await fsp.readFile(resolved.abs);
144
+ const sha256 = sha256Buffer(buffer);
145
+ let doc;
146
+ try {
147
+ doc = JSON.parse(buffer.toString("utf8"));
148
+ } catch (error) {
149
+ r.fail(`schema.${schema}`, rel, `${rel} is not valid JSON: ${error.message}`, { reason: "json_parse_failed" });
150
+ return { exists: true, valid: false, sha256 };
151
+ }
152
+ const result = await validateSchema(schema, doc);
153
+ if (!result.valid) {
154
+ r.fail(`schema.${schema}`, rel, `${rel} violates schema ${schema}: ${describeErrors(result.errors)}`, { details: result.errors });
155
+ return { exists: true, valid: false, sha256, doc };
156
+ }
157
+ r.ok(`schema.${schema}`, rel, "schema valid");
158
+ return { exists: true, valid: true, sha256, bytes: buffer.length, doc };
159
+ };
160
+
161
+ const D = {
162
+ investigation: await loadDoc("investigation.json", "investigation", { required: true }),
163
+ index: await loadDoc(INDEX_PATH, "evidence-index", { required: true }),
164
+ inventory: await loadDoc("inventory.json", "inventory", { required: false }),
165
+ claims: await loadDoc("claims.json", "claims", { required: false }),
166
+ review: await loadDoc("review.json", "review", { required: false }),
167
+ };
168
+ const inv = D.investigation.valid ? D.investigation.doc : null;
169
+ const idx = D.index.valid ? D.index.doc : null;
170
+ const inventory = D.inventory.valid ? D.inventory.doc : null;
171
+ const claimsDoc = D.claims.valid ? D.claims.doc : null;
172
+ const review = D.review.valid ? D.review.doc : null;
173
+
174
+ const base = {
175
+ tool: { name: "full-reverse-validator", ajv: ajvVersion, ajv_pinned: PINNED_AJV, node: process.versions.node },
176
+ command: "validate",
177
+ mode,
178
+ workspace: ws.abs,
179
+ max_parse_bytes: maxParseBytes,
180
+ };
181
+ if (!inv || !idx) {
182
+ r.fail("validation.prerequisites", "workspace", "investigation.json and evidence/index.json must both be valid before dependent checks can run");
183
+ return finish(r, base, D, { steps: [] });
184
+ }
185
+
186
+ // ---- identity across documents -------------------------------------------------
187
+ for (const [name, doc] of [["evidence/index.json", idx], ["inventory.json", inventory], ["claims.json", claimsDoc], ["review.json", review]]) {
188
+ if (doc && doc.investigation_id !== inv.investigation_id) {
189
+ r.fail("workspace.investigation_id", name, `${name} belongs to ${doc.investigation_id}, expected ${inv.investigation_id}`);
190
+ }
191
+ }
192
+
193
+ // ---- builds ----------------------------------------------------------------------
194
+ const builds = new Map();
195
+ for (const build of inv.builds) {
196
+ if (builds.has(build.build_id)) r.fail("build.unique", build.build_id, "duplicate build_id");
197
+ builds.set(build.build_id, build);
198
+ }
199
+ const digestOwners = new Map();
200
+ for (const build of inv.builds) {
201
+ const owners = digestOwners.get(build.identity.sha256) ?? [];
202
+ owners.push(build.build_id);
203
+ digestOwners.set(build.identity.sha256, owners);
204
+ if (build.parent_build_id !== null && !builds.has(build.parent_build_id)) r.fail("build.parent", build.build_id, `unknown parent ${build.parent_build_id}`);
205
+ if (build.kind === "member" && build.parent_build_id === null) r.fail("build.parent", build.build_id, "member builds need parent_build_id (member identity is kept separate from the archive)");
206
+ if (build.identity.method === "archive-member" && build.parent_build_id === null) r.fail("build.parent", build.build_id, "archive-member identity needs parent_build_id");
207
+ if (build.identity.method === "file-sha256" && build.identity.coverage !== "complete") r.fail("build.identity", build.build_id, "file-sha256 identity must have complete coverage");
208
+ if (build.identity.method === "file-sha256" && build.identity.bytes === null) r.unv("build.identity", build.build_id, "file-sha256 identity lacks byte size", { severity: "warning" });
209
+ if (build.identity.method === "rea-subject-digest" && build.identity.coverage !== "complete") {
210
+ r.ok("build.identity", build.build_id, `REA subject digest with ${build.identity.coverage} coverage; not a whole-directory fingerprint`);
211
+ }
212
+ }
213
+ for (const [digest, owners] of digestOwners) {
214
+ if (owners.length > 1) r.add("build.unique_digest", "unverified", digest.slice(0, 12), `builds ${owners.join(", ")} share one identity digest; evidence cannot distinguish them`, { severity: "warning" });
215
+ }
216
+ for (const build of inv.builds) {
217
+ const seen = new Set();
218
+ for (let cursor = build; cursor?.parent_build_id; cursor = builds.get(cursor.parent_build_id)) {
219
+ if (seen.has(cursor.build_id)) {
220
+ r.fail("build.parent", build.build_id, "parent chain is cyclic");
221
+ break;
222
+ }
223
+ seen.add(cursor.build_id);
224
+ }
225
+ }
226
+
227
+ // ---- planned/running work decides which resume blockers are real ------------------------
228
+ // Superseded, disposed or finished history never blocks a resume; only what the next planned or
229
+ // running step still depends on does. An explicit --check-targets keeps every build blocking.
230
+ const supersededIds = new Set(inv.steps.map((s) => s.supersedes_step_id).filter((id) => id !== null));
231
+ const openSteps = inv.steps.filter((s) => s.status === "planned" || s.status === "running");
232
+ const neededGrants = new Set(openSteps.map((s) => s.authorization_grant_id).filter((id) => id !== null));
233
+ const neededBuilds = new Set();
234
+ for (const step of openSteps) {
235
+ const seen = new Set();
236
+ for (let id = step.build_id; id && !seen.has(id); id = builds.get(id)?.parent_build_id ?? null) {
237
+ seen.add(id);
238
+ neededBuilds.add(id);
239
+ }
240
+ }
241
+
242
+ // ---- target identity re-check (resume) ------------------------------------------
243
+ if (targets) {
244
+ for (const build of inv.builds) {
245
+ const subject = build.build_id;
246
+ const blocking = { modes: checkTargets || neededBuilds.has(subject) ? ["resume", "closure"] : [] };
247
+ if (build.identity.method !== "file-sha256") {
248
+ r.unv("build.identity_current", subject, `identity method ${build.identity.method} cannot be recomputed by this helper; re-run the REA identity operation and compare the subject digest`, { ...blocking, reason: "requires_rea_reidentification" });
249
+ continue;
250
+ }
251
+ if (!build.local_path) {
252
+ r.unv("build.identity_current", subject, "no local_path recorded; cannot re-check identity", { ...blocking, reason: "no_local_path" });
253
+ continue;
254
+ }
255
+ const stat = await fsp.stat(build.local_path).catch(() => null);
256
+ if (!stat?.isFile()) {
257
+ r.unv("build.identity_current", subject, `target file is unavailable at ${build.local_path}; earlier evidence stays under ${subject} but is not current`, { ...blocking, reason: "target_unavailable" });
258
+ continue;
259
+ }
260
+ const live = await hashFile(build.local_path);
261
+ if (live.sha256 === build.identity.sha256) r.ok("build.identity_current", subject, "target still matches the recorded identity");
262
+ else r.fail("build.identity_current", subject, "target bytes changed since identification; new reads need a new build and earlier evidence is not current");
263
+ }
264
+ }
265
+
266
+ // ---- questions ---------------------------------------------------------------------
267
+ const questions = new Map();
268
+ for (const q of inv.questions) {
269
+ if (questions.has(q.question_id)) r.fail("question.unique", q.question_id, "duplicate question_id");
270
+ questions.set(q.question_id, q);
271
+ for (const b of q.build_ids ?? []) if (!builds.has(b)) r.fail("question.build", q.question_id, `unknown build ${b}`);
272
+ if (q.status === "pending") r.fail("question.final_disposition", q.question_id, "question is still pending", { modes: CLOSURE });
273
+ else if ((q.status === "partial" || q.status === "unresolved") && !q.reason) r.fail("question.reason", q.question_id, `${q.status} questions need a reason`);
274
+ else if (q.status !== "pending") r.ok("question.final_disposition", q.question_id, q.status);
275
+ }
276
+ if (closure && inv.questions.length === 0) r.fail("question.none", "investigation", "closure needs at least one scoped question", { modes: CLOSURE });
277
+
278
+ // ---- authorization and steps ---------------------------------------------------------
279
+ // A failed or interrupted attempt is disposed once it is finished, annotated, and every question it served is reasoned partial/unresolved.
280
+ const isDisposed = (step) =>
281
+ step.finished_at !== null &&
282
+ step.note.trim() !== "" &&
283
+ step.question_ids.length > 0 &&
284
+ step.question_ids.every((id) => {
285
+ const question = questions.get(id);
286
+ return question && ["partial", "unresolved"].includes(question.status) && question.reason?.trim();
287
+ });
288
+ const grants = new Map(inv.authorizations.map((g) => [g.grant_id, g]));
289
+ const stepIds = new Map();
290
+ const stepReport = [];
291
+ const liveHash = new Map();
292
+ const hashCached = async (rel) => {
293
+ if (liveHash.has(rel)) return liveHash.get(rel);
294
+ const resolved = await resolveInWorkspace(ws.abs, rel).catch(() => null);
295
+ const value = resolved?.exists && resolved.stat.isFile() ? await hashFile(resolved.abs) : null;
296
+ liveHash.set(rel, value);
297
+ return value;
298
+ };
299
+ for (const step of inv.steps) {
300
+ if (stepIds.has(step.step_id)) r.fail("step.unique", step.step_id, "duplicate step_id");
301
+ stepIds.set(step.step_id, step);
302
+ }
303
+ for (const step of inv.steps) {
304
+ const reasons = [];
305
+ for (const qid of step.question_ids) if (!questions.has(qid)) r.fail("step.question", step.step_id, `unknown question ${qid}`);
306
+ if (step.build_id !== null && !builds.has(step.build_id)) r.fail("step.build", step.step_id, `unknown build ${step.build_id}`);
307
+ if (step.supersedes_step_id !== null) {
308
+ const prior = stepIds.get(step.supersedes_step_id);
309
+ if (!prior) r.fail("step.attempt_chain", step.step_id, `supersedes unknown step ${step.supersedes_step_id}`);
310
+ else {
311
+ if (step.attempt <= prior.attempt) r.fail("step.attempt_chain", step.step_id, "attempt number must exceed the superseded attempt");
312
+ else r.ok("step.attempt_chain", step.step_id, "linked retry");
313
+ if (!["superseded", "interrupted", "failed"].includes(prior.status)) r.unv("step.attempt_chain", step.step_id, `superseded step ${prior.step_id} is ${prior.status}, not superseded/interrupted/failed`, { severity: "warning" });
314
+ }
315
+ }
316
+ if (step.effects.length > 0) {
317
+ const grant = step.authorization_grant_id ? grants.get(step.authorization_grant_id) : null;
318
+ if (!grant) r.fail("authorization.step", step.step_id, "step with effects needs an existing authorization_grant_id");
319
+ else if (grant.build_id !== step.build_id) r.fail("authorization.step", step.step_id, `grant ${grant.grant_id} is bound to ${grant.build_id}, step targets ${step.build_id}`);
320
+ else if (step.effects.some((effect) => !grant.permitted_effects.includes(effect))) r.fail("authorization.step", step.step_id, `effects exceed grant ${grant.grant_id}: ${step.effects.filter((e) => !grant.permitted_effects.includes(e)).join(",")}`);
321
+ else r.ok("authorization.step", step.step_id, "effects within grant");
322
+ }
323
+ if (step.effects.length > 0 && step.status !== "planned" && step.authorization_grant_id && grants.has(step.authorization_grant_id)) {
324
+ // Steps that began before the latest resume are history and are never invalidated retroactively.
325
+ const grant = grants.get(step.authorization_grant_id);
326
+ const started = Date.parse(step.started_at ?? "");
327
+ const resumedAt = inv.lifecycle.last_resumed_at ? Date.parse(inv.lifecycle.last_resumed_at) : null;
328
+ const recordedAt = Date.parse(grant.recorded_at);
329
+ const reconfirmedInWindow = grant.reconfirmed_at && resumedAt !== null && Date.parse(grant.reconfirmed_at) >= resumedAt && Date.parse(grant.reconfirmed_at) <= started;
330
+ const grantedThisSession = resumedAt !== null && recordedAt >= resumedAt; // recorded <= started is enforced above
331
+ if (Number.isFinite(started) && started < recordedAt) r.fail("authorization.fresh", step.step_id, `step started before grant ${grant.grant_id} was recorded`);
332
+ else if (Number.isFinite(started) && resumedAt !== null && started >= resumedAt && grant.persistence !== "persistent" && !grantedThisSession && !reconfirmedInWindow) r.fail("authorization.fresh", step.step_id, `session grant ${grant.grant_id} was recorded before the last resume and not reconfirmed between the resume and this step`);
333
+ else if (Number.isFinite(started)) r.ok("authorization.fresh", step.step_id, "grant was recorded and current when the step started");
334
+ else r.fail("authorization.fresh", step.step_id, "step with effects needs started_at");
335
+ }
336
+ if (step.status === "running" || step.status === "interrupted") reasons.push(`step_${step.status}`);
337
+ if (step.status === "running" || step.status === "interrupted") {
338
+ if (step.status === "interrupted" && (supersededIds.has(step.step_id) || isDisposed(step))) r.ok("step.state", step.step_id, "interrupted attempt is superseded or disposed; history only");
339
+ else r.unv("step.state", step.step_id, `step is ${step.status}; its outputs are incomplete and must not be reused`, { modes: ["resume"], reason: `step_${step.status}` });
340
+ }
341
+ if (step.status === "running" || step.status === "planned") r.fail("step.closure", step.step_id, `step is ${step.status}; finish, interrupt or remove it before closure`, { modes: ["closure"] });
342
+ if ((step.status === "interrupted" || step.status === "failed") && !supersededIds.has(step.step_id) && !isDisposed(step)) {
343
+ r.fail("step.closure", step.step_id, `step is ${step.status} without a superseding attempt or reasoned partial/unresolved question disposition`, { modes: ["closure"] });
344
+ }
345
+ if (step.status === "completed") {
346
+ for (const output of step.outputs) {
347
+ const live = await hashCached(output.path);
348
+ if (!live) {
349
+ r.fail("step.output", `${step.step_id}:${output.path}`, "published output is missing");
350
+ reasons.push(`output_missing:${output.path}`);
351
+ } else if (live.sha256 !== output.sha256) {
352
+ // Mutable outputs may legitimately be republished by a later step; immutable evidence may not change.
353
+ const immutable = EVIDENCE_DIRS.some((d) => output.path.startsWith(`${d}/`));
354
+ r.add("step.output", "failed", `${step.step_id}:${output.path}`, immutable ? "immutable evidence output changed" : "output changed after this step completed", { severity: immutable ? "error" : "warning" });
355
+ reasons.push(`output_changed:${output.path}`);
356
+ }
357
+ }
358
+ for (const input of step.inputs) {
359
+ const live = await hashCached(input.path);
360
+ if (!live || live.sha256 !== input.sha256) {
361
+ reasons.push(`input_changed:${input.path}`);
362
+ r.add("step.input", "failed", `${step.step_id}:${input.path}`, "input changed or missing since the step completed; do not reuse it as current", { severity: "warning" });
363
+ }
364
+ }
365
+ }
366
+ stepReport.push({ step_id: step.step_id, status: step.status, attempt: step.attempt, reusable: step.status === "completed" && reasons.length === 0, reasons });
367
+ }
368
+ if (mode === "resume") {
369
+ for (const grant of inv.authorizations) {
370
+ if (grant.permitted_effects.length === 0) continue;
371
+ const resumedAt = inv.lifecycle.last_resumed_at;
372
+ const current = resumedAt && ((grant.reconfirmed_at && Date.parse(grant.reconfirmed_at) >= Date.parse(resumedAt)) || Date.parse(grant.recorded_at) >= Date.parse(resumedAt));
373
+ if (grant.persistence === "persistent") r.ok("authorization.resume", grant.grant_id, "user-granted persistence; target/effect scope must still match");
374
+ else if (current) r.ok("authorization.resume", grant.grant_id, "recorded or reconfirmed since resume");
375
+ else if (neededGrants.has(grant.grant_id)) r.unv("authorization.resume", grant.grant_id, "session-scoped grant must be reconfirmed by the user after this resume before any effect", { modes: ["resume"], reason: "grant_not_reconfirmed" });
376
+ else r.unv("authorization.resume", grant.grant_id, "session-scoped grant predates this resume; no planned or running step uses it, but it must be reconfirmed before any new effect", { severity: "warning", reason: "grant_not_reconfirmed" });
377
+ }
378
+ }
379
+
380
+ // ---- evidence index --------------------------------------------------------------------
381
+ const entryByFile = new Map();
382
+ const entryById = new Map();
383
+ const info = new Map(); // file -> { entry, abs, live, trusted }
384
+ for (const entry of idx.entries) {
385
+ if (entryById.has(entry.entry_id)) r.fail("evidence.unique", entry.entry_id, "duplicate entry_id");
386
+ entryById.set(entry.entry_id, entry);
387
+ if (entryByFile.has(entry.file)) r.fail("evidence.unique", entry.file, "file indexed twice");
388
+ entryByFile.set(entry.file, entry);
389
+ }
390
+ for (const entry of idx.entries) {
391
+ const subject = entry.entry_id;
392
+ const state = { entry, abs: null, live: null, trusted: false };
393
+ info.set(entry.file, state);
394
+ const normalized = normalizeRelPath(entry.file);
395
+ const dir = kindDir(entry.kind);
396
+ if (!normalized || !normalized.startsWith(`${dir}/`)) {
397
+ r.fail("evidence.path", subject, `${entry.file} must be a safe path under ${dir}/ for kind ${entry.kind}`);
398
+ continue;
399
+ }
400
+ const resolved = await resolveInWorkspace(ws.abs, entry.file).catch((error) => ({ error }));
401
+ if (resolved.error) {
402
+ r.fail("evidence.path", subject, resolved.error.message);
403
+ continue;
404
+ }
405
+ if (!resolved.exists || !resolved.stat.isFile()) {
406
+ r.fail("evidence.file_exists", subject, `indexed file is missing: ${entry.file}`);
407
+ continue;
408
+ }
409
+ state.abs = resolved.abs;
410
+ const canonicalKind = CANONICAL_KINDS.has(entry.kind);
411
+ const live = await inspectFile(resolved.abs, { maxParseBytes, shallow: !canonicalKind });
412
+ state.live = live;
413
+ if (live.sha256 !== entry.sha256 || live.bytes !== entry.bytes) {
414
+ r.fail("evidence.file_hash", subject, `${entry.file} content differs from the indexed SHA-256/size`);
415
+ continue;
416
+ }
417
+ r.ok("evidence.file_hash", subject, "streamed SHA-256 and size match");
418
+ const digest = semanticDigest(entry);
419
+ const idPrefix = digest.slice(0, 16);
420
+ if (entry.entry_id !== `ei_${idPrefix}` || !path.posix.basename(entry.file).startsWith(`${idPrefix}-`)) {
421
+ r.fail("evidence.entry_identity", subject, `entry_id and file name do not derive from the entry's semantic content (expected ei_${idPrefix} and a ${idPrefix}- file prefix); the index entry was edited or mislabeled`);
422
+ continue;
423
+ }
424
+ r.ok("evidence.entry_identity", subject, "entry_id and file prefix derive from the semantic publication digest");
425
+ if (entry.kind === "external") {
426
+ const ext = entry.external;
427
+ if (!ext || ext.ext_id !== `ext_${live.sha256}` || ext.file.sha256 !== live.sha256 || ext.file.path !== entry.file || ext.file.bytes !== live.bytes) {
428
+ r.fail("evidence.external_identity", subject, "ext_id/file digest does not derive from the saved file bytes");
429
+ continue;
430
+ }
431
+ r.ok("evidence.external_identity", subject, "ext_id derives from file SHA-256");
432
+ state.trusted = true;
433
+ const assoc = ext.target_association;
434
+ if (assoc.status === "declared" && !builds.has(assoc.build_id)) r.fail("evidence.target_binding", subject, `external evidence declares unknown build ${assoc.build_id}`);
435
+ if (ext.acquisition.provenance_status !== "known") r.unv("evidence.provenance", subject, `external provenance is ${ext.acquisition.provenance_status}; supported claim strength is limited`, { severity: "warning" });
436
+ continue;
437
+ }
438
+ if (!canonicalKind) {
439
+ state.trusted = true; // diagnostics are preserved but never support assertions
440
+ continue;
441
+ }
442
+ // Canonical evidence: recompute classification, identities and bindings from the saved bytes.
443
+ if (live.kind_hint !== entry.kind) {
444
+ r.fail("evidence.kind", subject, `index labels ${entry.file} as ${entry.kind} but its saved content classifies as ${live.kind_hint ?? live.content_type}${live.reasons.length ? ` (${live.reasons.join(", ")})` : ""}`);
445
+ continue;
446
+ }
447
+ r.ok("evidence.kind", subject, "label matches saved content");
448
+ if (live.id_authentication.status === "failed") {
449
+ r.fail("evidence.record_id", subject, `evidence_id does not match record content: ${live.id_authentication.reason}`);
450
+ continue;
451
+ }
452
+ if (live.id_authentication.status === "unverified") {
453
+ r.unv("evidence.record_id", subject, `evidence_id authentication skipped: ${live.id_authentication.reason}${live.parse === "header-only" ? `; raise --max-parse-bytes above ${live.bytes} (and Node heap) or cite smaller views` : ""}`, { reason: live.id_authentication.reason });
454
+ } else r.ok("evidence.record_id", subject, "semantic evidence_id recomputed for every record");
455
+ const sameRecords =
456
+ !live.records_truncated &&
457
+ live.records.length === entry.records.length &&
458
+ live.records.every((rec, i) => {
459
+ const other = entry.records[i];
460
+ return other && rec.evidence_id === other.evidence_id && rec.pointer === other.pointer && rec.operation === other.operation && rec.confidence === other.confidence && rec.authority === other.authority && JSON.stringify(rec.binding_digests) === JSON.stringify(other.binding_digests) && rec.parent_evidence_id === other.parent_evidence_id;
461
+ });
462
+ if (!live.records_truncated && !sameRecords) {
463
+ r.fail("evidence.records", subject, "index record summaries differ from the saved records (labels are not identity)");
464
+ continue;
465
+ }
466
+ if (live.records_truncated) r.unv("evidence.records", subject, "bundle exceeds the indexed record limit; only a prefix is verifiable", { severity: "warning" });
467
+ const liveDigests = [...live.binding.digests].sort();
468
+ const indexDigests = [...entry.target.binding_digests].sort();
469
+ if (JSON.stringify(liveDigests) !== JSON.stringify(indexDigests) || live.binding.source !== entry.target.binding_source) {
470
+ r.fail("evidence.target_binding", subject, "index target digests differ from the saved record's intrinsic target digests");
471
+ continue;
472
+ }
473
+ if (liveDigests.length > 0) {
474
+ const match = matchBuild(inv.builds, liveDigests);
475
+ const expected = match ? match.build_id : null;
476
+ if ((entry.target.build_id !== null && entry.target.build_id !== expected) || (entry.target.association === "intrinsic" && expected === null)) {
477
+ r.fail("evidence.build_label", subject, `index binds this record to ${entry.target.build_id ?? "no build"} but its own target digest identifies ${expected ?? "no investigation build"}`);
478
+ continue;
479
+ }
480
+ if (expected) r.ok("evidence.target_binding", subject, `record's own target digest equals ${expected}`);
481
+ else r.unv("evidence.target_binding", subject, "record's target digest matches no investigation build yet", { severity: "warning", reason: "no_matching_build" });
482
+ } else if (entry.target.association === "declared" && entry.target.build_id && builds.has(entry.target.build_id)) {
483
+ r.unv("evidence.target_binding", subject, `record carries no target digest; bound to ${entry.target.build_id} only by analyst declaration`, { severity: "warning", reason: "declared_binding_only" });
484
+ } else {
485
+ r.unv("evidence.target_binding", subject, "record carries no target digest and no valid declared build", { severity: "warning", reason: "unbound_record" });
486
+ }
487
+ if (live.binding.identity_state === "unknown") r.unv("evidence.identity_state", subject, "producer reports executable identity state unknown", { severity: "warning" });
488
+ state.trusted = true;
489
+ }
490
+
491
+ // orphans and staged files
492
+ for (const dir of EVIDENCE_DIRS) {
493
+ for (const file of await walkFiles(ws.abs, dir)) {
494
+ if (file.symlink) r.fail("evidence.path", file.rel, "symbolic link inside evidence");
495
+ else if (!entryByFile.has(file.rel)) r.unv("evidence.orphan", file.rel, "published file has no index entry and is not citable", { severity: "warning" });
496
+ }
497
+ }
498
+ for (const file of await walkFiles(ws.abs, "evidence/.staging")) {
499
+ r.unv("evidence.staged", file.rel, "unpublished staged output present (possible interrupted run)", { severity: "warning" });
500
+ }
501
+ for (const entry of idx.entries) {
502
+ if (entry.invocation.stderr_file) {
503
+ const target = entryByFile.get(entry.invocation.stderr_file);
504
+ if (!target || target.sha256 !== entry.invocation.stderr_sha256) r.fail("evidence.stderr", entry.entry_id, "stderr companion is not an indexed file with the recorded digest");
505
+ }
506
+ }
507
+
508
+ // ---- reference machinery ------------------------------------------------------------------
509
+ const pointerRequests = new Map();
510
+ const collect = (doc) => {
511
+ forEachRef(doc, (ref) => {
512
+ if (ref.kind !== "canonical") return;
513
+ const st = info.get(ref.file);
514
+ if (!st?.abs || !st.live || !st.trusted || !st.live.records) return;
515
+ const rec = st.live.records.find((x) => x.evidence_id === ref.evidence_id);
516
+ if (!rec) return;
517
+ const request = pointerRequests.get(st.abs) ?? { bytes: st.entry.bytes, pointers: new Set() };
518
+ for (const selector of ref.selectors ?? []) request.pointers.add(`${rec.pointer}${selector.pointer}`);
519
+ pointerRequests.set(st.abs, request);
520
+ });
521
+ };
522
+ collect(inventory);
523
+ collect(claimsDoc);
524
+ const store = new PointerStore({ maxParseBytes });
525
+ await store.prepare(pointerRequests);
526
+ const externalFacts = new ExternalFacts({ maxParseBytes });
527
+
528
+ /** Evaluate one evidence reference. allowedBuilds: Set of build ids, or null to skip binding. */
529
+ const evalRef = async (ref, subject, { allowedBuilds, assertion }) => {
530
+ const out = { usable: false, rank: 0, authority: null, external: null, selectors: [], kind: ref.kind, summary: null, entry: null, provider: null };
531
+ const st = info.get(ref.file);
532
+ const entry = st?.entry;
533
+ if (!entry) {
534
+ r.fail("ref.resolve", subject, `${ref.file} is not in the evidence index`);
535
+ return out;
536
+ }
537
+ out.entry = entry;
538
+ if (entry.sha256 !== ref.sha256) {
539
+ r.fail("ref.digest", subject, `reference digest ${ref.sha256.slice(0, 12)}… differs from the indexed file ${entry.sha256.slice(0, 12)}…`);
540
+ return out;
541
+ }
542
+ if (!st.trusted) {
543
+ r.fail("ref.entry_untrusted", subject, `${ref.file} failed integrity checks; it cannot be cited`);
544
+ return out;
545
+ }
546
+ if (ref.kind === "canonical") {
547
+ if (!CANONICAL_KINDS.has(entry.kind)) {
548
+ if (assertion) r.fail("ref.entry_kind", subject, `${entry.kind} evidence cannot support observed/derived/inferred claims`);
549
+ else r.ok("ref.entry_kind", subject, `${entry.kind} cited as limitation/unavailability record`);
550
+ return out;
551
+ }
552
+ const rec = st.live.records.find((x) => x.evidence_id === ref.evidence_id);
553
+ if (!rec) {
554
+ if (st.live.records_truncated) {
555
+ out.unverified = true;
556
+ r.unv("ref.resolve", subject, `${ref.evidence_id.slice(0, 15)}… is outside the indexed record prefix of ${ref.file}; the unindexed suffix cannot be checked`, { reason: "record_beyond_index_limit" });
557
+ } else r.fail("ref.resolve", subject, `${ref.evidence_id.slice(0, 15)}… is not a record in ${ref.file}`);
558
+ return out;
559
+ }
560
+ out.summary = rec;
561
+ const providerLookup = await store.lookup(st.abs, entry.bytes, `${rec.pointer}/provider`);
562
+ if (providerLookup.kind === "found") out.provider = providerLookup.value;
563
+ out.rank = RANK[rec.confidence] ?? 0;
564
+ out.authority = rec.authority;
565
+ out.usable = true;
566
+ if (allowedBuilds) {
567
+ const digests = rec.binding_digests;
568
+ if (digests.length > 0) {
569
+ const match = matchBuild(inv.builds, digests);
570
+ if (match && allowedBuilds.has(match.build_id)) r.ok("ref.target", subject, `record target equals ${match.build_id}`);
571
+ else {
572
+ r.fail("ref.cross_target", subject, `record's own target digest ${digests[0].slice(0, 12)}… ${match ? `belongs to ${match.build_id}` : "matches no investigation build"}; allowed: ${[...allowedBuilds].join(", ")}`);
573
+ out.usable = false;
574
+ return out;
575
+ }
576
+ } else if (entry.target.association === "declared" && allowedBuilds.has(entry.target.build_id)) {
577
+ r.unv("ref.target", subject, `record has no intrinsic target digest; bound to ${entry.target.build_id} by declaration only`, { severity: "warning", reason: "declared_binding_only" });
578
+ } else {
579
+ r.fail("ref.cross_target", subject, `record has no intrinsic target digest and no declared binding to ${[...allowedBuilds].join(", ")}`);
580
+ out.usable = false;
581
+ return out;
582
+ }
583
+ }
584
+ for (const [i, selector] of (ref.selectors ?? []).entries()) {
585
+ const sid = `${subject}/selector[${i}]`;
586
+ const lookup = await store.lookup(st.abs, entry.bytes, `${rec.pointer}${selector.pointer}`);
587
+ const result = judgePointer(selector, lookup);
588
+ r.add("ref.selector", result.status, sid, `${selector.role} ${selector.pointer || "(root)"}: ${result.detail ?? result.reason ?? ""}`.trim(), { reason: result.reason });
589
+ out.selectors.push({ selector, result });
590
+ }
591
+ return out;
592
+ }
593
+ // external
594
+ if (entry.kind !== "external" || entry.external?.ext_id !== ref.ext_id) {
595
+ r.fail("ref.resolve", subject, `${ref.ext_id.slice(0, 16)}… is not the external evidence in ${ref.file}`);
596
+ return out;
597
+ }
598
+ const ext = entry.external;
599
+ out.external = ext;
600
+ out.usable = true;
601
+ out.rank = ext.acquisition.provenance_status === "known" ? 3 : ext.acquisition.provenance_status === "partial" ? 2 : 1;
602
+ if (allowedBuilds) {
603
+ const a = ext.target_association;
604
+ if (a.status === "declared" && !allowedBuilds.has(a.build_id)) {
605
+ r.fail("ref.cross_target", subject, `external evidence is declared for ${a.build_id}; allowed: ${[...allowedBuilds].join(", ")}`);
606
+ out.usable = false;
607
+ return out;
608
+ }
609
+ if (a.status !== "declared") r.unv("ref.target", subject, `external evidence target association is ${a.status}`, { severity: "warning", reason: "external_target_unbound" });
610
+ else r.unv("ref.target", subject, `external evidence bound to ${a.build_id} by declaration only`, { severity: "warning", reason: "declared_binding_only" });
611
+ }
612
+ for (const [i, selector] of (ref.selectors ?? []).entries()) {
613
+ const sid = `${subject}/selector[${i}]`;
614
+ const result = await judgeExternal(selector, st.abs, entry, externalFacts);
615
+ r.add("ref.selector", result.status, sid, `${selector.type}: ${result.detail ?? result.reason ?? ""}`.trim(), { reason: result.reason });
616
+ out.selectors.push({ selector, result });
617
+ }
618
+ return out;
619
+ };
620
+
621
+ // ---- inventory ---------------------------------------------------------------------------------
622
+ if (inventory) {
623
+ const seenBuilds = new Set();
624
+ for (const artifact of inventory.artifacts) {
625
+ const subject = `inventory:${artifact.build_id}`;
626
+ if (!builds.has(artifact.build_id)) r.fail("inventory.build", subject, `unknown build ${artifact.build_id}`);
627
+ if (seenBuilds.has(artifact.build_id)) r.fail("inventory.build", subject, "build inventoried twice");
628
+ seenBuilds.add(artifact.build_id);
629
+ const allowed = new Set([artifact.build_id]);
630
+ const refs = [
631
+ ...artifact.evidence.map((ref, i) => [ref, `${subject}/evidence[${i}]`]),
632
+ ...artifact.components.flatMap((c, ci) => c.evidence.map((ref, i) => [ref, `${subject}/component[${ci}]/evidence[${i}]`])),
633
+ ...artifact.capabilities.flatMap((c, ci) => c.evidence.map((ref, i) => [ref, `${subject}/capability[${ci}]/evidence[${i}]`])),
634
+ ];
635
+ for (const [ref, sub] of refs) await evalRef(ref, sub, { allowedBuilds: allowed, assertion: true });
636
+ for (const component of artifact.components) {
637
+ if (component.sha256 === null && component.path === null) r.unv("inventory.component", `${subject}:${component.component_id}`, "component has neither path nor digest", { severity: "warning" });
638
+ }
639
+ }
640
+ if (closure && inv.builds.some((b) => !seenBuilds.has(b.build_id))) {
641
+ r.fail("inventory.coverage", "inventory", `builds not inventoried: ${inv.builds.filter((b) => !seenBuilds.has(b.build_id)).map((b) => b.build_id).join(", ")}`, { modes: CLOSURE });
642
+ }
643
+ if (D.index.sha256 && inventory.inputs.evidence_index && inventory.inputs.evidence_index.sha256 !== D.index.sha256) {
644
+ r.unv("inventory.inputs", "inventory", "inventory was derived from an older evidence index; its references are still validated against the current index", { severity: "warning", reason: "inputs_stale" });
645
+ }
646
+ } else if (closure) r.fail("inventory.missing", "inventory.json", "closure needs inventory.json", { modes: CLOSURE });
647
+
648
+ // ---- claims --------------------------------------------------------------------------------------------
649
+ const claimMap = new Map();
650
+ if (claimsDoc) {
651
+ for (const claim of claimsDoc.claims) {
652
+ if (claimMap.has(claim.claim_id)) r.fail("claim.unique", claim.claim_id, "duplicate claim_id");
653
+ claimMap.set(claim.claim_id, claim);
654
+ }
655
+ if (claimsDoc.inputs.evidence_index.sha256 !== D.index.sha256) r.unv("claims.inputs", "claims", "claims were written against an older evidence index; references are validated against the current index", { severity: "warning", reason: "inputs_stale" });
656
+ if (claimsDoc.inputs.inventory && D.inventory.sha256 && claimsDoc.inputs.inventory.sha256 !== D.inventory.sha256) r.unv("claims.inputs", "claims", "claims were written against an older inventory", { severity: "warning", reason: "inputs_stale" });
657
+ for (const claim of claimsDoc.claims) await checkClaim(claim);
658
+ // derivation graph: cycles and weaker inputs
659
+ const visiting = new Set();
660
+ const done = new Set();
661
+ const visit = (id) => {
662
+ if (done.has(id)) return false;
663
+ if (visiting.has(id)) return true;
664
+ visiting.add(id);
665
+ const claim = claimMap.get(id);
666
+ let cyclic = false;
667
+ for (const input of claim?.derivation?.input_claim_ids ?? []) if (claimMap.has(input) && visit(input)) cyclic = true;
668
+ visiting.delete(id);
669
+ done.add(id);
670
+ return cyclic;
671
+ };
672
+ for (const id of claimMap.keys()) if (visit(id)) r.fail("claim.derivation_cycle", id, "derivation inputs form a cycle");
673
+ // question <-> claim links
674
+ for (const q of inv.questions) {
675
+ for (const cid of q.claim_ids) {
676
+ const claim = claimMap.get(cid);
677
+ if (!claim) r.fail("question.claim_links", q.question_id, `unknown claim ${cid}`);
678
+ else if (!claim.question_ids.includes(q.question_id)) r.fail("question.claim_links", q.question_id, `claim ${cid} does not list this question`);
679
+ }
680
+ if ((q.status === "answered" || q.status === "partial") && !q.claim_ids.some((id) => claimMap.has(id))) r.fail("question.claim_links", q.question_id, `${q.status} question has no supporting claim`);
681
+ if (q.status === "answered" && !q.claim_ids.some((id) => ["observed", "derived"].includes(claimMap.get(id)?.state))) r.fail("question.claim_links", q.question_id, "answered question needs at least one observed or derived claim; inferred-only support is partial");
682
+ }
683
+ } else if (closure && inv.questions.some((q) => q.status === "answered" || q.status === "partial")) {
684
+ r.fail("claims.missing", "claims.json", "closure needs claims.json for answered/partial questions", { modes: CLOSURE });
685
+ }
686
+
687
+ async function checkClaim(claim) {
688
+ const subject = claim.claim_id;
689
+ const asserting = ASSERTING.has(claim.state);
690
+ const allowed = new Set([claim.build_id, ...claim.comparison_build_ids]);
691
+ for (const b of allowed) if (!builds.has(b)) r.fail("claim.build", subject, `unknown build ${b}`);
692
+ for (const q of claim.question_ids) if (!questions.has(q)) r.fail("claim.question", subject, `unknown question ${q}`);
693
+ for (const other of claim.contradicts) if (!claimMap.has(other)) r.fail("claim.contradicts", subject, `contradicts unknown claim ${other}`);
694
+
695
+ // state-specific structure
696
+ const structure = [];
697
+ if ((claim.state === "derived") !== (claim.derivation !== null)) structure.push("derivation must be present exactly for derived claims");
698
+ if ((claim.state === "inferred") !== (claim.inference !== null)) structure.push("inference must be present exactly for inferred claims");
699
+ if ((claim.state === "unavailable") !== (claim.unavailable_reason !== null && claim.unavailable_reason !== "")) structure.push("unavailable_reason must be present exactly for unavailable claims");
700
+ if ((claim.basis === "user-statement") !== (claim.user_statement !== null)) structure.push("user_statement must be present exactly for user-statement basis");
701
+ if (claim.basis === "user-statement" && claim.state === "observed") structure.push("a user statement is not an observation of the target");
702
+ if (claim.state === "derived" && claim.derivation && claim.derivation.input_claim_ids.length + claim.derivation.input_evidence.length === 0) structure.push("derived claims need explicit inputs");
703
+ if (claim.state === "unknown" && claim.coverage.status === "complete") structure.push("an unknown claim cannot have complete coverage");
704
+ if (claim.state === "unavailable" && !["unavailable", "unknown"].includes(claim.coverage.status)) structure.push("unavailable claims need coverage unavailable or unknown");
705
+ if (asserting && claim.evidence.length === 0 && claim.basis !== "user-statement") structure.push("asserted claims need evidence references");
706
+ if (structure.length) for (const s of structure) r.fail("claim.structure", subject, s);
707
+ else r.ok("claim.structure", subject, "state/basis fields consistent");
708
+
709
+ // evidence references
710
+ const evaluated = [];
711
+ for (const [i, ref] of claim.evidence.entries()) evaluated.push(await evalRef(ref, `${subject}/evidence[${i}]`, { allowedBuilds: allowed, assertion: asserting }));
712
+ const usable = evaluated.filter((e) => e.usable);
713
+
714
+ // locations
715
+ for (const [li, location] of claim.locations.entries()) await checkLocation(claim, location, `${subject}/location[${li}]`, allowed, asserting);
716
+
717
+ // coverage
718
+ const coverageEvaluated = [];
719
+ for (const [i, ref] of claim.coverage.evidence.entries()) coverageEvaluated.push(await evalRef(ref, `${subject}/coverage[${i}]`, { allowedBuilds: allowed, assertion: asserting }));
720
+ const coverageSelectors = coverageEvaluated.flatMap((e) => e.selectors).filter((s) => s.selector.role === "coverage" && s.selector.expect && ("equals" in s.selector.expect || "sha256" in s.selector.expect));
721
+ const absence = claim.polarity === "absence" && asserting;
722
+ if (absence || (asserting && claim.coverage.status === "complete")) {
723
+ const problems = [];
724
+ if (claim.coverage.status !== "complete") problems.push(`absence needs complete coverage, found ${claim.coverage.status}; record the outcome as an unknown claim instead`);
725
+ if (!claim.coverage.scope.trim()) problems.push("coverage scope is empty");
726
+ if (coverageSelectors.length === 0) problems.push("no coverage-role selector asserts the saved completeness value (expect.equals or expect.sha256; expect.exists only proves the pointer resolves)");
727
+ else if (!coverageSelectors.some((s) => s.result.status === "verified")) {
728
+ const unverifiedOnly = coverageSelectors.every((s) => s.result.status === "unverified");
729
+ if (unverifiedOnly) r.unv("claim.absence_coverage", subject, "coverage selectors could not be verified", { reason: coverageSelectors[0].result.reason });
730
+ else problems.push("coverage selectors do not verify against saved evidence");
731
+ }
732
+ if (problems.length) for (const p of problems) r.fail("claim.absence_coverage", subject, p);
733
+ else if (coverageSelectors.some((s) => s.result.status === "verified")) r.ok("claim.absence_coverage", subject, "coverage selectors verified");
734
+ }
735
+ if (claim.polarity === "absence" && !asserting && claim.state === "inferred") r.fail("claim.absence_coverage", subject, "inferred absence is not allowed without complete coverage");
736
+
737
+ if (!asserting) return;
738
+ // derivation inputs set the ceiling of a derived claim; unrelated stronger evidence cannot promote it
739
+ let derivationCeiling = null;
740
+ if (claim.derivation) {
741
+ const cited = new Set(claim.evidence.map(refKey));
742
+ const ranks = [];
743
+ let unverifiedInput = false;
744
+ for (const input of claim.derivation.input_claim_ids) {
745
+ const source = claimMap.get(input);
746
+ if (!source) r.fail("claim.derivation", subject, `derivation input ${input} does not exist`);
747
+ else if (!ASSERTING.has(source.state)) r.fail("claim.derivation", subject, `derivation input ${input} is ${source.state}`);
748
+ else ranks.push(RANK[source.state]);
749
+ }
750
+ for (const [i, ref] of claim.derivation.input_evidence.entries()) {
751
+ const input = await evalRef(ref, `${subject}/derivation[${i}]`, { allowedBuilds: allowed, assertion: true });
752
+ ranks.push(input.usable ? input.rank : 0);
753
+ unverifiedInput ||= input.unverified === true;
754
+ if (!cited.has(refKey(ref))) r.fail("claim.derivation", subject, `derivation input_evidence[${i}] must also be listed in claim.evidence`);
755
+ }
756
+ derivationCeiling = ranks.length ? Math.min(...ranks) : 0;
757
+ if (unverifiedInput && derivationCeiling === 0) r.unv("claim.derivation", subject, "an input outside the indexed record prefix cannot establish the derivation ceiling", { reason: "record_beyond_index_limit" });
758
+ else if (RANK[claim.state] > derivationCeiling) r.fail("claim.derivation", subject, `derived claim is stronger than its weakest input (rank ${derivationCeiling}); inputs and cited evidence must support the state`);
759
+ else r.ok("claim.derivation", subject, "inputs support the claim state");
760
+ }
761
+ // strength: never strengthen producer inference or weak provenance
762
+ if (claim.basis !== "user-statement" && derivationCeiling === null) {
763
+ const best = Math.max(0, ...usable.map((e) => e.rank));
764
+ if (usable.length > 0 && RANK[claim.state] > best) r.fail("claim.state_exceeds_evidence", subject, `claim state ${claim.state} is stronger than the strongest cited evidence (rank ${best}: ${best === 3 ? "observed" : best === 2 ? "derived" : "inferred"}); downgrade the state or cite stronger evidence`);
765
+ else if (usable.length > 0) r.ok("claim.state_exceeds_evidence", subject, "state not stronger than evidence");
766
+ }
767
+ // basis consistency
768
+ const canonicalUsable = usable.filter((e) => e.kind === "canonical");
769
+ const externalUsable = usable.filter((e) => e.kind === "external");
770
+ let basisProblem = null;
771
+ switch (claim.basis) {
772
+ case "static":
773
+ if (!canonicalUsable.some((e) => e.authority === "shipped-artifact")) basisProblem = "static basis needs a cited canonical record with authority shipped-artifact";
774
+ break;
775
+ case "runtime":
776
+ if (!canonicalUsable.some((e) => e.authority === "controlled-replay") && !externalUsable.some((e) => e.external.acquisition.scenario_id)) basisProblem = "runtime basis needs a controlled-replay record or external evidence tied to a scenario_id";
777
+ break;
778
+ case "recorded":
779
+ if (!canonicalUsable.some((e) => ["historical-reference", "external-service"].includes(e.authority)) && externalUsable.length === 0) basisProblem = "recorded basis needs a recording-derived record (historical-reference/external-service) or external original";
780
+ break;
781
+ case "external":
782
+ if (claim.evidence.length === 0 || claim.evidence.some((ref) => ref.kind !== "external")) basisProblem = "external basis needs evidence made only of ext_ references";
783
+ break;
784
+ default:
785
+ break;
786
+ }
787
+ if (basisProblem) {
788
+ if (["static", "runtime", "recorded"].includes(claim.basis) && evaluated.some((e) => e.kind === "canonical" && e.unverified)) {
789
+ r.unv("claim.basis", subject, `${basisProblem}; a canonical reference outside the indexed record prefix remains unchecked`, { reason: "record_beyond_index_limit" });
790
+ } else r.fail("claim.basis", subject, basisProblem);
791
+ } else if (claim.basis !== "user-statement") r.ok("claim.basis", subject, `${claim.basis} basis consistent`);
792
+ // external provenance ceiling
793
+ for (const e of externalUsable) {
794
+ const cap = e.external.acquisition.provenance_status === "known" ? 3 : e.external.acquisition.provenance_status === "partial" ? 2 : 1;
795
+ if (usable.every((u) => u.kind === "external") && RANK[claim.state] > cap) r.fail("claim.provenance_ceiling", subject, `external provenance is ${e.external.acquisition.provenance_status}; claim state cannot exceed ${cap === 2 ? "derived" : "inferred"}`);
796
+ }
797
+ // derivation: self-reference only; ranks and inputs were evaluated above
798
+ if (claim.derivation && claim.derivation.input_claim_ids.includes(claim.claim_id)) r.fail("claim.derivation", subject, "claim derives from itself");
799
+ }
800
+
801
+ async function checkLocation(claim, location, subject, allowed, asserting) {
802
+ const evaluated = [];
803
+ for (const [i, ref] of location.evidence.entries()) evaluated.push(await evalRef(ref, `${subject}/evidence[${i}]`, { allowedBuilds: allowed, assertion: asserting }));
804
+ let claimed;
805
+ switch (location.kind) {
806
+ case "artifact-member": claimed = location.member_path; break;
807
+ case "source-range": claimed = location.path; break;
808
+ case "native-procedure": claimed = location.address; break;
809
+ case "resource-node": claimed = location.node_id; break;
810
+ default: claimed = location.event_id;
811
+ }
812
+ if (location.kind === "source-range" && location.end_line < location.start_line) r.fail("location.range", subject, "end_line precedes start_line");
813
+ if (location.kind === "native-procedure") {
814
+ const digests = evaluated.filter((e) => e.summary).flatMap((e) => e.summary.binding_digests);
815
+ if (digests.length > 0 && digests.some((d) => d !== location.target_sha256)) r.fail("location.target", subject, "location target_sha256 differs from the cited record's own target digest");
816
+ else if (digests.length > 0) r.ok("location.target", subject, "target digest matches cited evidence");
817
+ else r.unv("location.target", subject, "cited evidence carries no target digest to compare with target_sha256", { severity: "warning" });
818
+ const owner = matchBuild(inv.builds, [location.target_sha256]);
819
+ if (!owner || !allowed.has(owner.build_id)) r.fail("location.target", subject, "location target_sha256 is not an allowed build identity for this claim");
820
+ if (location.file_offset !== undefined && !location.mapping) r.fail("location.mapping", subject, "file offsets need an observed mapping");
821
+ }
822
+ let tied = false;
823
+ let mismatch = null;
824
+ for (const e of evaluated) {
825
+ for (const { selector, result } of e.selectors) {
826
+ if (result.status !== "verified") continue;
827
+ if (selector.type === "json-pointer" && selector.expect && "equals" in selector.expect) {
828
+ if (JSON.stringify(selector.expect.equals) === JSON.stringify(claimed)) tied = true;
829
+ else mismatch = selector.expect.equals;
830
+ }
831
+ if (selector.type === "event-ordinal" && location.kind === "runtime-event" && selector.ordinal === claimed) tied = true;
832
+ if (selector.type === "line-range" && location.kind === "source-range" && selector.start === location.start_line && selector.end === location.end_line) tied = true;
833
+ }
834
+ }
835
+ if (tied) r.ok("location.tie", subject, "claimed location value equals a verified selector value");
836
+ else if (evaluated.every((e) => !e.usable)) r.unv("location.tie", subject, "location evidence is not usable", { reason: "evidence_unusable" });
837
+ else r.unv("location.tie", subject, mismatch !== null ? "verified selectors assert a different value than the claimed location" : "no verified selector with expect.equals ties the claimed location value to saved evidence", { reason: "selector_missing_or_untied" });
838
+ if (location.kind === "source-range") {
839
+ let bounded = false;
840
+ for (const e of evaluated) {
841
+ for (const { selector, result } of e.selectors) {
842
+ if (result.status !== "verified") continue;
843
+ if (selector.type === "line-range" && selector.start === location.start_line && selector.end === location.end_line) bounded = true;
844
+ if (selector.type === "json-pointer" && selector.role === "line-count" && selector.expect && Number.isInteger(selector.expect.equals) && selector.expect.equals >= location.end_line) bounded = true;
845
+ }
846
+ }
847
+ if (bounded) r.ok("location.range", subject, "line range is bounded by a verified line-range selector or line-count");
848
+ else if (evaluated.every((e) => !e.usable)) r.unv("location.range", subject, "location evidence is not usable", { reason: "evidence_unusable" });
849
+ else r.unv("location.range", subject, "no verified line-range selector or role=line-count selector bounds end_line against the saved source", { reason: "range_not_bounded_by_evidence" });
850
+ }
851
+ if (location.kind === "native-procedure") {
852
+ for (const e of evaluated.filter((x) => x.entry)) {
853
+ const provider = e.provider;
854
+ if (!provider) r.unv("location.provider", subject, "cited evidence does not report a single provider to compare with location.provider", { reason: "provider_not_reported" });
855
+ else if (provider.id === location.provider.id && (provider.version ?? null) === (location.provider.version ?? null)) r.ok("location.provider", subject, "provider id/version match the saved record");
856
+ else r.fail("location.provider", subject, `location provider ${location.provider.id}@${location.provider.version ?? "null"} differs from saved record ${provider.id}@${provider.version ?? "null"}`);
857
+ }
858
+ if (location.address_space === "provider-reported") r.ok("location.address_space", subject, "address is stated as provider-reported; no mapping is claimed");
859
+ else {
860
+ const asserted = evaluated.some((e) => e.selectors.some(({ selector, result }) => result.status === "verified" && selector.role === "identity" && selector.expect && selector.expect.equals === location.address_space));
861
+ if (asserted) r.ok("location.address_space", subject, "address space equals a verified identity selector");
862
+ else r.unv("location.address_space", subject, `address_space ${location.address_space} is not reported by saved evidence; use provider-reported or cite a role=identity selector that exposes it`, { reason: "address_space_not_in_evidence" });
863
+ }
864
+ if (location.file_offset !== undefined) {
865
+ const selectors = evaluated.flatMap((e) => e.selectors).filter((s) => s.result.status === "verified" && s.selector.type === "json-pointer" && s.selector.expect);
866
+ const offsetFound = selectors.some((s) => s.selector.expect.equals === location.file_offset);
867
+ const mappingFound = selectors.some((s) => s.selector.role === "identity" && s.selector.expect.equals === location.mapping);
868
+ if (offsetFound && mappingFound) r.ok("location.mapping", subject, "file offset and mapping are explicitly tied to saved values; semantic correspondence still requires review");
869
+ else r.unv("location.mapping", subject, "file offset and mapping need verified value/identity selectors; a mapping description alone is not evidence", { reason: "mapping_not_in_evidence" });
870
+ }
871
+ }
872
+ }
873
+
874
+ // ---- specs ------------------------------------------------------------------------------------------------
875
+ const specFiles = (await walkFiles(ws.abs, "specs")).filter((f) => f.rel.endsWith(".md") || f.symlink);
876
+ const liveSpecs = [];
877
+ for (const file of specFiles) {
878
+ if (file.symlink) {
879
+ r.fail("spec.path", file.rel, "symbolic link inside specs");
880
+ continue;
881
+ }
882
+ liveSpecs.push(file.rel);
883
+ }
884
+ const specDigests = new Map();
885
+ let indexText = null;
886
+ for (const rel of liveSpecs) {
887
+ const resolved = await resolveInWorkspace(ws.abs, rel);
888
+ const buffer = await fsp.readFile(resolved.abs);
889
+ specDigests.set(rel, sha256Buffer(buffer));
890
+ if (rel === "specs/index.md") indexText = buffer.toString("utf8");
891
+ const isFeatureFile = /^specs\/[^/]+\/(requirements|reference-design)\.md$/.test(rel);
892
+ if (isFeatureFile) checkSpec(rel, buffer.toString("utf8"));
893
+ }
894
+ const features = new Set(liveSpecs.map((rel) => rel.split("/")[1]).filter((name) => name && name !== "index.md" && liveSpecs.some((x) => x.startsWith(`specs/${name}/`))));
895
+ if (closure && features.size > 0) {
896
+ if (!liveSpecs.includes("specs/index.md")) r.fail("spec.layout", "specs/index.md", "specs/index.md is required", { modes: CLOSURE });
897
+ for (const feature of features) {
898
+ for (const required of ["requirements.md", "reference-design.md"]) {
899
+ if (!liveSpecs.includes(`specs/${feature}/${required}`)) r.fail("spec.layout", `specs/${feature}/${required}`, "required spec file is missing", { modes: CLOSURE });
900
+ }
901
+ }
902
+ }
903
+ if (indexText !== null && features.size > 0) {
904
+ // Warning-only: resolve inline links against specs/ and expect each feature's requirements and
905
+ // reference design (a link to the feature directory covers both). Fenced examples are ignored.
906
+ const reachable = new Set();
907
+ const directories = new Set();
908
+ for (const target of markdownLinkTargets(parseFrontMatter(indexText).body)) {
909
+ if (target === "" || target.startsWith("#") || target.startsWith("//") || /^[a-z][a-z0-9+.-]*:/i.test(target)) continue;
910
+ let decoded = target.replace(/[?#].*$/, "");
911
+ try {
912
+ decoded = decodeURIComponent(decoded);
913
+ } catch {
914
+ /* keep the raw target; it will fail to resolve below */
915
+ }
916
+ const normalized = path.posix.normalize(path.posix.join("specs", decoded));
917
+ if (normalized !== "specs" && !normalized.startsWith("specs/")) continue;
918
+ const trimmed = normalized.replace(/\/+$/, "");
919
+ const resolved = await resolveInWorkspace(ws.abs, trimmed).catch(() => null);
920
+ if (!resolved?.exists) {
921
+ r.unv("spec.index_links", `specs/index.md:${target}`, `index link ${target} does not resolve to a file or directory under specs/`, { severity: "warning", reason: "index_link_broken" });
922
+ continue;
923
+ }
924
+ if (resolved.stat.isDirectory()) directories.add(trimmed);
925
+ else reachable.add(trimmed);
926
+ }
927
+ for (const feature of features) {
928
+ for (const required of ["requirements.md", "reference-design.md"]) {
929
+ const file = `specs/${feature}/${required}`;
930
+ if (!liveSpecs.includes(file)) continue;
931
+ if (reachable.has(file) || directories.has(`specs/${feature}`)) r.ok("spec.index_links", file, "index links to this spec");
932
+ else r.unv("spec.index_links", file, `specs/index.md has no link to ${file} (link the file or its feature directory)`, { severity: "warning", reason: "index_link_missing" });
933
+ }
934
+ }
935
+ }
936
+
937
+ function parseFrontMatter(text) {
938
+ if (!text.startsWith("---\n") && !text.startsWith("---\r\n")) return { data: null, body: text };
939
+ const end = text.search(/\n---\s*(\r?\n|$)/);
940
+ if (end === -1) return { data: null, body: text };
941
+ const block = text.slice(4, end);
942
+ const body = text.slice(text.indexOf("\n", end + 1) + 1);
943
+ const data = {};
944
+ let currentKey = null;
945
+ for (const line of block.split(/\r?\n/)) {
946
+ let m = /^([A-Za-z_][A-Za-z0-9_]*):\s*(.*)$/.exec(line);
947
+ if (m) {
948
+ currentKey = m[1];
949
+ const value = m[2].trim();
950
+ if (value === "") data[currentKey] = [];
951
+ else if (value.startsWith("[") && value.endsWith("]")) data[currentKey] = value.slice(1, -1).split(",").map((s) => s.trim().replace(/^["']|["']$/g, "")).filter(Boolean);
952
+ else data[currentKey] = value.replace(/^["']|["']$/g, "");
953
+ continue;
954
+ }
955
+ m = /^\s+-\s+(.*)$/.exec(line);
956
+ if (m && currentKey && Array.isArray(data[currentKey])) data[currentKey].push(m[1].trim().replace(/^["']|["']$/g, ""));
957
+ }
958
+ return { data, body };
959
+ }
960
+
961
+ function checkSpec(rel, text) {
962
+ const { data, body } = parseFrontMatter(text);
963
+ if (!data) {
964
+ r.fail("spec.front_matter", rel, "missing YAML front matter");
965
+ } else {
966
+ for (const key of ["investigation_id", "feature", "reference_build_ids", "claims_digest"]) if (data[key] === undefined || data[key] === "") r.fail("spec.front_matter", rel, `front matter key ${key} is missing`);
967
+ if (data.investigation_id && data.investigation_id !== inv.investigation_id) r.fail("spec.front_matter", rel, `investigation_id ${data.investigation_id} does not match`);
968
+ if (data.feature && data.feature !== rel.split("/")[1]) r.fail("spec.front_matter", rel, `feature ${data.feature} does not match directory ${rel.split("/")[1]}`);
969
+ const refBuilds = Array.isArray(data.reference_build_ids) ? data.reference_build_ids : [];
970
+ for (const b of refBuilds) if (!builds.has(b)) r.fail("spec.front_matter", rel, `unknown reference build ${b}`);
971
+ if (data.claims_digest) {
972
+ const digest = String(data.claims_digest).replace(/^sha256:/, "");
973
+ if (D.claims.sha256 && digest !== D.claims.sha256) r.fail("spec.claims_digest", rel, "claims_digest differs from the current claims.json", { modes: CLOSURE, reason: "spec_stale" });
974
+ else if (D.claims.sha256) r.ok("spec.claims_digest", rel, "claims_digest current");
975
+ }
976
+ data.__builds = new Set(refBuilds);
977
+ }
978
+ const lines = body.split(/\r?\n/);
979
+ const inFenceLine = makeFenceTracker();
980
+ let inNarrative = false;
981
+ let linked = 0;
982
+ for (let n = 0; n < lines.length; n += 1) {
983
+ const line = lines[n];
984
+ const subject = `${rel}:${n + 1 + (text.length - body.length > 0 ? text.slice(0, text.length - body.length).split("\n").length - 1 : 0)}`;
985
+ if (inFenceLine(line)) continue;
986
+ const heading = /^(#{1,6})\s+(.*)$/.exec(line);
987
+ if (heading) {
988
+ // Only the template's exact level-2 section is exempt, and it ends at the next heading of any level.
989
+ inNarrative = heading[1].length === 2 && heading[2].replace(/\s+#+\s*$/, "").trim() === NARRATIVE_HEADING;
990
+ continue;
991
+ }
992
+ if (inNarrative) continue;
993
+ let content = null;
994
+ const list = /^\s*(?:[-*+]|\d+[.)])\s+(.*)$/.exec(line);
995
+ if (list) content = list[1];
996
+ else if (/^\s*\|.*\|\s*$/.test(line)) {
997
+ if (/^\s*\|[\s:|-]+\|\s*$/.test(line)) {
998
+ continue;
999
+ }
1000
+ const next = lines[n + 1] ?? "";
1001
+ if (/^\s*\|[\s:|-]+\|\s*$/.test(next)) {
1002
+ continue;
1003
+ }
1004
+ content = line;
1005
+ }
1006
+ if (content === null || content.trim() === "") continue;
1007
+ const claimTokens = [...content.matchAll(/\[claims:\s*([^\]]*)\]/g)].flatMap((m) => m[1].split(",").map((s) => s.trim()).filter(Boolean));
1008
+ const questionTokens = [...content.matchAll(/\[questions:\s*([^\]]*)\]/g)].flatMap((m) => m[1].split(",").map((s) => s.trim()).filter(Boolean));
1009
+ if (claimTokens.length === 0 && questionTokens.length === 0) {
1010
+ r.fail("spec.statement_link", subject, "statement has no [claims: …] or [questions: …] token");
1011
+ continue;
1012
+ }
1013
+ let bad = false;
1014
+ for (const id of claimTokens) if (!claimMap.has(id)) { r.fail("spec.claim_link", subject, `unknown claim ${id}`); bad = true; }
1015
+ for (const id of questionTokens) if (!questions.has(id)) { r.fail("spec.question_link", subject, `unknown question ${id}`); bad = true; }
1016
+ if (bad) continue;
1017
+ linked += 1;
1018
+ const plain = content.replace(/^(\*\*|__)/, "");
1019
+ const prefix = /^(Inferred|Unknown):/.exec(plain)?.[1] ?? null;
1020
+ const states = claimTokens.map((id) => claimMap.get(id).state);
1021
+ let problem = null;
1022
+ if (prefix === "Inferred") {
1023
+ if (claimTokens.length === 0 || states.some((s) => s !== "inferred")) problem = "Inferred: statements must cite only inferred claims";
1024
+ } else if (prefix === "Unknown") {
1025
+ if (states.some((s) => s !== "unknown" && s !== "unavailable")) problem = "Unknown: statements may cite only unknown/unavailable claims or questions";
1026
+ } else if (claimTokens.length === 0) problem = "unprefixed statements must cite claims";
1027
+ else if (states.some((s) => s !== "observed" && s !== "derived")) problem = "unprefixed statements may cite only observed/derived claims; prefix inferred ones with Inferred: and unknown ones with Unknown:";
1028
+ if (problem) r.fail("spec.state_prefix", subject, problem);
1029
+ if (data?.__builds) {
1030
+ for (const id of claimTokens) {
1031
+ const claim = claimMap.get(id);
1032
+ const claimBuilds = [claim.build_id, ...claim.comparison_build_ids];
1033
+ if (claimBuilds.some((b) => !data.__builds.has(b))) r.fail("spec.build_scope", subject, `claim ${id} concerns build(s) outside reference_build_ids`);
1034
+ }
1035
+ }
1036
+ if (!problem) r.ok("spec.state_prefix", subject, "statement linked");
1037
+ }
1038
+ if (linked === 0) r.fail("spec.statements", rel, "spec contains no linked statements (a statement is a list item or table body row outside code fences and the exact `## Narrative (non-factual)` section)", { modes: CLOSURE, reason: "no_linked_statements" });
1039
+ }
1040
+
1041
+ // ---- review and closure ------------------------------------------------------------------------------------
1042
+ if (review) {
1043
+ const staleReasons = [];
1044
+ const expectEq = (label, entry, rel, sha) => {
1045
+ if (!entry) {
1046
+ if (sha) staleReasons.push(`${label} exists but the review recorded none`);
1047
+ return;
1048
+ }
1049
+ if (entry.path !== rel) staleReasons.push(`${label} path ${entry.path} != ${rel}`);
1050
+ if (!sha) staleReasons.push(`${label} no longer exists`);
1051
+ else if (entry.sha256 !== sha) staleReasons.push(`${label} changed since review`);
1052
+ };
1053
+ const reviewedScope = review.reviewed_inputs.scope?.sha256 ?? null;
1054
+ const currentScope = scopeDigest(inv);
1055
+ if (reviewedScope !== currentScope) staleReasons.push("investigation scope (objective, non-goals, question text/feature/build scope or build identities) changed since review");
1056
+ expectEq("inventory", review.reviewed_inputs.inventory, "inventory.json", D.inventory.sha256);
1057
+ expectEq("evidence_index", review.reviewed_inputs.evidence_index, INDEX_PATH, D.index.sha256);
1058
+ expectEq("claims", review.reviewed_inputs.claims, "claims.json", D.claims.sha256);
1059
+ const reviewedSpecs = new Map(review.reviewed_inputs.specs.map((s) => [s.path, s.sha256]));
1060
+ for (const rel of liveSpecs) {
1061
+ if (!reviewedSpecs.has(rel)) staleReasons.push(`spec ${rel} was not part of the review`);
1062
+ else if (reviewedSpecs.get(rel) !== specDigests.get(rel)) staleReasons.push(`spec ${rel} changed since review`);
1063
+ }
1064
+ for (const rel of reviewedSpecs.keys()) if (!specDigests.has(rel)) staleReasons.push(`reviewed spec ${rel} no longer exists`);
1065
+ if (staleReasons.length) r.fail("review.inputs_current", "review.json", `review is stale: ${staleReasons.slice(0, 6).join("; ")}`, { modes: CLOSURE, reason: "review_stale", details: staleReasons });
1066
+ else r.ok("review.inputs_current", "review.json", "review digests equal current inputs");
1067
+ const dispositions = new Map();
1068
+ for (const d of review.dispositions) {
1069
+ if (dispositions.has(d.question_id)) r.fail("review.dispositions", d.question_id, "duplicate disposition", { modes: CLOSURE });
1070
+ dispositions.set(d.question_id, d);
1071
+ }
1072
+ for (const q of inv.questions) {
1073
+ const d = dispositions.get(q.question_id);
1074
+ if (!d) r.fail("review.dispositions", q.question_id, "review has no disposition for this question", { modes: CLOSURE });
1075
+ else if (d.disposition !== q.status) r.fail("review.dispositions", q.question_id, `review disposition ${d.disposition} differs from investigation status ${q.status}`, { modes: CLOSURE });
1076
+ else {
1077
+ const unknownClaims = d.claim_ids.filter((cid) => !claimMap.has(cid));
1078
+ for (const cid of unknownClaims) r.fail("review.dispositions", q.question_id, `review cites unknown claim ${cid}`, { modes: CLOSURE });
1079
+ const same = d.claim_ids.length === q.claim_ids.length && new Set(d.claim_ids).size === d.claim_ids.length && q.claim_ids.every((cid) => d.claim_ids.includes(cid));
1080
+ if (!same) r.fail("review.dispositions", q.question_id, "review claim_ids differ from the question's claim_ids", { modes: CLOSURE });
1081
+ if (!d.reason || !d.reason.trim()) r.fail("review.dispositions", q.question_id, "review disposition needs a reason", { modes: CLOSURE });
1082
+ else if (q.reason && d.reason !== q.reason) r.fail("review.dispositions", q.question_id, "review reason differs from the question's recorded reason", { modes: CLOSURE });
1083
+ if (unknownClaims.length === 0 && same && d.reason && d.reason.trim() && (!q.reason || d.reason === q.reason)) r.ok("review.dispositions", q.question_id, d.disposition);
1084
+ }
1085
+ }
1086
+ for (const id of dispositions.keys()) if (!questions.has(id)) r.fail("review.dispositions", id, "disposition for unknown question", { modes: CLOSURE });
1087
+ for (const f of review.findings) {
1088
+ if (f.claim_id && !claimMap.has(f.claim_id)) r.fail("review.findings", f.finding_id, `unknown claim ${f.claim_id}`);
1089
+ if (f.status === "open" && (f.severity === "blocking" || f.severity === "major")) r.fail("review.findings", f.finding_id, `open ${f.severity} finding${f.route_to ? ` (route to ${f.route_to})` : ""}: ${f.text.slice(0, 120)}`, { modes: CLOSURE });
1090
+ else if (f.status === "open") r.unv("review.findings", f.finding_id, `open ${f.severity} finding`, { severity: "warning" });
1091
+ }
1092
+ if (!["approved", "approved-with-limits"].includes(review.verdict)) r.fail("review.verdict", "review.json", `verdict ${review.verdict} does not allow closure`, { modes: CLOSURE });
1093
+ else r.ok("review.verdict", "review.json", review.verdict);
1094
+ if (inv.lifecycle.review_sha256 !== D.review.sha256) r.fail("review.checkpoint_binding", "investigation.json", "lifecycle.review_sha256 does not equal the current review.json digest", { modes: CLOSURE, reason: "checkpoint_unbound" });
1095
+ else r.ok("review.checkpoint_binding", "investigation.json", "lifecycle binds the current review");
1096
+ } else if (closure) r.fail("review.missing", "review.json", "closure needs a current review.json", { modes: CLOSURE });
1097
+ if (closure && !["verify", "complete"].includes(inv.phase)) r.fail("investigation.phase", "investigation.json", `phase ${inv.phase} cannot close; use verify or complete`, { modes: CLOSURE });
1098
+
1099
+ return finish(r, base, D, { steps: stepReport });
1100
+ }
1101
+
1102
+ function finish(r, base, D, extra) {
1103
+ const mode = base.mode;
1104
+ let outcome = "pass";
1105
+ if (r.errorFailed > 0) outcome = "fail";
1106
+ else if (mode === "closure" && r.errorUnverified > 0) outcome = "blocked";
1107
+ else if (mode === "resume" && r.resumeBlockers.length > 0) outcome = "blocked";
1108
+ return {
1109
+ ...base,
1110
+ outcome,
1111
+ summary: {
1112
+ verified: r.totals.verified,
1113
+ failed: r.totals.failed,
1114
+ unverified: r.totals.unverified,
1115
+ error_failed: r.errorFailed,
1116
+ error_unverified: r.errorUnverified,
1117
+ listed_checks: r.checks.length,
1118
+ omitted_checks: r.omitted,
1119
+ note: "Structural validation only; it does not establish that cited evidence supports a statement.",
1120
+ },
1121
+ by_rule: r.counts,
1122
+ digests: {
1123
+ investigation: D.investigation.sha256 ?? null,
1124
+ evidence_index: D.index.sha256 ?? null,
1125
+ inventory: D.inventory.sha256 ?? null,
1126
+ claims: D.claims.sha256 ?? null,
1127
+ review: D.review.sha256 ?? null,
1128
+ },
1129
+ resume_blockers: r.resumeBlockers,
1130
+ checks: r.checks,
1131
+ ...extra,
1132
+ };
1133
+ }