nsauditor-ai 0.2.53 → 0.2.55

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1122 @@
1
+ // Cross-run delta — "what changed since the last scan" (Pro/Enterprise).
2
+ //
3
+ // ⚠️ NOT `utils/delta_reporter.mjs`. That is the FREE CE last-vs-current alerting delta on the
4
+ // webhook path and it stays free, untouched and un-gated. This is a second engine, over two
5
+ // ARBITRARY run records, at FINDING level.
6
+ //
7
+ // ⚠️ THE WHOLE DESIGN IS THE REFUSAL, because "RESOLVED" IS THE DANGEROUS VERDICT. A finding
8
+ // that disappeared for any reason other than being fixed reads to a buyer, and to an assessor,
9
+ // as remediation. Five ways a finding can vanish without being fixed, every one observed in this
10
+ // product's own history:
11
+ // 1. HOST — the host was not scanned in the current run.
12
+ // 2. PLUGIN — the plugin that produced it did not run (`--plugins` differed).
13
+ // 3. SCOPE — an evidence gap: AccessDenied, budget exceeded, incomplete region enumeration.
14
+ // A FINDING THAT DISAPPEARED BECAUSE THE SCANNER LOST PERMISSION IS NOT FIXED.
15
+ // 4. FRAMEWORK — the control enumeration moved (PCI went 19/9/39 → 19/9/44 in one cycle), so a
16
+ // control that "appeared" or "left" is an enumeration change, not a posture change.
17
+ // 5. PRODUCT — the §5.3 semantics boundary, below. This one is written in our own contract.
18
+ // A finding may be called RESOLVED only when its host, plugin, scope and framework enumeration
19
+ // were in scope in BOTH runs. Otherwise it goes to NOT-COMPARABLE **with its reason** — never
20
+ // into `resolved`, and never silently dropped.
21
+ // Integrity is a fact about BYTES ON DISK, so the caller measures it with `utils/run_chain.mjs`
22
+ // and passes the verdict in. Keeping it out of this module means the comparability rules can be
23
+ // driven without a filesystem, and the chain can be driven without the delta.
24
+ export const SCAN_DELTA_SCHEMA = 1;
25
+
26
+ // ⚠️ REASON CODES ARE APPEND-ONLY UNDER `SCAN_DELTA_SCHEMA = 1`. Adding one is additive and needs
27
+ // no schema bump; RENAMING or REMOVING one changes what an existing consumer's stored verdicts
28
+ // mean, and bumps the schema.
29
+ //
30
+ // ⚠️ AND THEY ARE DECLARED HERE BECAUSE TWO SURFACES ARE WRITTEN FROM THEM RATHER THAN FROM A
31
+ // COUNT SOMEBODY DID BY HAND. The CE CHANGELOG's "N ways a finding can vanish without being
32
+ // fixed" sentence and the agent-skill's item (2) have both already shipped a count that disagreed
33
+ // with the code — a count with no list beside it is invisible to every gate this product owns.
34
+ // `tests/scan_delta.test.mjs` holds these in EQUALITY with the codes derived from this file's own
35
+ // source: a new code that forgets the declaration fails, and so does a declaration whose code is
36
+ // gone. The ORDER is the order the engine evaluates them, which is the order a reader meets them.
37
+ //
38
+ // ⚠️ CE 0.2.55 IS THE FIRST TARBALL THAT SHIPS THIS FILE, SO IT IS THE LAST RELEASE IN WHICH A
39
+ // CODE HERE CAN BE RENAMED FOR FREE. Measured, both directions: `npm pack nsauditor-ai@0.2.54`
40
+ // contains 87 entries, 47 under `utils/`, and NO `utils/scan_delta.mjs` (positive control:
41
+ // `utils/report_inputs.mjs` is present); `npm pack --dry-run` at this version contains 92 entries
42
+ // INCLUDING `scan_delta.mjs`, `scan_delta_view.mjs` and `executive_report.mjs`. That is why
43
+ // `plugin-identity-basis-changed` could be renamed to `identity-basis-changed` this cycle rather
44
+ // than papered over with a display label — zero published verdicts carried it. From the next
45
+ // release on, a consumer can hold stored verdicts keyed on these strings, and a rename becomes a
46
+ // schema change that must be versioned rather than an edit. Check the claim before relying on
47
+ // either half of it: the question is what the PUBLISHED TARBALL contains, never what git says.
48
+ // ⚠️ ONE NAME, TWO CONSUMERS. The delta refuses a straddling row with this reason, and Enterprise's
49
+ // MTTR engine withholds the same row's CLOSURE with the same reason (EE build 5, F1(b)) — the two
50
+ // must not disagree about what counts as the same finding, so the second one IMPORTS the value
51
+ // rather than typing it. Same value as before, so no stored verdict changes meaning.
52
+ export const IDENTITY_BASIS_CHANGED_REASON = 'identity-basis-changed';
53
+ // The same rule for the two PLUGIN-scope reasons: Enterprise's MTTR engine withholds a prior row's
54
+ // closure when its plugin did not run (or ran and was not measured) in the current scan — the
55
+ // delta's own two verdicts — so it imports these names rather than typing the strings. Values
56
+ // unchanged, so no stored verdict changes meaning.
57
+ export const PLUGIN_NOT_RUN_REASON = 'plugin-not-run';
58
+ export const PLUGIN_NOT_MEASURED_REASON = 'plugin-not-measured';
59
+ // And the COVERAGE reason: Enterprise's MTTR engine refuses to call a prior regional row remediated
60
+ // when its region lay outside the current scan's recorded scope — the delta's own verdict, same name.
61
+ export const SCOPE_NOT_SCANNED_REASON = 'scope-not-scanned';
62
+ // And the PORT reason (EE 1.1.0 build 9, F6). A probe RAN on a port the port scanner saw open and did not
63
+ // complete its connection there (a reset, a timeout, a handshake that never finished), so that port was
64
+ // not measured in the run. Enterprise records it as a service-set INPUT GAP carrying the port. A baseline
65
+ // row on that port that is absent now was not fixed — nobody looked. Measured on a live acceptance run:
66
+ // the router's 443 answered the port scanner and then reset the HTTPS probe; crypto_agent's row about 443
67
+ // could not be produced, and this engine reported it RESOLVED. Enterprise's MTTR engine applies the same
68
+ // reason to a prior row's CLOSURE and imports both names from here, so the two channels cannot disagree.
69
+ export const PROBE_NOT_MEASURED_REASON = 'probe-not-measured';
70
+ // And the port that STOPPED ANSWERING (EE 1.1.0 build 12). The port scanner saw it OPEN in the run that holds
71
+ // the finding, and in the other run it RAN and recorded the port neither open nor closed — no answer inside its
72
+ // timeout (filtered), or not probed at all. Nothing was measured there, so a row on that port neither vanished nor
73
+ // appeared. Measured on Gate 2 of build 11: the gateway filtered 21 and 80 for three minutes, and three findings on
74
+ // those ports read RESOLVED in the client's delta; five minutes later both ports were open again. A port in the
75
+ // other run's tcpClosed WAS measured, and RESOLVED there stays legitimate. Enterprise's MTTR imports this name.
76
+ export const PORT_NOT_MEASURED_REASON = 'port-not-measured';
77
+ // The gap class Enterprise's service-set input gap stamps (`evidence.raw.gapClass`). Spelled ONCE, here, on
78
+ // the reading side: Enterprise imports it, so the producer and this reader cannot drift to two spellings.
79
+ export const INPUT_GAP_CLASS = 'input_gap';
80
+
81
+ export const NOT_COMPARABLE_REASONS = Object.freeze([
82
+ 'host-not-scanned', // the other run never wrote this host
83
+ 'producer-unknown', // the finding carries no producer identity to adjudicate
84
+ PLUGIN_NOT_RUN_REASON, // the producing plugin was not requested in the other run
85
+ 'evidence-gap', // a producer DECLARED it could not read the surface
86
+ PLUGIN_NOT_MEASURED_REASON, // the plugin was attempted on the host and errored / timed out / was skipped
87
+ 'framework-enumeration-changed', // the control left or joined the enumeration between the runs
88
+ IDENTITY_BASIS_CHANGED_REASON, // the producer changed WHAT IT NAMES between the two releases
89
+ SCOPE_NOT_SCANNED_REASON, // the finding's coverage unit was outside the OTHER run's recorded scope
90
+ PROBE_NOT_MEASURED_REASON, // a probe ran on the finding's port, open per the port scanner, and did not complete there
91
+ PORT_NOT_MEASURED_REASON, // the finding's port was open in its run and neither open nor closed in the other (filtered / unprobed)
92
+ ]);
93
+
94
+ /**
95
+ * WHEN EACH PRODUCER LAST CHANGED WHAT IT NAMES AS A FINDING'S OBJECT.
96
+ *
97
+ * A finding's identity is keyed partly on `resource`. Canonicalisation (`finding_identity.mjs`)
98
+ * makes an old decorated value and a new clean one compute the SAME key, so a producer that
99
+ * always named its object compares across an upgrade unchanged. It cannot do that for a producer
100
+ * that GAINED an identity: a finding keyed on `'-'` in the baseline and on `sg-0def2…` in the
101
+ * current run is one exposure wearing two keys, and a naive delta reports it as one RESOLVED
102
+ * plus one NEW — a fabricated remediation and a fabricated exposure in the same table, on the
103
+ * feature's headline use.
104
+ *
105
+ * The alternative was bumping `RUN_RECORD_SCHEMA`, which refuses EVERY pre-upgrade baseline
106
+ * outright — including for the ~20 producers that did not move. Per-producer declaration pays
107
+ * the cost in DISCLOSURE instead: the affected producer's rows are declared not comparable and
108
+ * every other producer's delta stands.
109
+ *
110
+ * ⚠️ THIS TABLE IS A CLAIM SURFACE IN BOTH DIRECTIONS AND BOTH ARE FATAL. An UNDECLARED mover
111
+ * fabricates churn — the defect this exists to prevent. A DECLARED producer that did NOT move
112
+ * throws away a real comparison, silently, for ever. It is held in equality with what the
113
+ * artifacts actually show by `tests/identity_basis_instrument.test.mjs`, which derives the mover
114
+ * set by running THIS loader over a pre-change run and a post-change run and comparing the key
115
+ * sets for the same objects. **Derive it; never hand-edit it to match a prediction.**
116
+ *
117
+ * The nine members are E1's own movers: 1150 / 1170 / 1190 claimed a REGION as their object and
118
+ * now name the real one; 1020 / 1024 / 1025 / 1030 / 1200 / 1210 named nothing and now name an
119
+ * object or their scope. `null → a value` moves a key exactly as `region → object id` does.
120
+ */
121
+ /**
122
+ * THE NON-NUMERIC PRODUCERS A DECLARATION MAY NAME.
123
+ *
124
+ * ⚠️ THE TABLE BELOW WAS KEYED ON PLUGIN IDS AND PINNED `/^\d{3,4}$/`, because every declared
125
+ * producer was a plugin. Enterprise's ANALYSIS AGENTS are producers too — a finding out of the
126
+ * finding QUEUE carries `evidence.source` as its identity, never an id — and one of them changes
127
+ * what it names at EE 1.1.0. So the vocabulary has to widen, and widening it to "any string" is
128
+ * not an option: a typo would declare a producer that does not exist, `identityBasisChanged` would
129
+ * find nothing on lookup, and the REAL producer would stay undeclared and fabricate churn. That is
130
+ * the defect the table exists to prevent, arriving through the table's own key.
131
+ *
132
+ * ⚠️ IT IS DECLARED HERE AND DERIVED THERE. Community cannot see Enterprise's agent registry, and
133
+ * Enterprise cannot edit this file — so a hand list in either repo is a copy that rots when the
134
+ * other side moves. `tests/agent_producer_vocabulary.test.mjs` in EE holds this set in TWO-WAY
135
+ * equality with the set derived from EE's own registry and its mapper's exported source constant:
136
+ * a member here that EE does not emit fails, and a source EE emits that is missing here fails too.
137
+ * A subset check in either direction would be a superset guard, structurally unable to catch the
138
+ * other side's omission.
139
+ */
140
+ export const AGENT_PRODUCER_KEYS = Object.freeze([
141
+ 'auth_agent', 'crypto_agent', 'config_agent', 'service_agent', 'exposure_agent',
142
+ 'intelligence_engine',
143
+ ]);
144
+
145
+ export const IDENTITY_BASIS_CHANGED_AT = Object.freeze({
146
+ 1020: '1.1.0', 1024: '1.1.0', 1025: '1.1.0', 1030: '1.1.0',
147
+ // ⚠️ 1040 JOINS AT THE STAMP, LIKE 1120 BELOW. Every CloudTrail-auditor row carried NO region at all
148
+ // (20 of 20 on build 4's pack), so `scopeNotScanned` read each as account-wide; EE build 5 stamps
149
+ // the region of the object a row is about (a trail's home region, a trail bucket's own location,
150
+ // config.region for the alarm and Config checks). That puts `region` into `keyOf` and CHANGES WHAT
151
+ // THOSE FINDINGS ARE, so the straddle is declared exactly as 1120's was.
152
+ 1040: '1.1.0',
153
+ // ⚠️ 1110 IS A THIRD KIND: A TEXT CORRECTION, NOT A RENAMED OBJECT OR A NEW REGION. Its HIGH row
154
+ // (kms:Decrypt on Resource:*) told the reader the KMS key-policy and grant layers had been
155
+ // cross-referenced under a HIGH→INFO downgrade contract — and from EE 1.1.0 build 5 that downgrade
156
+ // runs on no shipped path, because a one-region read cannot establish that no key trusts a
157
+ // principal (CFN-3). The corrected sentence changes the row's content digest, which is part of
158
+ // `keyOf`, so every such HIGH straddling the upgrade would read as one finding resolved and a new
159
+ // one appearing. Declared for the same reason as the rest: what identifies the finding changed.
160
+ 1110: '1.1.0',
161
+ // ⚠️ 1120 JOINS AT THE STAMP, NOT AT THE RENAME. Its replication and lifecycle rows recorded
162
+ // their region as `details.sourceBucketRegion` and carried NO top-level `region`, so in this
163
+ // engine's frame they were non-regional: `scopeNotScanned` skipped them and narrowing a later
164
+ // scan away from the source region read them as REMEDIATED. Stamping the field the loader
165
+ // actually reads puts `region` into `keyOf`, which CHANGES WHAT THOSE FINDINGS ARE — 36 of its
166
+ // 297 archived rows gain one — so the straddle must be declared like any other basis change.
167
+ // 1200 and 1210 take the same stamp and are already declared above; only 1120 was new.
168
+ 1120: '1.1.0',
169
+ 1150: '1.1.0', 1170: '1.1.0', 1190: '1.1.0', 1200: '1.1.0', 1210: '1.1.0',
170
+ // ⚠️ 1023 JOINS AT BUILD 12, BATCHED WITH THE SIX ABOVE SO CUSTOMERS TAKE ONE 1023 STRADDLE, NEVER TWO. Two of the
171
+ // four rows the build-11 delta called RESOLVED were 1023's and no port rule could reach them: "FTP port open —
172
+ // verify no anonymous or default credentials" is emitted BECAUSE 21 is open yet carried no port, and the exposure
173
+ // row carried its COUNT in the title, so 16 → 15 open ports read as a fix plus a new finding. Build 12 attaches the
174
+ // gating port to every row emitted because one port is open (21 · 22 · 23 · 3389 · 5900) and gives the exposure
175
+ // rows one count-free title with the count in details. Both put new values into `keyOf` — `port`, `title` — which
176
+ // CHANGES WHAT THOSE FINDINGS ARE, so the straddle is declared like 1040's region stamp.
177
+ 1023: '1.1.0',
178
+ // ⚠️ AN ANALYSIS AGENT, NOT A PLUGIN, AND THE FIRST NON-NUMERIC KEY THIS TABLE HAS HELD. A
179
+ // finding from Enterprise's finding QUEUE emits no resource, no region, no identity qualifier
180
+ // and no content digest, so `keyOf` reduces to `host · producer · port · TITLE` — the title IS
181
+ // what this producer names. EE 1.1.0 moved the coverage-gap title off `${program} ${version}`,
182
+ // a discovery fingerprint that changes when the scan does (measured: one service came back
183
+ // `DNS-SD/mDNS` in one run and `Unknown` in the next, same port, same host), and onto
184
+ // `${protocol}/${service}`. Same definition as the nine above, read off the field that carries
185
+ // the answer in this container.
186
+ //
187
+ // The key is bounded by `AGENT_PRODUCER_KEYS` and held in two-way equality with what Enterprise
188
+ // actually emits, because a key outside the vocabulary can never match a finding — and a
189
+ // declaration that matches nothing is SILENT while the real producer stays undeclared.
190
+ intelligence_engine: '1.1.0',
191
+ });
192
+
193
+ // ⚠️ NO SECOND COMPARATOR. This file already has `cmpVersion` (below, used by the
194
+ // finding-count-semantics boundary) and my first draft shipped a duplicate beside it — two
195
+ // version comparators in one module is exactly how two call sites come to disagree about what
196
+ // "earlier" means. `String(null)` parses to 0 there, so a null baseline version sorts BEFORE any
197
+ // release, which is the behaviour this declaration needs: a run record with no `eeVersion`
198
+ // predates every declared change.
199
+
200
+ /**
201
+ * Did this producer's identity basis change BETWEEN the two runs?
202
+ * True only when the baseline predates the declared change and the current run is at or after
203
+ * it — never merely because the versions differ, which would declare on every upgrade for ever.
204
+ */
205
+ export function identityBasisChanged(plugin, baselineEeVersion, currentEeVersion) {
206
+ const at = IDENTITY_BASIS_CHANGED_AT[plugin];
207
+ if (!at) return false;
208
+ return cmpVersion(baselineEeVersion, at) < 0 && cmpVersion(currentEeVersion, at) >= 0;
209
+ }
210
+
211
+ // ⚠️ THERE IS NO DISPLAY-LABEL MAP HERE, AND THE REASON IS A MEASUREMENT.
212
+ //
213
+ // `plugin-identity-basis-changed` was named when every declared producer WAS a plugin. EE 1.1.0
214
+ // declares an analysis AGENT, and both render seams build their cell as `${f.reason}: ${f.detail}`
215
+ // — so the row read "plugin-identity-basis-changed: producer intelligence_engine changed what it
216
+ // names ...", detail repaired and CODE still asserting "plugin" about an agent.
217
+ //
218
+ // The first repair added a render-time LABEL map and kept the code, justified by: "the vocabulary
219
+ // is append-only under SCAN_DELTA_SCHEMA; renaming changes what a consumer's stored verdicts
220
+ // MEAN." That premise is CHECKABLE and was not checked. Measured against the published artifact
221
+ // rather than against git — git answers about the repo, not about what a consumer holds —
222
+ // `npm pack nsauditor-ai@0.2.54` does not contain THIS FILE at all, and the string appears
223
+ // nowhere in those bytes (positive control: `utils/report_inputs.mjs` present, 47 files in
224
+ // `utils/`). The engine is unpublished: zero stored verdicts carry the code, and no guard in
225
+ // either repo ever stated the append-only rule.
226
+ //
227
+ // The map was also actively worse than the rename. It was a SECOND COPY of the outcome's spelling,
228
+ // free to drift from the code it stood in for; and it moved ONE of two human surfaces, so the
229
+ // terminal said `plugin-identity-basis-changed` while the client HTML said `identity basis
230
+ // changed` — one outcome under two names, which makes a support call unanswerable. The code is
231
+ // renamed instead: one spelling, true of a plugin and of an agent alike, on every surface that
232
+ // interpolates it — including any seam added later, which a per-seam label can never cover.
233
+
234
+ // Whole-comparison refusals: cases where NO per-finding verdict is trustworthy, so none is offered.
235
+ export const REFUSAL_REASONS = Object.freeze([
236
+ 'ee-presence-differs',
237
+ 'tier-differs',
238
+ 'finding-count-semantics-boundary',
239
+ 'run-record-schema-differs',
240
+ 'baseline-chain-broken',
241
+ 'baseline-integrity-unmeasurable',
242
+ ]);
243
+
244
+ // ⚠️ SEPARATE, AND DELIBERATELY SO. `tests/scan_delta.test.mjs` holds `REFUSAL_REASONS` in EXACT
245
+ // equality with the `refuse('…')` calls in THIS file — a guard that catches both a new code that
246
+ // forgets the constant and a code deleted from the constant while still produced. A view-level
247
+ // refusal put into that array breaks the equality, and the only way to keep one array would be to
248
+ // relax it to a SUBSET check: a superset guard, which this repo's own notes call structurally
249
+ // incapable of catching an omission. So the vocabulary is still one thing to a reader
250
+ // (`DECLARED_OUTCOMES` unions them) and two things to the two derivations that can each stay
251
+ // EXACT. Raised by `scan_delta_view.mjs`, never by `buildScanDelta`: the baseline record exists
252
+ // but this build cannot read it — a schema it does not understand, or a corrupt file.
253
+ export const VIEW_REFUSAL_REASONS = Object.freeze([
254
+ 'baseline-unloadable',
255
+ ]);
256
+
257
+ // ⚠️ THE BOUNDARY CONTRACT. Three fields were dropped at ONE seam before this existed —
258
+ // `resource` (a new exposure masked), `plugin` (every comparison fell to plugin-not-run) and
259
+ // `control` (a finding whose control left the enumeration read as RESOLVED). Each was invisible
260
+ // to this module's own tests because each fixture was built by hand with the field present: a fix
261
+ // verified only against fixtures the author constructs is verified against the author's idea of
262
+ // the input. `tests/delta_boundary_contract.test.mjs` asserts CONSUMED ⊆ EMITTED ∪ DECLARED_ABSENT
263
+ // against the REAL loader, so the fourth instance fails by name instead of shipping.
264
+ export const CONSUMED_FINDING_FIELDS = ['host', 'plugin', 'pluginName', 'producerKind', 'evidenceGap', 'gapClass', 'deferredScope',
265
+ 'contentDigest', 'identityQualifier', 'resource', 'region', 'port', 'title', 'severity', 'control'];
266
+
267
+ // Fields this module WRITES onto its output records; they are never read from a loaded finding,
268
+ // so they must not be demanded of the loader. Declared so the derivation can subtract them.
269
+ export const OUTPUT_ONLY_FINDING_FIELDS = ['reason', 'detail', 'direction', 'from', 'to'];
270
+
271
+ // A consumed field the loader legitimately cannot produce. NOT a bare allowlist: each entry names
272
+ // the limit that DISCLOSES its absence, and the guard verifies that limit is actually emitted —
273
+ // a carve-out whose premise nobody checks is how an absence becomes a silent pass.
274
+ // ⚠️ EVERY DECLARATION NAMES THE PATH IT EXCUSES. A finding reaches the delta through one of two
275
+ // containers — a plugin ENVELOPE (`shapeFinding`) or the finding QUEUE (`shapeQueueEntry`) — and
276
+ // they have different vocabularies. An unqualified declaration excuses a field on BOTH, so the
277
+ // day either container stopped emitting it the carve-out would already be there to cover it.
278
+ export const DECLARED_ABSENT_FINDING_FIELDS = {
279
+ control: {
280
+ paths: ['plugin', 'queue'],
281
+ reason: 'Community ships no compliance data (data/compliance is empty) and report_inputs.mjs '
282
+ + 'emits no control id, so a CE run record cannot carry one. Compliance routing is EE\'s.',
283
+ disclosedBy: 'FRAMEWORK_MOVEMENT_NOT_EVALUATED',
284
+ },
285
+ // ── QUEUE-PATH ABSENCES ───────────────────────────────────────────────────────────────────
286
+ // Found because the guard above had never been asked about this path: its fixture built a
287
+ // plugin envelope, so CONSUMED ⊆ EMITTED was checked against one container and believed of two.
288
+ contentDigest: {
289
+ paths: ['queue'],
290
+ reason: 'The digest exists because PLUGIN titles are SYNTHESISED from `issues` and CUT at 160 '
291
+ + 'characters — three CRITICAL ingress rules on one security group once synthesised to the '
292
+ + 'same 158-character string and held one identity. A queue title is AUTHORED by its '
293
+ + 'producer and never truncated, so the collision the digest was built for cannot arise on '
294
+ + 'this path. A digest here would have to hash the DESCRIPTION, which carries the same '
295
+ + 'volatile program name that EE 1.1.0 is moving OUT of identity — the volatility would '
296
+ + 'return through the digest. A collision that does occur anyway is named, per identity, at '
297
+ + 'runtime by IDENTITY_COLLAPSE.',
298
+ disclosedBy: 'AGENT_SCOPE_FROM_TIER',
299
+ },
300
+ identityQualifier: {
301
+ paths: ['queue'],
302
+ reason: 'A producer-emitted rule discriminator (`details.groupId` / protocol / port range). '
303
+ + 'Analysis agents emit no rule ids and carry no `details` object at all, so there is '
304
+ + 'nothing to qualify with. The key falls back rather than requiring it.',
305
+ disclosedBy: 'AGENT_SCOPE_FROM_TIER',
306
+ },
307
+ resource: {
308
+ paths: ['queue'],
309
+ reason: 'A cloud noun — the object a cloud producer names. A queue finding is scoped to a '
310
+ + 'HOST and a PORT, which the path does emit; inventing a resource for it would put a '
311
+ + 'fabricated object identity into the delta key.',
312
+ disclosedBy: 'AGENT_SCOPE_FROM_TIER',
313
+ },
314
+ region: {
315
+ paths: ['queue'],
316
+ reason: 'A cloud coverage unit. Queue findings come from network analysis agents, which have '
317
+ + 'no region; `scopeNotScanned` is silent for a finding with no unit, which is the correct '
318
+ + 'reading — a narrowed region says nothing about a port on a network host.',
319
+ disclosedBy: 'AGENT_SCOPE_FROM_TIER',
320
+ },
321
+ };
322
+
323
+
324
+ // ⚠️ DERIVED FROM THE FROZEN CONTRACT, NOT INVENTED HERE. `docs/contract-v1.md` §5.3: the
325
+ // `findingCount` key KEPT ITS NAME AND CHANGED ITS VALUE in the first release after EE 0.46.0 —
326
+ // every archived pack before that release printed the ISSUE count under it. A delta spanning that
327
+ // boundary would report a mass remediation that never happened, so it is REFUSED, not annotated.
328
+ export const FINDING_COUNT_BOUNDARY_EE = '0.46.0';
329
+
330
+ // The honest label, and it rides in the OUTPUT rather than only in the docs: a hash chain on the
331
+ // same disk as the records it covers is defeated by anyone who can recompute the successors.
332
+ export const CHAIN_ASSURANCE_LABEL =
333
+ 'Baseline integrity is tamper-EVIDENT against accidental corruption, partial restore and ' +
334
+ 'unsophisticated edits. It is NOT tamper-proof against an attacker with host-level access, ' +
335
+ 'and it is not non-repudiation: the chain carries no signature and names no author.';
336
+
337
+ // EXPORTED so a consumer that asks "was this written before release X?" asks it with THIS
338
+ // comparator — Enterprise's MTTR loader gates its region-identity warning on it (item 26). The "no
339
+ // second comparator" rule above holds across the two packages, not only within this file.
340
+ export const cmpVersion = (a, b) => {
341
+ const pa = String(a).split('.').map((n) => parseInt(n, 10) || 0);
342
+ const pb = String(b).split('.').map((n) => parseInt(n, 10) || 0);
343
+ for (let i = 0; i < 3; i += 1) if ((pa[i] ?? 0) !== (pb[i] ?? 0)) return (pa[i] ?? 0) < (pb[i] ?? 0) ? -1 : 1;
344
+ return 0;
345
+ };
346
+
347
+ // ⚠️ IDENTITY MUST CARRY THE RESOURCE, and the first draft of this did not — a HIGH defect an
348
+ // independent seat found by reading the FIXTURES rather than the code. Cloud issue strings carry
349
+ // the DEFECT, not the resource ("No public access block configured…"), so twelve buckets with one
350
+ // misconfiguration share a title; resource identity lives in `finding.resource` (contract-v1 §1.1).
351
+ // Under a host+plugin+title key those twelve collapse to ONE, and the failure is in the dangerous
352
+ // direction: fix bucket-c, acquire bucket-d with the same defect, and the delta reported
353
+ // "unchanged" — A NEW EXPOSURE ABSENT FROM `newFindings` ENTIRELY, measured, not theorised.
354
+ //
355
+ // ⚠️ `severity` IS DELIBERATELY NOT IN THE KEY. Adding it would render one escalation as a
356
+ // resolved finding PLUS a new one — two false verdicts from one true change. Identity first, then
357
+ // severity compared between MATCHED findings into `changed`.
358
+ //
359
+ // ⚠️ `originalIndex` IS NOT A CANDIDATE, though contract-v1 §1.2 freezes it on every record: it is
360
+ // the index in the INPUT array and all fan-out records of one cloud finding SHARE it (§1.2:53).
361
+ // Positional and non-unique — adopting it as an id would be worse than this composed key.
362
+ // ⚠️ NO `?? f.target` FALLBACK, and its removal is the F5 lesson applied to a benign case. The
363
+ // boundary guard flagged `target` as consumed-but-never-emitted on its FIRST run: report_inputs
364
+ // already folds target INTO resource (`resource: f?.resource ?? f?.target ?? …`), so a finding can
365
+ // never reach here carrying a bare target. A fallback that cannot fire through the shipped path is
366
+ // not defensive depth — it is dead code that reads as coverage, which is exactly what made the
367
+ // framework-enumeration leg look complete while being inert.
368
+ // ⚠️ NEVER OVER A TRUNCATED STRING. The title is SYNTHESISED and CUT AT 160 CHARS for producers
369
+ // that emit none, and on a live estate three world-open ingress rules on one security group
370
+ // differed only past the cut — one identity for three CRITICAL exposures, so a NEW world-open
371
+ // database port would have read as UNCHANGED. `contentDigest` covers the untrucated content and
372
+ // `identityQualifier` carries the producer's OWN rule discriminators; the title remains the
373
+ // fallback for anything emitting neither.
374
+ // ⚠️ BOTH `title` AND `contentDigest`, NEVER ONE INSTEAD OF THE OTHER — measured, and the first
375
+ // draft got it wrong in the dangerous direction. `contentDigest ?? title` looked equivalent-or-
376
+ // better and was STRICTLY COARSER for producers that emit no raw title: 1020 names the bucket only
377
+ // in the SYNTHESISED title, while the digest covers `[rawTitle, issues]` where rawTitle is null and
378
+ // the issue text carries no bucket. Two different S3 buckets — `aws-config-logs-…` and
379
+ // `cloudtrail-violator-logs-…` — hashed identically and collapsed. The fix for one producer's
380
+ // masking re-created it at another, which is why this was caught on the REAL record and not by the
381
+ // fixtures written from 1170.
382
+ //
383
+ // Taking both is strictly finer than either: the digest separates content that TRUNCATION hid, the
384
+ // title separates naming the digest never saw, and the qualifier separates rules that share both.
385
+ //
386
+ // ⚠️ `region` IS A COMPONENT SINCE E1, AND REMOVING THE SUFFIX WITHOUT ADDING IT WOULD COLLAPSE
387
+ // A POPULATION. Before E1 the region entered this key only as decoration inside `resource`
388
+ // (` [us-east-1]`, appended downstream by EE's `_stampRegion`), so a per-region finding that
389
+ // names no object — a scope literal like `backup:account`, emitted once per region — was
390
+ // separated ONLY by that suffix. De-suffixing alone would have mapped every region's copy onto
391
+ // one key and reported N-1 of them as removed. It is taken from the finding's own FIELD, which
392
+ // both sides of any comparison recompute identically, so unlike a suffix it fabricates nothing
393
+ // across an upgrade.
394
+ // The host is compared by `hostKey` (census G6): one host is one host whatever case it was typed in.
395
+ const keyOf = (f) => [hostKey(f.host), f.plugin, f.resource ?? '-', f.port ?? '-',
396
+ f.region ?? '-', f.identityQualifier ?? '-', f.title, f.contentDigest ?? '-'].join('|');
397
+
398
+ // A plugin status that means THE SURFACE WAS NOT READ. `ran` is the only status that licenses a
399
+ // comparison; the rest are the machine saying so itself.
400
+ // ⚠️ EXPORTED AS A FROZEN LIST, NOT AS THE SET. Enterprise's MTTR engine reads the same vocabulary
401
+ // (a prior row whose plugin was not measured now is not closed), and a shared Set is one any
402
+ // importer could `add` to. The vocabulary is CLOSED: `tests/scan_delta.test.mjs` holds every
403
+ // status literal the plugin manager assigns inside MEASURED_STATUS ∪ NOT_MEASURED_STATUSES.
404
+ export const MEASURED_STATUS = 'ran';
405
+ export const NOT_MEASURED_STATUSES = Object.freeze(['error', 'timeout', 'skipped']);
406
+ const NOT_MEASURED_STATUS = new Set(NOT_MEASURED_STATUSES);
407
+
408
+ // ⚠️ SCOPE IS WHAT A RUN MEASURED, WHICH IS NARROWER THAN WHAT IT REQUESTED IN THREE WAYS, and
409
+ // every one of them was fail-open here until 2026-09-20 — each produced a `resolved` row in a
410
+ // CLIENT artifact when driven through `report --since`:
411
+ // HOSTS were `hostsRequested ∪ hostsWritten`, so a host that was asked for and never produced
412
+ // output counted as scanned and its baseline findings read as remediation.
413
+ // PLUGIN STATUS was not consulted at all, though the loader has always carried it, so a plugin
414
+ // that ERRORED or TIMED OUT on a host counted as having measured it.
415
+ // EVIDENCE GAPS were read from `side.evidenceGaps`, which NO shipped caller ever populated —
416
+ // dead since the day it was written, and the fall-through from a dead leg is `resolved`.
417
+ // ⚠️ THE `side.evidenceGaps` READ IS DELETED RATHER THAN REPAIRED. It was unreachable AND
418
+ // shape-wrong: the only producer of that record shape (`cloud_finding_summary.mjs`) builds
419
+ // `plugin: String(r?.id ?? '')` with NO `host` key at all, so even a caller that filled it would
420
+ // key `undefined|1170` against a lookup keyed `<host>|<id>`. Leaving a second unreachable path
421
+ // beside the repaired one is the F5 mistake this module already records at the framework leg —
422
+ // dead code that reads as coverage.
423
+ /**
424
+ * The COVERAGE UNIT a finding sits in, per provider, and whether the OTHER run recorded covering
425
+ * it. Returns a detail string when the finding is outside that run's recorded scope, else null.
426
+ *
427
+ * Each provider resolves a different unit, so the unit is read per provider rather than assumed:
428
+ * aws → REGION, carried per finding as `f.region`
429
+ * azure → SUBSCRIPTION, carried by the HOST (every finding of that host shares it)
430
+ * gcp → PROJECT, likewise
431
+ *
432
+ * ⚠️ THREE SILENCES, EACH DELIBERATE. (1) BOTH sides unknown → silent: every record written
433
+ * before this field existed lacks it, and firing there would turn every historical comparison
434
+ * into a wall of rows. (2) A finding with NO unit (`iam:account`, no region) is not scoped by
435
+ * that unit and is untouched — otherwise a narrowed region makes the whole account
436
+ * not-comparable. (3) A side that DISAGREED with itself about its coverage is UNKNOWN, not
437
+ * covered: the resolver memo is keyed on the credential fingerprint, so two keys resolving
438
+ * different sets inside one host's scan means the run has no single coverage to difference.
439
+ */
440
+ // ⚠️ IMPORTED, NOT DECLARED HERE — this module's ONLY import, and it buys the removal of a
441
+ // second copy. Enterprise's CPE mapper needs the same provider list to know that a cloud host
442
+ // has no service-feeding upstreams; `utils/cloud_providers.mjs` is the single home and derives
443
+ // its host set from THIS map's keys, so adding a provider is one edit in one file.
444
+ import { PROVIDER_SCOPE_UNIT, canonicalHost, hostKey } from './cloud_providers.mjs';
445
+
446
+ // ── A PORT THE RUN COULD NOT MEASURE (EE 1.1.0 build 9, F6) — one decision, shared with Enterprise's MTTR ──
447
+ /** A port-scoped input gap: Enterprise's record that a probe could not complete on this port. */
448
+ // Written as plain `f.` reads so the boundary contract's derivation SEES them (it reads `f.<field>`; an
449
+ // optional chain is invisible to it, and `gapClass` would read as consumed-but-undeclared).
450
+ export const isPortInputGap = (f) => f != null && f.evidenceGap === true && f.gapClass === INPUT_GAP_CLASS && Number(f.port) > 0;
451
+ /** The key a port gap is looked up by — `hostKey`, so a host typed in two cases is one host. */
452
+ export const portGapKey = (host, port) => `${hostKey(host)}|${Number(port)}`;
453
+ /** Every port a run could not measure: `portGapKey` → the gap's title (what the reader is told). */
454
+ export function portsNotMeasured(findings) {
455
+ const out = new Map();
456
+ for (const f of Array.isArray(findings) ? findings : []) {
457
+ if (!isPortInputGap(f)) continue;
458
+ const k = portGapKey(f.host, f.port);
459
+ if (!out.has(k)) out.set(k, f.title ?? 'an input gap was recorded on this port');
460
+ }
461
+ return out;
462
+ }
463
+
464
+ /**
465
+ * Per host, what the port scanner recorded: `hostKey` → { open: Set, closed: Set }. Read from the loader's
466
+ * `portScan`, which it sets only where 003 RAN and carried both lists. Two directories naming one host whose scans
467
+ * DISAGREE about a port are resolved conservatively: a port counts as open if either saw it open and as closed only
468
+ * if some scan closed it — the verdict below needs "open on one side" AND "in neither list on the other".
469
+ */
470
+ function portStateOf(byHost) {
471
+ const out = new Map();
472
+ for (const h of Array.isArray(byHost) ? byHost : []) {
473
+ const ps = h?.portScan;
474
+ if (!ps || !Array.isArray(ps.tcpOpen) || !Array.isArray(ps.tcpClosed)) continue;
475
+ const k = hostKey(h.host);
476
+ const e = out.get(k) ?? { open: new Set(), closed: new Set() };
477
+ for (const p of ps.tcpOpen) if (Number(p) > 0) e.open.add(Number(p));
478
+ for (const p of ps.tcpClosed) if (Number(p) > 0) e.closed.add(Number(p));
479
+ out.set(k, e);
480
+ }
481
+ return out;
482
+ }
483
+
484
+ function scopeNotScanned(f, mine, theirs) {
485
+ const provider = hostKey(f?.host);
486
+ const mineEntry = mine?.scopeScanned?.[provider] ?? null;
487
+ const theirEntry = theirs?.scopeScanned?.[provider] ?? null;
488
+ if (!mineEntry && !theirEntry) return null; // (1) neither side knows
489
+
490
+ // ⚠️ THE UNIT IS A PROPERTY OF THE PROVIDER, NEVER OF THE RECORD ENTRY — and the first draft
491
+ // had it the other way round, which made the both-sides-unknown guard DEAD CODE: with no entry
492
+ // on either side the unit fell back to a literal that is not 'region', so the value lookup
493
+ // returned null and the guard below caught every case first. A mutant deleting guard (1)
494
+ // survived the whole fixture set, which is how it was found. Reading the unit from the provider
495
+ // makes the guard load-bearing: a pre-fix pair with a regional finding now reaches it.
496
+ const unitName = PROVIDER_SCOPE_UNIT[provider] ?? null;
497
+ if (unitName === null) return null; // a provider with no coverage unit of its own
498
+ // ⚠️ THE UNIT COMES FROM THE FINDING'S OWN SIDE, NEVER BORROWED FROM THE OTHER — and borrowing
499
+ // was a live defect. A region RIDES ON the finding, so a finding's existence proves its own run
500
+ // covered that region and the only unknown is ever the OTHER side's coverage; that asymmetry is
501
+ // deliberate. A subscription or project rides on the HOST, so when the finding's own record
502
+ // carries no scope there is nothing to say WHICH subscription it belonged to. Reading
503
+ // `mineEntry ?? theirEntry` made `covered.has(value)` true by construction: measured, a baseline
504
+ // azure finding whose record lacked the field read RESOLVED against a current scoped to a
505
+ // DIFFERENT subscription — the first delta after upgrading, on a possibly different estate.
506
+ let value;
507
+ if (unitName === 'region') {
508
+ value = typeof f?.region === 'string' && f.region ? f.region : null;
509
+ if (value === null) return null; // (2) not a regional finding
510
+ } else if (!mineEntry) {
511
+ // Guard (1) already returned when NEITHER side knows, so the other side does know — and this
512
+ // finding's own run does not. Which unit it belongs to is unknowable, so it fails closed.
513
+ return `this run's record carries no ${unitName} scope, so which ${unitName} this finding `
514
+ + 'belongs to is not known — it cannot be called fixed or new against the other run';
515
+ } else {
516
+ value = mineEntry.scanned?.[0] ?? null;
517
+ if (value === null) return null; // (2) recorded an empty scope
518
+ }
519
+
520
+ if (!theirEntry) {
521
+ return `the other run's record carries no ${unitName} scope, so it is not known whether `
522
+ + `${unitName} ${value} was covered there — this finding cannot be called fixed or new `
523
+ + 'against an unknown scope';
524
+ }
525
+ if (theirEntry.disagreed === true || mineEntry?.disagreed === true) {
526
+ return `a run disagreed with itself about which ${unitName}s it covered, so its scope is ` // (3)
527
+ + `unknown; ${unitName} ${value} cannot be differenced against it`;
528
+ }
529
+ const covered = new Set(Array.isArray(theirEntry.scanned) ? theirEntry.scanned : []);
530
+ if (covered.has(value)) {
531
+ // ⚠️ THE RUN-LEVEL SET IS A UNION, AND A UNION IS NOT A STATEMENT ABOUT ONE PRODUCER. It is
532
+ // every region ANY producer resolved in that run, so a record saying `[us-east-1, eu-west-1]`
533
+ // can contain a producer that only ever looked at `us-east-1` — 1200 resolves regions ONLY
534
+ // under an explicit `awsRegionIntent`, and several plugins iterate no regions at all. Without
535
+ // this refinement the check answers "eu-west-1 WAS covered", the finding is differenced, and a
536
+ // surface that producer never examined reads as REMEDIATED: the same defect `scope-not-scanned`
537
+ // exists for, one level of granularity down.
538
+ //
539
+ // ⚠️ A MISSING ENTRY IS NOT A NEGATIVE, and that asymmetry is the rule. `byPlugin` refines
540
+ // only where it SPEAKS: no map at all is every record written before the field shipped, and a
541
+ // map without THIS producer means the run made no statement about it — a producer that earns
542
+ // no entry because it resolves no regions is the BACKSTOP's subject, adjudicated on its own
543
+ // terms. Reading either silence as "covered nothing" would fire on the ABSENCE of information
544
+ // and refuse every archived comparison at once.
545
+ const perProducer = theirEntry.byPlugin?.[String(f?.plugin)];
546
+ if (Array.isArray(perProducer) && !perProducer.includes(value)) {
547
+ return `${unitName} ${value} is inside the other run's overall scope, but producer `
548
+ + `${f.plugin} only covered ${perProducer.join(', ') || 'no ' + unitName + 's'} there — `
549
+ + 'the run-level set is the union across producers, so this surface was not looked at by '
550
+ + 'the producer that would have found it, which is not the same as the finding being fixed';
551
+ }
552
+ return null; // genuinely comparable
553
+ }
554
+ return `${unitName} ${value} was outside the other run's recorded scope `
555
+ + `(${[...covered].join(', ') || 'none recorded'}) — the surface was not looked at there, `
556
+ + 'which is not the same as the finding being fixed';
557
+ }
558
+
559
+ const scopeOf = (side) => {
560
+ const rec = side?.record ?? {};
561
+ // ⚠️ EVERY HOST HERE IS A `hostKey` (census G6). This set and `scopeScanned` below come from the RAW
562
+ // record, not the loader's model, so a record written as `AWS` before the parse-time fold — or a host
563
+ // typed `MyHost.local` in one run and `myhost.local` in the next — must be keyed HERE, or the pair
564
+ // reads host-not-scanned. And the gap and statement maps are keyed the same way: a host set that paired
565
+ // across case while the gap map did not would lose the other run's gap and call its finding RESOLVED.
566
+ // What a reader is SHOWN keeps the recorded spelling (`hostNames`).
567
+ const hostNames = new Map();
568
+ const keyHost = (h) => {
569
+ const k = hostKey(h);
570
+ if (k && !hostNames.has(k)) hostNames.set(k, canonicalHost(h));
571
+ return k;
572
+ };
573
+ const hosts = new Set((rec.hostsWritten ?? []).map((h) => keyHost(h?.host)).filter(Boolean));
574
+ const plugins = new Set(rec.pluginsRequested ?? []);
575
+ const gaps = new Map();
576
+ // Gaps as the PRODUCER records them — on the finding, where they already ride.
577
+ // ⚠️ EXCEPT A PORT-SCOPED INPUT GAP (EE 1.1.0 build 9). Enterprise emits one per analysis agent for each
578
+ // port a probe could not measure, so keyed here by PRODUCER it would set aside that agent's rows on EVERY
579
+ // port of the host, and a genuine fix on a port that WAS measured would stop reading resolved. It is
580
+ // scoped to its port, in `portGaps`, and nowhere else.
581
+ for (const fi of side?.findings ?? []) {
582
+ if (fi?.evidenceGap === true && fi.plugin != null && !isPortInputGap(fi)) {
583
+ gaps.set(`${keyHost(fi.host)}|${fi.plugin}`,
584
+ { kind: 'recorded-gap', reason: fi.title ?? fi.detail ?? 'an evidence gap was recorded' });
585
+ }
586
+ }
587
+ // ⚠️ SCOPE STATEMENTS — a producer's own declaration of what it does NOT examine
588
+ // (`details.deferredScope`). Not an exposure and not a gap: set aside here, beside the gaps, so the
589
+ // pairing below never sees them and a reworded boundary cannot read as a fix plus a new finding.
590
+ // A row carrying BOTH flags is a GAP: "could not read" outranks "does not examine".
591
+ const statements = new Map();
592
+ for (const fi of side?.findings ?? []) {
593
+ if (fi?.deferredScope === true && fi?.evidenceGap !== true && fi.plugin != null) {
594
+ const k = `${keyHost(fi.host)}|${fi.plugin}`;
595
+ statements.set(k, [...(statements.get(k) ?? []), fi.title ?? '']);
596
+ }
597
+ }
598
+ // And as the ENGINE records them: a plugin the host never successfully ran.
599
+ for (const h of (Array.isArray(side?.pluginStatus) ? side.pluginStatus : [])) {
600
+ for (const ps of (h?.status ?? [])) {
601
+ if (!NOT_MEASURED_STATUS.has(ps?.status)) continue;
602
+ // ⚠️ A DIFFERENT KIND, AND THE DIFFERENCE IS CLIENT-VISIBLE. Both mean "this surface was not
603
+ // measured", so both belong in the same scope map — but a producer DECLARING a gap
604
+ // (AccessDenied, budget exceeded) and a plugin CRASHING are not the same event, and the
605
+ // first draft of this reported the second with the first's sentence: "the other run
606
+ // recorded an evidence gap … the plugin's status on that host was error". The other run
607
+ // recorded nothing of the sort. That sentence renders into the client's basis cell, which
608
+ // is the surface this whole engine exists to keep honest.
609
+ gaps.set(`${keyHost(h.host)}|${String(ps.id)}`, { kind: 'not-measured',
610
+ reason: `the plugin's status on that host was "${ps.status}"${ps.reason ? `: ${ps.reason}` : ''}` });
611
+ }
612
+ }
613
+ return {
614
+ hosts, hostNames, plugins, gaps, statements,
615
+ // The ports this side could not measure, keyed `hostKey|port` (EE 1.1.0 build 9).
616
+ portGaps: portsNotMeasured(side?.findings),
617
+ // What this side's PORT SCANNER recorded, per host (EE 1.1.0 build 12): present only where 003 RAN and wrote
618
+ // both lists (the loader's `portScan`), so an absent entry means "no oracle", never "nothing open".
619
+ portState: portStateOf(side?.pluginStatus),
620
+ // The release that WROTE this side. Carried on the scope because the identity-basis
621
+ // declaration is a property of the comparison — which releases the two runs straddle — and
622
+ // `incomparabilityReason` sees only the two scopes.
623
+ eeVersion: rec.eeVersion ?? null,
624
+ // ⚠️ WHAT THE RUN ACTUALLY COVERED, per provider — never the FLAG. `undefined` means the
625
+ // record predates this field and the scope is UNKNOWN, which is not the same as "covered
626
+ // nothing": the rule below fails closed on unknown and stays silent when BOTH sides are
627
+ // unknown, because every record written before this change lacks it.
628
+ scopeScanned: rec.scopeScanned
629
+ ? Object.fromEntries(Object.entries(rec.scopeScanned).map(([h, v]) => [hostKey(h), v]))
630
+ : null,
631
+ // ⚠️ AN ABSENT ORACLE IS NOT A CLEAN ONE. `[]` means "measured, no gaps"; MISSING means
632
+ // nothing was measured, and the two must not render alike. Declared in `limits`, never
633
+ // absorbed here — gate:cascade's LEG (ii) is this repo's precedent for the distinction.
634
+ // ⚠️ THIS READ `Array.isArray(side?.pluginStatus)` UNTIL THE OUTCOME CENSUS DROVE IT. Through
635
+ // the shipped path `side.pluginStatus` is `model.plugins.byHost`, which the loader ALWAYS
636
+ // builds — so the test was always true, `scope-not-evaluated` was dead, and a run that
637
+ // recorded no per-host plugin status rendered identically to one that recorded a clean
638
+ // status. That is the exact false clean the comment above forbids, living inside the guard
639
+ // written to prevent it. The loader now carries `pluginStatusRecorded` beside the defaulted
640
+ // array, and this asks the question the comment always meant.
641
+ evaluable: Array.isArray(side?.pluginStatus)
642
+ && side.pluginStatus.every((h) => h?.pluginStatusRecorded !== false),
643
+ frameworks: side?.frameworkEnumeration ?? null,
644
+ };
645
+ };
646
+
647
+ export const SCOPE_NOT_EVALUATED =
648
+ 'Scope non-evaluation: one of the two runs recorded no per-host plugin status, so evidence gaps '
649
+ + 'and failed plugins could NOT be distinguished from a clean scan on that side. A finding '
650
+ + 'reported as resolved here may instead be one the scanner could not read. Re-run the '
651
+ + 'comparison against a run recorded by this release or later before treating any row as remediation.';
652
+
653
+ // ⚠️ THE SECOND HALF WAS ADDED BECAUSE FOUR DECLARED ABSENCES POINT AT THIS SENTENCE AND IT SAID
654
+ // NOTHING ABOUT THEM. `contentDigest`, `identityQualifier`, `resource` and `region` are all absent
655
+ // on the queue path and each is excused HERE — but the text disclosed only how an agent's SCOPE is
656
+ // derived, while what those absences change is IDENTITY. A carve-out is disclosed only if the
657
+ // sentence a reader actually meets is the sentence that discloses it.
658
+ //
659
+ // The consequence is not theoretical: on a real 192.168.1.1 record, TWENTY CVE rows share host,
660
+ // port, protocol, service, program and version — `53/udp/dns`, `dnsmasq 2.78` — and are separated
661
+ // by their titles alone (20 rows, 20 distinct titles, derived; a first count of 21 was mine and
662
+ // was wrong). With no digest on this path, one edit to that title template collapses all twenty
663
+ // into a single identity, and IDENTITY_COLLAPSE is the only thing that would say so — after the
664
+ // fact. The 1.1.1 repair must hash STABLE content (for a CVE row, `evidence.cve[]` sorted plus
665
+ // the service key) and NEVER `description`, which carries the program name identity is moving
666
+ // away from — a digest over volatile prose reintroduces the volatility through the digest.
667
+ export const AGENT_SCOPE_FROM_TIER =
668
+ 'Agent-produced findings: their scope is derived from the run TIER, not from a per-agent run '
669
+ + 'record — this edition persists no per-agent status, and the agent set is a function of the '
670
+ + 'licensed capabilities. The two runs carry the same tier, which is what makes them comparable; '
671
+ + 'a tier difference refuses the comparison outright rather than narrowing it. '
672
+ + 'Their IDENTITY is also narrower than a plugin finding\'s: an agent finding is keyed on host, '
673
+ + 'producer, port and title, and on nothing else — it carries no object (resource), no region, '
674
+ + 'no rule qualifier and NO CONTENT DIGEST. So two agent findings that differ only in text the '
675
+ + 'title does not carry share one identity, and one of them can be masked by the other; where '
676
+ + 'that happens it is named individually under the identity-collapse limit.';
677
+
678
+ // The producer as a READER should see it. `plugin` is an id because that is the vocabulary the
679
+ // run record can be checked against; the id alone is not a sentence, and this string is rendered
680
+ // into the client's report. Both are printed when they differ, so the prose is readable AND the
681
+ // token matches the baseline scope line, which prints `pluginsRequested` — ids.
682
+ const producerLabel = (f) => (f.pluginName && f.pluginName !== f.plugin ? `${f.pluginName} (${f.plugin})` : String(f.plugin));
683
+
684
+ /**
685
+ * THE NOUN FOR A PRODUCER, KEYED ON ITS KIND AND NEVER ON ITS NAME.
686
+ *
687
+ * ⚠️ EXPORTED SO IT CAN BE PROVEN, because it CANNOT be driven. A reviewing seat's mutant re-keyed
688
+ * this from `producerKind` to `f.plugin === 'intelligence_engine'` and passed every leg in the
689
+ * declaration suite — with exactly ONE agent in `IDENTITY_BASIS_CHANGED_AT`, no fixture built from
690
+ * the corpus can tell agent-NESS from that one NAME, and the only branch that prints this noun
691
+ * requires a DECLARED producer. So the case that distinguishes the two implementations is not
692
+ * expressible through the shipped path today, and the day a second agent is declared the client
693
+ * cell would quietly call it a plugin. A helper that cannot be driven is exported and tested
694
+ * directly rather than left to be verified by a fixture that cannot reach it.
695
+ */
696
+ export const producerNoun = (f) => (f?.producerKind === 'agent' ? 'producer' : 'plugin');
697
+
698
+ // Why a finding present in ONE run cannot be compared against the other. Order matters only for
699
+ // which reason is reported first; each is independently sufficient.
700
+ function incomparabilityReason(f, mine, theirs) {
701
+ if (!theirs.hosts.has(hostKey(f.host))) return { reason: 'host-not-scanned', detail: `host ${f.host} was not scanned in the other run` };
702
+ // ⚠️ A NULL IDENTITY MAY NEVER SATISFY A SCOPE CHECK, and it may never be DESCRIBED as one
703
+ // either. Before this leg a finding with no producer fell into `plugin-not-run` and reported
704
+ // "plugin null did not run in the other run" — a sentence that is false about the run, about a
705
+ // finding whose comparability was never established. The honest verdict names the thing that is
706
+ // missing. It is checked FIRST because every leg below keys on the producer.
707
+ if (f.plugin == null) {
708
+ return { reason: 'producer-unknown',
709
+ detail: 'this finding carries no producer identity, so whether it was in scope in the other run cannot be established' };
710
+ }
711
+ // ⚠️ THE ORACLE DEPENDS ON THE PRODUCER KIND, and guessing it from the string's SHAPE is what
712
+ // this leg refuses to do. A plugin id is answerable from `pluginsRequested`. An EE analysis
713
+ // agent appears in no such list and never will, so checking it there would bucket every
714
+ // agent-produced finding as `plugin-not-run` for ever — safe, and useless, which is the failure
715
+ // mode this engine was built to avoid on the other axis. Its scope is the run TIER, and the two
716
+ // whole-comparison refusals above (`ee-presence-differs`, `tier-differs`) are what make that
717
+ // sound: by the time control reaches here both sides carry Enterprise and carry the SAME tier,
718
+ // so the agent set is identical on both. No per-finding agent check is written here, because a
719
+ // check that cannot fail through the shipped path is dead code that reads as coverage.
720
+ if (f.producerKind !== 'agent' && !theirs.plugins.has(f.plugin)) {
721
+ // ⚠️ THE NOUN HERE IS A LITERAL `plugin` AND THAT IS CORRECT — but only because of the
722
+ // `producerKind !== 'agent'` guard on this very line, which makes the branch unreachable for
723
+ // an agent. Delete that guard and this sentence starts calling agents plugins, which is the
724
+ // defect the identity branch below had to be repaired for. The coupling is written here
725
+ // because a reader deleting the guard is reading THIS line, not the sentence's test.
726
+ return { reason: PLUGIN_NOT_RUN_REASON, detail: `plugin ${producerLabel(f)} did not run in the other run` };
727
+ }
728
+ // ⚠️ THE PRODUCER CHANGED WHAT IT NAMES BETWEEN THESE TWO RELEASES, so its two keys for one
729
+ // object cannot be matched and must not be DIFFERENCED either. Checked here, before the
730
+ // evidence-gap legs, because it is a statement about the COMPARISON rather than about either
731
+ // run's coverage: the surface was read on both sides, and what moved is the noun.
732
+ //
733
+ // It is keyed on the baseline PREDATING the declared change, never on the versions merely
734
+ // differing — the latter would declare on every upgrade for ever and quietly retire the
735
+ // feature. `identityBasisChanged` is one-directional for that reason.
736
+ if (identityBasisChanged(f.plugin, mine.eeVersion, theirs.eeVersion)
737
+ || identityBasisChanged(f.plugin, theirs.eeVersion, mine.eeVersion)) {
738
+ const at = IDENTITY_BASIS_CHANGED_AT[f.plugin];
739
+ return { reason: IDENTITY_BASIS_CHANGED_REASON,
740
+ // ⚠️ "producer" FOR AN AGENT. This sentence renders into the client artifact's basis cell,
741
+ // and it hardcoded "plugin" — harmless while every declared producer WAS one, and false the
742
+ // moment an analysis agent joined the table. It was UNREACHABLE for an agent until that
743
+ // happened, which is why the wording lands in the same commit as the declaration rather
744
+ // than earlier: before this, no test could have driven it. Same class as the
745
+ // "plugin undefined did not run in the other run" detail this engine already had to fix.
746
+ detail: `${producerNoun(f)} ${producerLabel(f)} `
747
+ + `changed what it names as a finding's object at EE ${at}; `
748
+ + 'the two runs straddle that change, so this finding\'s identity is not comparable '
749
+ + 'between them. It is NOT reported as fixed or as new — rescan to compare.' };
750
+ }
751
+ // ⚠️ DID THE OTHER RUN EVEN LOOK THERE? Placed after plugin-not-run and the identity
752
+ // declaration — those are statements about the PRODUCER and the COMPARISON — and before the
753
+ // gap legs, because "the surface was outside the scan's scope" is more precise than "no gap was
754
+ // recorded for it": a region nobody scanned records no gap by construction.
755
+ //
756
+ // ⚠️ MEASURED, NOT HYPOTHETICAL. Two live AWS passes differing only in `--aws-region` reported
757
+ // EIGHT unremediated `eu-west-1` findings as RESOLVED — GuardDuty NOT ENABLED, Inspector2
758
+ // DISABLED, default EBS encryption DISABLED — because narrowing a follow-up scan is a normal
759
+ // operator action and nothing in the record could tell it from remediation.
760
+ const scopeMiss = scopeNotScanned(f, mine, theirs);
761
+ if (scopeMiss) return { reason: SCOPE_NOT_SCANNED_REASON, detail: scopeMiss };
762
+
763
+ // ⚠️ `theirs` ONLY, AND THE `?? mine.gaps` THAT USED TO SIT HERE WAS WRONG IN BOTH HALVES.
764
+ // A gap says "this run could not read that surface", so it explains what a run is MISSING —
765
+ // never what a run is HOLDING. This lookup only ever runs for an UNMATCHED finding, and in both
766
+ // directions `theirs` is the side that failed to look: for a row that vanished, the other run is
767
+ // where it should have reappeared; for a row that appeared, the other run is where it should
768
+ // already have been. `mine` is never the side that failed to look at a finding my own run is
769
+ // holding in its hand.
770
+ //
771
+ // ⚠️ IT WAS UNREACHABLE, WHICH IS WHY IT SHIPPED — and EE 1.1.0's widened mapper boundary made
772
+ // it live on ordinary pairs. Before, the `mine` half needed a gap on the finding's own side
773
+ // beside a bucketed row from the SAME producer, and no shipped producer made that pair. Now both
774
+ // sides of any degraded network comparison carry the mapper's gap. Driven before this fix: a
775
+ // genuinely NEW row on the degraded side, against a full baseline that recorded no gap at all,
776
+ // came back `new = 0` and `not-comparable · evidence-gap` — a suppressed new exposure, explained
777
+ // by the detail below, which says "the OTHER run recorded an evidence gap" and was false about
778
+ // the baseline. Narrowing to `theirs` is what makes that sentence unconditionally true.
779
+ const gap = theirs.gaps.get(`${hostKey(f.host)}|${f.plugin}`);
780
+ if (gap) {
781
+ return gap.kind === 'recorded-gap'
782
+ ? { reason: 'evidence-gap',
783
+ detail: `the other run recorded an evidence gap on ${f.host}/${producerLabel(f)}: ${gap.reason}` }
784
+ : { reason: PLUGIN_NOT_MEASURED_REASON,
785
+ detail: `${f.host}/${producerLabel(f)} was not measured in the other run — ${gap.reason}` };
786
+ }
787
+ // ⚠️ THE FINDING'S PORT WAS NOT MEASURED IN THE OTHER RUN (EE 1.1.0 build 9, F6). Any producer's row: a
788
+ // probe that could not complete on an OPEN port starves every consumer of that port's service. Placed
789
+ // AFTER the producer-wide legs, so a verdict they already give does not move. `theirs` only, as above.
790
+ const portGap = f.port != null && Number(f.port) > 0 ? theirs.portGaps?.get(portGapKey(f.host, f.port)) : null;
791
+ if (portGap) {
792
+ return { reason: PROBE_NOT_MEASURED_REASON,
793
+ detail: `port ${f.port} on ${f.host} was not measured in the other run — a probe ran there and did not `
794
+ + `complete its connection (${portGap}). It is NOT reported as fixed or as new — rescan to compare.` };
795
+ }
796
+ // ⚠️ THE FINDING'S PORT STOPPED ANSWERING (EE 1.1.0 build 12). Open in the run that HOLDS the finding, and in the
797
+ // other run the port scanner ran and put it in NEITHER list: no answer inside its timeout, or not probed. After
798
+ // F6, so a probe-not-measured verdict does not move. Keyed on the HOLDING run's tcpOpen, so a UDP service or a
799
+ // port no TCP list ever named is untouched; and only where the other run's scanner RAN on this host — with no
800
+ // oracle there the rule is silent and the legs above govern (a stated limit, not an inference).
801
+ const port = Number(f.port);
802
+ if (port > 0) {
803
+ const held = mine.portState?.get(hostKey(f.host));
804
+ const other = theirs.portState?.get(hostKey(f.host));
805
+ if (held?.open.has(port) && other && !other.open.has(port) && !other.closed.has(port)) {
806
+ return { reason: PORT_NOT_MEASURED_REASON,
807
+ detail: `port ${port} on ${f.host} was open in the run that holds this finding, and the other run's port `
808
+ + 'scanner recorded it neither open nor closed — no answer inside its timeout (filtered), or not probed — so '
809
+ + 'the port was not measured there. It is NOT reported as fixed or as new — rescan to compare. (This rule '
810
+ + 'reads TCP ports the port scanner saw open; UDP-only and host-wide findings are not judged by it.)' };
811
+ }
812
+ }
813
+ // ⚠️ NO SILENT SHORT-CIRCUIT. This used to read `mine.frameworks && theirs.frameworks &&
814
+ // f.control`, and all three are absent through the shipped path — so the leg returned null and
815
+ // the finding fell through to `resolved`. That is FAIL-OPEN, the opposite of the `plugin` gap:
816
+ // a finding whose control stopped being enumerated read as REMEDIATED in a client artifact.
817
+ // Whether the oracle exists is now decided ONCE, by `frameworkEnumerationEvaluable`, and its
818
+ // absence is DECLARED in `limits` rather than absorbed here.
819
+ if (frameworkEnumerationEvaluable(mine, theirs) && f.control) {
820
+ const inMine = mine.frameworks.includes(f.control);
821
+ const inTheirs = theirs.frameworks.includes(f.control);
822
+ if (inMine !== inTheirs) {
823
+ return { reason: 'framework-enumeration-changed', detail: `control ${f.control} is enumerated in only one of the two runs` };
824
+ }
825
+ }
826
+ return null;
827
+ }
828
+
829
+ // The oracle test, separated from the per-finding check so its ABSENCE has somewhere to be
830
+ // declared. gate:cascade's LEG (ii) is the precedent this repo already set: when the oracle is
831
+ // missing, print NOT EVALUATED and never pass silently.
832
+ export const FRAMEWORK_MOVEMENT_NOT_EVALUATED =
833
+ 'Coverage-matrix movement between the two runs was NOT EVALUATED: this edition records no '
834
+ + 'framework enumeration on a run, so if a control stopped being enumerated between the two '
835
+ + 'scans, findings that mapped to it may appear as resolved here. Compare the two runs\' '
836
+ + 'framework coverage separately before treating any row as remediation.';
837
+
838
+ export const BASELINE_UNCHAINED =
839
+ 'The baseline carries no integrity digest (it predates chained run records), so alteration of '
840
+ + 'the baseline could not be ruled out.';
841
+
842
+ export const EVIDENCE_GAPS_RECORDED =
843
+ 'One or more EVIDENCE GAPS were recorded. A gap is a surface the scanner could not read, not a '
844
+ + 'finding: gaps are listed under coverage, and any finding a gap covers is reported as '
845
+ + 'not-comparable rather than as resolved.';
846
+
847
+ // The stable head of the collision limit; the colliding identities are appended at the call site.
848
+ export const IDENTITY_COLLAPSE =
849
+ 'Two or more findings collapsed to one identity (same host, plugin, resource, port, rule '
850
+ + 'qualifier and content). Appearance and disappearance of individual instances cannot be '
851
+ + 'distinguished, so A NEW FINDING MAY BE MASKED by a surviving one that shares its identity. '
852
+ + 'Colliding identities: ';
853
+
854
+ // ⚠️ THESE TWO ARE EMITTED BY `scan_delta_view.mjs`, AND THEY LIVE HERE ANYWAY. The vocabulary is
855
+ // one module or it is two copies: the view imports them. Declaring them beside the view that
856
+ // pushes them would put half the census's subject outside the export the census reads.
857
+ export const CURRENT_UNCHAINED =
858
+ 'The current run carries no integrity digest (it predates chained run records), so alteration '
859
+ + 'of the run being reported could not be ruled out.';
860
+
861
+ export const CURRENT_CHAIN_LINK_BROKEN =
862
+ 'The current run record names a predecessor whose bytes no longer exist (the record was deleted '
863
+ + 'or rewritten). The baseline used here was named explicitly, so it is the one that was asked '
864
+ + 'for \u2014 but the run chain covering this comparison is incomplete.';
865
+
866
+
867
+ // ────────────────────────────────────────────────────────────────────────────────────────────
868
+ // THE OUTCOME VOCABULARY — ONE EXPORT, READ BY THE CENSUS, COPIED NOWHERE.
869
+ //
870
+ // ⚠️ WHY THIS EXISTS: a guard can be disarmed by a FIX THAT NARROWS ITS SUBJECT, with nothing
871
+ // failing and nobody editing the guard. It happened twice in two commits in this lane. The census
872
+ // (`tests/delta_outcome_census.test.mjs`) asserts SET EQUALITY between what is declared here and
873
+ // what the SHIPPED entry point actually produces, so a narrowed trigger fails BY NAME. A new
874
+ // outcome joins the census by being written here — incompleteness costs NOISE, never silence.
875
+ // ⚠️ APPEND-ONLY under `SCAN_DELTA_SCHEMA = 1`, exactly as the reason codes are.
876
+ // ────────────────────────────────────────────────────────────────────────────────────────────
877
+
878
+ // How much of a limit's sentence identifies it. A limit has no short code in its own text, so the
879
+ // census probes its HEAD; the self-check refuses two limits whose heads collide.
880
+ export const OUTCOME_PROBE_CHARS = 48;
881
+
882
+ export const DECLARED_LIMITS = Object.freeze({
883
+ 'baseline-unchained': BASELINE_UNCHAINED,
884
+ 'chain-assurance': CHAIN_ASSURANCE_LABEL,
885
+ 'framework-movement-not-evaluated': FRAMEWORK_MOVEMENT_NOT_EVALUATED,
886
+ 'scope-not-evaluated': SCOPE_NOT_EVALUATED,
887
+ 'evidence-gaps-recorded': EVIDENCE_GAPS_RECORDED,
888
+ 'agent-scope-from-tier': AGENT_SCOPE_FROM_TIER,
889
+ 'identity-collapse': IDENTITY_COLLAPSE,
890
+ 'current-unchained': CURRENT_UNCHAINED,
891
+ 'current-chain-link-broken': CURRENT_CHAIN_LINK_BROKEN,
892
+ });
893
+
894
+ const outcome = (species, probe) => Object.freeze({ species, probe });
895
+ export const DECLARED_OUTCOMES = Object.freeze({
896
+ ...Object.fromEntries(NOT_COMPARABLE_REASONS.map((c) => [c, outcome('not-comparable', c)])),
897
+ ...Object.fromEntries([...REFUSAL_REASONS, ...VIEW_REFUSAL_REASONS]
898
+ .map((c) => [c, outcome('refusal', c)])),
899
+ ...Object.fromEntries(Object.entries(DECLARED_LIMITS)
900
+ .map(([c, text]) => [c, outcome('limit', text.slice(0, OUTCOME_PROBE_CHARS))])),
901
+ });
902
+
903
+ // ⚠️ NOT A WAIVER, AND NOT A BARE ALLOWLIST. Each member names the limit that DISCLOSES its
904
+ // absence, and the census verifies that limit is actually produced — the same premise-checking
905
+ // discipline as `DECLARED_ABSENT_FINDING_FIELDS` above, for the same reason: a carve-out whose
906
+ // premise nobody re-checks is how an absence becomes a silent pass.
907
+ export const DECLARED_UNREACHABLE_OUTCOMES = Object.freeze({
908
+ 'run-record-schema-differs': {
909
+ reason: 'An EARLIER layer refuses first and this branch cannot be reached. `loadRun` pins the '
910
+ + 'run-record schema to the one this build understands and refuses any other outright, so two '
911
+ + 'records that both LOAD always agree on schema. The guard stays because it is the library '
912
+ + 'contract for a caller that builds a delta without the loader.',
913
+ instead: 'this build understands schema',
914
+ },
915
+ 'framework-enumeration-changed': {
916
+ reason: 'Reaching this needs a framework enumeration on BOTH runs and a `control` on a finding. '
917
+ + 'Community ships no compliance data, `report_inputs.mjs` emits no control id, and '
918
+ + '`scan_delta_view.mjs` passes no frameworkEnumeration to buildScanDelta — so no CE run can '
919
+ + 'produce it. It stays declared because EE routing supplies exactly these inputs, and the '
920
+ + 'engine must refuse rather than report a de-enumerated control as remediated.',
921
+ disclosedBy: 'framework-movement-not-evaluated',
922
+ },
923
+ });
924
+
925
+ function frameworkEnumerationEvaluable(mine, theirs) {
926
+ return Boolean(mine?.frameworks && theirs?.frameworks);
927
+ }
928
+
929
+ // Hosts are SHOWN as recorded: the maps are keyed by `hostKey`, and `hostNames` carries the spelling.
930
+ const shownHost = (scope, k) => scope.hostNames?.get(k) ?? k;
931
+
932
+ const statementList = (scope) => [...scope.statements.entries()].flatMap(([k, titles]) => {
933
+ const i = k.indexOf('|');
934
+ return titles.map((title) => ({ host: shownHost(scope, k.slice(0, i)), plugin: k.slice(i + 1), title }));
935
+ });
936
+
937
+ const gapList = (scope) => [...scope.gaps.entries()].map(([k, g]) => {
938
+ const i = k.indexOf('|');
939
+ return { host: shownHost(scope, k.slice(0, i)), plugin: k.slice(i + 1), kind: g.kind, reason: g.reason };
940
+ });
941
+
942
+ export function buildScanDelta({ baseline, current }) {
943
+ const limits = [];
944
+ const refuse = (reason, detail) => ({
945
+ schema: SCAN_DELTA_SCHEMA, comparable: false, refusal: { reason, detail },
946
+ newFindings: [], resolved: [], unchanged: [], changed: [], notComparable: [],
947
+ baselineIntegrity: null, currentIntegrity: null, limits: [detail],
948
+ });
949
+
950
+ // ── Whole-comparison refusals. Each is a case where NO per-finding verdict is trustworthy.
951
+ const bEE = baseline?.record?.eeVersion ?? null;
952
+ const cEE = current?.record?.eeVersion ?? null;
953
+ if ((bEE === null) !== (cEE === null)) {
954
+ return refuse('ee-presence-differs',
955
+ 'one run carries Enterprise findings and the other does not, so the two populations are not the same population');
956
+ }
957
+ if (bEE && cEE) {
958
+ const bPre = cmpVersion(bEE, FINDING_COUNT_BOUNDARY_EE) <= 0;
959
+ const cPre = cmpVersion(cEE, FINDING_COUNT_BOUNDARY_EE) <= 0;
960
+ if (bPre !== cPre) {
961
+ return refuse('finding-count-semantics-boundary',
962
+ `EE ${bEE} and EE ${cEE} sit on opposite sides of the ${FINDING_COUNT_BOUNDARY_EE} findingCount semantics boundary ` +
963
+ '(contract-v1 §5.3): before it the key carried the ISSUE count, so a comparison across it shows a fall that is a ' +
964
+ 'correction and not remediation');
965
+ }
966
+ }
967
+ // ⚠️ TIER IS A STATEMENT ABOUT THE PRODUCER POPULATION, not about how much detail a report
968
+ // shows. `agents/agent_runner.mjs` runs the agents the run's CAPABILITIES license, so an
969
+ // enterprise baseline compared against a pro current is missing an entire producer — and every
970
+ // finding that producer found would read as REMEDIATION. This refuses rather than annotating,
971
+ // for the same reason `ee-presence-differs` does: the two populations are not the same
972
+ // population, and no per-finding verdict across them is trustworthy.
973
+ const bTier = baseline?.record?.tier ?? null;
974
+ const cTier = current?.record?.tier ?? null;
975
+ if (bTier !== cTier) {
976
+ return refuse('tier-differs',
977
+ `the baseline ran at tier "${bTier}" and the current run at tier "${cTier}": the licensed `
978
+ + 'producer set differs between them, so findings absent from the narrower run may simply '
979
+ + 'never have been looked for');
980
+ }
981
+ if ((baseline?.record?.schema ?? null) !== (current?.record?.schema ?? null)) {
982
+ return refuse('run-record-schema-differs',
983
+ `run record schema ${baseline?.record?.schema} vs ${current?.record?.schema}: the two records do not describe coverage the same way`);
984
+ }
985
+
986
+ // ── Baseline integrity. A broken chain means the baseline on disk is not the baseline that was
987
+ // written, so every verdict derived from it is unsound — refuse rather than annotate.
988
+ const baselineIntegrity = baseline?.integrity ?? 'chain-absent';
989
+ // ⚠️ THE CURRENT RUN IS ALTERABLE TOO, and until T3 nothing could verify it. A basis that names
990
+ // only the baseline describes half the evidence the verdict rests on. Unlike the baseline this
991
+ // one is never a refusal HERE — the view refuses on it before the engine is called, because the
992
+ // engine is deliberately filesystem-free and integrity is a fact about bytes on disk.
993
+ const currentIntegrity = current?.integrity ?? null;
994
+ if (baselineIntegrity === 'chain-broken') {
995
+ return refuse('baseline-chain-broken',
996
+ 'the baseline run record does not match its recorded digest: it was altered, truncated or partially restored after the run');
997
+ }
998
+ // ⚠️ COULD-NOT-MEASURE IS NEVER A PASS. `chain-unreadable` is not `chain-absent`: absent means
999
+ // the record predates chaining and we know that; unreadable means the instrument failed and we
1000
+ // know nothing. Letting the second one through would be a verdict from a run that measured nothing.
1001
+ if (baselineIntegrity === 'chain-unreadable') {
1002
+ return refuse('baseline-integrity-unmeasurable',
1003
+ 'the baseline integrity digest could not be read, so alteration of the baseline could neither be confirmed nor ruled out');
1004
+ }
1005
+ if (baselineIntegrity === 'chain-absent') {
1006
+ limits.push(BASELINE_UNCHAINED);
1007
+ }
1008
+ limits.push(CHAIN_ASSURANCE_LABEL);
1009
+ // Declared once for the whole comparison, not per finding: it is a property of the two RUNS.
1010
+ if (!frameworkEnumerationEvaluable(scopeOf(baseline), scopeOf(current))) {
1011
+ limits.push(FRAMEWORK_MOVEMENT_NOT_EVALUATED);
1012
+ }
1013
+
1014
+ const bScope = scopeOf(baseline);
1015
+ const cScope = scopeOf(current);
1016
+ if (!bScope.evaluable || !cScope.evaluable) limits.push(SCOPE_NOT_EVALUATED);
1017
+
1018
+ // ⚠️ A GAP RECORD IS SCOPE AND MUST NOT BE BUCKETED AS A FINDING. It has a severity, a title and
1019
+ // — since the producer identity landed — a plugin, so it is comparable like anything else and
1020
+ // would otherwise arrive in the client's NEW EXPOSURES table as an INFO row reading
1021
+ // "Evidence gap (…)". It has already been read INTO `scopeOf` above, which is its whole job:
1022
+ // it explains why its neighbours are not comparable. Measured before the exclusion: the
1023
+ // `gap-leg` scenario read `1 new · 1 resolved` where the honest answer is `0 new · 0 resolved`.
1024
+ const isGap = (f) => f.evidenceGap === true;
1025
+ // A declared scope boundary is set aside the same way (read into `scopeOf` as a statement); a row
1026
+ // flagged as both is a gap, which the line above already removes.
1027
+ const isScopeStatement = (f) => f.deferredScope === true;
1028
+ const bFind = (baseline?.findings ?? []).filter((f) => !isGap(f) && !isScopeStatement(f));
1029
+ const cFind = (current?.findings ?? []).filter((f) => !isGap(f) && !isScopeStatement(f));
1030
+ if ((baseline?.findings ?? []).some(isGap) || (current?.findings ?? []).some(isGap)) {
1031
+ // ⚠️ THE WORDING AVOIDS THE LITERAL BUCKET NAME ON PURPOSE. `scripts/board_probe_delta_driver.mjs`
1032
+ // harvests reasons by matching that phrase against every output line, so a LIMIT containing it
1033
+ // is read back as if it were a per-finding reason — an instrument the next seat reads,
1034
+ // reporting a bucket that no finding is in.
1035
+ limits.push(EVIDENCE_GAPS_RECORDED);
1036
+ }
1037
+ if ((baseline?.findings ?? []).some((f) => f.producerKind === 'agent')
1038
+ || (current?.findings ?? []).some((f) => f.producerKind === 'agent')) {
1039
+ limits.push(AGENT_SCOPE_FROM_TIER);
1040
+ }
1041
+ const bMap = new Map(bFind.map((f) => [keyOf(f), f]));
1042
+ const cMap = new Map(cFind.map((f) => [keyOf(f), f]));
1043
+ // ⚠️ THE LIMIT NAMES THE DIRECTION, because the previous wording ("per-instance deltas are not
1044
+ // distinguished") read as a granularity note when the real consequence is a missing exposure.
1045
+ if (bMap.size !== bFind.length || cMap.size !== cFind.length) {
1046
+ // ⚠️ THE LIMIT NAMES EACH COLLIDING IDENTITY. A generic disclosure tells a reader that SOME
1047
+ // finding may be masked and gives them nothing to act on; the whole point is that they can go
1048
+ // and look at the one that collided.
1049
+ const collide = [];
1050
+ for (const [side, list] of [['baseline', bFind], ['current', cFind]]) {
1051
+ const seen = new Map();
1052
+ for (const f of list) {
1053
+ const k = keyOf(f);
1054
+ seen.set(k, (seen.get(k) ?? 0) + 1);
1055
+ }
1056
+ for (const f of list) {
1057
+ const k = keyOf(f);
1058
+ if ((seen.get(k) ?? 0) > 1 && !collide.some((c) => c.k === k)) {
1059
+ // ⚠️ THE REGION IS NAMED WHEN THERE IS NO RESOURCE (board E1). This limit exists so a
1060
+ // reader can ACT on a collision, which means it has to say WHICH object collided. The
1061
+ // population that collides most is the one whose producer emits no resource at all —
1062
+ // and before E1 those findings borrowed the REGION as their resource, so the limit
1063
+ // read `us-east-1` and looked actionable. E1 correctly stops that borrowing, which
1064
+ // would have left this limit saying `no resource` and nothing else. The region is a
1065
+ // field now, so it is named as what it is rather than impersonating an object id.
1066
+ const where = f.resource ?? (f.region ? `no resource · region ${f.region}` : 'no resource');
1067
+ collide.push({ k, text: `${side}: plugin ${f.plugin} · ${where} · `
1068
+ + `"${String(f.title ?? '').slice(0, 80)}" ×${seen.get(k)}` });
1069
+ }
1070
+ }
1071
+ }
1072
+ limits.push(IDENTITY_COLLAPSE + collide.map((c) => c.text).join(' | '));
1073
+ }
1074
+
1075
+ const resolved = [];
1076
+ const newFindings = [];
1077
+ const unchanged = [];
1078
+ const changed = [];
1079
+ const notComparable = [];
1080
+
1081
+ for (const [k, f] of bMap) {
1082
+ if (cMap.has(k)) {
1083
+ // Matched on identity — so a severity move is the SAME finding getting worse or better,
1084
+ // which is the trend report's actual deliverable. Never a resolution plus a new finding.
1085
+ const now = cMap.get(k);
1086
+ if ((f.severity ?? null) !== (now.severity ?? null)) changed.push({ ...now, from: f.severity ?? null, to: now.severity ?? null });
1087
+ else unchanged.push(f);
1088
+ continue;
1089
+ }
1090
+ const why = incomparabilityReason(f, bScope, cScope);
1091
+ if (why) notComparable.push({ ...f, direction: 'disappeared', ...why });
1092
+ else resolved.push(f);
1093
+ }
1094
+ for (const [k, f] of cMap) {
1095
+ if (bMap.has(k)) continue;
1096
+ // The mirror, and it is not symmetric in danger: a finding "new" only because its host or
1097
+ // plugin was out of scope last time is NEW COVERAGE, not a new exposure.
1098
+ const why = incomparabilityReason(f, cScope, bScope);
1099
+ if (why) notComparable.push({ ...f, direction: 'appeared', ...why });
1100
+ else newFindings.push(f);
1101
+ }
1102
+
1103
+ return {
1104
+ schema: SCAN_DELTA_SCHEMA, comparable: true, refusal: null,
1105
+ newFindings, resolved, unchanged, changed, notComparable,
1106
+ baselineIntegrity, currentIntegrity, limits,
1107
+ coverage: {
1108
+ hostsOnlyInBaseline: [...bScope.hosts].filter((h) => !cScope.hosts.has(h)).map((h) => shownHost(bScope, h)),
1109
+ hostsOnlyInCurrent: [...cScope.hosts].filter((h) => !bScope.hosts.has(h)).map((h) => shownHost(cScope, h)),
1110
+ pluginsOnlyInBaseline: [...bScope.plugins].filter((p) => !cScope.plugins.has(p)),
1111
+ pluginsOnlyInCurrent: [...cScope.plugins].filter((p) => !bScope.plugins.has(p)),
1112
+ // The gaps themselves, named. A reader who sees a NOT-COMPARABLE row needs to be able to
1113
+ // find out WHICH surface was unreadable without reading the raw envelope.
1114
+ gapsInBaseline: gapList(bScope),
1115
+ gapsInCurrent: gapList(cScope),
1116
+ // And the boundaries each run DECLARED — named, never paired: what a producer says it does not
1117
+ // examine is a fact about its scope, not a finding that can be fixed or appear.
1118
+ scopeStatementsInBaseline: statementList(bScope),
1119
+ scopeStatementsInCurrent: statementList(cScope),
1120
+ },
1121
+ };
1122
+ }