@clear-capabilities/agentic-security-scanner 0.161.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 (182) hide show
  1. package/CHANGELOG.md +46 -0
  2. package/bin/agentic-security.js +98 -2
  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 +59 -8
  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 +38 -2
  15. package/dist/8798.index.js +1 -1
  16. package/dist/9207.index.js +5 -0
  17. package/dist/9560.index.js +2336 -27
  18. package/dist/957.index.js +1 -1
  19. package/dist/{1905.index.js → 9999.index.js} +306 -13
  20. package/dist/agentic-security.mjs +12 -12
  21. package/dist/agentic-security.mjs.sha256 +1 -1
  22. package/package.json +39 -7
  23. package/src/capabilities/CLAUDE.md +114 -0
  24. package/src/capabilities/address.js +73 -0
  25. package/src/capabilities/attack-coverage.js +93 -0
  26. package/src/capabilities/decide.js +256 -0
  27. package/src/capabilities/delegate.js +69 -0
  28. package/src/capabilities/egress.js +20 -0
  29. package/src/capabilities/hook-advice.js +50 -0
  30. package/src/capabilities/index.js +32 -0
  31. package/src/capabilities/manifest.js +344 -0
  32. package/src/capabilities/outbound.js +189 -0
  33. package/src/capabilities/paths.js +57 -0
  34. package/src/capabilities/probes.js +211 -0
  35. package/src/capabilities/proxy.js +182 -0
  36. package/src/capabilities/reasons.js +67 -0
  37. package/src/capabilities/receipts.js +356 -0
  38. package/src/capabilities/records.js +58 -0
  39. package/src/capabilities/recovery.js +367 -0
  40. package/src/capabilities/report.js +81 -0
  41. package/src/capabilities/runner.js +236 -0
  42. package/src/capabilities/secrets.js +26 -0
  43. package/src/capabilities/tool-gate.js +119 -0
  44. package/src/capabilities/tool-registry.js +60 -0
  45. package/src/dataflow/CLAUDE.md +29 -0
  46. package/src/dataflow/catalog.js +21 -0
  47. package/src/dataflow/engine.js +20 -0
  48. package/src/dataflow/guard-dominance.js +352 -0
  49. package/src/discovery/CLAUDE.md +18 -0
  50. package/src/discovery/index.js +43 -1
  51. package/src/discovery/lenses.js +18 -0
  52. package/src/discovery/memory.js +7 -5
  53. package/src/engine.js +80 -15
  54. package/src/fix/apply-fix-service.js +33 -3
  55. package/src/integrations/index.js +5 -2
  56. package/src/ir/CLAUDE.md +14 -0
  57. package/src/ir/implicit-handler-params.js +74 -0
  58. package/src/ir/parser-cs.js +20 -0
  59. package/src/ir/parser-go.js +30 -13
  60. package/src/ir/parser-java.js +11 -0
  61. package/src/language/nix-eval-isolation.js +33 -5
  62. package/src/lineage/CLAUDE.md +31 -0
  63. package/src/lineage/deployment/adapter-cloud.js +202 -0
  64. package/src/lineage/deployment/adapter-compose.js +90 -0
  65. package/src/lineage/deployment/adapter-kit.js +142 -0
  66. package/src/lineage/deployment/adapter-kubernetes.js +247 -0
  67. package/src/lineage/deployment/attack-paths.js +443 -0
  68. package/src/lineage/deployment/boundaries-run.js +223 -0
  69. package/src/lineage/deployment/boundary-graph.js +567 -0
  70. package/src/lineage/deployment/cross-repo.js +253 -0
  71. package/src/lineage/deployment/drift.js +383 -0
  72. package/src/lineage/deployment/ingest.js +221 -0
  73. package/src/lineage/deployment/projection.js +244 -0
  74. package/src/lineage/deployment/trace-correlation.js +541 -0
  75. package/src/lineage/source-registry.js +19 -0
  76. package/src/mcp/CLAUDE.md +14 -3
  77. package/src/mcp/invariant-tools.js +64 -0
  78. package/src/mcp/portfolio-tools.js +79 -0
  79. package/src/mcp/redact.js +15 -0
  80. package/src/mcp/server.js +24 -3
  81. package/src/mcp/tools.js +30 -5
  82. package/src/posture/CLAUDE.md +505 -0
  83. package/src/posture/accuracy-scorecard.js +95 -4
  84. package/src/posture/assurance/baseline-inventory.js +218 -0
  85. package/src/posture/assurance/baseline.js +322 -0
  86. package/src/posture/assurance/bounded-io.js +145 -0
  87. package/src/posture/assurance/config.js +299 -0
  88. package/src/posture/assurance/contracts.js +315 -0
  89. package/src/posture/assurance/identity.js +62 -0
  90. package/src/posture/assurance/migrations.js +259 -0
  91. package/src/posture/assurance/rollback.js +214 -0
  92. package/src/posture/assurance/rollout.js +200 -0
  93. package/src/posture/assurance/schema-kit.js +111 -0
  94. package/src/posture/assurance/verification-record.js +258 -0
  95. package/src/posture/attestation.js +5 -0
  96. package/src/posture/autopilot.js +28 -1
  97. package/src/posture/business-logic.js +26 -4
  98. package/src/posture/evaluation/bakeoff.js +148 -0
  99. package/src/posture/evaluation/custody.js +200 -0
  100. package/src/posture/evaluation/deployment-ablation.js +416 -0
  101. package/src/posture/evaluation/dev-cases.js +65 -0
  102. package/src/posture/evaluation/economics.js +85 -0
  103. package/src/posture/evaluation/gates.js +161 -0
  104. package/src/posture/evaluation/grouping.js +101 -0
  105. package/src/posture/evaluation/interval.js +132 -0
  106. package/src/posture/evaluation/invariant-ablation.js +325 -0
  107. package/src/posture/evaluation/labels.js +200 -0
  108. package/src/posture/evaluation/protocol.js +308 -0
  109. package/src/posture/evaluation/release-gate.js +209 -0
  110. package/src/posture/evaluation/report.js +158 -0
  111. package/src/posture/evaluation/runner.js +273 -0
  112. package/src/posture/evaluation/scan-child.js +80 -0
  113. package/src/posture/evaluation/score.js +174 -0
  114. package/src/posture/evaluation/synthetic.js +90 -0
  115. package/src/posture/evaluation/why-missed.js +219 -0
  116. package/src/posture/evidence-bundle.js +93 -7
  117. package/src/posture/execution-proof.js +11 -1
  118. package/src/posture/fix-verify-loop.js +20 -6
  119. package/src/posture/fix-verify.js +37 -2
  120. package/src/posture/fleet.js +11 -2
  121. package/src/posture/invariants/cli.js +95 -0
  122. package/src/posture/invariants/coverage.js +164 -0
  123. package/src/posture/invariants/export.js +178 -0
  124. package/src/posture/invariants/expressions.js +46 -0
  125. package/src/posture/invariants/lifecycle.js +289 -0
  126. package/src/posture/invariants/project-input.js +55 -0
  127. package/src/posture/invariants/repair.js +340 -0
  128. package/src/posture/invariants/run.js +45 -0
  129. package/src/posture/invariants/scenarios.js +311 -0
  130. package/src/posture/invariants/schema.js +167 -0
  131. package/src/posture/invariants/shrink.js +407 -0
  132. package/src/posture/invariants/state-assertions.js +165 -0
  133. package/src/posture/logic-claims.js +12 -0
  134. package/src/posture/model-routing.js +40 -0
  135. package/src/posture/oracles/adapters.js +531 -0
  136. package/src/posture/oracles/conformance.js +276 -0
  137. package/src/posture/oracles/oracle.js +453 -0
  138. package/src/posture/oracles/registry.js +76 -0
  139. package/src/posture/oracles/scenario-classes.js +223 -0
  140. package/src/posture/portfolio/backend.js +279 -0
  141. package/src/posture/portfolio/bundle.js +314 -0
  142. package/src/posture/portfolio/cli.js +142 -0
  143. package/src/posture/portfolio/manifest.js +263 -0
  144. package/src/posture/portfolio/progress.js +267 -0
  145. package/src/posture/portfolio/resume.js +130 -0
  146. package/src/posture/portfolio/retention.js +207 -0
  147. package/src/posture/portfolio/scheduler.js +488 -0
  148. package/src/posture/portfolio/signing.js +155 -0
  149. package/src/posture/portfolio/wording.js +59 -0
  150. package/src/posture/portfolio/work-units.js +405 -0
  151. package/src/posture/replay/replay.js +250 -0
  152. package/src/posture/routing/adapter.js +140 -0
  153. package/src/posture/routing/calibration.js +278 -0
  154. package/src/posture/routing/canary.js +141 -0
  155. package/src/posture/routing/control.js +35 -0
  156. package/src/posture/routing/decide.js +211 -0
  157. package/src/posture/routing/drift.js +137 -0
  158. package/src/posture/routing/economics.js +185 -0
  159. package/src/posture/routing/feedback.js +207 -0
  160. package/src/posture/routing/index.js +31 -0
  161. package/src/posture/routing/outcomes.js +281 -0
  162. package/src/posture/routing/promotion.js +291 -0
  163. package/src/posture/routing/receipts.js +59 -0
  164. package/src/posture/routing/report.js +142 -0
  165. package/src/posture/routing/shadow.js +53 -0
  166. package/src/posture/specification-mining.js +26 -0
  167. package/src/posture/verification/advisory-state.js +71 -0
  168. package/src/posture/verification/emit.js +257 -0
  169. package/src/posture/verification/hypothesis-promotion.js +211 -0
  170. package/src/posture/verification/patch-negative.js +226 -0
  171. package/src/posture/verification/patch-promotion.js +113 -0
  172. package/src/posture/verification/projection.js +183 -0
  173. package/src/posture/verification/repair-records.js +124 -0
  174. package/src/posture/verifier.js +16 -0
  175. package/src/report/index.js +100 -1
  176. package/src/sandbox/CLAUDE.md +51 -0
  177. package/src/sandbox/backend-namespace.js +7 -0
  178. package/src/sandbox/backend-userspace.js +151 -34
  179. package/src/sandbox/control-probes.js +215 -0
  180. package/src/sandbox/supervise.js +201 -0
  181. package/src/sandbox/trust-boundary.js +113 -0
  182. package/src/sandbox/trust-domains.js +171 -0
