@clear-capabilities/agentic-security-scanner 0.162.0 → 0.163.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 (153) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/bin/agentic-security.js +87 -0
  3. package/dist/1340.index.js +1871 -0
  4. package/dist/3477.index.js +727 -0
  5. package/dist/3499.index.js +23 -3
  6. package/dist/4113.index.js +1 -1
  7. package/dist/5014.index.js +833 -0
  8. package/dist/5756.index.js +2 -2
  9. package/dist/6257.index.js +1 -1
  10. package/dist/6907.index.js +248 -0
  11. package/dist/6994.index.js +1 -1
  12. package/dist/7033.index.js +33 -5
  13. package/dist/7439.index.js +1822 -0
  14. package/dist/7838.index.js +1 -1
  15. package/dist/9560.index.js +2318 -23
  16. package/dist/957.index.js +1 -1
  17. package/dist/9999.index.js +43 -7
  18. package/dist/agentic-security.mjs +12 -12
  19. package/dist/agentic-security.mjs.sha256 +1 -1
  20. package/package.json +33 -6
  21. package/src/capabilities/CLAUDE.md +114 -0
  22. package/src/capabilities/address.js +73 -0
  23. package/src/capabilities/attack-coverage.js +93 -0
  24. package/src/capabilities/decide.js +256 -0
  25. package/src/capabilities/delegate.js +69 -0
  26. package/src/capabilities/egress.js +20 -0
  27. package/src/capabilities/hook-advice.js +50 -0
  28. package/src/capabilities/index.js +32 -0
  29. package/src/capabilities/manifest.js +344 -0
  30. package/src/capabilities/outbound.js +189 -0
  31. package/src/capabilities/paths.js +57 -0
  32. package/src/capabilities/probes.js +211 -0
  33. package/src/capabilities/proxy.js +182 -0
  34. package/src/capabilities/reasons.js +67 -0
  35. package/src/capabilities/receipts.js +356 -0
  36. package/src/capabilities/records.js +58 -0
  37. package/src/capabilities/recovery.js +367 -0
  38. package/src/capabilities/report.js +81 -0
  39. package/src/capabilities/runner.js +236 -0
  40. package/src/capabilities/secrets.js +26 -0
  41. package/src/capabilities/tool-gate.js +119 -0
  42. package/src/capabilities/tool-registry.js +60 -0
  43. package/src/dataflow/CLAUDE.md +29 -0
  44. package/src/dataflow/catalog.js +21 -0
  45. package/src/dataflow/engine.js +20 -0
  46. package/src/dataflow/guard-dominance.js +352 -0
  47. package/src/discovery/CLAUDE.md +9 -0
  48. package/src/discovery/index.js +15 -1
  49. package/src/discovery/lenses.js +18 -0
  50. package/src/engine.js +80 -15
  51. package/src/integrations/index.js +5 -2
  52. package/src/ir/CLAUDE.md +14 -0
  53. package/src/ir/implicit-handler-params.js +74 -0
  54. package/src/ir/parser-cs.js +20 -0
  55. package/src/ir/parser-go.js +30 -13
  56. package/src/ir/parser-java.js +11 -0
  57. package/src/language/nix-eval-isolation.js +33 -5
  58. package/src/lineage/CLAUDE.md +31 -0
  59. package/src/lineage/deployment/adapter-cloud.js +202 -0
  60. package/src/lineage/deployment/adapter-compose.js +90 -0
  61. package/src/lineage/deployment/adapter-kit.js +142 -0
  62. package/src/lineage/deployment/adapter-kubernetes.js +247 -0
  63. package/src/lineage/deployment/attack-paths.js +443 -0
  64. package/src/lineage/deployment/boundaries-run.js +223 -0
  65. package/src/lineage/deployment/boundary-graph.js +567 -0
  66. package/src/lineage/deployment/cross-repo.js +253 -0
  67. package/src/lineage/deployment/drift.js +383 -0
  68. package/src/lineage/deployment/ingest.js +221 -0
  69. package/src/lineage/deployment/projection.js +244 -0
  70. package/src/lineage/deployment/trace-correlation.js +541 -0
  71. package/src/lineage/source-registry.js +19 -0
  72. package/src/mcp/CLAUDE.md +5 -2
  73. package/src/mcp/invariant-tools.js +64 -0
  74. package/src/mcp/portfolio-tools.js +79 -0
  75. package/src/mcp/redact.js +15 -0
  76. package/src/mcp/server.js +24 -3
  77. package/src/mcp/tools.js +13 -1
  78. package/src/posture/CLAUDE.md +332 -1
  79. package/src/posture/accuracy-scorecard.js +95 -4
  80. package/src/posture/assurance/rollback.js +214 -0
  81. package/src/posture/assurance/rollout.js +200 -0
  82. package/src/posture/business-logic.js +26 -4
  83. package/src/posture/evaluation/bakeoff.js +148 -0
  84. package/src/posture/evaluation/custody.js +200 -0
  85. package/src/posture/evaluation/deployment-ablation.js +416 -0
  86. package/src/posture/evaluation/dev-cases.js +65 -0
  87. package/src/posture/evaluation/economics.js +85 -0
  88. package/src/posture/evaluation/gates.js +161 -0
  89. package/src/posture/evaluation/grouping.js +101 -0
  90. package/src/posture/evaluation/interval.js +132 -0
  91. package/src/posture/evaluation/invariant-ablation.js +325 -0
  92. package/src/posture/evaluation/labels.js +200 -0
  93. package/src/posture/evaluation/protocol.js +308 -0
  94. package/src/posture/evaluation/release-gate.js +209 -0
  95. package/src/posture/evaluation/report.js +158 -0
  96. package/src/posture/evaluation/runner.js +273 -0
  97. package/src/posture/evaluation/scan-child.js +80 -0
  98. package/src/posture/evaluation/score.js +174 -0
  99. package/src/posture/evaluation/synthetic.js +90 -0
  100. package/src/posture/evaluation/why-missed.js +219 -0
  101. package/src/posture/fleet.js +11 -2
  102. package/src/posture/invariants/cli.js +95 -0
  103. package/src/posture/invariants/coverage.js +164 -0
  104. package/src/posture/invariants/export.js +178 -0
  105. package/src/posture/invariants/expressions.js +46 -0
  106. package/src/posture/invariants/lifecycle.js +289 -0
  107. package/src/posture/invariants/project-input.js +55 -0
  108. package/src/posture/invariants/repair.js +340 -0
  109. package/src/posture/invariants/run.js +45 -0
  110. package/src/posture/invariants/scenarios.js +311 -0
  111. package/src/posture/invariants/schema.js +167 -0
  112. package/src/posture/invariants/shrink.js +407 -0
  113. package/src/posture/invariants/state-assertions.js +165 -0
  114. package/src/posture/logic-claims.js +12 -0
  115. package/src/posture/model-routing.js +40 -0
  116. package/src/posture/oracles/adapters.js +172 -1
  117. package/src/posture/oracles/conformance.js +3 -2
  118. package/src/posture/oracles/oracle.js +23 -3
  119. package/src/posture/oracles/registry.js +1 -1
  120. package/src/posture/portfolio/backend.js +279 -0
  121. package/src/posture/portfolio/bundle.js +314 -0
  122. package/src/posture/portfolio/cli.js +142 -0
  123. package/src/posture/portfolio/manifest.js +263 -0
  124. package/src/posture/portfolio/progress.js +267 -0
  125. package/src/posture/portfolio/resume.js +130 -0
  126. package/src/posture/portfolio/retention.js +207 -0
  127. package/src/posture/portfolio/scheduler.js +488 -0
  128. package/src/posture/portfolio/signing.js +155 -0
  129. package/src/posture/portfolio/wording.js +59 -0
  130. package/src/posture/portfolio/work-units.js +405 -0
  131. package/src/posture/replay/replay.js +1 -0
  132. package/src/posture/routing/adapter.js +140 -0
  133. package/src/posture/routing/calibration.js +278 -0
  134. package/src/posture/routing/canary.js +141 -0
  135. package/src/posture/routing/control.js +35 -0
  136. package/src/posture/routing/decide.js +211 -0
  137. package/src/posture/routing/drift.js +137 -0
  138. package/src/posture/routing/economics.js +185 -0
  139. package/src/posture/routing/feedback.js +207 -0
  140. package/src/posture/routing/index.js +31 -0
  141. package/src/posture/routing/outcomes.js +281 -0
  142. package/src/posture/routing/promotion.js +291 -0
  143. package/src/posture/routing/receipts.js +59 -0
  144. package/src/posture/routing/report.js +142 -0
  145. package/src/posture/routing/shadow.js +53 -0
  146. package/src/posture/specification-mining.js +26 -0
  147. package/src/posture/verification/patch-negative.js +16 -4
  148. package/src/posture/verification/patch-promotion.js +10 -2
  149. package/src/report/index.js +51 -1
  150. package/src/sandbox/CLAUDE.md +10 -0
  151. package/src/sandbox/backend-userspace.js +91 -13
  152. package/dist/5613.index.js +0 -500
  153. package/dist/6239.index.js +0 -469
