@clear-capabilities/agentic-security-scanner 0.145.0 → 0.147.5
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.
- package/CHANGELOG.md +266 -0
- package/bin/agentic-security.js +3508 -69
- package/dist/1122.index.js +779 -0
- package/dist/{301.index.js → 1301.index.js} +2 -2
- package/dist/1379.index.js +591 -0
- package/dist/{444.index.js → 1444.index.js} +2 -2
- package/dist/{660.index.js → 1660.index.js} +2 -2
- package/dist/{700.index.js → 1700.index.js} +2 -2
- package/dist/{905.index.js → 1905.index.js} +3 -3
- package/dist/{920.index.js → 1920.index.js} +3 -3
- package/dist/{238.index.js → 2238.index.js} +3 -3
- package/dist/{271.index.js → 2271.index.js} +2 -2
- package/dist/{985.index.js → 2376.index.js} +1260 -340
- package/dist/2432.index.js +793 -0
- package/dist/2659.index.js +93 -0
- package/dist/{826.index.js → 2826.index.js} +2 -2
- package/dist/{830.index.js → 2830.index.js} +2 -2
- package/dist/2923.index.js +298 -0
- package/dist/{1.index.js → 3001.index.js} +5 -5
- package/dist/{117.index.js → 3117.index.js} +3 -3
- package/dist/3180.index.js +379 -0
- package/dist/3276.index.js +117 -0
- package/dist/{415.index.js → 3415.index.js} +2 -2
- package/dist/{499.index.js → 3499.index.js} +2 -2
- package/dist/3518.index.js +450 -0
- package/dist/{526.index.js → 3526.index.js} +5 -5
- package/dist/{736.index.js → 3736.index.js} +4 -4
- package/dist/{839.index.js → 3839.index.js} +4 -4
- package/dist/{113.index.js → 4113.index.js} +5 -5
- package/dist/{265.index.js → 4265.index.js} +2 -2
- package/dist/{384.index.js → 4384.index.js} +3 -3
- package/dist/4547.index.js +268 -0
- package/dist/4863.index.js +422 -0
- package/dist/{970.index.js → 4970.index.js} +2 -2
- package/dist/5051.index.js +841 -0
- package/dist/{144.index.js → 5144.index.js} +5 -5
- package/dist/{333.index.js → 5333.index.js} +3 -3
- package/dist/5343.index.js +185 -0
- package/dist/5350.index.js +866 -0
- package/dist/5561.index.js +436 -0
- package/dist/{637.index.js → 5637.index.js} +3 -3
- package/dist/{449.index.js → 5830.index.js} +2 -2
- package/dist/6626.index.js +532 -0
- package/dist/6662.index.js +297 -0
- package/dist/{675.index.js → 6675.index.js} +5 -5
- package/dist/{730.index.js → 6730.index.js} +6 -6
- package/dist/6829.index.js +225 -0
- package/dist/6944.index.js +130 -0
- package/dist/{178.index.js → 7178.index.js} +3 -3
- package/dist/{227.index.js → 7227.index.js} +2 -2
- package/dist/7310.index.js +520 -0
- package/dist/{552.index.js → 7552.index.js} +4 -4
- package/dist/7709.index.js +78 -0
- package/dist/8218.index.js +160 -0
- package/dist/{476.index.js → 8476.index.js} +4 -4
- package/dist/{513.index.js → 8513.index.js} +5 -5
- package/dist/{520.index.js → 8520.index.js} +2 -2
- package/dist/{718.index.js → 8718.index.js} +2 -2
- package/dist/{752.index.js → 8752.index.js} +2 -2
- package/dist/8846.index.js +100 -0
- package/dist/{435.index.js → 9091.index.js} +560 -189
- package/dist/{207.index.js → 9207.index.js} +2 -2
- package/dist/{220.index.js → 9220.index.js} +2 -2
- package/dist/9390.index.js +163 -0
- package/dist/{503.index.js → 9503.index.js} +2 -2
- package/dist/{801.index.js → 9801.index.js} +2 -2
- package/dist/{824.index.js → 9824.index.js} +2 -2
- package/dist/agentic-security.mjs +16 -16
- package/dist/agentic-security.mjs.sha256 +1 -1
- package/dist/compliance-frameworks/hipaa-security-rule.json +3 -2
- package/dist/frontend/index.html +21 -0
- package/dist/frontend/src/app.js +176 -0
- package/dist/frontend/src/components/evidence-inspector.js +141 -0
- package/dist/frontend/src/components/filter-rail.js +119 -0
- package/dist/frontend/src/components/query-bar.js +126 -0
- package/dist/frontend/src/data/flagship-graph.js +1460 -0
- package/dist/frontend/src/export-entry.js +36 -0
- package/dist/frontend/src/lib/api-client.js +92 -0
- package/dist/frontend/src/lib/contrast.js +34 -0
- package/dist/frontend/src/lib/dom.js +24 -0
- package/dist/frontend/src/lib/escape-html.js +16 -0
- package/dist/frontend/src/lib/flow-path.js +40 -0
- package/dist/frontend/src/lib/focus-controls.js +149 -0
- package/dist/frontend/src/lib/protection-visual.js +46 -0
- package/dist/frontend/src/lib/query-language.js +240 -0
- package/dist/frontend/src/lib/row-filters.js +43 -0
- package/dist/frontend/src/lib/state.js +84 -0
- package/dist/frontend/src/main.js +83 -0
- package/dist/frontend/src/shell.js +184 -0
- package/dist/frontend/src/views/architecture-view.js +798 -0
- package/dist/frontend/src/views/inventory-view.js +292 -0
- package/dist/frontend/src/views/privacy-view.js +172 -0
- package/dist/frontend/src/views/trace-view.js +206 -0
- package/dist/frontend/styles/architecture-view.css +93 -0
- package/dist/frontend/styles/filter-rail.css +34 -0
- package/dist/frontend/styles/inspector.css +69 -0
- package/dist/frontend/styles/inventory-view.css +74 -0
- package/dist/frontend/styles/privacy-view.css +86 -0
- package/dist/frontend/styles/query-bar.css +107 -0
- package/dist/frontend/styles/shell.css +155 -0
- package/dist/frontend/styles/tokens.css +128 -0
- package/dist/frontend/styles/trace-view.css +95 -0
- package/package.json +13 -6
- package/src/dataflow/CLAUDE.md +1 -1
- package/src/dataflow/catalog.js +42 -0
- package/src/dataflow/orm-write-catalog.js +175 -0
- package/src/engine.js +92 -1
- package/src/ir/CLAUDE.md +1 -0
- package/src/ir/chrome-probe.mjs +150 -0
- package/src/ir/parser-js.js +94 -7
- package/src/lineage/CLAUDE.md +1203 -0
- package/src/lineage/DESIGN_DESTINATION_RESOLVER.md +156 -0
- package/src/lineage/DESIGN_GRAPH_BUILDER.md +938 -0
- package/src/lineage/DESIGN_HANDLING_ANALYZER.md +355 -0
- package/src/lineage/DESIGN_INTRAPROCEDURAL.md +628 -0
- package/src/lineage/DESIGN_PATH_PROVENANCE.md +3451 -0
- package/src/lineage/DESIGN_QUEUE_DETAIL.md +120 -0
- package/src/lineage/DESIGN_REGISTRIES.md +880 -0
- package/src/lineage/DESIGN_STORE_DETAIL.md +143 -0
- package/src/lineage/DESIGN_TRANSIT_PROTECTION.md +245 -0
- package/src/lineage/classification.js +56 -0
- package/src/lineage/coverage.js +658 -0
- package/src/lineage/cross-repo-link.js +107 -0
- package/src/lineage/dataflow-graph.schema.json +184 -0
- package/src/lineage/decision-story.js +206 -0
- package/src/lineage/drift-policy.js +279 -0
- package/src/lineage/driver.js +135 -0
- package/src/lineage/engine.js +992 -0
- package/src/lineage/export-briefing.js +628 -0
- package/src/lineage/export-csv.js +62 -0
- package/src/lineage/export-json.js +238 -0
- package/src/lineage/export-privacy.js +258 -0
- package/src/lineage/federation-loader.js +111 -0
- package/src/lineage/field-identity.js +78 -0
- package/src/lineage/fixtures/build-flagship-fixture.mjs +272 -0
- package/src/lineage/fixtures/flagship-graph.json +1453 -0
- package/src/lineage/flow-grade.js +221 -0
- package/src/lineage/governance-edit.js +169 -0
- package/src/lineage/graph-builder.js +1114 -0
- package/src/lineage/graph-diff.js +431 -0
- package/src/lineage/graph-snapshot.js +180 -0
- package/src/lineage/handling-analyzer.js +168 -0
- package/src/lineage/ids.js +349 -0
- package/src/lineage/impact-assessment.js +76 -0
- package/src/lineage/impact-engine.js +268 -0
- package/src/lineage/index.js +281 -0
- package/src/lineage/language-coverage-tiers.js +58 -0
- package/src/lineage/obligation-mapping.js +126 -0
- package/src/lineage/obligation-predicates.js +235 -0
- package/src/lineage/observation-adapters.js +282 -0
- package/src/lineage/observation-correlation.js +622 -0
- package/src/lineage/observation-store.js +497 -0
- package/src/lineage/path-query.js +410 -0
- package/src/lineage/path-store.js +400 -0
- package/src/lineage/protection.js +53 -0
- package/src/lineage/recipient-profile.js +192 -0
- package/src/lineage/recipient-registry.js +394 -0
- package/src/lineage/redact-graph.js +224 -0
- package/src/lineage/remediation.js +417 -0
- package/src/lineage/resolve-destination.js +91 -0
- package/src/lineage/runtime-observation.js +464 -0
- package/src/lineage/scenario-diff.js +84 -0
- package/src/lineage/scenario-engine.js +251 -0
- package/src/lineage/scenario.js +101 -0
- package/src/lineage/schema.js +167 -0
- package/src/lineage/sink-registry.js +427 -0
- package/src/lineage/source-registry.js +357 -0
- package/src/lineage/source-seeding.js +212 -0
- package/src/lineage/summaries.js +590 -0
- package/src/lineage/transform-catalog.js +397 -0
- package/src/lineage/transit-protection.js +150 -0
- package/src/lineage/validate.js +285 -0
- package/src/mcp/CLAUDE.md +7 -1
- package/src/mcp/dataflow-tools.js +160 -0
- package/src/mcp/server.js +1 -1
- package/src/mcp/tools.js +2 -1
- package/src/pipeline/scan-health.js +19 -1
- package/src/posture/CLAUDE.md +19 -0
- package/src/posture/artifact-registry.js +52 -0
- package/src/posture/auditor-walkthrough.js +76 -0
- package/src/posture/compliance-frameworks/hipaa-security-rule.json +3 -2
- package/src/posture/obligation-evidence-pack.js +202 -0
- package/src/posture/remediation-ledger.js +337 -0
- package/src/server/CLAUDE.md +47 -0
- package/src/server/graph-loader.js +141 -0
- package/src/server/http-server.js +325 -0
- package/src/server/routes.js +129 -0
- package/src/server/security.js +111 -0
- package/src/server/static-assets.js +144 -0
- package/src/shared/frontend-root.js +52 -0
- package/dist/11.index.js +0 -353
- package/dist/259.index.js +0 -975
- package/dist/317.index.js +0 -300
- package/dist/609.index.js +0 -741
- package/dist/838.index.js +0 -152
|
@@ -0,0 +1,464 @@
|
|
|
1
|
+
// runtime-observation.js — the RuntimeObservation §10.10 extension contract
|
|
2
|
+
// (M5 deliverable #7, "Runtime-Corroborated Digital Twin" — this deliverable
|
|
3
|
+
// ships ONLY the runtime-observed half, "7b"; the config-declared half "7a"
|
|
4
|
+
// is out of scope for this whole sub-project — see the scoping doc's §4.0).
|
|
5
|
+
// FR-505, AC-29 ("Runtime observation remains metadata-only and
|
|
6
|
+
// non-exclusionary" — the Milestone 5 exit-gate clause this module is
|
|
7
|
+
// gated on). Pure by contract: zero imports, no fs, no I/O, never throws —
|
|
8
|
+
// mirrors `impact-assessment.js`'s/`flow-grade.js`'s own precedent exactly.
|
|
9
|
+
//
|
|
10
|
+
// ── Why this module is CLOSED-WORLD when every sibling §10.10 contract is
|
|
11
|
+
// open-world ──────────────────────────────────────────────────────────
|
|
12
|
+
//
|
|
13
|
+
// Every other extension-contract validator in this package
|
|
14
|
+
// (`impact-assessment.js`, `recipient-profile.js`, `scenario.js`,
|
|
15
|
+
// `obligation-mapping.js`, `graph-snapshot.js`) is open-world: it checks
|
|
16
|
+
// that the fields it cares about are well-formed and is silent about
|
|
17
|
+
// anything else on the record. That is the right default for a record
|
|
18
|
+
// built from the scanner's OWN already-vetted graph content.
|
|
19
|
+
//
|
|
20
|
+
// A RuntimeObservation is different in kind: it is built from an OPERATOR-
|
|
21
|
+
// SUPPLIED telemetry export (OpenTelemetry spans, access logs, a queue's
|
|
22
|
+
// own delivery metadata) that this codebase never generated and cannot
|
|
23
|
+
// vouch for. PRD line 983 states the requirement directly: "Runtime
|
|
24
|
+
// records use approved metadata schemas and **reject** fields capable of
|
|
25
|
+
// carrying payload values." AC-29 clause 5 restates it as the acceptance
|
|
26
|
+
// bar: "no captured payload, prompt, response, record, log message, or
|
|
27
|
+
// sensitive value exists in the observation artifact." Both are REJECT
|
|
28
|
+
// requirements, not "redact" or "best-effort" requirements — an open-world
|
|
29
|
+
// validator that merely checks the fields it recognizes and ignores the
|
|
30
|
+
// rest would satisfy neither: any attribute name nobody thought to name in
|
|
31
|
+
// advance sails through unexamined, and a telemetry payload field is
|
|
32
|
+
// exactly the kind of key nobody names in advance (a caller controls the
|
|
33
|
+
// export, not this codebase). A scrub-known-bad DENYLIST has the identical
|
|
34
|
+
// failure mode one level down — it fails open on every attribute name
|
|
35
|
+
// nobody thought of. So this module allowlists both the top-level record
|
|
36
|
+
// shape (`RUNTIME_OBSERVATION_FIELDS`) and the attribute-key vocabulary
|
|
37
|
+
// (`RUNTIME_ATTRIBUTE_KEYS`), and rejects anything outside either. A future
|
|
38
|
+
// "simplification" that widens either sweep from "reject unknown" to
|
|
39
|
+
// "ignore unknown" is a silent AC-29 falsification, not a cleanup — it
|
|
40
|
+
// would let an unreviewed payload-shaped field or attribute key pass
|
|
41
|
+
// straight through this artifact.
|
|
42
|
+
//
|
|
43
|
+
// ── Reports, never mutates ─────────────────────────────────────────────
|
|
44
|
+
//
|
|
45
|
+
// `validateRuntimeObservation`/`validateObservationAttributes` never
|
|
46
|
+
// mutate, delete, or scrub their input — they return `{valid, errors}` and
|
|
47
|
+
// leave the record untouched (RO/4h). A scrubbing validator would let a
|
|
48
|
+
// caller persist the SCRUBBED REMAINDER as though it had been clean all
|
|
49
|
+
// along, silently laundering a rejected record into an accepted one one
|
|
50
|
+
// call site downstream. Reject-and-report keeps that decision visible to
|
|
51
|
+
// the caller, matching PRD line 983's own "reject" wording rather than an
|
|
52
|
+
// implied "redact".
|
|
53
|
+
//
|
|
54
|
+
// ── eventCountBand is a band, not a count ──────────────────────────────
|
|
55
|
+
//
|
|
56
|
+
// PRD line 971 specifies a "count/frequency band," not a raw number — an
|
|
57
|
+
// exact event count is itself a weak information channel (it can leak
|
|
58
|
+
// approximate traffic volume/business metrics through an artifact whose
|
|
59
|
+
// only job is "this destination was or wasn't observed"). `EVENT_COUNT_BANDS`
|
|
60
|
+
// is therefore a closed set of coarse buckets; `validateRuntimeObservation`
|
|
61
|
+
// rejects any numeric or free-form value in that field, banded or not.
|
|
62
|
+
//
|
|
63
|
+
// ── 'ambiguous' is enforced, not just documented ───────────────────────
|
|
64
|
+
//
|
|
65
|
+
// FR-505 requires that an ambiguous observation (more than one candidate
|
|
66
|
+
// node matched) remain a CANDIDATE and never silently merge distinct data
|
|
67
|
+
// elements. This module enforces that as a real cross-field rule (RO/6f):
|
|
68
|
+
// `matchedNodeIds.length > 1` REQUIRES `matchConfidence === 'ambiguous'` —
|
|
69
|
+
// a caller cannot report two candidate nodes at `'high'`/`'medium'`/`'low'`
|
|
70
|
+
// confidence, which would read as a confident but silently-arbitrary pick
|
|
71
|
+
// between them.
|
|
72
|
+
//
|
|
73
|
+
// ── OBSERVATION_LAYERS is three-valued on purpose ──────────────────────
|
|
74
|
+
//
|
|
75
|
+
// PRD line 2098 forbids treating "not observed" as "not occurring" — an
|
|
76
|
+
// unobserved flow may simply be outside the telemetry window, not absent
|
|
77
|
+
// from the system. `OBSERVATION_LAYERS` therefore keeps
|
|
78
|
+
// `not_observed_in_window` (evaluated, genuinely absent from the evidence)
|
|
79
|
+
// and `not_evaluated` (no attempt was made at all) as two GENUINELY
|
|
80
|
+
// DIFFERENT answers — the same distinction `flow.policyVerdict`'s own
|
|
81
|
+
// `not_evaluated` value already draws for a missing `privacy-policy.json`
|
|
82
|
+
// (Milestone 2, Sub-project G). Collapsing the two into one "no runtime
|
|
83
|
+
// evidence" bucket would silently misrepresent an unevaluated flow as one
|
|
84
|
+
// the operator actually checked and found nothing for.
|
|
85
|
+
//
|
|
86
|
+
// ── Scope: 7b only ──────────────────────────────────────────────────────
|
|
87
|
+
//
|
|
88
|
+
// This deliverable ships the RUNTIME-OBSERVED half of the Digital Twin
|
|
89
|
+
// only. The CONFIG-DECLARED half (7a — inferring an edge from IaC/config
|
|
90
|
+
// rather than from executed code) is Milestone 2 Sub-project F2/F3's own
|
|
91
|
+
// job (scoping doc §4.0) and is explicitly out of scope here — nothing in
|
|
92
|
+
// this module mints a non-code-derived edge or otherwise touches
|
|
93
|
+
// `edge.provenance`.
|
|
94
|
+
//
|
|
95
|
+
// ── Disclosed, accepted residual: short-word array smuggling ──────────
|
|
96
|
+
//
|
|
97
|
+
// Even with schema.attributeNames' own stricter per-element grammar
|
|
98
|
+
// (letters/digits/underscore only, no ':'/'.'/'-', 8 elements max), an
|
|
99
|
+
// adversarial or misconfigured exporter can still supply up to 8 short,
|
|
100
|
+
// individually-plausible-looking single words as separate array elements
|
|
101
|
+
// (e.g. a handful of English words split one-per-element). This is an
|
|
102
|
+
// INHERENT limitation of any bounded, per-element, character-class-only
|
|
103
|
+
// grammar with no semantic/natural-language-detection layer — the same
|
|
104
|
+
// class of residual as a base64-encoded secret hidden inside an
|
|
105
|
+
// otherwise-valid single value (also disclosed, also not attempted here).
|
|
106
|
+
// A real fix needs prose/sentence detection across the whole array, which
|
|
107
|
+
// is expensive and fuzzy by nature — explicitly out of scope for this
|
|
108
|
+
// deliverable. What this round's fix DOES close completely: every
|
|
109
|
+
// key:value-shaped smuggling case (a colon-structured secret like
|
|
110
|
+
// "password:hunter2"), which is what turned the original live repro into
|
|
111
|
+
// an unambiguous, directly-readable credential/PII leak rather than a mere
|
|
112
|
+
// word list. Operators integrating an automated telemetry exporter into an
|
|
113
|
+
// unattended `dataflow observations import` pipeline should still audit
|
|
114
|
+
// what that exporter actually sends.
|
|
115
|
+
//
|
|
116
|
+
// ── Data/artifact layer only ────────────────────────────────────────────
|
|
117
|
+
//
|
|
118
|
+
// AC-29's clauses are satisfied here at the data/artifact layer alone — no
|
|
119
|
+
// UI displays a RuntimeObservation record yet (scoping doc §4.7). A later
|
|
120
|
+
// UI increment inherits five properties from this contract it must not
|
|
121
|
+
// break: (1) closed-world field/attribute allowlisting, (2) report-not-
|
|
122
|
+
// mutate validation, (3) banded (never exact) event counts, (4) enforced
|
|
123
|
+
// (never advisory) ambiguity handling, (5) the three-valued observation
|
|
124
|
+
// layer. Breaking any of those at the UI layer would reopen exactly the
|
|
125
|
+
// gap this module exists to close.
|
|
126
|
+
|
|
127
|
+
export const RUNTIME_OBSERVATION_VERSION = '1.0.0';
|
|
128
|
+
|
|
129
|
+
// The 18 allowed top-level keys of a RuntimeObservation record — the
|
|
130
|
+
// closed-world sweep in `validateRuntimeObservation` rejects anything
|
|
131
|
+
// outside this set and requires everything inside it.
|
|
132
|
+
export const RUNTIME_OBSERVATION_FIELDS = Object.freeze([
|
|
133
|
+
'id', 'version', 'adapter', 'source', 'environment', 'windowStart', 'windowEnd',
|
|
134
|
+
'matchedNodeIds', 'matchedEdgeIds', 'matchedFlowIds', 'attributes', 'eventCountBand',
|
|
135
|
+
'firstObservedAt', 'lastObservedAt', 'matchMethod', 'matchConfidence', 'retention', 'importedAt',
|
|
136
|
+
]);
|
|
137
|
+
|
|
138
|
+
// Only one adapter is actually implemented today — an unimplemented
|
|
139
|
+
// adapter name must be rejected, not accepted on faith that it will
|
|
140
|
+
// someday exist (RO/3b).
|
|
141
|
+
export const RUNTIME_OBSERVATION_ADAPTERS = Object.freeze(['native-jsonl']);
|
|
142
|
+
|
|
143
|
+
// FR-505's own four named metadata families: service/workload identity,
|
|
144
|
+
// endpoint/destination identity, protocol/TLS metadata, and schema/
|
|
145
|
+
// attribute NAMES already approved for telemetry (never attribute VALUES,
|
|
146
|
+
// which is exactly the payload channel AC-29 forbids).
|
|
147
|
+
export const RUNTIME_ATTRIBUTE_KEYS = Object.freeze([
|
|
148
|
+
// service/workload identity
|
|
149
|
+
'service.name', 'service.namespace', 'service.version', 'service.instance.id',
|
|
150
|
+
'workload.name', 'workload.kind',
|
|
151
|
+
// endpoint or destination identity — 'destination.path' REMOVED (final
|
|
152
|
+
// review B1): it had zero consumers anywhere in this codebase and is a
|
|
153
|
+
// pure payload-smuggling channel (a URL path/query string can carry
|
|
154
|
+
// arbitrary key=value content by construction) — the module's own
|
|
155
|
+
// stated design ("REJECT, not redact") is best served by not accepting
|
|
156
|
+
// a field with no analytic value at all, rather than trying to sanitize
|
|
157
|
+
// one.
|
|
158
|
+
'destination.host', 'destination.port', 'destination.scheme', 'destination.service',
|
|
159
|
+
// protocol/TLS metadata
|
|
160
|
+
'network.protocol', 'network.transport', 'tls.version', 'tls.cipher', 'tls.verified',
|
|
161
|
+
// schema/attribute NAMES already approved for telemetry
|
|
162
|
+
'schema.name', 'schema.attributeNames',
|
|
163
|
+
]);
|
|
164
|
+
|
|
165
|
+
// The one attribute key whose value is an array rather than a scalar —
|
|
166
|
+
// itself must be a member of RUNTIME_ATTRIBUTE_KEYS.
|
|
167
|
+
export const RUNTIME_ARRAY_ATTRIBUTE_KEYS = Object.freeze(['schema.attributeNames']);
|
|
168
|
+
|
|
169
|
+
// A value-axis identifier grammar (final review B1): every approved
|
|
170
|
+
// attribute key holds an identifier-shaped token (a hostname, a port, a
|
|
171
|
+
// protocol/cipher/TLS-version string, a service/workload name, a table or
|
|
172
|
+
// attribute name) — none has a legitimate reason to contain whitespace, a
|
|
173
|
+
// quote, '=', '?', '#', '<', '>', ';', or free-form prose. This is what
|
|
174
|
+
// makes AC-29 clause 5 genuinely true rather than merely closed on the key
|
|
175
|
+
// axis: a value that doesn't look like an identifier is REJECTED outright
|
|
176
|
+
// (PRD line 983's own "reject", never "redact"), not truncated or
|
|
177
|
+
// sanitized. Tightened from the prior 256-char cap (which alone let an
|
|
178
|
+
// 18.5KB multi-entry payload through via schema.attributeNames' 64-entry
|
|
179
|
+
// array) to 128 — still generous for any real hostname/service/table name,
|
|
180
|
+
// and small enough that it is no longer a meaningful free-text channel.
|
|
181
|
+
export const RUNTIME_ATTRIBUTE_MAX_VALUE_LENGTH = 128; // was 256 — see comment above
|
|
182
|
+
export const RUNTIME_ATTRIBUTE_MAX_ARRAY_LENGTH = 8; // was 64 — see final review round 2 (I-NEW1)
|
|
183
|
+
export const RUNTIME_ATTRIBUTE_MAX_KEYS = 32;
|
|
184
|
+
|
|
185
|
+
const _IDENTIFIER_VALUE_RE = /^[A-Za-z0-9._:-]{1,128}$/;
|
|
186
|
+
|
|
187
|
+
function _isIdentifierValue(v) {
|
|
188
|
+
return typeof v === 'string' && v.length <= RUNTIME_ATTRIBUTE_MAX_VALUE_LENGTH && _IDENTIFIER_VALUE_RE.test(v);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// schema.attributeNames-specific grammar (final review round 2, I-NEW1): a
|
|
192
|
+
// real attribute/field NAME is always a single identifier token — letters,
|
|
193
|
+
// digits, underscore only, starting with a letter. It never legitimately
|
|
194
|
+
// contains ':', '.', or '-' — those are precisely what let an individual
|
|
195
|
+
// array element become a `key:value` pair (the round-1 re-review's own
|
|
196
|
+
// live repro: "password:hunter2", "ssn:123-45-6789",
|
|
197
|
+
// "pan:4111111111111111", each individually "identifier-shaped" under the
|
|
198
|
+
// GENERAL _isIdentifierValue grammar, since that grammar allows ':' for
|
|
199
|
+
// OTHER keys that genuinely need it — destination.host doesn't need a
|
|
200
|
+
// colon, but this field never needs one at all). Forbidding ':'/'.'/'-'
|
|
201
|
+
// here closes every key:value-shaped smuggling case completely, while
|
|
202
|
+
// still accepting every real attribute name shape (user_id, email,
|
|
203
|
+
// created_at, SSN, PAN — all pass; none of the disclosed-in-repro secrets
|
|
204
|
+
// do).
|
|
205
|
+
const _ATTRIBUTE_NAME_RE = /^[A-Za-z][A-Za-z0-9_]{0,63}$/;
|
|
206
|
+
|
|
207
|
+
function _isAttributeName(v) {
|
|
208
|
+
return typeof v === 'string' && _ATTRIBUTE_NAME_RE.test(v);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// A count/frequency BAND (PRD line 971) — never a raw number. An exact
|
|
212
|
+
// event count is itself a weak information channel.
|
|
213
|
+
export const EVENT_COUNT_BANDS = Object.freeze(['1', '2-10', '11-100', '101-1k', '1k+']);
|
|
214
|
+
|
|
215
|
+
export const RUNTIME_MATCH_METHODS = Object.freeze(['destination_literal', 'store_table', 'queue_topic', 'unmatched']);
|
|
216
|
+
export const RUNTIME_MATCH_CONFIDENCE = Object.freeze(['high', 'medium', 'low', 'ambiguous']);
|
|
217
|
+
|
|
218
|
+
// Three-valued deliberately — see the header comment above for why
|
|
219
|
+
// `not_observed_in_window` and `not_evaluated` must never collapse into
|
|
220
|
+
// one "no evidence" bucket (PRD line 2098).
|
|
221
|
+
export const OBSERVATION_LAYERS = Object.freeze(['runtime_observed', 'not_observed_in_window', 'not_evaluated']);
|
|
222
|
+
|
|
223
|
+
const _RETENTION_KEYS = ['expiresAt'];
|
|
224
|
+
|
|
225
|
+
function _isPlainObject(v) {
|
|
226
|
+
return typeof v === 'object' && v !== null && !Array.isArray(v);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function _isNonEmptyString(v, maxLen) {
|
|
230
|
+
if (typeof v !== 'string' || v.length === 0) return false;
|
|
231
|
+
if (typeof maxLen === 'number' && v.length > maxLen) return false;
|
|
232
|
+
return true;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
const _ISO_DATE_TIME_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?(Z|[+-]\d{2}:\d{2})$/;
|
|
236
|
+
|
|
237
|
+
function _isIsoDateTime(v) {
|
|
238
|
+
return typeof v === 'string' && _ISO_DATE_TIME_RE.test(v) && Number.isFinite(Date.parse(v));
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function _isPrefixedIdArray(v, prefix) {
|
|
242
|
+
return Array.isArray(v) && v.every((x) => typeof x === 'string' && x.startsWith(prefix));
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Closed-world validation of a RuntimeObservation record's `attributes`
|
|
247
|
+
* map (AC-29 clause 5's own core enforcement point). Never throws.
|
|
248
|
+
*/
|
|
249
|
+
export function validateObservationAttributes(attributes) {
|
|
250
|
+
const errors = [];
|
|
251
|
+
const err = (p, message) => errors.push({ path: p, message });
|
|
252
|
+
|
|
253
|
+
if (!_isPlainObject(attributes)) {
|
|
254
|
+
err('$.attributes', 'attributes must be a plain object');
|
|
255
|
+
return { valid: false, errors };
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
const keys = Object.keys(attributes);
|
|
259
|
+
if (keys.length > RUNTIME_ATTRIBUTE_MAX_KEYS) {
|
|
260
|
+
err('$.attributes', `attributes may carry at most ${RUNTIME_ATTRIBUTE_MAX_KEYS} keys, got ${keys.length}`);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
for (const [key, value] of Object.entries(attributes)) {
|
|
264
|
+
if (!RUNTIME_ATTRIBUTE_KEYS.includes(key)) {
|
|
265
|
+
err(`$.attributes["${key}"]`, 'unapproved attribute key — RuntimeObservation attributes are closed-world (FR-505): only the approved metadata allowlist is accepted, and an unrecognized key is rejected, never ignored');
|
|
266
|
+
continue; // the value of an unapproved key is not further inspected
|
|
267
|
+
}
|
|
268
|
+
if (RUNTIME_ARRAY_ATTRIBUTE_KEYS.includes(key)) {
|
|
269
|
+
if (!Array.isArray(value) || value.length > RUNTIME_ATTRIBUTE_MAX_ARRAY_LENGTH
|
|
270
|
+
|| !value.every((x) => _isAttributeName(x))) {
|
|
271
|
+
err(`$.attributes["${key}"]`, `must be an array of at most ${RUNTIME_ATTRIBUTE_MAX_ARRAY_LENGTH} attribute-name-shaped strings (letters, digits, underscore only, starting with a letter, 64 characters max) — an element containing ':', '.', '-', whitespace, or other punctuation is how a payload arrives disguised as an attribute name`);
|
|
272
|
+
}
|
|
273
|
+
continue;
|
|
274
|
+
}
|
|
275
|
+
const t = typeof value;
|
|
276
|
+
if (t === 'string') {
|
|
277
|
+
if (!_isIdentifierValue(value)) {
|
|
278
|
+
err(`$.attributes["${key}"]`, `string value must look like an identifier (letters, digits, '.', '_', ':', '-', ${RUNTIME_ATTRIBUTE_MAX_VALUE_LENGTH} characters max) — a value containing whitespace, quotes, or punctuation is how a payload arrives disguised as metadata`);
|
|
279
|
+
}
|
|
280
|
+
} else if (t === 'number') {
|
|
281
|
+
if (!Number.isFinite(value)) {
|
|
282
|
+
err(`$.attributes["${key}"]`, 'numeric attribute values must be finite');
|
|
283
|
+
}
|
|
284
|
+
} else if (t === 'boolean') {
|
|
285
|
+
// fine
|
|
286
|
+
} else {
|
|
287
|
+
err(`$.attributes["${key}"]`, `attribute value must be a string, number, or boolean scalar, got ${value === null ? 'null' : t}`);
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
return { valid: errors.length === 0, errors };
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* Closed-world structural validation of a RuntimeObservation record
|
|
296
|
+
* (M5 deliverable #7b, FR-505 §10.10, AC-29 clause 5). Never throws.
|
|
297
|
+
* Reports, never mutates (RO/4h).
|
|
298
|
+
*/
|
|
299
|
+
export function validateRuntimeObservation(record) {
|
|
300
|
+
const errors = [];
|
|
301
|
+
const err = (p, message) => errors.push({ path: p, message });
|
|
302
|
+
|
|
303
|
+
if (!_isPlainObject(record)) {
|
|
304
|
+
err('$', 'RuntimeObservation record must be an object');
|
|
305
|
+
return { valid: false, errors };
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// Closed-world sweep FIRST, before anything else — an unrecognized
|
|
309
|
+
// top-level key is always an error, regardless of whatever else is
|
|
310
|
+
// wrong or right about the record.
|
|
311
|
+
for (const key of Object.keys(record)) {
|
|
312
|
+
if (!RUNTIME_OBSERVATION_FIELDS.includes(key)) {
|
|
313
|
+
err(`$.${key}`, 'unknown field — RuntimeObservation records are closed-world (PRD line 983): only approved metadata fields are accepted, and an unrecognized field is rejected, never ignored');
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
// Missing-required sweep — closed-world cuts both ways.
|
|
318
|
+
for (const field of RUNTIME_OBSERVATION_FIELDS) {
|
|
319
|
+
if (!(field in record)) {
|
|
320
|
+
err(`$.${field}`, `${field} is required`);
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
// Per-field type/enum checks — each guarded so a malformed field
|
|
325
|
+
// doesn't get double-reported by a cross-field rule below.
|
|
326
|
+
const idOk = _isNonEmptyString(record.id) && record.id.startsWith('observation:');
|
|
327
|
+
if ('id' in record && !idOk) err('$.id', 'id is required and must start with "observation:"');
|
|
328
|
+
|
|
329
|
+
if ('version' in record && !_isNonEmptyString(record.version)) err('$.version', 'version is required');
|
|
330
|
+
|
|
331
|
+
if ('adapter' in record && !RUNTIME_OBSERVATION_ADAPTERS.includes(record.adapter)) {
|
|
332
|
+
err('$.adapter', `adapter must be one of ${RUNTIME_OBSERVATION_ADAPTERS.join('|')}`);
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
if ('source' in record && !_isNonEmptyString(record.source, 512)) {
|
|
336
|
+
err('$.source', 'source is required and must be at most 512 characters');
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
if ('environment' in record && !_isNonEmptyString(record.environment, 64)) {
|
|
340
|
+
err('$.environment', 'environment is required and must be at most 64 characters');
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
const windowStartOk = 'windowStart' in record && _isIsoDateTime(record.windowStart);
|
|
344
|
+
if ('windowStart' in record && !windowStartOk) err('$.windowStart', 'windowStart must be a parseable ISO-8601 date-time');
|
|
345
|
+
|
|
346
|
+
const windowEndOk = 'windowEnd' in record && _isIsoDateTime(record.windowEnd);
|
|
347
|
+
if ('windowEnd' in record && !windowEndOk) err('$.windowEnd', 'windowEnd must be a parseable ISO-8601 date-time');
|
|
348
|
+
|
|
349
|
+
const matchedNodeIdsOk = 'matchedNodeIds' in record && _isPrefixedIdArray(record.matchedNodeIds, 'node:');
|
|
350
|
+
if ('matchedNodeIds' in record && !matchedNodeIdsOk) {
|
|
351
|
+
err('$.matchedNodeIds', 'matchedNodeIds must be an array of node:-prefixed ids');
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
const matchedEdgeIdsOk = 'matchedEdgeIds' in record && _isPrefixedIdArray(record.matchedEdgeIds, 'edge:');
|
|
355
|
+
if ('matchedEdgeIds' in record && !matchedEdgeIdsOk) {
|
|
356
|
+
err('$.matchedEdgeIds', 'matchedEdgeIds must be an array of edge:-prefixed ids');
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
const matchedFlowIdsOk = 'matchedFlowIds' in record && _isPrefixedIdArray(record.matchedFlowIds, 'flow:');
|
|
360
|
+
if ('matchedFlowIds' in record && !matchedFlowIdsOk) {
|
|
361
|
+
err('$.matchedFlowIds', 'matchedFlowIds must be an array of flow:-prefixed ids');
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
if ('attributes' in record) {
|
|
365
|
+
const attrResult = validateObservationAttributes(record.attributes);
|
|
366
|
+
errors.push(...attrResult.errors);
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
const eventCountBandOk = 'eventCountBand' in record && EVENT_COUNT_BANDS.includes(record.eventCountBand);
|
|
370
|
+
if ('eventCountBand' in record && !eventCountBandOk) {
|
|
371
|
+
err('$.eventCountBand', `eventCountBand must be a band (${EVENT_COUNT_BANDS.join('|')}) — an exact count is itself an information channel`);
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
const firstObservedAtOk = 'firstObservedAt' in record && _isIsoDateTime(record.firstObservedAt);
|
|
375
|
+
if ('firstObservedAt' in record && !firstObservedAtOk) err('$.firstObservedAt', 'firstObservedAt must be a parseable ISO-8601 date-time');
|
|
376
|
+
|
|
377
|
+
const lastObservedAtOk = 'lastObservedAt' in record && _isIsoDateTime(record.lastObservedAt);
|
|
378
|
+
if ('lastObservedAt' in record && !lastObservedAtOk) err('$.lastObservedAt', 'lastObservedAt must be a parseable ISO-8601 date-time');
|
|
379
|
+
|
|
380
|
+
const matchMethodOk = 'matchMethod' in record && RUNTIME_MATCH_METHODS.includes(record.matchMethod);
|
|
381
|
+
if ('matchMethod' in record && !matchMethodOk) {
|
|
382
|
+
err('$.matchMethod', `matchMethod must be one of ${RUNTIME_MATCH_METHODS.join('|')}`);
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
const matchConfidenceOk = 'matchConfidence' in record && RUNTIME_MATCH_CONFIDENCE.includes(record.matchConfidence);
|
|
386
|
+
if ('matchConfidence' in record && !matchConfidenceOk) {
|
|
387
|
+
err('$.matchConfidence', `matchConfidence must be one of ${RUNTIME_MATCH_CONFIDENCE.join('|')}`);
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
// retention: closed-key object with exactly `expiresAt`, which is
|
|
391
|
+
// either null or a parseable ISO-8601 date-time.
|
|
392
|
+
if ('retention' in record) {
|
|
393
|
+
const retention = record.retention;
|
|
394
|
+
if (!_isPlainObject(retention)) {
|
|
395
|
+
err('$.retention', 'retention must be an object with exactly the key expiresAt');
|
|
396
|
+
} else {
|
|
397
|
+
for (const key of Object.keys(retention)) {
|
|
398
|
+
if (!_RETENTION_KEYS.includes(key)) {
|
|
399
|
+
err(`$.retention.${key}`, 'unknown field — retention is closed-world: only expiresAt is accepted');
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
const expiresAt = retention.expiresAt;
|
|
403
|
+
const expiresAtOk = expiresAt === null || _isIsoDateTime(expiresAt);
|
|
404
|
+
if (!expiresAtOk) {
|
|
405
|
+
err('$.retention.expiresAt', 'retention.expiresAt must be null or a parseable ISO-8601 date-time');
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
if ('importedAt' in record && !_isIsoDateTime(record.importedAt)) {
|
|
411
|
+
err('$.importedAt', 'importedAt must be a parseable ISO-8601 date-time');
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
// ── Cross-field rules — each guarded so it only fires when both
|
|
415
|
+
// operands are already well-formed (never cascade a window-ordering
|
|
416
|
+
// error off a malformed date). ──────────────────────────────────
|
|
417
|
+
|
|
418
|
+
if (windowStartOk && windowEndOk) {
|
|
419
|
+
if (Date.parse(record.windowStart) > Date.parse(record.windowEnd)) {
|
|
420
|
+
err('$.windowEnd', 'windowEnd must not be before windowStart');
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
if (firstObservedAtOk && lastObservedAtOk) {
|
|
425
|
+
if (Date.parse(record.firstObservedAt) > Date.parse(record.lastObservedAt)) {
|
|
426
|
+
err('$.lastObservedAt', 'lastObservedAt must not be before firstObservedAt');
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
if (firstObservedAtOk && windowStartOk) {
|
|
431
|
+
if (Date.parse(record.firstObservedAt) < Date.parse(record.windowStart)) {
|
|
432
|
+
err('$.firstObservedAt', 'firstObservedAt must fall on or after windowStart');
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
if (lastObservedAtOk && windowEndOk) {
|
|
437
|
+
if (Date.parse(record.lastObservedAt) > Date.parse(record.windowEnd)) {
|
|
438
|
+
err('$.lastObservedAt', 'lastObservedAt must fall on or before windowEnd');
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
if (matchMethodOk && matchedNodeIdsOk && matchedEdgeIdsOk && matchedFlowIdsOk) {
|
|
443
|
+
const allEmpty = record.matchedNodeIds.length === 0 && record.matchedEdgeIds.length === 0
|
|
444
|
+
&& record.matchedFlowIds.length === 0;
|
|
445
|
+
if (record.matchMethod === 'unmatched') {
|
|
446
|
+
if (!allEmpty) {
|
|
447
|
+
err('$.matchMethod', 'matchMethod "unmatched" requires every matched*Ids array to be empty — a matched id contradicts an "unmatched" record');
|
|
448
|
+
}
|
|
449
|
+
} else if (record.matchedNodeIds.length === 0) {
|
|
450
|
+
err('$.matchedNodeIds', 'a non-"unmatched" matchMethod requires at least one matched node id');
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
// Deliberately scoped to matchedNodeIds only — edges and flows derive
|
|
455
|
+
// from a matched node, so several of them is normal and not itself an
|
|
456
|
+
// ambiguity; a rule over the union of all three arrays would fire on
|
|
457
|
+
// every ordinary match (a node with 2 edges and 3 flows, say).
|
|
458
|
+
if (matchedNodeIdsOk && matchConfidenceOk && record.matchedNodeIds.length > 1
|
|
459
|
+
&& record.matchConfidence !== 'ambiguous') {
|
|
460
|
+
err('$.matchConfidence', 'more than one matched node requires matchConfidence "ambiguous" — an ambiguous observation must remain a candidate and never silently merge data elements (FR-505)');
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
return { valid: errors.length === 0, errors };
|
|
464
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
// scenario-diff.js — M5 deliverable #3a (FR-502): a dedicated
|
|
2
|
+
// comparison for a Scenario's own simulated graph delta. Deliberately
|
|
3
|
+
// NOT graph-diff.js's computeGraphDiff — see this sub-project's own
|
|
4
|
+
// scoping doc for the full reasoning: computeGraphDiff's flow-
|
|
5
|
+
// reidentification pairing and causeClassification vocabulary
|
|
6
|
+
// (`'application_change'`, `'possible_coverage_regression'`,
|
|
7
|
+
// `'reidentified'`) are shaped for a REAL rescan across two commits,
|
|
8
|
+
// where the cause of a change is genuinely ambiguous. A Scenario's own
|
|
9
|
+
// delta has no such ambiguity — every difference between the base graph
|
|
10
|
+
// and a scenario clone IS the declared hypothetical operation that
|
|
11
|
+
// produced it — so this module reports only "what differs", with no
|
|
12
|
+
// cause classification and no reidentification.
|
|
13
|
+
|
|
14
|
+
export const WATCHED_SCENARIO_FIELDS = Object.freeze({
|
|
15
|
+
// 'destination' + 'storeDetail' are exhaustive by construction, not by
|
|
16
|
+
// convention: change_storage_fact/change_governance_fact write into an
|
|
17
|
+
// already-watched CONTAINER object, so any field they set is caught
|
|
18
|
+
// regardless of key; replace_recipient_fact is the one operation that
|
|
19
|
+
// writes directly onto the node's own top level, and scenario-engine.js
|
|
20
|
+
// restricts it, at the applier level, to exactly node.destination (any
|
|
21
|
+
// other `field` value is skipped, never applied) — so this list never
|
|
22
|
+
// needs to name a field replace_recipient_fact could reach beyond
|
|
23
|
+
// 'destination'. See scenario-engine.js's _applyReplaceRecipientFact.
|
|
24
|
+
node: Object.freeze(['destination', 'storeDetail']),
|
|
25
|
+
edge: Object.freeze(['protection.transit', 'protection.atRest', 'protection.handling']),
|
|
26
|
+
flow: Object.freeze(['policyVerdict', 'protectionSummary', 'governanceRefs']),
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
function _get(obj, dottedPath) {
|
|
30
|
+
return dottedPath.split('.').reduce((v, k) => (v == null ? v : v[k]), obj);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function _deepEqual(a, b) { return JSON.stringify(a) === JSON.stringify(b); }
|
|
34
|
+
|
|
35
|
+
function _byId(arr) { return new Map((arr ?? []).map((x) => [x.id, x])); }
|
|
36
|
+
|
|
37
|
+
function _diffKind(beforeArr, afterArr, kind) {
|
|
38
|
+
const beforeMap = _byId(beforeArr);
|
|
39
|
+
const afterMap = _byId(afterArr);
|
|
40
|
+
const changed = [];
|
|
41
|
+
const removed = [];
|
|
42
|
+
for (const [id, beforeEntity] of beforeMap) {
|
|
43
|
+
const afterEntity = afterMap.get(id);
|
|
44
|
+
if (!afterEntity) { removed.push(id); continue; }
|
|
45
|
+
const changedFields = [];
|
|
46
|
+
for (const field of WATCHED_SCENARIO_FIELDS[kind]) {
|
|
47
|
+
const before = _get(beforeEntity, field);
|
|
48
|
+
const after = _get(afterEntity, field);
|
|
49
|
+
if (!_deepEqual(before, after)) changedFields.push({ field, before, after });
|
|
50
|
+
}
|
|
51
|
+
if (changedFields.length) changed.push({ id, kind, changedFields });
|
|
52
|
+
}
|
|
53
|
+
return { changed, removed };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// AC-26 ("What-if changes cannot masquerade as implementation"): a
|
|
57
|
+
// simulated edge must be LABELED `HYPOTHETICAL`, not merely carry an
|
|
58
|
+
// `evidenceGrade: 'assumed'` value a reader has to notice buried inside
|
|
59
|
+
// a before/after diff. Mirrors AC-29's own precedent
|
|
60
|
+
// (`observation-correlation.js`'s literal `'runtime_observed'` layer
|
|
61
|
+
// value) — a named acceptance criterion that specifies an exact string
|
|
62
|
+
// gets an exact string in the shipped output, not an inference left to
|
|
63
|
+
// the reader.
|
|
64
|
+
function _hasAssumedEvidence(changedEntity) {
|
|
65
|
+
return changedEntity.changedFields.some((f) => f.after && typeof f.after === 'object' && f.after.evidenceGrade === 'assumed');
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Compare `baseGraph` against `scenarioGraph` (either a real
|
|
70
|
+
* applyScenario({graph}) result, or another Scenario's own graph, for a
|
|
71
|
+
* scenario-vs-scenario comparison). Never throws — an empty/missing
|
|
72
|
+
* entity array on either side is treated as zero entities.
|
|
73
|
+
*/
|
|
74
|
+
export function diffScenarioGraph(baseGraph, scenarioGraph) {
|
|
75
|
+
const nodeDiff = _diffKind(baseGraph.nodes ?? [], scenarioGraph.nodes ?? [], 'node');
|
|
76
|
+
const edgeDiff = _diffKind(baseGraph.edges ?? [], scenarioGraph.edges ?? [], 'edge');
|
|
77
|
+
const flowDiff = _diffKind(baseGraph.flows ?? [], scenarioGraph.flows ?? [], 'flow');
|
|
78
|
+
const changedEntities = [...nodeDiff.changed, ...edgeDiff.changed, ...flowDiff.changed]
|
|
79
|
+
.map((e) => (e.kind === 'edge' && _hasAssumedEvidence(e) ? { ...e, label: 'HYPOTHETICAL' } : e));
|
|
80
|
+
return {
|
|
81
|
+
changedEntities,
|
|
82
|
+
removedEntityIds: [...nodeDiff.removed, ...edgeDiff.removed, ...flowDiff.removed],
|
|
83
|
+
};
|
|
84
|
+
}
|