@@ -0,0 +1,416 @@
1
+ // deployment-ablation.js: a frozen, SYNTHETIC deployment-context ablation (X-308).
2
+ //
3
+ // Question: does reading deployment configuration change which findings are worth acting on, compared with the source alone?
4
+ // The test set holds IDENTICAL source deployed under two boundary configurations per case, one exploitable and one not. Two
5
+ // arms are scored on the same instances:
6
+ // source-only the engine's findings on the source, every one reported (what the scan does today);
7
+ // graph-enabled the same findings plus the boundary context built from the deployment files by the shipped
8
+ // `agentic-security boundaries` code path (`runBoundaries`), under the frozen rule below.
9
+ //
10
+ // What this is NOT. The cases, their labels and the decision rule were all authored by the tooling's developers, the labels
11
+ // carry no independent adjudication, and the set is tiny. It is flagged `synthetic: true` everywhere and the real-code gates
12
+ // refuse it (gates.js). It checks a MECHANISM on constructed cases; it estimates nothing about real programs, and one case
13
+ // (the unresolved-ingress one) exists because the rule was tightened after the first run exposed a lost defect. Do not quote
14
+ // its figures as engine accuracy or as a benefit on real code.
15
+ //
16
+ // Frozen with the QA-001 machinery: the case set is bound into a protocol (`freezeProtocol`, hash-pinned, `synthetic: true`)
17
+ // whose target digests pin each case's source tree, and a separate `frozenHash` also covers every deployment tree digest, the
18
+ // labels and the decision rule. `verifyFrozenSet` recomputes both from disk. Nothing here counts toward a real-population gate.
19
+ //
20
+ // Uncertainty uses a SIMPLE PAIRED CASE BOOTSTRAP (resample cases, both of a case's instances travel together, fixed seed).
21
+ // It is not the grouped bootstrap the QA-004 report calls for; that code is not in this base. With a handful of cases the
22
+ // interval is wide and exploratory, and it says so.
23
+
24
+ import * as fs from 'node:fs';
25
+ import * as path from 'node:path';
26
+ import { digestOf, digestOfBytes } from '../assurance/identity.js';
27
+ import { resolveAssuranceConfig } from '../assurance/config.js';
28
+ import { runBoundaries } from '../../lineage/deployment/boundaries-run.js';
29
+ import { computeAttackPaths } from '../../lineage/deployment/attack-paths.js';
30
+ import { ADAPTER_NAMES } from '../../lineage/deployment/ingest.js';
31
+ import { digestTree, targetDigestOf, runEvaluation } from './runner.js';
32
+ import { freezeProtocol, validateProtocol, CORE_LANGUAGES, PREREGISTERED_THRESHOLDS, DEFAULT_MATCHING } from './protocol.js';
33
+ import { evaluateGates } from './gates.js';
34
+
35
+ export const ABLATION_SET_SCHEMA = 'agentic-security/deployment-ablation-set';
36
+ const ABLATION_REPORT_SCHEMA = 'agentic-security/deployment-ablation-report';
37
+ const ABLATION_VERSION = 1;
38
+ export const VARIANTS = Object.freeze(['exploitable', 'non-exploitable']);
39
+ export const FROZEN_FILE = 'frozen.json';
40
+ const BOOTSTRAP = Object.freeze({ method: 'paired-case-bootstrap', resamples: 4000, seed: 20261009, interval: 0.95 });
41
+
42
+ /**
43
+ * The frozen decision rule of the graph-enabled arm. Changing any of it changes `frozenHash`.
44
+ * - A source finding is demoted (dropped from the reported set) only when its boundary context says exposure is `blocked`, or
45
+ * `none-found` AND the boundary graph has no gap at all. A gap can stand for a missing relationship, so it never licenses
46
+ * treating absence of a path as absence of exposure. An unbound finding, an unresolved or possible exposure is kept.
47
+ * - A deployment-exposure finding is added for a case when a `possible` or `runtime-supported` path leads from an exposed
48
+ * entry point to a privileged resource. A blocked or unresolved path adds nothing.
49
+ */
50
+ export const DECISION_RULE = Object.freeze({
51
+ id: 'graph-arm-decision-rule', version: 1,
52
+ demoteSourceFindingWhen: Object.freeze(['exposure:blocked', 'exposure:none-found and graph-gap-count:0']),
53
+ keepSourceFindingWhen: Object.freeze(['exposure:possible', 'exposure:runtime-supported', 'exposure:unresolved', 'exposure:not-assessed']),
54
+ addDeploymentFindingWhen: Object.freeze(['path-state:possible or runtime-supported from an exposed entry to a privileged resource']),
55
+ });
56
+
57
+ const SYNTHETIC_LICENSE = 'synthetic-test-fixture';
58
+ const SHA = (c) => c.repeat(40);
59
+
60
+ function readJson(file) { return JSON.parse(fs.readFileSync(file, 'utf8')); }
61
+
62
+ /** Read the case definitions under `<fixturesDir>/cases`. Structure problems are returned, never thrown. */
63
+ export function loadAblationCases(fixturesDir) {
64
+ const errors = [];
65
+ const root = path.join(fixturesDir, 'cases');
66
+ let names = [];
67
+ try { names = fs.readdirSync(root, { withFileTypes: true }).filter((e) => e.isDirectory()).map((e) => e.name).sort(); } catch (e) { return { cases: [], errors: [`cannot read ${root}: ${e.code || e.message}`] }; }
68
+ const cases = [];
69
+ for (const name of names) {
70
+ const dir = path.join(root, name);
71
+ let meta;
72
+ try { meta = readJson(path.join(dir, 'case.json')); } catch (e) { errors.push(`${name}: case.json unreadable (${e.message})`); continue; }
73
+ if (meta.id !== name) errors.push(`${name}: case.json id '${meta.id}' does not match the directory`);
74
+ if (!['source-finding', 'deployment-only'].includes(meta.kind)) errors.push(`${name}: kind must be source-finding or deployment-only`);
75
+ for (const v of VARIANTS) {
76
+ if (!fs.existsSync(path.join(dir, v))) errors.push(`${name}: missing deployment '${v}'`);
77
+ const label = meta.deployments?.[v]?.exploitable;
78
+ if (typeof label !== 'boolean') errors.push(`${name}: deployment '${v}' has no boolean exploitable label`);
79
+ else if (label !== (v === 'exploitable')) errors.push(`${name}: deployment '${v}' is labelled exploitable=${label}, which contradicts its directory name`);
80
+ }
81
+ if (!fs.existsSync(path.join(dir, 'source'))) errors.push(`${name}: missing source tree`);
82
+ cases.push({ ...meta, dir });
83
+ }
84
+ if (!cases.length && !errors.length) errors.push('no cases found');
85
+ return { cases, errors };
86
+ }
87
+
88
+ /** Identical source across both deployments is structural: the source tree lives once and no deployment tree carries code. */
89
+ export function deploymentTreesCarryNoSource(c) {
90
+ const bad = [];
91
+ for (const v of VARIANTS) {
92
+ const walk = (d) => { for (const e of fs.readdirSync(d, { withFileTypes: true })) { const p = path.join(d, e.name); if (e.isDirectory()) walk(p); else if (/\.(?:[cm]?js|ts|py|java|go|rb|php)$/i.test(e.name)) bad.push(path.relative(c.dir, p)); } };
93
+ walk(path.join(c.dir, v));
94
+ }
95
+ return bad;
96
+ }
97
+
98
+ function digestsOf(c) {
99
+ return {
100
+ source: digestTree(path.join(c.dir, 'source')),
101
+ deployments: Object.fromEntries(VARIANTS.map((v) => [v, digestTree(path.join(c.dir, v))])),
102
+ };
103
+ }
104
+
105
+ function caseRecord(c, d) {
106
+ return {
107
+ id: c.id, title: c.title, kind: c.kind, defect: c.defect, sourceDigest: d.source,
108
+ deployments: Object.fromEntries(VARIANTS.map((v) => [v, { digest: d.deployments[v], exploitable: c.deployments[v].exploitable, rationale: c.deployments[v].rationale }])),
109
+ };
110
+ }
111
+
112
+ /**
113
+ * Freeze the set: hash-pin every tree, the labels and the decision rule, and bind the cases into a QA-001 protocol. Never throws.
114
+ * @returns {{ ok: boolean, errors: string[], set: object|null }}
115
+ */
116
+ export function freezeAblationSet({ fixturesDir } = {}) {
117
+ const loaded = loadAblationCases(fixturesDir);
118
+ const errors = [...loaded.errors];
119
+ for (const c of loaded.cases) { const bad = deploymentTreesCarryNoSource(c); if (bad.length) errors.push(`${c.id}: deployment trees must not carry source code (${bad.join(', ')})`); }
120
+ if (errors.length) return { ok: false, errors, set: null };
121
+ const records = loaded.cases.map((c) => caseRecord(c, digestsOf(c)));
122
+ const targets = loaded.cases.map((c, i) => ({
123
+ id: c.id, language: 'javascript', upstream: `example.invalid/synthetic/deployment-ablation/${c.id}`, pairId: `pair-${c.id}`, advisoryIds: [],
124
+ preCommit: SHA((i + 1).toString(16)[0]), postCommit: null, license: SYNTHETIC_LICENSE, digest: targetDigestOf({ pre: records[i].sourceDigest, post: null }),
125
+ }));
126
+ const draft = {
127
+ synthetic: true,
128
+ engine: { version: '0.0.0-synthetic', bundleDigest: digestOf('synthetic-deployment-ablation-engine') },
129
+ measurement: { commit: SHA('d'), cleanTree: true },
130
+ tools: { node: 'synthetic' },
131
+ models: [],
132
+ datasetLicenses: { [SYNTHETIC_LICENSE]: 'authored for tooling tests; not real-world code' },
133
+ scope: { languages: [...CORE_LANGUAGES], families: ['command-injection', 'deployment-exposure'] },
134
+ matching: { ...DEFAULT_MATCHING },
135
+ limits: { perTargetTimeoutMs: 120000, spendCeilingUsd: 0, replicates: 1 },
136
+ thresholds: { ...PREREGISTERED_THRESHOLDS },
137
+ targets,
138
+ splits: { dev: [], sealed: targets.map((t) => t.id).sort() },
139
+ grouping: { method: 'union-find', keys: ['pair', 'upstream', 'advisory', 'commit', 'template'], salt: 'synthetic-deployment-ablation' },
140
+ };
141
+ const frozen = freezeProtocol(draft);
142
+ if (!frozen.ok) return { ok: false, errors: frozen.errors.map((e) => `${e.path}: ${e.message}`), set: null };
143
+ const labels = records.map((r) => ({ id: r.id, exploitable: VARIANTS.map((v) => r.deployments[v].exploitable) }));
144
+ const body = {
145
+ schema: ABLATION_SET_SCHEMA, schemaVersion: ABLATION_VERSION, synthetic: true,
146
+ note: 'Authored by the tooling developers; labels are not independently adjudicated. Never counted toward a real-population gate.',
147
+ protocolHash: frozen.protocol.protocolHash, decisionRule: DECISION_RULE, labelsHash: digestOf(labels), cases: records,
148
+ };
149
+ const set = { ...body, protocol: frozen.protocol, frozenHash: digestOf(body) };
150
+ return { ok: true, errors: [], set };
151
+ }
152
+
153
+ /** Recompute the set from disk and compare it with a stored one. Names what moved. */
154
+ export function verifyFrozenSet({ fixturesDir, stored } = {}) {
155
+ const errors = [];
156
+ if (!stored || stored.schema !== ABLATION_SET_SCHEMA) return { ok: false, errors: ['no frozen set to verify against'] };
157
+ const fresh = freezeAblationSet({ fixturesDir });
158
+ if (!fresh.ok) return { ok: false, errors: fresh.errors };
159
+ const v = validateProtocol(stored.protocol);
160
+ if (!v.ok) errors.push(...v.errors.map((e) => `stored protocol: ${e.code} ${e.path}`));
161
+ if (stored.synthetic !== true) errors.push('stored set is not marked synthetic');
162
+ const byId = new Map(stored.cases.map((c) => [c.id, c]));
163
+ for (const c of fresh.set.cases) {
164
+ const s = byId.get(c.id);
165
+ if (!s) { errors.push(`${c.id}: not in the frozen set`); continue; }
166
+ if (s.sourceDigest !== c.sourceDigest) errors.push(`${c.id}: source tree changed since it was frozen`);
167
+ for (const x of VARIANTS) {
168
+ if (s.deployments?.[x]?.digest !== c.deployments[x].digest) errors.push(`${c.id}: ${x} deployment changed since it was frozen`);
169
+ if (s.deployments?.[x]?.exploitable !== c.deployments[x].exploitable) errors.push(`${c.id}: ${x} label changed since it was frozen`);
170
+ }
171
+ byId.delete(c.id);
172
+ }
173
+ for (const id of byId.keys()) errors.push(`${id}: in the frozen set but no longer on disk`);
174
+ if (stored.labelsHash !== fresh.set.labelsHash) errors.push('labels changed since they were frozen');
175
+ if (digestOf(stored.decisionRule) !== digestOf(DECISION_RULE)) errors.push('the decision rule changed since it was frozen');
176
+ if (stored.frozenHash !== fresh.set.frozenHash) errors.push('frozenHash does not match the recomputed set');
177
+ return { ok: errors.length === 0, errors };
178
+ }
179
+
180
+ // ------------------------------------------------------------ arms
181
+
182
+ /** Does a graph source (file + digest recorded at ingest) still match the file on disk? Missing and changed are separate. */
183
+ export function graphFreshness(graph, dir) {
184
+ const stale = [], missing = [];
185
+ for (const s of graph.sources) {
186
+ let bytes;
187
+ try { bytes = fs.readFileSync(path.join(dir, s.file)); } catch { missing.push(s.file); continue; }
188
+ if (digestOfBytes(bytes) !== s.digest) stale.push(s.file);
189
+ }
190
+ return { stale: stale.sort(), missing: missing.sort() };
191
+ }
192
+
193
+ /** The graph arm's verdict on one source finding, from its boundary context. Pure. */
194
+ export function graphArmKeeps(row, graphGapCount) {
195
+ const state = row?.boundaryContext?.exposure?.state ?? 'not-assessed';
196
+ if (state === 'blocked') return { keep: false, state, why: 'every path from an exposed entry crosses an explicit deny' };
197
+ if (state === 'none-found' && graphGapCount === 0) return { keep: false, state, why: 'no path from an exposed entry in a graph with no gaps' };
198
+ return { keep: true, state, why: state === 'none-found' ? 'no path found, but the graph has gaps so absence is not shown' : `exposure is ${state}` };
199
+ }
200
+
201
+ function matchesDefect(finding, defect) { return defect.file ? finding.file === defect.file && finding.family === defect.family : false; }
202
+
203
+ const median = (xs) => { if (!xs.length) return null; const s = [...xs].sort((a, b) => a - b); const m = s.length >> 1; return s.length % 2 ? s[m] : (s[m - 1] + s[m]) / 2; };
204
+
205
+ function graphArm({ c, variant, findings, config, now }) {
206
+ const dir = path.join(c.dir, variant);
207
+ const t0 = performance.now();
208
+ const res = runBoundaries({ config, from: dir, findings, now });
209
+ if (res.status !== 'ok') {
210
+ return { ok: false, reason: `${res.status}: ${res.reason}`, reported: [], demoted: [], ms: performance.now() - t0, coverage: null, adapters: [] };
211
+ }
212
+ const reported = [], demoted = [], states = [];
213
+ const rows = res.report.findings;
214
+ for (const [idx, row] of rows.entries()) {
215
+ const matched = matchesDefect(findings[idx] ?? {}, c.defect);
216
+ const verdict = graphArmKeeps(row, res.report.graph.gapCount);
217
+ states.push(verdict.state);
218
+ if (matched) (verdict.keep ? reported : demoted).push(`src:${c.id}`);
219
+ }
220
+ const paths = computeAttackPaths(res.graph).paths;
221
+ const exposed = paths.filter((p) => p.state === 'possible' || p.state === 'runtime-supported');
222
+ if (exposed.length) reported.push(`dep:${c.id}`);
223
+ const fresh = graphFreshness(res.graph, dir);
224
+ const bound = res.report.coverage.bound;
225
+ return {
226
+ ok: true, reported: [...new Set(reported)].sort(), demoted: [...new Set(demoted)].sort(), ms: performance.now() - t0,
227
+ adapters: [...new Set(res.report.graph.files.map((f) => f.adapter).filter(Boolean))].sort(),
228
+ coverage: {
229
+ findings: rows.length, bound, notAssessed: rows.length - bound, exposureStates: states.sort(),
230
+ unresolvedExposure: states.filter((s) => s === 'unresolved').length,
231
+ graphGaps: res.report.graph.gapCount, unresolvedNodes: res.report.graph.unresolvedNodeCount,
232
+ staleSources: fresh.stale.length, missingSources: fresh.missing.length,
233
+ pathsFound: paths.length, pathsUnresolved: paths.filter((p) => p.state === 'unresolved').length, pathsBlocked: paths.filter((p) => p.state === 'blocked').length,
234
+ traces: res.report.observation.status,
235
+ },
236
+ };
237
+ }
238
+
239
+ function prf(tp, fp, fn) {
240
+ const precision = tp + fp ? tp / (tp + fp) : null;
241
+ const recall = tp + fn ? tp / (tp + fn) : null;
242
+ const f1 = precision !== null && recall !== null && precision + recall > 0 ? (2 * precision * recall) / (precision + recall) : null;
243
+ return { tp, fp, fn, precision, recall, f1 };
244
+ }
245
+
246
+ function mulberry32(seed) {
247
+ let a = seed >>> 0;
248
+ return () => { a = (a + 0x6d2b79f5) >>> 0; let t = a; t = Math.imul(t ^ (t >>> 15), t | 1); t ^= t + Math.imul(t ^ (t >>> 7), t | 61); return ((t ^ (t >>> 14)) >>> 0) / 4294967296; };
249
+ }
250
+
251
+ /** Simple paired case bootstrap of (graph - source) precision and recall. Deterministic for a fixed seed. */
252
+ export function pairedBootstrap(perCase, { resamples = BOOTSTRAP.resamples, seed = BOOTSTRAP.seed } = {}) {
253
+ const rnd = mulberry32(seed);
254
+ const n = perCase.length;
255
+ const sum = (idx, arm) => idx.reduce((a, i) => ({ tp: a.tp + perCase[i][arm].tp, fp: a.fp + perCase[i][arm].fp, fn: a.fn + perCase[i][arm].fn }), { tp: 0, fp: 0, fn: 0 });
256
+ const diffs = { precision: [], recall: [] };
257
+ const undefinedCount = { precision: 0, recall: 0 };
258
+ for (let b = 0; b < resamples && n; b++) {
259
+ const idx = Array.from({ length: n }, () => Math.floor(rnd() * n));
260
+ const s = sum(idx, 'source'), g = sum(idx, 'graph');
261
+ for (const k of ['precision', 'recall']) {
262
+ const a = prf(s.tp, s.fp, s.fn)[k], z = prf(g.tp, g.fp, g.fn)[k];
263
+ if (a === null || z === null) undefinedCount[k] += 1; else diffs[k].push(z - a);
264
+ }
265
+ }
266
+ const q = (xs, p) => { if (!xs.length) return null; const s = [...xs].sort((x, y) => x - y); return s[Math.min(s.length - 1, Math.max(0, Math.floor(p * s.length)))]; };
267
+ const all = Array.from({ length: n }, (_, i) => i);
268
+ const s0 = prf(...Object.values(sum(all, 'source'))), g0 = prf(...Object.values(sum(all, 'graph')));
269
+ const out = {};
270
+ for (const k of ['precision', 'recall']) {
271
+ out[k] = { sourceOnly: s0[k], graphEnabled: g0[k], difference: s0[k] === null || g0[k] === null ? null : g0[k] - s0[k], low: q(diffs[k], 0.025), high: q(diffs[k], 0.975), undefinedResamples: undefinedCount[k] };
272
+ }
273
+ return {
274
+ method: BOOTSTRAP.method, resamples, seed, interval: BOOTSTRAP.interval, cases: n, ...out,
275
+ label: 'Simple paired case bootstrap (cases resampled with both of their instances, fixed seed). NOT the grouped bootstrap of the QA-004 report, which is not in this base. With this few cases the interval is wide and exploratory.',
276
+ };
277
+ }
278
+
279
+ /** Which adapters have positive AND negative fixtures with no lost defect? Only those may be claimed as deployment-aware. */
280
+ export function adapterValidation(instances) {
281
+ const out = ADAPTER_NAMES.map((name) => ({ adapter: name, positives: 0, negatives: 0, lostDefects: 0, instances: 0 }));
282
+ const by = new Map(out.map((o) => [o.adapter, o]));
283
+ for (const i of instances) {
284
+ for (const a of i.adapters) {
285
+ const o = by.get(a);
286
+ if (!o) continue;
287
+ o.instances += 1;
288
+ const truthIds = new Set(i.truth);
289
+ const graphHit = i.graph.reported.some((id) => truthIds.has(id));
290
+ if (i.exploitable && graphHit) o.positives += 1;
291
+ if (!i.exploitable && i.graph.reported.length === 0) o.negatives += 1;
292
+ o.lostDefects += i.lost.length;
293
+ }
294
+ }
295
+ return out.map((o) => {
296
+ const validated = o.positives >= 1 && o.negatives >= 1 && o.lostDefects === 0;
297
+ const reason = validated ? 'positive and negative fixtures, no baseline defect lost'
298
+ : o.instances === 0 ? 'no fixture in the frozen set uses this adapter'
299
+ : o.lostDefects ? 'a baseline confirmed defect was lost'
300
+ : o.positives < 1 ? 'no positive fixture' : 'no negative fixture';
301
+ return { ...o, validated, reason };
302
+ });
303
+ }
304
+
305
+ // ------------------------------------------------------------ the evaluation
306
+
307
+ /**
308
+ * Run both arms on every frozen instance and report paired counts. Refuses a set that does not verify against the disk.
309
+ *
310
+ * @param {object} o
311
+ * @param {string} o.fixturesDir
312
+ * @param {object} o.stored the committed frozen set
313
+ * @param {Function} [o.scanFn] injectable engine scan (tests); the default is the QA-003 child-process scan
314
+ * @param {string} [o.now] ISO clock for staleness judgements (fixed by default so the counts are deterministic)
315
+ */
316
+ export async function runPairedAblation({ fixturesDir, stored, scanFn, now = '2026-10-09T00:00:00.000Z' } = {}) {
317
+ const verified = verifyFrozenSet({ fixturesDir, stored });
318
+ if (!verified.ok) return { ok: false, errors: verified.errors, report: null };
319
+ const loaded = loadAblationCases(fixturesDir);
320
+ const config = resolveAssuranceConfig({ scanRoot: fixturesDir, overrides: { features: { 'deployment-boundaries': true } } });
321
+
322
+ // source-only arm: one scan per case; the source is the same under both deployments.
323
+ const run = await runEvaluation({
324
+ protocol: stored.protocol, config: { layer: 'deterministic-only' }, split: 'all', allowSealed: true, protectedTerms: [],
325
+ resolveTarget: (t) => ({ dir: path.join(fixturesDir, 'cases', t.id, 'source') }), ...(scanFn ? { scanFn } : {}),
326
+ });
327
+ if (!run.ok) return { ok: false, errors: run.errors.map((e) => `${e.code}: ${e.message}`), report: null };
328
+
329
+ const instances = [], perCase = [], unscored = [];
330
+ const scanMs = [];
331
+ for (const c of loaded.cases) {
332
+ const oc = run.run.outcomes.find((o) => o.targetId === c.id && o.variant === 'pre');
333
+ const completed = oc?.status === 'completed';
334
+ const findings = completed ? oc.findings : [];
335
+ if (completed) scanMs.push(oc.durationMs);
336
+ const matched = findings.filter((f) => matchesDefect(f, c.defect));
337
+ for (const f of findings) if (!matchesDefect(f, c.defect)) unscored.push({ case: c.id, file: f.file, family: f.family, reason: 'a finding the case labels do not account for' });
338
+ const caseStats = { id: c.id, source: { tp: 0, fp: 0, fn: 0 }, graph: { tp: 0, fp: 0, fn: 0 } };
339
+ for (const variant of VARIANTS) {
340
+ const exploitable = c.deployments[variant].exploitable;
341
+ const defectId = c.kind === 'source-finding' ? `src:${c.id}` : `dep:${c.id}`;
342
+ const truth = exploitable ? [defectId] : [];
343
+ const sourceReported = matched.length ? [`src:${c.id}`] : [];
344
+ const g = graphArm({ c, variant, findings, config, now });
345
+ const t = new Set(truth);
346
+ const count = (reported, arm) => {
347
+ const r = new Set(reported);
348
+ for (const id of r) { if (t.has(id)) caseStats[arm].tp += 1; else caseStats[arm].fp += 1; }
349
+ for (const id of t) if (!r.has(id)) caseStats[arm].fn += 1;
350
+ };
351
+ count(sourceReported, 'source'); count(g.reported, 'graph');
352
+ const sr = new Set(sourceReported), gr = new Set(g.reported);
353
+ instances.push({
354
+ caseId: c.id, variant, exploitable, truth, adapters: g.adapters, graphOk: g.ok, graphFailure: g.ok ? null : g.reason,
355
+ sourceOnly: { reported: sourceReported, completed },
356
+ graph: { reported: g.reported, demoted: g.demoted, ms: g.ms, coverage: g.coverage },
357
+ fpReduced: sourceReported.filter((id) => !t.has(id) && !gr.has(id)),
358
+ fpAdded: g.reported.filter((id) => !t.has(id) && !sr.has(id)),
359
+ confirmedAdded: g.reported.filter((id) => t.has(id) && !sr.has(id)),
360
+ lost: sourceReported.filter((id) => t.has(id) && !gr.has(id)),
361
+ });
362
+ }
363
+ perCase.push(caseStats);
364
+ }
365
+
366
+ const tot = (arm) => perCase.reduce((a, c) => ({ tp: a.tp + c[arm].tp, fp: a.fp + c[arm].fp, fn: a.fn + c[arm].fn }), { tp: 0, fp: 0, fn: 0 });
367
+ const s = tot('source'), g = tot('graph');
368
+ const sum = (key) => instances.reduce((a, i) => a + i[key].length, 0);
369
+ const cov = instances.map((i) => i.graph.coverage).filter(Boolean);
370
+ const graphMs = instances.map((i) => i.graph.ms);
371
+ const paired = {
372
+ instances: instances.length, cases: loaded.cases.length,
373
+ falsePositivesReduced: sum('fpReduced'), confirmedDefectsAdded: sum('confirmedAdded'),
374
+ baselineConfirmedDefectsLost: sum('lost'), falsePositivesAdded: sum('fpAdded'),
375
+ unchanged: instances.filter((i) => !i.fpReduced.length && !i.fpAdded.length && !i.confirmedAdded.length && !i.lost.length).length,
376
+ };
377
+ const gates = evaluateGates({ protocol: stored.protocol, score: null, defects: [], negatives: [] });
378
+ const report = {
379
+ schema: ABLATION_REPORT_SCHEMA, schemaVersion: ABLATION_VERSION, synthetic: true,
380
+ statement: 'SYNTHETIC cases authored by the tooling developers. These counts check a mechanism on constructed cases; they say nothing about benefit on real programs.',
381
+ frozenHash: stored.frozenHash, protocolHash: stored.protocol.protocolHash, runId: run.run.runId, decisionRule: DECISION_RULE,
382
+ arms: {
383
+ sourceOnly: { ...prf(s.tp, s.fp, s.fn) }, graphEnabled: { ...prf(g.tp, g.fp, g.fn) },
384
+ },
385
+ paired,
386
+ uncertainty: pairedBootstrap(perCase),
387
+ runtime: {
388
+ deterministic: false,
389
+ sourceOnlyScanMsMedian: median(scanMs), graphEnabledAddedMsMedian: median(graphMs),
390
+ note: 'Wall-clock milliseconds on the machine that ran this; they differ run to run and are not part of the frozen comparison. The graph-enabled arm costs its scan plus the added time.',
391
+ },
392
+ coverage: {
393
+ sourceOnly: { scansCompleted: loaded.cases.filter((c) => run.run.outcomes.find((o) => o.targetId === c.id)?.status === 'completed').length, of: loaded.cases.length, deploymentContext: 'none: every finding is reported without any deployment evidence' },
394
+ graphEnabled: {
395
+ graphBuilt: instances.filter((i) => i.graphOk).length, of: instances.length,
396
+ findingsWithContext: cov.reduce((a, c) => a + c.findings, 0), findingsBoundToAService: cov.reduce((a, c) => a + c.bound, 0), findingsNotAssessed: cov.reduce((a, c) => a + c.notAssessed, 0),
397
+ exposureUnresolved: cov.reduce((a, c) => a + c.unresolvedExposure, 0),
398
+ instancesWithGraphGaps: cov.filter((c) => c.graphGaps > 0).length, graphGapsTotal: cov.reduce((a, c) => a + c.graphGaps, 0), unresolvedNodesTotal: cov.reduce((a, c) => a + c.unresolvedNodes, 0),
399
+ staleSources: cov.reduce((a, c) => a + c.staleSources, 0), missingSources: cov.reduce((a, c) => a + c.missingSources, 0),
400
+ traces: 'no runtime traces are part of this set, so observed, sampled and stale trace coverage are all unmeasured here',
401
+ },
402
+ },
403
+ unscoredFindings: unscored,
404
+ adapters: adapterValidation(instances),
405
+ realCodeGates: { overall: gates.overall, note: 'the protocol is synthetic, so no real-code gate can pass' },
406
+ instances,
407
+ };
408
+ return { ok: true, errors: [], report };
409
+ }
410
+
411
+ /** The deterministic part of a report (no wall-clock figures), for comparison across runs and for the docs. */
412
+ export function deterministicView(report) {
413
+ const { runtime, instances, ...rest } = report;
414
+ void runtime;
415
+ return { ...rest, instances: instances.map(({ graph, ...i }) => ({ ...i, graph: { reported: graph.reported, demoted: graph.demoted, coverage: graph.coverage } })) };
416
+ }
@@ -0,0 +1,65 @@
1
+ // A DEVELOPMENT case suite for engine work (QA-005.AC03, QA-006), scored by the same frozen policy as everything else.
2
+ //
3
+ // `test/fixtures/engine-mechanisms/` holds small projects, each a vulnerable `pre/` tree and a patched `post/` tree, plus a `cases.json`
4
+ // naming the reviewed location of each defect. This module turns them into a protocol, labels and a resolver so that
5
+ // `runEvaluation` + `scoreRun` (same matching policy, same end-to-end rules) can answer "which of these defects does the engine
6
+ // recover, and which patched trees stay silent" for ANY engine checkout. That makes a before/after comparison a measurement and not
7
+ // an anecdote: run it once per engine revision and compare.
8
+ //
9
+ // What this is NOT: an independent population. The projects and the labels were written by the people who wrote the fix, so every
10
+ // label is flagged `synthetic: true` and gates.js will never count them toward a real-code minimum. They exist to show that a
11
+ // change recovers the mechanism it was made for, and that its patched twin stays quiet. They say nothing about real-code accuracy.
12
+
13
+ import * as fs from 'node:fs';
14
+ import * as path from 'node:path';
15
+ import { digestOf } from '../assurance/identity.js';
16
+ import { digestTree, targetDigestOf } from './runner.js';
17
+ import { freezeProtocol, CORE_LANGUAGES, PREREGISTERED_THRESHOLDS, DEFAULT_MATCHING } from './protocol.js';
18
+ import { buildDefectLabel, buildNegativeLabel } from './labels.js';
19
+
20
+ const LICENSE = 'development-fixture';
21
+ const hex = (n) => n.toString(16).padStart(40, '0');
22
+ const adjudication = (tag) => ({ status: 'adjudicated', independent: true, reviewers: [{ reviewerId: `dev-reviewer-a-${tag}`, verdict: 'confirmed' }, { reviewerId: `dev-reviewer-b-${tag}`, verdict: 'confirmed' }] });
23
+
24
+ function readDevCases(fixturesDir) {
25
+ return JSON.parse(fs.readFileSync(path.join(fixturesDir, 'cases.json'), 'utf8')).cases;
26
+ }
27
+
28
+ /** @returns {{protocol, defects, negatives, freezeErrors}} everything flagged synthetic; every target in the development split. */
29
+ export function buildDevSuite({ fixturesDir, cases = readDevCases(fixturesDir) }) {
30
+ const targets = cases.map((c, i) => ({
31
+ id: c.id, language: c.language, upstream: `example.invalid/dev/${c.id}`, pairId: `pair-${c.id}`, preCommit: hex(2 * i + 1), postCommit: hex(2 * i + 2), advisoryIds: [], license: LICENSE,
32
+ digest: targetDigestOf({ pre: digestTree(path.join(fixturesDir, c.id, 'pre')), post: digestTree(path.join(fixturesDir, c.id, 'post')) }),
33
+ }));
34
+ const draft = {
35
+ synthetic: true,
36
+ engine: { version: '0.0.0-dev-cases', bundleDigest: digestOf('dev-cases-engine') },
37
+ measurement: { commit: hex(0xabc), cleanTree: true },
38
+ tools: { node: 'dev' }, models: [], datasetLicenses: { [LICENSE]: 'authored for engine development; not real-world code' },
39
+ scope: { languages: [...CORE_LANGUAGES], families: [...new Set(cases.map((c) => c.family))].sort() },
40
+ matching: { ...DEFAULT_MATCHING },
41
+ limits: { perTargetTimeoutMs: 120000, spendCeilingUsd: 0, replicates: 3 },
42
+ thresholds: { ...PREREGISTERED_THRESHOLDS },
43
+ targets, splits: { dev: targets.map((t) => t.id).sort(), sealed: [] },
44
+ grouping: { method: 'union-find', keys: ['pair', 'upstream', 'advisory', 'commit', 'template'], salt: 'dev-cases' },
45
+ };
46
+ const frozen = freezeProtocol(draft);
47
+ const defects = []; const negatives = [];
48
+ cases.forEach((c, i) => {
49
+ const base = (tag) => ({ synthetic: true, adjudication: adjudication(`${tag}${i}`), proposedBy: { role: 'worker', id: 'dev-proposer' } });
50
+ const d = buildDefectLabel({
51
+ ...base('d'), targetId: c.id, rootCauseId: `development-root-cause-${c.id}`, affected: { commit: hex(2 * i + 1) },
52
+ language: c.language, family: c.family, cwe: c.cwe, location: { file: c.file, startLine: c.startLine, endLine: c.endLine },
53
+ evidence: [{ ref: `development-review-note-${c.id}`, producer: 'human' }],
54
+ });
55
+ defects.push(d);
56
+ negatives.push(buildNegativeLabel({
57
+ ...base('n'), targetId: c.id, variant: 'post', kind: 'patched', language: c.language, family: c.family,
58
+ scope: { files: [c.file] }, pairedDefectId: d.id, rationale: `development: the patched ${c.id} was reviewed as not exploitable`,
59
+ }));
60
+ });
61
+ return { protocol: frozen.ok ? frozen.protocol : null, freezeErrors: frozen.errors, defects, negatives, cases };
62
+ }
63
+
64
+ /** resolveTarget for the runner over the dev fixtures. */
65
+ export const devResolver = (fixturesDir) => (target, variant) => ({ dir: path.join(fixturesDir, target.id, variant) });
@@ -0,0 +1,85 @@
1
+ // Security economics (QA-004.AC03): what finding and fixing defects cost, with the cost kinds kept apart.
2
+ //
3
+ // A ledger is a list of entries, each one thing that was spent:
4
+ // { id, kind, amountUsd | minutes, targetId, attemptId?, rootCauseId?, outcome? }
5
+ // `kind` is one of
6
+ // model model inference charged to the evaluation
7
+ // tool tool and infrastructure compute (scanner time, sandbox, runner)
8
+ // cache cache charges (writes and reads priced separately from fresh inference)
9
+ // human-review reviewer MINUTES, never converted to dollars here (a rate is a policy choice, not a measurement)
10
+ // `outcome` classifies the work the spend bought: confirmed | validated-fix | refuted | inconclusive | failed | duplicate.
11
+ //
12
+ // The rules that keep these figures honest:
13
+ // - the kinds are reported side by side and never silently summed; human minutes are not dollars;
14
+ // - an entry with no amount is UNMEASURED and counted as such, never read as zero;
15
+ // - cost per confirmed defect divides ALL machine spend (successful, failed, refuted and duplicate attempts alike) by the number
16
+ // of DISTINCT root causes independently confirmed, so failed attempts raise the price and a defect found twice is one defect;
17
+ // - cost per validated fix divides the same spend by distinct root causes with an independently validated repair;
18
+ // - with no confirmed defect the ratio is `null` (undefined), not 0 and not the total.
19
+ //
20
+ // Pure: no fs, no clock.
21
+
22
+ import { SCHEMA_VERSION } from '../assurance/schema-kit.js';
23
+ import { digestOf } from '../assurance/identity.js';
24
+
25
+ const COST_KINDS = Object.freeze(['model', 'tool', 'cache', 'human-review']);
26
+ export const OUTCOMES = Object.freeze(['confirmed', 'validated-fix', 'refuted', 'inconclusive', 'failed', 'duplicate']);
27
+ const ECON_SCHEMA = 'agentic-security/evaluation-economics';
28
+
29
+ function validateEntry(e, i) {
30
+ const errs = [];
31
+ if (!e || typeof e !== 'object') return [{ code: 'BAD_TYPE', path: `entries[${i}]`, message: 'must be an object' }];
32
+ if (!COST_KINDS.includes(e.kind)) errs.push({ code: 'BAD_ENUM', path: `entries[${i}].kind`, message: `kind must be one of ${COST_KINDS.join(', ')}` });
33
+ if (e.outcome !== undefined && !OUTCOMES.includes(e.outcome)) errs.push({ code: 'BAD_ENUM', path: `entries[${i}].outcome`, message: `outcome must be one of ${OUTCOMES.join(', ')}` });
34
+ const field = e.kind === 'human-review' ? 'minutes' : 'amountUsd';
35
+ const other = e.kind === 'human-review' ? 'amountUsd' : 'minutes';
36
+ if (e[other] !== undefined && e[other] !== null) errs.push({ code: 'RULE_VIOLATION', path: `entries[${i}].${other}`, message: `a ${e.kind} entry is measured in ${field}; ${other} would mix units` });
37
+ if (e[field] !== undefined && e[field] !== null && !(typeof e[field] === 'number' && Number.isFinite(e[field]) && e[field] >= 0)) errs.push({ code: 'BAD_VALUE', path: `entries[${i}].${field}`, message: `${field} must be a non-negative number or absent (unmeasured)` });
38
+ if ((e.outcome === 'confirmed' || e.outcome === 'validated-fix') && !e.rootCauseId) errs.push({ code: 'RULE_VIOLATION', path: `entries[${i}].rootCauseId`, message: 'a confirmed or validated outcome must name its root cause so duplicates cannot be counted twice' });
39
+ return errs;
40
+ }
41
+
42
+ /** @param {object[]} entries */
43
+ export function accountCosts(entries) {
44
+ const errors = (entries || []).flatMap(validateEntry);
45
+ if (errors.length) return { ok: false, errors };
46
+
47
+ const totals = { model: { usd: 0, entries: 0, unmeasured: 0 }, tool: { usd: 0, entries: 0, unmeasured: 0 }, cache: { usd: 0, entries: 0, unmeasured: 0 }, humanReview: { minutes: 0, entries: 0, unmeasured: 0 } };
48
+ const slot = { model: 'model', tool: 'tool', cache: 'cache', 'human-review': 'humanReview' };
49
+ const confirmed = new Set(); const validated = new Set();
50
+ const byOutcome = {};
51
+ for (const e of entries || []) {
52
+ const t = totals[slot[e.kind]];
53
+ t.entries++;
54
+ const v = e.kind === 'human-review' ? e.minutes : e.amountUsd;
55
+ if (v === undefined || v === null) t.unmeasured++;
56
+ else if (e.kind === 'human-review') t.minutes += v; else t.usd += v;
57
+ const o = e.outcome || 'unclassified';
58
+ byOutcome[o] = byOutcome[o] || { usd: 0, minutes: 0, entries: 0 };
59
+ byOutcome[o].entries++;
60
+ if (v !== undefined && v !== null) { if (e.kind === 'human-review') byOutcome[o].minutes += v; else byOutcome[o].usd += v; }
61
+ if (e.outcome === 'confirmed') confirmed.add(e.rootCauseId);
62
+ if (e.outcome === 'validated-fix') { validated.add(e.rootCauseId); confirmed.add(e.rootCauseId); }
63
+ }
64
+ // Machine spend: every kind except reviewer minutes, INCLUDING failed, refuted, inconclusive and duplicate work.
65
+ const machineUsd = totals.model.usd + totals.tool.usd + totals.cache.usd;
66
+ const unmeasuredMachine = totals.model.unmeasured + totals.tool.unmeasured + totals.cache.unmeasured;
67
+ const div = (n, d) => (d > 0 ? n / d : null);
68
+ const out = {
69
+ schema: ECON_SCHEMA, schemaVersion: SCHEMA_VERSION, ok: true, errors: [],
70
+ totals,
71
+ separation: 'model, tool, cache and human-review figures are never combined; human review is in minutes, the rest in USD',
72
+ machineSpendUsd: machineUsd,
73
+ unmeasuredMachineEntries: unmeasuredMachine,
74
+ confirmedDefects: confirmed.size, validatedFixes: validated.size,
75
+ costPerConfirmedDefectUsd: div(machineUsd, confirmed.size),
76
+ costPerValidatedFixUsd: div(machineUsd, validated.size),
77
+ humanMinutesPerConfirmedDefect: div(totals.humanReview.minutes, confirmed.size),
78
+ humanMinutesPerValidatedFix: div(totals.humanReview.minutes, validated.size),
79
+ byOutcome,
80
+ note: 'ratios divide ALL machine spend (failed, refuted and duplicate attempts included) by DISTINCT independently confirmed root causes; a ratio over an incomplete ledger is a lower bound',
81
+ lowerBound: unmeasuredMachine > 0,
82
+ };
83
+ out.economicsHash = digestOf({ ...out, economicsHash: undefined });
84
+ return out;
85
+ }