@@ -0,0 +1,407 @@
1
+ // Dependency-aware scenario shrinking (X-405).
2
+ //
3
+ // A scenario that reproduces a violation usually carries steps that have nothing to do with it. Shrinking finds a smaller
4
+ // sequence that still reproduces THE SAME violation, so a reviewer reads three steps instead of eleven. It is a search over
5
+ // REMOVALS only, verified by execution:
6
+ //
7
+ // - A candidate is the original scenario with one unit taken out (an attack step, one member of a parallel group, a control
8
+ // step, or a seed record). Nothing is edited, reordered, added or substituted, so the actor behind every surviving step, the
9
+ // tenants, roles, forbidden outcomes, the pinned fixture and the invariant it asserts cannot change.
10
+ // - A candidate is KEPT only if its replay (through `runScenario`, so a replay manifest, the trust boundary and a verifier
11
+ // receipt as for every other run) settles `confirmed`, with every precondition held (the control flow visibly worked, was
12
+ // clean and cleaned up, and the attack reproduced itself across two fresh runs), and with the SAME authoritative failing
13
+ // assertion as the original. A removal that loses the verdict, swaps it for another assertion or removes the evidence that the
14
+ // application was working is rejected, and the reason is recorded.
15
+ // - A unit the surviving steps still reference is never offered for removal (a seed record a step reads, a record that carries a
16
+ // marker the assertions look for): that is a setup dependency, and the attempt is recorded as guarded rather than skipped
17
+ // silently. Everything removed is listed, so no precondition disappears without a trace.
18
+ //
19
+ // "Authoritative failing assertion": the first violated assertion of the original run, attack phase first, in the contract's own
20
+ // forbidden-outcome order. It is identified by the contract's forbidden-outcome id and the phase.
21
+ //
22
+ // Budget. A fixed number of replays and a wall-clock ceiling, both with hard limits; a shrink that runs out of either returns
23
+ // the best scenario found so far and says `minimal: false`. `minimal: true` means a full pass over the remaining units found
24
+ // nothing more to remove (1-minimal within these removal units), not that no smaller reproduction exists. One further replay
25
+ // of the final scenario confirms reproduction on the pinned fixture and sits outside the shrink budget.
26
+ //
27
+ // Reproduction limits, reported rather than hidden. If the original does not reproduce, or the minimized scenario does not
28
+ // reproduce on its confirming replay, the result says why: a parallel group means cooperative scheduling inside one process
29
+ // (the oracle compares two fresh runs and refuses a verdict when they disagree), and the fixture is scanned for signals of
30
+ // randomness, clocks, timers and network or process access that a disposable, network-less replay cannot hold constant. Those
31
+ // signals are pattern checks over the fixture text, a disclosure and not a proof either way.
32
+ //
33
+ // Linkage. The result carries a content-addressed record joining the original and the minimized scenario by digest, replay
34
+ // manifest id, verification record id and receipt digest, plus every attempt. `verifyShrinkRecord` recomputes those links and
35
+ // calls `checkMinimization`, which re-derives from the two scenarios alone that the minimized one is an order-preserving subset
36
+ // of the original with the invariant, actors, fixture and assertions untouched.
37
+ //
38
+ // Limits: receipts are in-process objects; the record carries their digests, not signatures (signing belongs to the signer
39
+ // domain). Linux is not claimed.
40
+ import { digestOf, semanticId } from '../assurance/identity.js';
41
+ import { SCHEMA_VERSION, isPlainObject } from '../assurance/schema-kit.js';
42
+ import { featureStatus, resolveAssuranceConfig } from '../assurance/config.js';
43
+ import { runScenario, deriveScenario, FEATURE } from './scenarios.js';
44
+
45
+ const SHRINK_SCHEMA = 'agentic-security/scenario-shrink';
46
+ export const SHRINK_HARD_BUDGET = Object.freeze({ maxRuns: 40, timeBudgetMs: 120_000 });
47
+ const DEFAULT_BUDGET = Object.freeze({ maxRuns: 24, timeBudgetMs: 60_000 });
48
+ const RECORD_ID_FIELDS = ['invariant', 'fixtureDigest', 'original', 'minimized', 'removed', 'attempts', 'minimal'];
49
+ const PRECONDITIONS = ['controlRan', 'controlDurableEffect', 'controlClean', 'cleanedUp', 'deterministic'];
50
+
51
+ const clone = (v) => JSON.parse(JSON.stringify(v));
52
+
53
+ /** Validate and complete a shrink budget. Nothing is clamped silently. */
54
+ export function resolveShrinkBudget(input = {}) {
55
+ const errors = [];
56
+ const budget = { ...DEFAULT_BUDGET };
57
+ if (!isPlainObject(input)) return { ok: false, budget, errors: ['budget must be an object'] };
58
+ for (const [k, v] of Object.entries(input)) {
59
+ if (!(k in SHRINK_HARD_BUDGET)) { errors.push(`unknown budget '${k}'`); continue; }
60
+ if (!Number.isInteger(v) || v < 1) errors.push(`budget '${k}' must be a positive integer`);
61
+ else if (v > SHRINK_HARD_BUDGET[k]) errors.push(`budget '${k}' (${v}) exceeds the ceiling of ${SHRINK_HARD_BUDGET[k]}`);
62
+ else budget[k] = v;
63
+ }
64
+ return { ok: errors.length === 0, budget, errors };
65
+ }
66
+
67
+ // ---------------------------------------------------------------- units and candidates
68
+
69
+ const stepsOf = (items) => items.flatMap((it) => (it.parallel ? it.parallel : [it]));
70
+ const keyOf = (step) => (Array.isArray(step.args) && typeof step.args[0] === 'string' ? step.args[0] : null);
71
+ const label = (step) => `${step.actor}.${step.action}(${keyOf(step) ?? ''})`;
72
+ const opCountOf = (items) => items.reduce((n, it) => n + (it.parallel ? it.parallel.length : 1), 0);
73
+
74
+ function withoutIndex(list, i) { return list.filter((_, j) => j !== i); }
75
+
76
+ /** Every removal unit of an input set, in the order they are tried. A unit is `{ unit, why?, build }`; `why` marks a guard. */
77
+ function unitsOf(inputs) {
78
+ const out = [];
79
+ const retained = stepsOf([...inputs.attack, ...inputs.control]);
80
+ const referenced = new Set(retained.map(keyOf).filter(Boolean));
81
+ const markers = new Set((inputs.markers || []).map((m) => m.marker));
82
+
83
+ inputs.attack.forEach((it, i) => {
84
+ if (it.parallel) {
85
+ out.push({ unit: `attack[${i}] parallel group of ${it.parallel.length}`, kind: 'attack', blocked: inputs.attack.length < 2, why: 'the attack sequence must keep at least one item', build: (x) => ({ ...x, attack: withoutIndex(x.attack, i) }) });
86
+ it.parallel.forEach((s, j) => {
87
+ out.push({
88
+ unit: `attack[${i}] member ${j} ${label(s)}`, kind: 'attack', blocked: it.parallel.length <= 2, why: 'a parallel group keeps at least two members: below that it is no longer a concurrency scenario',
89
+ build: (x) => {
90
+ const g = x.attack[i];
91
+ const members = withoutIndex(g.parallel, j);
92
+ const schedule = g.schedule.filter((idx) => idx !== j).map((idx) => (idx > j ? idx - 1 : idx));
93
+ return { ...x, attack: x.attack.map((item, k) => (k === i ? { parallel: members, schedule } : item)) };
94
+ },
95
+ });
96
+ });
97
+ } else {
98
+ out.push({ unit: `attack[${i}] ${label(it)}`, kind: 'attack', blocked: inputs.attack.length < 2, why: 'the attack sequence must keep at least one step', build: (x) => ({ ...x, attack: withoutIndex(x.attack, i) }) });
99
+ }
100
+ });
101
+ inputs.control.forEach((it, i) => {
102
+ const text = it.parallel ? `control[${i}] parallel group of ${it.parallel.length}` : `control[${i}] ${label(it)}`;
103
+ out.push({ unit: text, kind: 'control', blocked: inputs.control.length < 2, why: 'the control flow is the evidence the application works and must keep at least one step', build: (x) => ({ ...x, control: withoutIndex(x.control, i) }) });
104
+ });
105
+ inputs.seed.forEach((rec, i) => {
106
+ const byStep = referenced.has(rec.key);
107
+ const byMarker = typeof rec.value?.marker === 'string' && markers.has(rec.value.marker);
108
+ out.push({
109
+ unit: `seed ${rec.key}`, kind: 'seed', blocked: byStep || byMarker,
110
+ why: byStep ? 'a surviving step reads or writes this record (a setup dependency)' : 'this record carries a marker the assertions look for (a setup dependency)',
111
+ build: (x) => ({ ...x, seed: withoutIndex(x.seed, i) }),
112
+ });
113
+ });
114
+ return out;
115
+ }
116
+
117
+ // ---------------------------------------------------------------- what a run established
118
+
119
+ function authoritativeOf(report) {
120
+ const hit = (report?.assertions || []).filter((a) => a.violated);
121
+ const first = hit.find((a) => a.phase === 'attack') || hit[0];
122
+ return first ? { id: first.id, phase: first.phase } : null;
123
+ }
124
+
125
+ const heldAll = (report) => PRECONDITIONS.every((k) => report?.preconditions?.[k] === true);
126
+ const reproducesAssertion = (report, a) => !!a && (report?.assertions || []).some((x) => x.violated && x.id === a.id && x.phase === a.phase);
127
+
128
+ /** Decide whether a candidate's replay may replace the current scenario. Exported so the rules are testable one by one. */
129
+ export function judgeCandidate(run, assertion) {
130
+ if (!run || run.status !== 'completed' || !run.executed) {
131
+ return { kept: false, code: 'candidate-not-executable', reason: run?.errors?.[0]?.message || run?.record?.reason || `the candidate did not execute (status '${run?.status}')` };
132
+ }
133
+ if (run.outcome !== 'confirmed') {
134
+ const nondet = run.report?.preconditions?.deterministic === false;
135
+ return { kept: false, code: nondet ? 'nondeterministic' : 'verdict-lost', reason: nondet ? 'the candidate did not reproduce itself across two fresh runs, so no verdict was formed' : `the candidate settled '${run.outcome}', not confirmed` };
136
+ }
137
+ if (!heldAll(run.report)) {
138
+ const lost = PRECONDITIONS.filter((k) => run.report?.preconditions?.[k] !== true);
139
+ return { kept: false, code: 'precondition-lost', reason: `the candidate no longer shows: ${lost.join(', ')}` };
140
+ }
141
+ if (!reproducesAssertion(run.report, assertion)) {
142
+ const now = authoritativeOf(run.report);
143
+ return { kept: false, code: 'assertion-changed', reason: `the candidate violates ${now ? `'${now.id}'` : 'a different assertion'}, not '${assertion.id}'` };
144
+ }
145
+ return { kept: true, code: null, reason: null };
146
+ }
147
+
148
+ // ---------------------------------------------------------------- reproducibility disclosure
149
+
150
+ const NONDETERMINISM = [
151
+ [/\bMath\.random\b/, 'Math.random'], [/\bDate\.now\b|\bnew Date\b/, 'wall clock'], [/\bsetTimeout\b|\bsetInterval\b/, 'timers'],
152
+ [/\brandomUUID\b|\brandomBytes\b|\brandomInt\b/, 'crypto randomness'], [/\bperformance\.now\b/, 'performance.now'],
153
+ ];
154
+ const EXTERNAL = [
155
+ [/\bfetch\s*\(/, 'fetch'], [/\bprocess\.env\b/, 'process.env'],
156
+ [/from\s+['"]node:(net|http|https|http2|dns|dgram|tls|child_process|worker_threads|cluster)['"]/, 'node network/process module'],
157
+ [/from\s+['"](?!\.{1,2}\/|node:)[^'"]+['"]/, 'third-party package import'], [/\brequire\s*\(\s*['"](?!\.{1,2}\/|node:)/, 'third-party package require'],
158
+ ];
159
+
160
+ /** Signals in the fixture text that a disposable, network-less replay cannot hold constant. Pattern checks: a disclosure, not a proof. */
161
+ export function reproductionSignals(files) {
162
+ const nondeterminism = []; const external = [];
163
+ for (const [file, text] of Object.entries(isPlainObject(files) ? files : {})) {
164
+ if (typeof text !== 'string') continue;
165
+ for (const [re, what] of NONDETERMINISM) if (re.test(text)) nondeterminism.push({ file, signal: what });
166
+ for (const [re, what] of EXTERNAL) if (re.test(text)) external.push({ file, signal: what });
167
+ }
168
+ return { nondeterminism, external };
169
+ }
170
+
171
+ function reproducibility(scenario, run, files) {
172
+ const sig = reproductionSignals(files);
173
+ const concurrency = scenario.inputs.attack.some((it) => it.parallel);
174
+ const reproduced = run?.status === 'completed' && run.outcome === 'confirmed';
175
+ const blockedBy = [];
176
+ if (!reproduced) {
177
+ const nondet = run?.report?.preconditions?.deterministic === false;
178
+ if (nondet && concurrency) blockedBy.push({ code: 'concurrency', detail: 'the parallel group did not reproduce itself across two fresh runs: scheduling here is cooperative inside one process, so an interleaving-dependent defect can fail to reproduce deterministically' });
179
+ else if (nondet) blockedBy.push({ code: 'nondeterministic-behaviour', detail: 'the two fresh runs of the same sequence disagreed' });
180
+ if (sig.external.length) blockedBy.push({ code: 'external-dependency', detail: `the fixture shows ${[...new Set(sig.external.map((s) => s.signal))].join(', ')}, which a network-less disposable replay cannot reproduce` });
181
+ if (sig.nondeterminism.length) blockedBy.push({ code: 'nondeterminism-source', detail: `the fixture reads ${[...new Set(sig.nondeterminism.map((s) => s.signal))].join(', ')}` });
182
+ if (run && run.status !== 'completed') blockedBy.push({ code: 'prerequisite-unmet', detail: (run.prerequisites || []).map((p) => `${p.kind} ${p.id} (${p.state})`).join('; ') || run.errors?.[0]?.message || 'the replay did not execute' });
183
+ }
184
+ return {
185
+ reproduced, deterministic: run?.report?.preconditions?.deterministic ?? null, concurrency,
186
+ schedulingModel: concurrency ? 'cooperative-fixed-start-order (one process, interleavings at await points)' : 'sequential',
187
+ blockedBy, signals: sig,
188
+ };
189
+ }
190
+
191
+ // ---------------------------------------------------------------- the structural check
192
+
193
+ const same = (a, b) => digestOf(a) === digestOf(b);
194
+
195
+ function isSubsequence(small, big, eq) {
196
+ let j = 0;
197
+ for (const x of small) { while (j < big.length && !eq(x, big[j])) j++; if (j >= big.length) return false; j++; }
198
+ return true;
199
+ }
200
+ const sameItem = (a, b) => same(a, b);
201
+ const sameGroupSubset = (a, b) => Array.isArray(a.parallel) && Array.isArray(b.parallel) && isSubsequence(a.parallel, b.parallel, same);
202
+
203
+ /**
204
+ * Re-derive, from the two scenarios alone, that `minimized` is a pure removal-subset of `original`: same invariant, fixture,
205
+ * entry, seed, actors, tenants, markers, forbidden outcomes and cleanup; every surviving step, group and seed record equal to
206
+ * one of the original's, in the original's order; nothing added; no emptied sequence. Never throws.
207
+ * @returns {{ ok: boolean, errors: Array<{ code: string, message: string }> }}
208
+ */
209
+ export function checkMinimization(original, minimized) {
210
+ const errors = [];
211
+ const fail = (code, message) => errors.push({ code, message });
212
+ try {
213
+ if (!isPlainObject(original) || !isPlainObject(minimized) || !isPlainObject(original.inputs) || !isPlainObject(minimized.inputs)) return { ok: false, errors: [{ code: 'malformed', message: 'both scenarios must be scenario objects' }] };
214
+ if (!same(original.invariant, minimized.invariant)) fail('invariant-changed', 'minimization cannot change the invariant a scenario asserts');
215
+ if (!same(original.inputs.invariant, minimized.inputs.invariant)) fail('invariant-changed', 'the invariant in the run inputs differs');
216
+ if (original.fixtureDigest !== minimized.fixtureDigest || original.entry !== minimized.entry || original.seed !== minimized.seed || original.kind !== minimized.kind) fail('fixture-changed', 'the pinned fixture, entry, seed or scenario family differs');
217
+ if (!same(original.inputs.actors, minimized.inputs.actors)) fail('actor-changed', 'the actors (identity, tenant, role) must be exactly the original\'s');
218
+ if (!same(original.inputs.forbidden, minimized.inputs.forbidden) || !same(original.inputs.markers, minimized.inputs.markers) || !same(original.inputs.cleanup, minimized.inputs.cleanup) || original.inputs.export !== minimized.inputs.export) fail('assertion-changed', 'the forbidden outcomes, markers, cleanup or entry export differ');
219
+ for (const seq of ['attack', 'control']) {
220
+ const a = original.inputs[seq]; const m = minimized.inputs[seq];
221
+ if (!Array.isArray(m) || m.length === 0) { fail('precondition-removed', `the ${seq} sequence cannot be emptied`); continue; }
222
+ let ok = true;
223
+ let j = 0;
224
+ for (const item of m) {
225
+ let found = false;
226
+ while (j < a.length) {
227
+ const cand = a[j++];
228
+ if (sameItem(item, cand) || (item.parallel && cand.parallel && sameGroupSubset(item, cand) && groupScheduleConsistent(item, cand))) { found = true; break; }
229
+ }
230
+ if (!found) { ok = false; break; }
231
+ }
232
+ if (!ok) fail('step-added', `the minimized ${seq} sequence holds a step or group that is not, in order, a part of the original`);
233
+ if (opCountOf(m) > opCountOf(a)) fail('step-added', `the minimized ${seq} sequence is longer than the original`);
234
+ }
235
+ if (!isSubsequence(minimized.inputs.seed, original.inputs.seed, same)) fail('step-added', 'the minimized seed holds a record the original does not');
236
+ // a surviving step may not lose the record it reads: a required precondition cannot vanish silently
237
+ const have = new Set(minimized.inputs.seed.map((r) => r.key));
238
+ const origKeys = new Set(original.inputs.seed.map((r) => r.key));
239
+ for (const s of stepsOf([...minimized.inputs.attack, ...minimized.inputs.control])) {
240
+ const k = keyOf(s);
241
+ if (k && origKeys.has(k) && !have.has(k)) fail('precondition-removed', `a surviving step ${label(s)} reads '${k}', which was removed from the seed`);
242
+ }
243
+ for (const m of minimized.inputs.markers || []) {
244
+ const rec = original.inputs.seed.find((r) => r.value?.marker === m.marker);
245
+ if (rec && !have.has(rec.key)) fail('precondition-removed', `the record carrying marker '${m.marker}' was removed from the seed`);
246
+ }
247
+ } catch (e) {
248
+ fail('malformed', `the check could not complete: ${String(e?.message || e).slice(0, 120)}`);
249
+ }
250
+ return { ok: errors.length === 0, errors };
251
+ }
252
+
253
+ // A reduced group keeps the original's relative start order for the members it kept. Members can be identical, so the question is
254
+ // whether SOME choice of original positions explains the reduced group (groups hold at most 4 members: at most 16 choices).
255
+ function groupScheduleConsistent(small, big) {
256
+ const n = big.parallel.length;
257
+ for (let mask = 0; mask < (1 << n); mask++) {
258
+ const at = [];
259
+ for (let i = 0; i < n; i++) if (mask & (1 << i)) at.push(i);
260
+ if (at.length !== small.parallel.length || !at.every((pos, k) => same(small.parallel[k], big.parallel[pos]))) continue;
261
+ const order = big.schedule.filter((idx) => at.includes(idx)).map((idx) => at.indexOf(idx));
262
+ if (same(order, small.schedule)) return true;
263
+ }
264
+ return false;
265
+ }
266
+
267
+ // ---------------------------------------------------------------- the shrink
268
+
269
+ const failure = (status, code, reason, extra = {}) => ({ status, code, reason, minimized: null, record: null, ...extra });
270
+
271
+ /**
272
+ * Shrink one failing scenario within a fixed budget. Never throws.
273
+ *
274
+ * @param {object} scenario from `generateScenarios` (or a derived one); must reproduce a violation when replayed
275
+ * @param {object} o
276
+ * @param {{ files: object }} o.fixture the disposable fixture the scenario was generated from
277
+ * @param {string} o.commit exact commit (a decided verdict needs one)
278
+ * @param {object} [o.config] assurance config; `invariant-scenarios` and `verification-oracles` must be enabled
279
+ * @param {object} [o.budget] { maxRuns, timeBudgetMs }, see `SHRINK_HARD_BUDGET`
280
+ * @param {object} [o.runOptions] oracle runner seams (test)
281
+ * @returns {Promise<object>} { status: 'minimized'|'not-reproduced'|'disabled'|'rejected', code, reason, minimized, minimal, record, ... }
282
+ */
283
+ export async function shrinkScenario(scenario, o = {}) {
284
+ const config = o.config || resolveAssuranceConfig({ env: process.env });
285
+ const gate = featureStatus(config, FEATURE);
286
+ if (gate.status !== 'ok') return failure('disabled', gate.code || 'disabled', `${FEATURE} is not available: ${gate.reason}`);
287
+ const rb = resolveShrinkBudget(o.budget);
288
+ if (!rb.ok) return failure('rejected', 'bad-budget', 'the shrink budget is not valid', { errors: rb.errors });
289
+ if (!isPlainObject(scenario) || !isPlainObject(scenario.inputs) || !Array.isArray(scenario.inputs.attack) || !Array.isArray(scenario.inputs.control) || !Array.isArray(scenario.inputs.seed)) {
290
+ return failure('rejected', 'bad-scenario', 'not a generated scenario');
291
+ }
292
+ const { budget } = rb;
293
+ const files = o.fixture?.files;
294
+ const started = Date.now();
295
+ let runs = 0; let ops = 0;
296
+ const replay = async (scn) => {
297
+ const run = await runScenario(scn, { fixture: o.fixture, commit: o.commit, config, runOptions: o.runOptions });
298
+ runs++;
299
+ if (run.executed) ops += opCountOf(scn.inputs.attack) + opCountOf(scn.inputs.control);
300
+ return run;
301
+ };
302
+ const summaryOf = (scn, run, extra = {}) => ({
303
+ scenarioId: scn.id, scenarioDigest: digestOf(scn), manifestId: run?.manifestId ?? null, recordId: run?.record?.id ?? null,
304
+ receiptDigest: run?.receipt?.receiptDigest ?? null, outcome: run?.outcome ?? null, limits: scn.limits, ...extra,
305
+ });
306
+
307
+ // ---- the original must reproduce, or there is nothing to preserve
308
+ const ref = await replay(scenario);
309
+ if (ref.status === 'rejected') return failure('rejected', ref.errors?.[0]?.code || 'rejected', ref.errors?.[0]?.message || 'the scenario was rejected', { reproducibility: reproducibility(scenario, ref, files), cost: { runs, requests: ops, elapsedMs: Date.now() - started } });
310
+ const assertion = ref.outcome === 'confirmed' ? authoritativeOf(ref.report) : null;
311
+ if (ref.status !== 'completed' || ref.outcome !== 'confirmed' || !assertion || !heldAll(ref.report)) {
312
+ const code = ref.status !== 'completed' ? 'prerequisite-unmet' : ref.outcome === 'confirmed' ? 'preconditions-unproven' : `original-${ref.outcome}`;
313
+ return failure('not-reproduced', code, ref.status !== 'completed'
314
+ ? `the original scenario did not execute: ${(ref.prerequisites || []).map((p) => `${p.kind} ${p.id} (${p.state})`).join('; ') || ref.record?.reason || 'unavailable'}`
315
+ : `the original scenario settled '${ref.outcome}'${ref.record?.reason ? `: ${String(ref.record.reason).slice(0, 200)}` : ''}; there is no reproduced violation to preserve`, {
316
+ original: summaryOf(scenario, ref), reproducibility: reproducibility(scenario, ref, files), cost: { runs, requests: ops, elapsedMs: Date.now() - started },
317
+ });
318
+ }
319
+
320
+ // ---- greedy removal passes: after a kept removal the unit list is rebuilt and the same position is tried again; a pass that
321
+ // keeps nothing proves the scenario 1-minimal over these units. A candidate whose content was already tried is not run twice.
322
+ let current = scenario;
323
+ let currentRun = ref;
324
+ const attempts = []; const removed = { attack: [], control: [], seed: [] };
325
+ const tried = new Set([scenario.id]); const guardedSeen = new Set();
326
+ let exhausted = false;
327
+ const outOfBudget = () => runs >= budget.maxRuns || Date.now() - started >= budget.timeBudgetMs;
328
+ let changed = true;
329
+ while (changed && !exhausted) {
330
+ changed = false;
331
+ let units = unitsOf(current.inputs);
332
+ let i = 0;
333
+ while (i < units.length) {
334
+ const u = units[i];
335
+ if (u.blocked) {
336
+ if (!guardedSeen.has(u.unit)) { guardedSeen.add(u.unit); attempts.push({ unit: u.unit, decision: 'guarded', code: 'required-precondition', reason: u.why, candidateId: null, recordId: null, receiptDigest: null }); }
337
+ i++; continue;
338
+ }
339
+ const cand = deriveScenario(current, u.build(clone(current.inputs)));
340
+ if (tried.has(cand.id)) { i++; continue; }
341
+ if (outOfBudget()) { exhausted = true; break; }
342
+ tried.add(cand.id);
343
+ const run = await replay(cand);
344
+ const verdict = judgeCandidate(run, assertion);
345
+ attempts.push({ unit: u.unit, decision: verdict.kept ? 'removed' : 'rejected', code: verdict.code, reason: verdict.reason, candidateId: cand.id, recordId: run.record?.id ?? null, receiptDigest: run.receipt?.receiptDigest ?? null });
346
+ if (verdict.kept) {
347
+ removed[u.kind].push(u.unit);
348
+ current = cand; currentRun = run; changed = true;
349
+ units = unitsOf(current.inputs);
350
+ } else i++;
351
+ }
352
+ }
353
+ const cleanPass = !exhausted;
354
+
355
+ // ---- confirm on the pinned fixture; this replay is outside the shrink budget
356
+ const confirm = await replay(current);
357
+ const repro = reproducibility(current, confirm, files);
358
+ const confirmJudge = judgeCandidate(confirm, assertion);
359
+ repro.reproduced = confirmJudge.kept;
360
+ if (!confirmJudge.kept && !repro.blockedBy.length) repro.blockedBy.push({ code: confirmJudge.code, detail: confirmJudge.reason });
361
+
362
+ const originalSummary = summaryOf(scenario, ref, { assertion });
363
+ const minimizedSummary = summaryOf(current, confirm, { assertion, acceptedRecordId: currentRun.record?.id ?? null, acceptedReceiptDigest: currentRun.receipt?.receiptDigest ?? null });
364
+ const record = {
365
+ schema: SHRINK_SCHEMA, schemaVersion: SCHEMA_VERSION,
366
+ invariant: scenario.invariant, fixtureDigest: scenario.fixtureDigest,
367
+ original: originalSummary, minimized: minimizedSummary, removed, attempts, minimal: cleanPass && !exhausted,
368
+ };
369
+ record.id = semanticId('shrk', record, RECORD_ID_FIELDS);
370
+ const check = checkMinimization(scenario, current);
371
+ return {
372
+ status: confirmJudge.kept && check.ok ? 'minimized' : 'not-reproduced',
373
+ code: confirmJudge.kept && check.ok ? null : (check.ok ? 'minimized-not-reproduced' : 'minimization-invalid'),
374
+ reason: confirmJudge.kept && check.ok ? null : (check.ok ? `the minimized scenario did not reproduce on its confirming replay: ${confirmJudge.reason}` : check.errors[0].message),
375
+ minimized: current, original: scenario, minimal: record.minimal, record,
376
+ budget: { ...budget, runsUsed: runs - 1, exhausted },
377
+ reproducibility: repro,
378
+ preserved: { invariant: scenario.invariant, actors: scenario.inputs.actors, fixtureDigest: scenario.fixtureDigest, assertion },
379
+ cost: { runs, requests: ops, elapsedMs: Date.now() - started },
380
+ summary: `${removed.attack.length + removed.control.length + removed.seed.length} unit(s) removed; attack ${opCountOf(scenario.inputs.attack)} -> ${opCountOf(current.inputs.attack)} operation(s)${record.minimal ? ', no single removal left that preserves the verdict' : ', budget reached before a full pass'}; authoritative assertion '${assertion.id}' (${assertion.phase}) preserved`,
381
+ };
382
+ }
383
+
384
+ /**
385
+ * Check a shrink record against the two scenarios it claims to link: digests, ids, the invariant and the structural subset
386
+ * relation. A record whose links do not recompute is rejected. Never throws.
387
+ */
388
+ export function verifyShrinkRecord({ record, original, minimized }) {
389
+ const errors = [];
390
+ const fail = (code, message) => errors.push({ code, message });
391
+ try {
392
+ if (!isPlainObject(record) || record.schema !== SHRINK_SCHEMA) return { ok: false, errors: [{ code: 'malformed', message: 'not a scenario shrink record' }] };
393
+ if (semanticId('shrk', record, RECORD_ID_FIELDS) !== record.id) fail('record-tampered', 'the record id does not match its content');
394
+ if (record.original?.scenarioDigest !== digestOf(original)) fail('original-link-broken', 'the original scenario does not match the digest the record links');
395
+ if (record.minimized?.scenarioDigest !== digestOf(minimized)) fail('minimized-link-broken', 'the minimized scenario does not match the digest the record links');
396
+ if (record.original?.scenarioId !== original?.id || record.minimized?.scenarioId !== minimized?.id) fail('id-link-broken', 'a scenario id does not match the record');
397
+ if (!same(record.invariant, original?.invariant) || !same(record.invariant, minimized?.invariant)) fail('invariant-changed', 'the record, the original and the minimized scenario must name the same invariant');
398
+ for (const side of ['original', 'minimized']) {
399
+ const r = record[side];
400
+ if (typeof r?.receiptDigest !== 'string' || !/^sha256:[0-9a-f]{64}$/.test(r.receiptDigest) || typeof r.recordId !== 'string' || typeof r.manifestId !== 'string') fail('receipt-missing', `the ${side} side carries no receipt digest, verification record id and replay manifest id`);
401
+ }
402
+ for (const e of checkMinimization(original, minimized).errors) fail(e.code, e.message);
403
+ } catch (e) {
404
+ fail('malformed', `the check could not complete: ${String(e?.message || e).slice(0, 120)}`);
405
+ }
406
+ return { ok: errors.length === 0, errors };
407
+ }
@@ -0,0 +1,165 @@
1
+ // Verifier-side evaluation of invariant expressions over durable state (X-404).
2
+ //
3
+ // The business-state oracle's harness only DRIVES a scenario and records what the application wrote: per operation, the net
4
+ // durable writes (key, before, after), the side-effect events it emitted, and the response it returned. It never decides
5
+ // anything. This module reads that log, after the whole process tree is dead, and decides whether each forbidden outcome of the
6
+ // invariant happened. The decision looks at durable state and permitted side effects. The response status is recorded in the
7
+ // event sequence for the reader, but it is never an input to a verdict: a handler that answers 403 while it wrote another
8
+ // tenant's record has violated the contract, and a handler that answers 200 and wrote nothing forbidden has not.
9
+ //
10
+ // Pure functions: no fs, no clock, no randomness.
11
+ import { digestOf, semanticId } from '../assurance/identity.js';
12
+
13
+ const EVIDENCE_ID_PREFIX = 'ievd';
14
+ const EVIDENCE_ID_FIELDS = ['invariantId', 'forbiddenId', 'op', 'logDigest', 'ops'];
15
+ const SECRET_KEY = /secret|token|passw|api[_-]?key|authorization|cookie|credential|ssn|private/i;
16
+ const MAX_SNAPSHOT_KEYS = 24;
17
+ const MAX_TEXT = 80;
18
+
19
+ /** A snapshot safe to store in a record: secret-looking fields redacted, long strings cut, size bounded. Never throws. */
20
+ export function sanitizeSnapshot(snapshot) {
21
+ const clean = (v, depth) => {
22
+ if (v === null || typeof v === 'number' || typeof v === 'boolean') return v;
23
+ if (typeof v === 'string') return v.length > MAX_TEXT ? `${v.slice(0, MAX_TEXT)}...` : v;
24
+ if (depth > 3 || v === undefined) return null;
25
+ if (Array.isArray(v)) return v.slice(0, 8).map((x) => clean(x, depth + 1));
26
+ if (typeof v === 'object') {
27
+ const out = {};
28
+ for (const k of Object.keys(v).slice(0, 16)) out[k] = SECRET_KEY.test(k) ? '[redacted]' : clean(v[k], depth + 1);
29
+ return out;
30
+ }
31
+ return null;
32
+ };
33
+ const out = {};
34
+ const src = snapshot && typeof snapshot === 'object' ? snapshot : {};
35
+ for (const k of Object.keys(src).slice(0, MAX_SNAPSHOT_KEYS)) out[k] = SECRET_KEY.test(k) ? '[redacted]' : clean(src[k], 0);
36
+ return out;
37
+ }
38
+
39
+ const tenantOf = (rec) => (rec && typeof rec === 'object' && typeof rec.tenant === 'string' ? rec.tenant : null);
40
+ const num = (v) => (typeof v === 'number' && Number.isFinite(v) ? v : 0);
41
+
42
+ function opsOf(phaseLog) { return Array.isArray(phaseLog?.steps) ? phaseLog.steps : []; }
43
+
44
+ /** Replay the write log over the initial snapshot, item by item (an item is one step or one parallel group). */
45
+ function itemStates(phaseLog) {
46
+ const state = new Map(Object.entries(phaseLog?.initial || {}));
47
+ const items = new Map();
48
+ for (const op of opsOf(phaseLog)) {
49
+ if (!items.has(op.item)) items.set(op.item, { item: op.item, ops: [], before: new Map(state) });
50
+ items.get(op.item).ops.push(op);
51
+ for (const w of op.writes || []) { if (w.after === null || w.after === undefined) state.delete(w.key); else state.set(w.key, w.after); }
52
+ items.get(op.item).after = new Map(state);
53
+ }
54
+ return [...items.values()];
55
+ }
56
+
57
+ const sumOf = (m, prefix, field) => { let s = 0; for (const [k, v] of m) if (k.startsWith(prefix)) s += num(v?.[field]); return s; };
58
+
59
+ /**
60
+ * Evaluate one forbidden expression over one phase log.
61
+ * @returns {{ violated: boolean, ops: number[], detail: string }} `ops` are the operation indices that carry the violation.
62
+ */
63
+ function evaluateOne(expr, phaseLog, ctx) {
64
+ const actors = ctx.actors;
65
+ const ops = opsOf(phaseLog);
66
+ const hit = [];
67
+ let detail = '';
68
+ switch (expr.op) {
69
+ case 'cross-tenant-write':
70
+ for (const op of ops) {
71
+ const a = actors[op.actor];
72
+ if (!a) continue;
73
+ for (const w of op.writes || []) {
74
+ if (expr.prefix && !w.key.startsWith(expr.prefix)) continue;
75
+ const owner = tenantOf(w.before) ?? tenantOf(w.after);
76
+ if (owner !== null && owner !== a.tenant) { hit.push(op.op); detail = `${op.actor} (tenant ${a.tenant}) changed '${w.key}' owned by tenant ${owner}`; break; }
77
+ }
78
+ }
79
+ break;
80
+ case 'cross-tenant-read':
81
+ for (const op of ops) {
82
+ const a = actors[op.actor];
83
+ if (!a || typeof op.response !== 'string') continue;
84
+ const leaked = (ctx.markers || []).find((m) => m.tenant !== a.tenant && op.response.includes(m.marker));
85
+ if (leaked) { hit.push(op.op); detail = `the response to ${op.actor} (tenant ${a.tenant}) carried a marker owned by tenant ${leaked.tenant}`; }
86
+ }
87
+ break;
88
+ case 'unauthorized-role-change':
89
+ for (const op of ops) {
90
+ const a = actors[op.actor];
91
+ if (!a || !expr.actions.includes(op.action) || expr.allowedRoles.includes(a.role)) continue;
92
+ if ((op.writes || []).length > 0) { hit.push(op.op); detail = `${op.actor} (role ${a.role}) changed durable state through '${op.action}'`; }
93
+ }
94
+ break;
95
+ case 'sum-not-conserved':
96
+ for (const it of itemStates(phaseLog)) {
97
+ if (it.ops.some((op) => (expr.allowedActions || []).includes(op.action))) continue;
98
+ const b = sumOf(it.before, expr.prefix, expr.field);
99
+ const af = sumOf(it.after, expr.prefix, expr.field);
100
+ if (b !== af) { hit.push(...it.ops.map((o) => o.op)); detail = `the total of '${expr.field}' under '${expr.prefix}' moved from ${b} to ${af}`; }
101
+ }
102
+ break;
103
+ case 'transition-outside': {
104
+ const allowed = new Set(expr.allowed.map((t) => `${t.from}>${t.to}`));
105
+ for (const op of ops) {
106
+ for (const w of op.writes || []) {
107
+ if (!w.key.startsWith(expr.prefix) || !w.before || !w.after) continue;
108
+ const from = w.before[expr.field]; const to = w.after[expr.field];
109
+ if (from === to || typeof from !== 'string' || typeof to !== 'string') continue;
110
+ if (!allowed.has(`${from}>${to}`)) { hit.push(op.op); detail = `'${w.key}' moved ${from} to ${to}, which is not an allowed transition`; }
111
+ }
112
+ }
113
+ break;
114
+ }
115
+ case 'duplicate-effect': {
116
+ const counts = new Map();
117
+ for (const op of ops) for (const ev of op.events || []) {
118
+ if (ev.name !== expr.event) continue;
119
+ const k = ev.key ?? '';
120
+ const c = counts.get(k) || { n: 0, ops: [] };
121
+ c.n++; c.ops.push(op.op); counts.set(k, c);
122
+ }
123
+ const max = expr.max ?? 1;
124
+ for (const [k, c] of counts) if (c.n > max) { hit.push(...c.ops); detail = `effect '${expr.event}'${k ? ` for key '${k}'` : ''} happened ${c.n} times (at most ${max} allowed)`; }
125
+ break;
126
+ }
127
+ default:
128
+ return { violated: false, ops: [], detail: `unsupported expression '${String(expr.op).slice(0, 40)}'`, unsupported: true };
129
+ }
130
+ return { violated: hit.length > 0, ops: [...new Set(hit)].sort((a, b) => a - b), detail };
131
+ }
132
+
133
+ /**
134
+ * Evaluate every forbidden outcome of an invariant over one phase log.
135
+ * @param {object} args
136
+ * @param {string} args.invariantId
137
+ * @param {Array} args.forbidden the invariant's expressions
138
+ * @param {object} args.actors actor id -> { tenant, role }
139
+ * @param {Array} [args.markers] [{ tenant, marker }] strings owned by a tenant
140
+ * @param {object} args.phaseLog the harness log for one phase
141
+ * @returns {Array<{ id, op, violated, ops, detail, evidenceId }>}
142
+ */
143
+ export function evaluateAssertions({ invariantId, forbidden, actors, markers = [], phaseLog }) {
144
+ const logDigest = digestOf({ initial: phaseLog?.initial ?? null, steps: opsOf(phaseLog), final: phaseLog?.final ?? null });
145
+ return forbidden.map((expr) => {
146
+ const r = evaluateOne(expr, phaseLog, { actors, markers });
147
+ return {
148
+ id: expr.id, op: expr.op, violated: r.violated, ops: r.ops, detail: r.detail, unsupported: r.unsupported === true,
149
+ evidenceId: semanticId(EVIDENCE_ID_PREFIX, { invariantId, forbiddenId: expr.id, op: expr.op, logDigest, ops: r.ops }, EVIDENCE_ID_FIELDS),
150
+ };
151
+ });
152
+ }
153
+
154
+ /** The ordered event sequence of a phase: what ran, who ran it, the durable keys it changed and the effects it emitted. */
155
+ export function eventSequence(phaseLog) {
156
+ return opsOf(phaseLog).map((op) => ({
157
+ item: op.item, op: op.op, actor: op.actor, action: op.action, status: op.status ?? null, ok: op.ok === true,
158
+ wrote: (op.writes || []).map((w) => w.key), emitted: (op.events || []).map((e) => e.name),
159
+ }));
160
+ }
161
+
162
+ /** True if at least one operation changed durable state or emitted a permitted effect (the control produced a durable effect). */
163
+ export function hasDurableEffect(phaseLog) {
164
+ return opsOf(phaseLog).some((op) => (op.writes || []).length > 0 || (op.events || []).length > 0);
165
+ }
@@ -41,6 +41,7 @@
41
41
  // reviewer asked to double-check itself is the same party voting twice.
42
42
 
43
43
  import { recordProducer, recordVerdict, consensusOf } from './verification-separation.js';
44
+ import { inferredInvariant } from './invariants/lifecycle.js';
44
45
 
45
46
  export const PRODUCER = 'agent:logic-reviewer';
46
47
 
@@ -263,4 +264,15 @@ export function renderLogicClaimSummary(s) {
263
264
  // batch summariser have no consumer outside this module. Kept internal rather
264
265
  // than exported-and-unused — an export with no call site is how dead code gets
265
266
  // shipped and then trusted.
267
+ // X-402: a reviewing agent's claim read as a proposed contract. The claim kinds that describe a state rule map to an invariant
268
+ // class; the rest (a bare race, "other") map to nothing, because no business-state skeleton exists for them. The result is
269
+ // `proposed`, authored by a model, and never approved by this function: a claim cannot establish its own ground truth.
270
+ const INVARIANT_CLASS_BY_CLAIM = Object.freeze({ 'missing-ownership-check': 'tenant-isolation', 'missing-authorization': 'privilege-constraint', 'state-transition-bypass': 'workflow-order' });
271
+
272
+ export function invariantFromLogicClaim(claim) {
273
+ const cls = INVARIANT_CLASS_BY_CLAIM[claim?.kind];
274
+ if (!cls || typeof claim.file !== 'string') return null;
275
+ return inferredInvariant({ class: cls, source: 'logic-claim', evidence: { file: claim.file, line: Number.isInteger(claim.line) ? claim.line : null, claimKind: claim.kind }, uncertainty: 0.8 });
276
+ }
277
+
266
278
  export const _internals = { AUTH_HINTS, OWNERSHIP_HINTS, _enclosingBlock, QUOTE_WINDOW, CLAIM_KINDS, VERIFIER_QUOTATION, summarizeLogicClaims };