@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.
Files changed (195) hide show
  1. package/CHANGELOG.md +266 -0
  2. package/bin/agentic-security.js +3508 -69
  3. package/dist/1122.index.js +779 -0
  4. package/dist/{301.index.js → 1301.index.js} +2 -2
  5. package/dist/1379.index.js +591 -0
  6. package/dist/{444.index.js → 1444.index.js} +2 -2
  7. package/dist/{660.index.js → 1660.index.js} +2 -2
  8. package/dist/{700.index.js → 1700.index.js} +2 -2
  9. package/dist/{905.index.js → 1905.index.js} +3 -3
  10. package/dist/{920.index.js → 1920.index.js} +3 -3
  11. package/dist/{238.index.js → 2238.index.js} +3 -3
  12. package/dist/{271.index.js → 2271.index.js} +2 -2
  13. package/dist/{985.index.js → 2376.index.js} +1260 -340
  14. package/dist/2432.index.js +793 -0
  15. package/dist/2659.index.js +93 -0
  16. package/dist/{826.index.js → 2826.index.js} +2 -2
  17. package/dist/{830.index.js → 2830.index.js} +2 -2
  18. package/dist/2923.index.js +298 -0
  19. package/dist/{1.index.js → 3001.index.js} +5 -5
  20. package/dist/{117.index.js → 3117.index.js} +3 -3
  21. package/dist/3180.index.js +379 -0
  22. package/dist/3276.index.js +117 -0
  23. package/dist/{415.index.js → 3415.index.js} +2 -2
  24. package/dist/{499.index.js → 3499.index.js} +2 -2
  25. package/dist/3518.index.js +450 -0
  26. package/dist/{526.index.js → 3526.index.js} +5 -5
  27. package/dist/{736.index.js → 3736.index.js} +4 -4
  28. package/dist/{839.index.js → 3839.index.js} +4 -4
  29. package/dist/{113.index.js → 4113.index.js} +5 -5
  30. package/dist/{265.index.js → 4265.index.js} +2 -2
  31. package/dist/{384.index.js → 4384.index.js} +3 -3
  32. package/dist/4547.index.js +268 -0
  33. package/dist/4863.index.js +422 -0
  34. package/dist/{970.index.js → 4970.index.js} +2 -2
  35. package/dist/5051.index.js +841 -0
  36. package/dist/{144.index.js → 5144.index.js} +5 -5
  37. package/dist/{333.index.js → 5333.index.js} +3 -3
  38. package/dist/5343.index.js +185 -0
  39. package/dist/5350.index.js +866 -0
  40. package/dist/5561.index.js +436 -0
  41. package/dist/{637.index.js → 5637.index.js} +3 -3
  42. package/dist/{449.index.js → 5830.index.js} +2 -2
  43. package/dist/6626.index.js +532 -0
  44. package/dist/6662.index.js +297 -0
  45. package/dist/{675.index.js → 6675.index.js} +5 -5
  46. package/dist/{730.index.js → 6730.index.js} +6 -6
  47. package/dist/6829.index.js +225 -0
  48. package/dist/6944.index.js +130 -0
  49. package/dist/{178.index.js → 7178.index.js} +3 -3
  50. package/dist/{227.index.js → 7227.index.js} +2 -2
  51. package/dist/7310.index.js +520 -0
  52. package/dist/{552.index.js → 7552.index.js} +4 -4
  53. package/dist/7709.index.js +78 -0
  54. package/dist/8218.index.js +160 -0
  55. package/dist/{476.index.js → 8476.index.js} +4 -4
  56. package/dist/{513.index.js → 8513.index.js} +5 -5
  57. package/dist/{520.index.js → 8520.index.js} +2 -2
  58. package/dist/{718.index.js → 8718.index.js} +2 -2
  59. package/dist/{752.index.js → 8752.index.js} +2 -2
  60. package/dist/8846.index.js +100 -0
  61. package/dist/{435.index.js → 9091.index.js} +560 -189
  62. package/dist/{207.index.js → 9207.index.js} +2 -2
  63. package/dist/{220.index.js → 9220.index.js} +2 -2
  64. package/dist/9390.index.js +163 -0
  65. package/dist/{503.index.js → 9503.index.js} +2 -2
  66. package/dist/{801.index.js → 9801.index.js} +2 -2
  67. package/dist/{824.index.js → 9824.index.js} +2 -2
  68. package/dist/agentic-security.mjs +16 -16
  69. package/dist/agentic-security.mjs.sha256 +1 -1
  70. package/dist/compliance-frameworks/hipaa-security-rule.json +3 -2
  71. package/dist/frontend/index.html +21 -0
  72. package/dist/frontend/src/app.js +176 -0
  73. package/dist/frontend/src/components/evidence-inspector.js +141 -0
  74. package/dist/frontend/src/components/filter-rail.js +119 -0
  75. package/dist/frontend/src/components/query-bar.js +126 -0
  76. package/dist/frontend/src/data/flagship-graph.js +1460 -0
  77. package/dist/frontend/src/export-entry.js +36 -0
  78. package/dist/frontend/src/lib/api-client.js +92 -0
  79. package/dist/frontend/src/lib/contrast.js +34 -0
  80. package/dist/frontend/src/lib/dom.js +24 -0
  81. package/dist/frontend/src/lib/escape-html.js +16 -0
  82. package/dist/frontend/src/lib/flow-path.js +40 -0
  83. package/dist/frontend/src/lib/focus-controls.js +149 -0
  84. package/dist/frontend/src/lib/protection-visual.js +46 -0
  85. package/dist/frontend/src/lib/query-language.js +240 -0
  86. package/dist/frontend/src/lib/row-filters.js +43 -0
  87. package/dist/frontend/src/lib/state.js +84 -0
  88. package/dist/frontend/src/main.js +83 -0
  89. package/dist/frontend/src/shell.js +184 -0
  90. package/dist/frontend/src/views/architecture-view.js +798 -0
  91. package/dist/frontend/src/views/inventory-view.js +292 -0
  92. package/dist/frontend/src/views/privacy-view.js +172 -0
  93. package/dist/frontend/src/views/trace-view.js +206 -0
  94. package/dist/frontend/styles/architecture-view.css +93 -0
  95. package/dist/frontend/styles/filter-rail.css +34 -0
  96. package/dist/frontend/styles/inspector.css +69 -0
  97. package/dist/frontend/styles/inventory-view.css +74 -0
  98. package/dist/frontend/styles/privacy-view.css +86 -0
  99. package/dist/frontend/styles/query-bar.css +107 -0
  100. package/dist/frontend/styles/shell.css +155 -0
  101. package/dist/frontend/styles/tokens.css +128 -0
  102. package/dist/frontend/styles/trace-view.css +95 -0
  103. package/package.json +13 -6
  104. package/src/dataflow/CLAUDE.md +1 -1
  105. package/src/dataflow/catalog.js +42 -0
  106. package/src/dataflow/orm-write-catalog.js +175 -0
  107. package/src/engine.js +92 -1
  108. package/src/ir/CLAUDE.md +1 -0
  109. package/src/ir/chrome-probe.mjs +150 -0
  110. package/src/ir/parser-js.js +94 -7
  111. package/src/lineage/CLAUDE.md +1203 -0
  112. package/src/lineage/DESIGN_DESTINATION_RESOLVER.md +156 -0
  113. package/src/lineage/DESIGN_GRAPH_BUILDER.md +938 -0
  114. package/src/lineage/DESIGN_HANDLING_ANALYZER.md +355 -0
  115. package/src/lineage/DESIGN_INTRAPROCEDURAL.md +628 -0
  116. package/src/lineage/DESIGN_PATH_PROVENANCE.md +3451 -0
  117. package/src/lineage/DESIGN_QUEUE_DETAIL.md +120 -0
  118. package/src/lineage/DESIGN_REGISTRIES.md +880 -0
  119. package/src/lineage/DESIGN_STORE_DETAIL.md +143 -0
  120. package/src/lineage/DESIGN_TRANSIT_PROTECTION.md +245 -0
  121. package/src/lineage/classification.js +56 -0
  122. package/src/lineage/coverage.js +658 -0
  123. package/src/lineage/cross-repo-link.js +107 -0
  124. package/src/lineage/dataflow-graph.schema.json +184 -0
  125. package/src/lineage/decision-story.js +206 -0
  126. package/src/lineage/drift-policy.js +279 -0
  127. package/src/lineage/driver.js +135 -0
  128. package/src/lineage/engine.js +992 -0
  129. package/src/lineage/export-briefing.js +628 -0
  130. package/src/lineage/export-csv.js +62 -0
  131. package/src/lineage/export-json.js +238 -0
  132. package/src/lineage/export-privacy.js +258 -0
  133. package/src/lineage/federation-loader.js +111 -0
  134. package/src/lineage/field-identity.js +78 -0
  135. package/src/lineage/fixtures/build-flagship-fixture.mjs +272 -0
  136. package/src/lineage/fixtures/flagship-graph.json +1453 -0
  137. package/src/lineage/flow-grade.js +221 -0
  138. package/src/lineage/governance-edit.js +169 -0
  139. package/src/lineage/graph-builder.js +1114 -0
  140. package/src/lineage/graph-diff.js +431 -0
  141. package/src/lineage/graph-snapshot.js +180 -0
  142. package/src/lineage/handling-analyzer.js +168 -0
  143. package/src/lineage/ids.js +349 -0
  144. package/src/lineage/impact-assessment.js +76 -0
  145. package/src/lineage/impact-engine.js +268 -0
  146. package/src/lineage/index.js +281 -0
  147. package/src/lineage/language-coverage-tiers.js +58 -0
  148. package/src/lineage/obligation-mapping.js +126 -0
  149. package/src/lineage/obligation-predicates.js +235 -0
  150. package/src/lineage/observation-adapters.js +282 -0
  151. package/src/lineage/observation-correlation.js +622 -0
  152. package/src/lineage/observation-store.js +497 -0
  153. package/src/lineage/path-query.js +410 -0
  154. package/src/lineage/path-store.js +400 -0
  155. package/src/lineage/protection.js +53 -0
  156. package/src/lineage/recipient-profile.js +192 -0
  157. package/src/lineage/recipient-registry.js +394 -0
  158. package/src/lineage/redact-graph.js +224 -0
  159. package/src/lineage/remediation.js +417 -0
  160. package/src/lineage/resolve-destination.js +91 -0
  161. package/src/lineage/runtime-observation.js +464 -0
  162. package/src/lineage/scenario-diff.js +84 -0
  163. package/src/lineage/scenario-engine.js +251 -0
  164. package/src/lineage/scenario.js +101 -0
  165. package/src/lineage/schema.js +167 -0
  166. package/src/lineage/sink-registry.js +427 -0
  167. package/src/lineage/source-registry.js +357 -0
  168. package/src/lineage/source-seeding.js +212 -0
  169. package/src/lineage/summaries.js +590 -0
  170. package/src/lineage/transform-catalog.js +397 -0
  171. package/src/lineage/transit-protection.js +150 -0
  172. package/src/lineage/validate.js +285 -0
  173. package/src/mcp/CLAUDE.md +7 -1
  174. package/src/mcp/dataflow-tools.js +160 -0
  175. package/src/mcp/server.js +1 -1
  176. package/src/mcp/tools.js +2 -1
  177. package/src/pipeline/scan-health.js +19 -1
  178. package/src/posture/CLAUDE.md +19 -0
  179. package/src/posture/artifact-registry.js +52 -0
  180. package/src/posture/auditor-walkthrough.js +76 -0
  181. package/src/posture/compliance-frameworks/hipaa-security-rule.json +3 -2
  182. package/src/posture/obligation-evidence-pack.js +202 -0
  183. package/src/posture/remediation-ledger.js +337 -0
  184. package/src/server/CLAUDE.md +47 -0
  185. package/src/server/graph-loader.js +141 -0
  186. package/src/server/http-server.js +325 -0
  187. package/src/server/routes.js +129 -0
  188. package/src/server/security.js +111 -0
  189. package/src/server/static-assets.js +144 -0
  190. package/src/shared/frontend-root.js +52 -0
  191. package/dist/11.index.js +0 -353
  192. package/dist/259.index.js +0 -975
  193. package/dist/317.index.js +0 -300
  194. package/dist/609.index.js +0 -741
  195. package/dist/838.index.js +0 -152
@@ -0,0 +1,1114 @@
1
+ //
2
+ // graph-builder.js — Sub-project E, increment 3 (E3).
3
+ //
4
+ // Extracts the already-designed-and-proven graph-projection mechanism out of
5
+ // `DESIGN_GRAPH_BUILDER.md` §4-§8 (binding ADR) and its throwaway
6
+ // proof-of-concept (`test/lineage/graph-builder-poc.test.js`'s
7
+ // `receiverJustified`/`resolveSinkAtCallSite`/`enumerateSinkSites`/
8
+ // `degradedTerminals`/`calleeDescriptor`/`buildDataFlowGraph`, `E1/6`-`E1/13`)
9
+ // into a real, permanent, shipped module. This produces, for the first time
10
+ // in this codebase, a real, `validateGraph()`-clean `DataFlowGraph v1`
11
+ // document from an actual repository's real code.
12
+ //
13
+ // This is mechanical porting of an already-reviewed design, not a redesign
14
+ // — see `DESIGN_GRAPH_BUILDER.md` §4-§9.3 in full for the binding rules this
15
+ // file implements, and its own header for why the PoC (not this document)
16
+ // is authoritative if the two ever disagree.
17
+ //
18
+ // **Signature discrepancy, resolved per that stated policy**:
19
+ // `DESIGN_GRAPH_BUILDER.md` §9.3 item 1 originally stated the signature as
20
+ // `buildDataFlowGraph(perFileIR, callGraph, opts)` (three arguments), but
21
+ // the PoC's own shipped, tested implementation is
22
+ // `buildDataFlowGraph(callGraph, opts)` (two arguments) — it never used a
23
+ // separate `perFileIR` parameter, reading everything it needs from
24
+ // `callGraph.functions[*].cfg`. Per §9.1's own policy ("where this document
25
+ // and that PoC disagree, the PoC is right and this document is stale — fix
26
+ // it here, do not fork it"), this module ships the PoC's actual
27
+ // two-argument signature; §9.3 item 1's own prose was corrected to match in
28
+ // the same commit that shipped this file.
29
+ //
30
+ // E4 addition (DESIGN_GRAPH_BUILDER.md §9.4 item 5b): `opts.resolveSiteDecision`
31
+ // is the chosen resolution of that item's open hook-vs-post-processing-pass
32
+ // question. A post-processing pass over the BUILT GRAPH was rejected: FR-203
33
+ // changes a decision's `kind`/`externality`, which are part of a node's own
34
+ // identity discriminator (§6.1) — adjusting them after nodes are already
35
+ // minted would mean re-minting nodes and re-linking every edge/dataElement
36
+ // reference by hand, in effect reimplementing this function a second time.
37
+ // Intercepting the DECISION before minting, once, right after
38
+ // `enumerateSinkSites`, is the only point where an override is both
39
+ // consistent (every later read of `site.decision` sees it) and cheap
40
+ // (one small hook, zero structural changes to the projection below).
41
+ //
42
+ // Milestone 2, Sub-project A, increment 1 addition (FR-202, DESIGN_
43
+ // DESTINATION_RESOLVER.md): `opts.resolveDestination`, a SEPARATE,
44
+ // additive hook applied at the exact same point as `opts.resolveSiteDecision`
45
+ // above, right after it — same reasoning (a node's `destination` field is
46
+ // set once, at mint time, so intercepting before minting is the only
47
+ // consistent point). Composes with `resolveSiteDecision`, never collapses
48
+ // into it: the two hooks answer independent questions (is this sink's
49
+ // CLASSIFICATION resolvable vs. what does its DESTINATION EXPRESSION look
50
+ // like) and a site can carry both an `unresolved` decision and a `dynamic`
51
+ // destination at once.
52
+ //
53
+ // Milestone 2, Sub-project D, increment 1 addition (FR-403, DESIGN_
54
+ // HANDLING_ANALYZER.md): every emitted `flow` gains a `flow.handling`
55
+ // taxonomy label (`classifyHandling(p, callGraph)`, `./handling-analyzer.js`
56
+ // — a new import, mirroring `recognizeTransformation`'s own reuse boundary
57
+ // one line above), computed once inside the `groupsByFlowKey` flow-
58
+ // construction loop below, from that flow's own representative
59
+ // reconstructed `Path`. This is the same attachment-point discipline
60
+ // `emptyProtection()` already established one loop up for `edge.protection`
61
+ // — set once, at mint time, never a post-processing pass. See
62
+ // `DESIGN_HANDLING_ANALYZER.md` for the exact `transform-catalog.js` `kind`
63
+ // -> `HANDLING_VALUES` mapping and why this is NOT the same field as
64
+ // `protection.js`'s own `PROTECTION_DIMENSIONS`' `handling` dimension.
65
+ //
66
+ // Milestone 2, Sub-project B, increment 2 addition (FR-401, DESIGN_
67
+ // TRANSIT_PROTECTION.md §6): `opts.resolveTransitProtection`, a SEPARATE,
68
+ // additive hook applied at the exact same edge-construction point as
69
+ // `opts.resolveDestination` above — composing into the edge's `protection`
70
+ // object (`protection: { ...emptyProtection(), transit: resolved ??
71
+ // emptyProtection().transit }`) rather than replacing it. Writes ONLY
72
+ // `protection.transit`; `.atRest`/`.handling` stay `emptyProtection()`'s
73
+ // own defaults. A no-op when omitted, mirroring every prior additive
74
+ // hook's own "byte-identical when the hook is absent" contract.
75
+ //
76
+ // M5 deliverable #7 (FR-505 §7.12, AC-29, Runtime-Corroborated Digital Twin,
77
+ // "7b" only): `opts.correlateObservations(graph) -> correlationResult |
78
+ // undefined`, a FIFTH additive hook of the identical shape as the four
79
+ // above — applied once every graph array (`nodes`/`edges`/`flows`/
80
+ // `dataElements`) AND `recipientProfiles` are populated (the same
81
+ // placement lesson `opts.buildRecipientProfile`'s own fix round already
82
+ // established: a hook that runs before those arrays exist gets an empty
83
+ // envelope to reason over). Assigned to `graph.runtimeCorroboration` ONLY
84
+ // when the hook returns truthy — the key is genuinely ABSENT, never `null`,
85
+ // when the hook is omitted or returns falsy, which is what keeps the output
86
+ // byte-identical to a build with no hook at all (`M2A1/hook-1`'s own proven
87
+ // contract) and is also AC-29 clause 2's `not_evaluated` state expressed at
88
+ // the top level. See this hook's own inline comment below for the full
89
+ // reasoning, including why it never touches `edge.provenance`.
90
+ //
91
+ //
92
+ // M5 deliverable #8 (FR-304 "declared" half only, §10.10): `opts.
93
+ // crossRepoLinks(graph) -> CrossRepoLink[]`, a SIXTH additive hook of
94
+ // the identical shape — applied once every graph array AND
95
+ // `recipientProfiles` are populated, mirroring `opts.buildRecipientProfile`'s
96
+ // own placement exactly (the hook can validate a declared
97
+ // `local.nodeId` against the CURRENT graph's own real node set).
98
+ // `graph.crossRepoLinks` is always present — mirrors
99
+ // `graph.recipientProfiles[]`'s own "always an array, possibly empty"
100
+ // shape, NOT `graph.runtimeCorroboration`'s own "genuinely absent when
101
+ // the hook is omitted" shape, since a CrossRepoLink array has no
102
+ // `not_evaluated` state to preserve the way runtime corroboration does.
103
+ //
104
+ // Reuse boundary (§12, confirmed against the source): imports ONLY
105
+ // `matchSource`/`matchSinkOrSanitizer` from `../dataflow/catalog.js`,
106
+ // `matchPrivacySink` from `../dataflow/privacy-catalog.js`, and
107
+ // `accessPathOf` from `../dataflow/access-paths.js` — never
108
+ // `dataflow/engine.js`'s live taint state, never `dataflow/summaries.js`'s
109
+ // SummaryCache, never `dataflow/index.js`'s `runDeepAnalysis` (PRD §18.1).
110
+ // This module MIRRORS that shape (an opt-in, best-effort orchestration
111
+ // entry point), it does not wrap it.
112
+
113
+ import { matchSinkOrSanitizer } from '../dataflow/catalog.js';
114
+ import { matchPrivacySink } from '../dataflow/privacy-catalog.js';
115
+ import { matchOrmWrite } from '../dataflow/orm-write-catalog.js';
116
+ // Milestone 2, Sub-project G, increment 1 (FR-408/AC-09): `isSinkPermitted`/
117
+ // `permittingRules` are pure, vocabulary-agnostic functions (no hardcoded
118
+ // sink-name check anywhere in that module — confirmed by direct read) that
119
+ // this module reuses UNMODIFIED, mirroring dataflow/privacy-taint.js's own
120
+ // real usage precedent exactly. See the flow-construction loop below for
121
+ // the actual `flow.policyVerdict` computation.
122
+ import { isSinkPermitted, permittingRules } from '../dataflow/privacy-sink-policy.js';
123
+
124
+ import { runFieldIdentityAnalysis } from './driver.js';
125
+ import { PathStore } from './path-store.js';
126
+ import { reconstructPaths } from './path-query.js';
127
+ import { gradePath, DEGRADED_LOSS_REASONS } from './flow-grade.js';
128
+ import { reclassifySink, reclassifyPrivacySink, reclassifyOrmWrite } from './sink-registry.js';
129
+ import { recognizeTransformation } from './transform-catalog.js';
130
+ import { classifyHandling } from './handling-analyzer.js';
131
+ import { emptyGraphEnvelope } from './schema.js';
132
+ import { emptyProtection, aggregateVerdicts } from './protection.js';
133
+ import * as ids from './ids.js';
134
+ import { planSeeds, seedEntryStateFactory, exprRoots, walkExpr } from './source-seeding.js';
135
+ import { RECIPIENT_FACT_FIELDS } from './recipient-profile.js';
136
+
137
+ // =========================================================================
138
+ // §4.2 / §4.3 — registry-backed sink enumeration, multi-candidate resolution
139
+ // =========================================================================
140
+
141
+ /**
142
+ * §4.2. Did the matcher accept this candidate BECAUSE of a textual receiver
143
+ * constraint it declared? `receiverTypeIn` deliberately does NOT count: it
144
+ * gates on a CHA-resolved class TYPE and is vacuously allowed whenever no
145
+ * `receiverType` is supplied, which is always, here (D5 measured exactly
146
+ * this trap). Reading a returned candidate's own declared constraint is not
147
+ * re-deriving the match — `matchSinkOrSanitizer` already ran it.
148
+ */
149
+ function receiverJustified(entry) {
150
+ return Boolean(entry.match && (entry.match.receiver || entry.match.receiverBase));
151
+ }
152
+
153
+ /** §4.3. Multi-candidate sink resolution: represent, never silently pick. */
154
+ function resolveSinkAtCallSite(calleeExpr, file) {
155
+ const general = (matchSinkOrSanitizer(calleeExpr, file, undefined) ?? []).filter((h) => h.kind === 'sink');
156
+ const privacy = matchPrivacySink(calleeExpr, file, undefined) ?? [];
157
+ const cands = [
158
+ ...general.map((e) => ({ entry: e, decision: reclassifySink(e) })),
159
+ ...privacy.map((e) => ({ entry: e, decision: reclassifyPrivacySink(e) })),
160
+ ];
161
+ if (cands.length === 0) return null;
162
+ if (cands.length === 1) return { ...cands[0], ambiguity: null };
163
+
164
+ const justified = cands.filter((c) => receiverJustified(c.entry));
165
+ if (justified.length === 1) {
166
+ return {
167
+ ...justified[0],
168
+ ambiguity: { resolvedBy: 'receiver', alternatives: cands.filter((c) => c !== justified[0]).map((c) => c.entry.id).sort() },
169
+ };
170
+ }
171
+ const pool = justified.length > 1 ? justified : cands;
172
+ const cats = [...new Set(pool.map((c) => c.decision.category))];
173
+ if (cats.length === 1) {
174
+ return { ...pool[0], ambiguity: { resolvedBy: 'unanimous-category', alternatives: pool.slice(1).map((c) => c.entry.id).sort() } };
175
+ }
176
+ const counts = new Map();
177
+ for (const c of pool) counts.set(c.decision.category, (counts.get(c.decision.category) ?? 0) + 1);
178
+ const winner = [...counts.entries()].sort((a, b) => (b[1] - a[1]) || (String(a[0]) < String(b[0]) ? -1 : 1))[0][0];
179
+ const chosen = pool.find((c) => c.decision.category === winner);
180
+ const others = [...counts.keys()].filter((c) => c !== winner).sort();
181
+ return {
182
+ entry: chosen.entry,
183
+ decision: {
184
+ ...chosen.decision,
185
+ coverageStatus: 'partial',
186
+ reason: `${chosen.decision.reason}; AMBIGUOUS at this call site: also matches ${others.join(', ')} (candidates: ${pool.map((c) => c.entry.id).sort().join(', ')})`,
187
+ },
188
+ ambiguity: { resolvedBy: 'plurality', alternatives: others },
189
+ };
190
+ }
191
+
192
+ /**
193
+ * Milestone 2, Sub-project E, increment 1 (ORM-write sink recognition,
194
+ * `docs/superpowers/plans/2026-08-31-data-flow-explorer-m2-subproject-e1-plan.md`).
195
+ * A SEPARATE resolver from `resolveSinkAtCallSite` above, deliberately —
196
+ * `ORM_WRITE_CATALOG` is its own isolated catalog (never merged into
197
+ * `CATALOG`/`PRIVACY_SINK_CATALOG`; see `dataflow/orm-write-catalog.js`'s
198
+ * own header) and needs a precision signal `resolveSinkAtCallSite`'s two
199
+ * matchers never do: the call's FIRST ARGUMENT must be an object-literal
200
+ * expression (`kind: 'object'` — confirmed against `parser-js.js`'s real
201
+ * `ObjectExpression` lowering, NOT `resolve-destination.js`'s `isLiteral`
202
+ * helper, which tests for the unrelated `kind: 'literal'`). That check
203
+ * cannot live inside `matchOrmWrite` itself (its signature mirrors
204
+ * `matchPrivacySink`'s and never receives the call's arguments), so it is
205
+ * applied HERE, as a hard exclusion, before a match is returned at all —
206
+ * not a `coverageStatus` downgrade the way `resolveSinkAtCallSite`'s own
207
+ * ambiguity handling works. A call whose first argument is not an object
208
+ * literal (`User.create(req.body)`, a bare identifier) never becomes an
209
+ * ORM-write sink candidate.
210
+ *
211
+ * No multi-candidate ambiguity resolution: `ORM_WRITE_CATALOG`'s entries
212
+ * never share a callee name with each other or with `CATALOG`/
213
+ * `PRIVACY_SINK_CATALOG` (verified directly — no bare `create`/`save`/
214
+ * `update`/`upsert` entry with an unconstrained or capitalized-identifier
215
+ * receiver exists in either catalog today), so `ambiguity` is always
216
+ * `null` here, unlike `resolveSinkAtCallSite`'s return shape.
217
+ */
218
+ /**
219
+ * Milestone 2, Sub-project E, increment 2 (`node.storeDetail.operation`,
220
+ * DESIGN_STORE_DETAIL.md §4). Mapping logic, not an enum — lives here, not
221
+ * `schema.js`, per this package's own established separation. `save` maps
222
+ * to `'upsert'`, NOT `'create'` — a deliberate, disclosed judgment call:
223
+ * Mongoose's `.save()` performs an INSERT on a new document but an UPDATE
224
+ * on one loaded from the database, which is genuinely undecidable
225
+ * statically from the call site alone; `'upsert'` is the honest umbrella
226
+ * covering both, not a guess at which one it is.
227
+ */
228
+ const ORM_OPERATION_MAP = Object.freeze({
229
+ create: 'create',
230
+ update: 'update',
231
+ upsert: 'upsert',
232
+ save: 'upsert',
233
+ });
234
+
235
+ /**
236
+ * Milestone 2, Sub-project E, increment 2 (DESIGN_STORE_DETAIL.md §5).
237
+ * `arg0.props` entries are EITHER `{key: <string>, value: <exprDesc>}` (a
238
+ * real or literal-computed property) OR `{spread: true, value: <exprDesc>}`
239
+ * (an object spread — no key at all), per `parser-js.js`'s real
240
+ * `ObjectExpression` lowering (confirmed by direct read, ~line 116-159). A
241
+ * spread entry has no key to report; a `'*'`-keyed entry is a genuinely
242
+ * UNKNOWN column name (a non-literal computed key, `resolveObjectKey`'s
243
+ * own convention), not a literal column named `"*"` — reporting either
244
+ * would be a fabrication. Deduplicated since two distinct-value properties
245
+ * can share a key in real (if unusual) source (`{email: a, email: b}`).
246
+ */
247
+ function ormWriteColumns(arg0) {
248
+ const props = Array.isArray(arg0?.props) ? arg0.props : [];
249
+ const keys = props
250
+ .filter((p) => !p.spread && typeof p.key === 'string' && p.key !== '*')
251
+ .map((p) => p.key);
252
+ return [...new Set(keys)];
253
+ }
254
+
255
+ /**
256
+ * Milestone 2, Sub-project E, increment 3 (`node.queueDetail.topic`,
257
+ * DESIGN_QUEUE_DETAIL.md §3). A small local copy of `resolve-destination
258
+ * .js`'s `isLiteral` check shape (not imported — this package's own
259
+ * established "small local copy over cross-module dependency" precedent,
260
+ * already used by `ormWriteColumns`/`calleeDescriptor` elsewhere in this
261
+ * file), scoped to this file's own extraction needs.
262
+ */
263
+ function isLiteral(e) {
264
+ return Boolean(e) && typeof e === 'object' && e.kind === 'literal';
265
+ }
266
+
267
+ // Milestone 2, Sub-project E, increment 3 (DESIGN_QUEUE_DETAIL.md §3.1). A
268
+ // short, disclosed alias list — deliberately not widened speculatively.
269
+ // Checked in this order: the first alias with a matching, literal-valued
270
+ // property wins.
271
+ const QUEUE_TOPIC_KEY_ALIASES = Object.freeze(['QueueUrl', 'TopicArn', 'topic', 'queueName']);
272
+
273
+ /**
274
+ * `extractQueueDetail(args)` — only needs `args`: `operation` is always
275
+ * `'publish'` when this is called at all (every real
276
+ * `PRIVACY_SINK_CATALOG` queue entry — `sendMessage`/`publish` — is
277
+ * unambiguously a write), so no `calleeExpr`/callee-name parameter is
278
+ * needed, unlike `resolveOrmWriteAtCallSite`'s `table`/`operation`
279
+ * extraction, which did need the callee.
280
+ *
281
+ * `topic` extraction reuses `ormWriteColumns`'s exact filter shape
282
+ * (exclude spread entries, exclude `'*'`-keyed computed entries) but wants
283
+ * the VALUE of one specific matching key, not every key name — a
284
+ * different, smaller function, not a call to `ormWriteColumns` itself.
285
+ * Covers `privacy-js-queue-sendMessage`'s shape
286
+ * (`sqs.sendMessage({QueueUrl: '...', MessageBody: ...})`) directly. For
287
+ * `privacy-js-queue-publish`'s shape (`topic.publish(...)`), the topic
288
+ * identity typically lives in a SEPARATE, earlier statement that
289
+ * constructed the receiver — a cross-statement lookup this package has no
290
+ * primitive for — so that shape's own call arguments never carry a
291
+ * matching key here, and `topic` stays `null`, honestly, exactly as it
292
+ * would for any other call whose object-literal argument (if any) carries
293
+ * none of the recognized aliases. This is a disclosed, deferred gap, not a
294
+ * half-attempt — see DESIGN_QUEUE_DETAIL.md §3.2.
295
+ */
296
+ function extractQueueDetail(args) {
297
+ const arg0 = Array.isArray(args) ? args[0] : undefined;
298
+ const props = Array.isArray(arg0?.props) ? arg0.props : [];
299
+ let topic = null;
300
+ for (const alias of QUEUE_TOPIC_KEY_ALIASES) {
301
+ const prop = props.find((p) => !p.spread && p.key === alias);
302
+ if (prop && isLiteral(prop.value)) {
303
+ topic = String(prop.value.value);
304
+ break;
305
+ }
306
+ }
307
+ return { provider: null, topic, operation: 'publish' };
308
+ }
309
+
310
+ function resolveOrmWriteAtCallSite(calleeExpr, args, file) {
311
+ const hits = matchOrmWrite(calleeExpr, file);
312
+ if (!hits) return null;
313
+ const arg0 = Array.isArray(args) ? args[0] : undefined;
314
+ if (!arg0 || arg0.kind !== 'object') return null;
315
+ const entry = hits[0];
316
+ // DESIGN_STORE_DETAIL.md §3: `table` re-verifies defensively rather than
317
+ // assuming the shape `_ormReceiverIsCapitalizedIdent` already checked
318
+ // inside `matchOrmWrite` survived unchanged into this file.
319
+ const table = typeof calleeExpr?.object?.name === 'string' ? calleeExpr.object.name : null;
320
+ const operation = ORM_OPERATION_MAP[entry.match?.callee] ?? null;
321
+ const storeDetail = {
322
+ provider: null, host: null, database: null, schema: null,
323
+ table, operation, columns: ormWriteColumns(arg0),
324
+ };
325
+ return { entry, decision: reclassifyOrmWrite(entry), ambiguity: null, storeDetail };
326
+ }
327
+
328
+ /**
329
+ * §4.1. Registry-backed sink enumeration — the replacement for
330
+ * `sinkCandidates()`. A CFG `call` STATEMENT node is the only shape that
331
+ * produces an `escape` provenance node (engine.js `step()` case 'call' →
332
+ * `write-out`/`call-arg`), so it is the only shape a sink-rooted
333
+ * reconstruction can start from. Every OTHER call expression is counted,
334
+ * never silently dropped.
335
+ */
336
+ export function enumerateSinkSites(callGraph) {
337
+ const sites = [];
338
+ const nonStatementSites = [];
339
+ for (const fn of callGraph.functions.values()) {
340
+ for (const [nid, node] of Object.entries(fn.cfg?.nodes ?? {})) {
341
+ if (node.kind === 'call' && node.callee) {
342
+ const r = resolveSinkAtCallSite(node.callee, fn.file);
343
+ if (r) {
344
+ sites.push({ file: fn.file, qid: fn.qid, nodeId: nid, line: node.line ?? null, calleeExpr: node.callee, args: node.args ?? [], ...r });
345
+ // Milestone 2, Sub-project E, increment 3: queue/topic identity
346
+ // extraction, a conditional POST-step on the already-pushed site
347
+ // object — not a change to `resolveSinkAtCallSite`'s own
348
+ // signature (that function has no `args` parameter today, and
349
+ // adding one would be a wider, unnecessary change for a fact
350
+ // only the queue category needs).
351
+ if (r.decision.category === 'queue') {
352
+ const site = sites[sites.length - 1];
353
+ site.queueDetail = extractQueueDetail(node.args ?? []);
354
+ }
355
+ }
356
+ // Milestone 2, Sub-project E, increment 1: ORM-write recognition,
357
+ // additive and independent of the general/privacy match above —
358
+ // see `resolveOrmWriteAtCallSite`'s own header for why no
359
+ // interaction between the two is expected or handled.
360
+ const ormR = resolveOrmWriteAtCallSite(node.callee, node.args ?? [], fn.file);
361
+ if (ormR) sites.push({ file: fn.file, qid: fn.qid, nodeId: nid, line: node.line ?? null, calleeExpr: node.callee, args: node.args ?? [], ...ormR });
362
+ }
363
+ for (const root of exprRoots(node)) {
364
+ walkExpr(root, (e) => {
365
+ if (e.kind !== 'call') return;
366
+ const r = resolveSinkAtCallSite(e.callee, fn.file);
367
+ if (!r) return;
368
+ if (sites.some((x) => x.qid === fn.qid && x.nodeId === nid && x.entry.id === r.entry.id && x.calleeExpr === e.callee)) return;
369
+ nonStatementSites.push({ file: fn.file, qid: fn.qid, nodeId: nid, line: node.line ?? null, nodeKind: node.kind, entryId: r.entry.id });
370
+ });
371
+ }
372
+ }
373
+ }
374
+ return { sites, nonStatementSites };
375
+ }
376
+
377
+ /**
378
+ * §5. `DESIGN_PATH_PROVENANCE.md` §16.7 Finding 2's enumerator half, via
379
+ * the `diagnostics()`-union mechanism rather than a sixth `path-store.js`
380
+ * node kind: "a `path` node with zero out-edges whose in-edges carry a
381
+ * context-cap-degraded annotation". Computed from the PUBLIC read API only
382
+ * — `nodes()`, `edgesFrom()`, `edgesTo()` — with no change to
383
+ * `path-store.js` whatsoever.
384
+ */
385
+ export function degradedTerminals(store) {
386
+ const out = [];
387
+ for (const n of store.nodes()) {
388
+ if (store.edgesFrom(n.id).length !== 0) continue;
389
+ const inEdges = store.edgesTo(n.id);
390
+ if (inEdges.length === 0) continue;
391
+ const degraded = inEdges.some((e) =>
392
+ (e.lossReasons ?? []).some((r) => DEGRADED_LOSS_REASONS.includes(r))
393
+ || (e.annotations ?? []).some((a) => DEGRADED_LOSS_REASONS.includes(a.lossReason)));
394
+ if (degraded) out.push(n);
395
+ }
396
+ return out;
397
+ }
398
+
399
+ function calleeDescriptor(calleeExpr) {
400
+ if (typeof calleeExpr === 'string') return { type: 'call', callee: calleeExpr };
401
+ if (!calleeExpr) return null;
402
+ if (calleeExpr.kind === 'ident' && calleeExpr.name) return { type: 'call', callee: calleeExpr.name };
403
+ if (calleeExpr.kind === 'member' && typeof calleeExpr.prop === 'string') {
404
+ const obj = calleeExpr.object && calleeExpr.object.kind === 'ident' ? calleeExpr.object.name : null;
405
+ return obj ? { type: 'member-call', object: obj, method: calleeExpr.prop } : { type: 'call', callee: calleeExpr.prop };
406
+ }
407
+ return null;
408
+ }
409
+ const calleeDisplay = (d) => (!d ? null : d.type === 'member-call' ? `${d.object}.${d.method}` : d.callee);
410
+
411
+ const nodeLabel = (kind, category, reason) => (category
412
+ ? `${category} (${kind})`
413
+ : `unsupported ${kind}: ${String(reason).split(';')[0].slice(0, 80)}`);
414
+
415
+ // =========================================================================
416
+ // §6-§8 — THE PROJECTION. A `DataFlowGraph v1` node is a REGISTRY DECISION,
417
+ // not a provenance node and not a call site: its identity is
418
+ // `(kind, subtypeKey, coverageStatus, externality, destination)`. Edges and
419
+ // flows stay field- and path-granular, so nothing FR-305 protects is lost.
420
+ // =========================================================================
421
+
422
+ /**
423
+ * `buildDataFlowGraph(callGraph, opts)` — mirrors `dataflow/index.js`'s
424
+ * `runDeepAnalysis` SHAPE (an opt-in, best-effort orchestration entry
425
+ * point) and imports NOTHING from it. Seeds and drives the field-identity
426
+ * analysis, builds a `PathStore`, enumerates registry-backed sink
427
+ * candidates, resolves multi-candidate matches, reconstructs paths per
428
+ * sink, and projects the result into a `validateGraph()`-clean
429
+ * `DataFlowGraph v1` envelope.
430
+ *
431
+ * See the module header for the two-argument-vs-three-argument signature
432
+ * discrepancy this ships against, and the module's DESIGN_GRAPH_BUILDER.md
433
+ * §6-§8 for the projection rules implemented below.
434
+ */
435
+ export function buildDataFlowGraph(callGraph, opts = {}) {
436
+ const repository = opts.repository ?? 'repo';
437
+ const { seeds, unseedable } = planSeeds(callGraph, { repository });
438
+ const hops = [];
439
+ runFieldIdentityAnalysis(callGraph, {
440
+ recordHop: (h) => hops.push(h),
441
+ seedEntryState: seedEntryStateFactory(seeds),
442
+ ...(opts.maxContextsPerFn === undefined ? {} : { maxContextsPerFn: opts.maxContextsPerFn }),
443
+ });
444
+ const store = new PathStore();
445
+ store.addHops(hops);
446
+
447
+ const cfgByQid = new Map();
448
+ for (const fn of callGraph.functions.values()) cfgByQid.set(fn.qid, { fn, nodes: fn.cfg?.nodes ?? {} });
449
+
450
+ const seedByDe = new Map();
451
+ const seedSitesByDe = new Map();
452
+ for (const s of seeds) {
453
+ if (!seedByDe.has(s.dataElementId)) seedByDe.set(s.dataElementId, s);
454
+ if (!seedSitesByDe.has(s.dataElementId)) seedSitesByDe.set(s.dataElementId, []);
455
+ seedSitesByDe.get(s.dataElementId).push(s);
456
+ }
457
+
458
+ const graph = emptyGraphEnvelope({
459
+ graphId: ids.graphId({ repository, commit: opts.commit ?? 'uncommitted', configHash: opts.configHash ?? 'default' }),
460
+ // Determinism (§9.3 item 4): `generatedAt` must be injectable via
461
+ // `opts.generatedAt` rather than defaulting to `new Date().toISOString()`
462
+ // (which `emptyGraphEnvelope()` itself would fall back to if this
463
+ // module ever passed `undefined`/omitted the key entirely).
464
+ generatedAt: opts.generatedAt ?? '1970-01-01T00:00:00.000Z',
465
+ });
466
+
467
+ const nodesById = new Map();
468
+ const edgesById = new Map();
469
+ const flowsById = new Map();
470
+ const transformsById = new Map();
471
+ const deById = new Map();
472
+ // Every registry decision that landed on each node id — the raw material
473
+ // for the "no two DIFFERENT decisions collided onto one node" check.
474
+ const decisionsByNodeId = new Map();
475
+ // Milestone 2, Sub-project G, increment 1 (FR-408/AC-09): the first real
476
+ // populator of `graph.evidence[]` (`evidenceId` — `ids.js` — was minted
477
+ // but never called until this increment). One entry per DISTINCT
478
+ // permitting policy rule actually applied to a flow, deduplicated by
479
+ // content hash exactly like `transformsById`/`edgesById` above.
480
+ const evidenceById = new Map();
481
+ // Milestone 4, FR-506 (Third-Party and Cross-Border Intelligence): every
482
+ // non-null `opts.buildRecipientProfile(site, graph)` result, deduplicated
483
+ // by the record's own `id` — the same recipient reached by two different
484
+ // sink sites in one scan must not mint two records; a duplicate
485
+ // resolution merges its `site.nodeId` into the first record's
486
+ // `contributingGraphIds` instead of overwriting it.
487
+ const recipientProfilesById = new Map();
488
+
489
+ const mintNode = ({ kind, category, coverageStatus, externality, coverageReason, subtypeKey, lifecycleStages, destination, storeDetail, queueDetail }) => {
490
+ // NOTE (Milestone 2, Sub-project A, increment 1; Sub-project E,
491
+ // increments 2 and 3): neither `destination` nor `storeDetail` nor
492
+ // `queueDetail` is part of this discriminator — the node identity
493
+ // model stays exactly what it was in Milestone 1 (a registry decision,
494
+ // never a per-call-site fact), so two sites sharing one
495
+ // (kind, subtypeKey, coverageStatus, externality) tuple still mint/
496
+ // collide onto ONE node, and that node's `destination`/`storeDetail`/
497
+ // `queueDetail` is whichever site's resolution was applied FIRST
498
+ // (mintNode only sets it at creation, same as every other field
499
+ // below) — a known, disclosed coarsening, not a bug; see
500
+ // DESIGN_DESTINATION_RESOLVER.md, DESIGN_STORE_DETAIL.md, and
501
+ // DESIGN_QUEUE_DETAIL.md respectively.
502
+ const id = ids.nodeId(kind, [repository, subtypeKey ?? category ?? '', coverageStatus, externality, /* destination, always null in M1 */ '']);
503
+ let n = nodesById.get(id);
504
+ if (!n) {
505
+ n = {
506
+ id, kind,
507
+ // Decision 1 (DESIGN_REGISTRIES.md §9.0): the registry's `category`
508
+ // becomes the node's `subtype`; a null category becomes a null
509
+ // subtype and `kind` + the reason carry the meaning.
510
+ subtype: category ?? null,
511
+ label: nodeLabel(kind, category, coverageReason),
512
+ aliases: [],
513
+ // A category-granular node has no single source location. The
514
+ // flagship fixture sets `location: null` on all 14 of its own
515
+ // nodes, so this matches the one shipped precedent.
516
+ location: null,
517
+ system: { application: repository, environment: null },
518
+ destination: destination ?? null,
519
+ storeDetail: storeDetail ?? null,
520
+ queueDetail: queueDetail ?? null,
521
+ externality: { value: externality, evidenceRefs: [] },
522
+ lifecycleStages, governanceRefs: {},
523
+ dataElementIds: [], evidenceRefs: [],
524
+ confidence: coverageStatus === 'modeled' ? { score: 0.9, tier: 'high' } : { score: 0.6, tier: 'medium' },
525
+ coverageStatus,
526
+ coverageReason,
527
+ };
528
+ nodesById.set(id, n);
529
+ decisionsByNodeId.set(id, []);
530
+ }
531
+ decisionsByNodeId.get(id).push({ kind, category, coverageStatus, externality });
532
+ return n;
533
+ };
534
+ const sourceNodeFor = (s) => mintNode({
535
+ kind: 'source', category: s.category, coverageStatus: s.coverageStatus, externality: s.externality,
536
+ coverageReason: s.reason, subtypeKey: s.category ?? `unsupported-source:${s.entryId}`,
537
+ lifecycleStages: ['collection'],
538
+ });
539
+ const sinkNodeFor = (site) => mintNode({
540
+ kind: site.decision.kind, category: site.decision.category,
541
+ coverageStatus: site.decision.coverageStatus, externality: site.decision.externality,
542
+ coverageReason: site.decision.reason,
543
+ subtypeKey: site.decision.category ?? `unsupported-sink:${site.entry.vuln?.cwe ?? site.entry.id}`,
544
+ lifecycleStages: [site.decision.externality === 'external' ? 'sharing' : 'storage'],
545
+ destination: site.destination ?? null,
546
+ storeDetail: site.storeDetail ?? null,
547
+ queueDetail: site.queueDetail ?? null,
548
+ });
549
+ const mintDataElement = (s) => {
550
+ let d = deById.get(s.dataElementId);
551
+ if (!d) {
552
+ d = {
553
+ id: s.dataElementId, name: s.canonicalName, aliases: [], declaredType: null,
554
+ dataClasses: s.dataClasses, aiContexts: [], sourceLocations: [],
555
+ dataSubjectCategory: null, classificationEvidence: [], manualOverride: false,
556
+ };
557
+ deById.set(s.dataElementId, d);
558
+ for (const site of seedSitesByDe.get(s.dataElementId) ?? []) {
559
+ const loc = { file: site.file, line: site.line, scope: site.qid, path: site.seedPath };
560
+ if (!d.sourceLocations.some((l) => l.file === loc.file && l.line === loc.line && l.path === loc.path)) d.sourceLocations.push(loc);
561
+ }
562
+ }
563
+ return d;
564
+ };
565
+
566
+ const { sites, nonStatementSites } = enumerateSinkSites(callGraph);
567
+ // E4's FR-203 closure hook (DESIGN_GRAPH_BUILDER.md §9.4 item 5b): a
568
+ // caller may substitute an adjusted decision for a site — e.g.
569
+ // coverage.js's `resolveSiteDecision`, which reclassifies a sink whose
570
+ // destination expression is not statically resolvable. Applied once,
571
+ // before anything else reads `site.decision`, so every later use in
572
+ // this build (node minting, coverage counting, flow limitations) sees
573
+ // the same, consistent decision. A no-op when omitted — every existing
574
+ // caller's behavior is unchanged.
575
+ if (typeof opts.resolveSiteDecision === 'function') {
576
+ for (const site of sites) {
577
+ const override = opts.resolveSiteDecision(site);
578
+ if (override) site.decision = override;
579
+ }
580
+ }
581
+ // Milestone 2, Sub-project A, increment 1 (FR-202): a SEPARATE,
582
+ // additive hook — composes with, never replaces, `resolveSiteDecision`
583
+ // above. A site can be BOTH `kind: 'unresolved'` (FR-203's node-
584
+ // classification answer) AND carry a `resolutionStatus: 'dynamic'`
585
+ // destination (this hook's own, narrower "what does the destination
586
+ // expression look like" answer) — the two questions are independent,
587
+ // per DESIGN_DESTINATION_RESOLVER.md. Applied at the exact same point in
588
+ // the pipeline, right after `resolveSiteDecision`, for the same reason:
589
+ // once, before anything else reads `site.destination`
590
+ // (`sinkNodeFor`/the edge-protocol block below). A no-op when omitted —
591
+ // `site.destination` stays `undefined`, `sinkNodeFor` normalizes that to
592
+ // `null`, and the edge's `protocol.destinationResolution` stays
593
+ // `'unknown'` — byte-identical to pre-M2 behavior.
594
+ if (typeof opts.resolveDestination === 'function') {
595
+ for (const site of sites) {
596
+ const destination = opts.resolveDestination(site);
597
+ if (destination) site.destination = destination;
598
+ }
599
+ }
600
+ const escapesBySite = new Map();
601
+ for (const e of store.nodes()) {
602
+ if (e.kind !== 'escape') continue;
603
+ const k = `${e.scope}|${e.siteNodeId}`;
604
+ if (!escapesBySite.has(k)) escapesBySite.set(k, []);
605
+ escapesBySite.get(k).push(e);
606
+ }
607
+
608
+ const stats = { connectedSinkSites: 0, pathsEnumerated: 0, pathsProjected: 0, truncatedQueries: 0, unknownTransforms: 0 };
609
+ const groupsByFlowKey = new Map();
610
+
611
+ for (const site of sites) {
612
+ let connected = false;
613
+ for (const esc of escapesBySite.get(`${site.qid}|${site.nodeId}`) ?? []) {
614
+ const r = reconstructPaths(store, esc.id, opts.budget ?? {});
615
+ if (r.truncated) stats.truncatedQueries += 1;
616
+ stats.pathsEnumerated += r.enumeratedPathCount;
617
+ for (const p of r.paths) {
618
+ const seed = seedByDe.get(p.dataElementId);
619
+ if (!seed) continue;
620
+ connected = true;
621
+ stats.pathsProjected += 1;
622
+ const g = gradePath(p);
623
+
624
+ // ---- §7. transformations on this path ----
625
+ const tIds = [];
626
+ const unattributed = [];
627
+ for (const h of p.hops) {
628
+ const cfg = cfgByQid.get(h.scope);
629
+ const cn = cfg?.nodes?.[h.siteNodeId];
630
+ if (!cn) continue;
631
+ const calls = [];
632
+ for (const root of exprRoots(cn)) walkExpr(root, (e) => { if (e.kind === 'call') calls.push(e); });
633
+ if (cn.kind === 'call' && cn.callee) calls.unshift({ kind: 'call', callee: cn.callee, args: cn.args ?? [] });
634
+ const widened = (h.widenReasons ?? []).includes('unresolved-call')
635
+ || (h.annotations ?? []).some((a) => a.widenReason === 'unresolved-call');
636
+
637
+ let d = null;
638
+ let rec = null;
639
+ for (const c of calls) {
640
+ const d0 = calleeDescriptor(c.callee);
641
+ const r0 = d0 && recognizeTransformation(d0);
642
+ if (r0) { d = d0; rec = r0; break; }
643
+ }
644
+ if (!rec) {
645
+ // §7.2's `unknown` case, scoped so attribution is never a
646
+ // guess: emit an entity only when the widening hop's CFG node
647
+ // carries exactly ONE call expression.
648
+ if (!widened) continue;
649
+ if (calls.length !== 1) {
650
+ unattributed.push(`an unrecognized call widened this value at ${cfg.fn.file}:${h.line ?? '?'}; ${calls.length} call expressions at that site, so the transforming callee is not attributable`);
651
+ continue;
652
+ }
653
+ d = calleeDescriptor(calls[0].callee);
654
+ if (!d) { unattributed.push(`an unrecognized call widened this value at ${cfg.fn.file}:${h.line ?? '?'}; its callee is not a resolvable name`); continue; }
655
+ stats.unknownTransforms += 1;
656
+ }
657
+ const anchor = sinkNodeFor(site).id;
658
+ const t = {
659
+ id: ids.transformationId(anchor, calleeDisplay(d), [cfg.fn.file, h.line ?? '', h.fromPath ?? '', h.toPath ?? '']),
660
+ inputPath: h.fromPath ?? null, outputPath: h.toPath ?? null,
661
+ callee: calleeDisplay(d), function: h.scope,
662
+ location: { file: cfg.fn.file, line: h.line ?? null },
663
+ kind: rec ? rec.kind : 'unknown',
664
+ reversibility: rec ? rec.reversibility : 'unknown',
665
+ algorithm: rec ? rec.algorithm : null,
666
+ confidence: rec
667
+ ? { score: rec.confidence === 'high' ? 0.9 : 0.6, tier: rec.confidence }
668
+ : { score: 0.3, tier: 'low' },
669
+ evidence: rec
670
+ ? rec.evidence
671
+ : `a call to ${calleeDisplay(d)} widened this value's identity (hop record: unresolved-call); transform-catalog.js recognizes no transformation for this callee`,
672
+ // §7.3: honest absences at MINT time. `appliesToAllPaths`
673
+ // needs FR-307's all-path proof, which nothing in this
674
+ // per-hop loop does — it can only be answered once every
675
+ // flow group sharing this transform's coarse (source, sink,
676
+ // dataElement) key is known, which is not true yet at this
677
+ // point in the build. Milestone 2, Sub-project D, increment 2
678
+ // overwrites this `null` with a real `true`/`false` in the
679
+ // aggregation pass below (after `groupsByFlowKey` is fully
680
+ // populated) — see DESIGN_HANDLING_ANALYZER.md §5. There is
681
+ // still NO separate control-credit key of any kind — not even
682
+ // a bare `false` here would read as "considered and denied"
683
+ // this early (Decision 2); the real answer is written once,
684
+ // later, by the pass that can actually prove it.
685
+ appliesToAllPaths: null,
686
+ };
687
+ if (!transformsById.has(t.id)) transformsById.set(t.id, t);
688
+ if (!tIds.includes(t.id)) tIds.push(t.id);
689
+ }
690
+
691
+ const src = sourceNodeFor(seed);
692
+ const snk = sinkNodeFor(site);
693
+ const de = mintDataElement(seed);
694
+ if (!src.dataElementIds.includes(de.id)) src.dataElementIds.push(de.id);
695
+ if (!snk.dataElementIds.includes(de.id)) snk.dataElementIds.push(de.id);
696
+
697
+ const mappingType = tIds.length > 0 ? 'transformation' : (g.grade === 'explicit' ? 'identity' : 'unknown');
698
+ const fromPath = seed.seedPath;
699
+ // A call ARGUMENT is not an access path. Never fabricate one —
700
+ // DESIGN_PATH_PROVENANCE.md Decision 5's forbidden bug class.
701
+ const toPath = null;
702
+ const sortedT = [...tIds].sort();
703
+ const edgeIdStr = ids.edgeId(src.id, snk.id, 'data_flow', [fromPath, toPath ?? '', de.id, mappingType, ...sortedT]);
704
+ if (!edgesById.has(edgeIdStr)) {
705
+ edgesById.set(edgeIdStr, {
706
+ id: edgeIdStr, from: src.id, to: snk.id, relationship: 'data_flow',
707
+ fieldMappings: [{ fromPath, toPath, dataElementIds: [de.id], mappingType, transformationIds: sortedT }],
708
+ protocol: { name: 'in-process', destinationResolution: site.destination?.resolutionStatus ?? 'unknown' },
709
+ boundaryCrossings: [],
710
+ // Milestone 2, Sub-project F, increment 1 (FR-304): which
711
+ // mechanism discovered this edge. Every edge minted here today
712
+ // is genuinely code-derived — this is an unconditional, honest
713
+ // literal, never a guess (see schema.js's EDGE_PROVENANCE_VALUES
714
+ // comment for the full rationale).
715
+ provenance: 'code',
716
+ // Milestone 2, Sub-project B, increment 2 (FR-401):
717
+ // `opts.resolveTransitProtection(site) -> {verdict, evidenceGrade}
718
+ // | undefined`, applied at this exact point — the same block
719
+ // that already reads `site.destination` above — mirroring
720
+ // `opts.resolveDestination`'s own additive-hook contract
721
+ // exactly: composes with `emptyProtection()`'s default, never
722
+ // replaces it wholesale, and is a no-op (byte-identical output)
723
+ // when the hook is omitted or returns falsy. This increment
724
+ // writes ONLY `.transit` — `.atRest`/`.handling` stay
725
+ // `emptyProtection()`'s own defaults, Sub-project C's and a
726
+ // later increment's own jobs respectively.
727
+ protection: { ...emptyProtection(), transit: opts.resolveTransitProtection?.(site) ?? emptyProtection().transit },
728
+ evidenceRefs: [], coverageStatus: snk.coverageStatus,
729
+ });
730
+ }
731
+ // §6.4 / FR-305: the flow key carries every signal that makes two
732
+ // paths MATERIALLY different (shape, evidence grade, the ordered
733
+ // transformation set). Everything else collapses — including the
734
+ // per-function entry CONTEXT dimension, which is absent from this
735
+ // key by design and therefore never splits a flow.
736
+ const flowKey = [src.id, snk.id, de.id, p.shape, g.grade, sortedT.join(',')].join('|');
737
+ if (!groupsByFlowKey.has(flowKey)) groupsByFlowKey.set(flowKey, []);
738
+ groupsByFlowKey.get(flowKey).push({ p, g, src, snk, de, edgeIdStr, sortedT, site, unattributed, truncated: r.truncated, truncationReasons: r.truncationReasons });
739
+ }
740
+ }
741
+ site.connected = connected;
742
+ if (connected) stats.connectedSinkSites += 1;
743
+ // AC-11's coarse half: a sink node exists whether or not anything
744
+ // reached it. A disconnected sink is a node with no flow, never absent.
745
+ sinkNodeFor(site);
746
+ }
747
+
748
+ for (const [, group] of [...groupsByFlowKey.entries()].sort((a, b) => (a[0] < b[0] ? -1 : 1))) {
749
+ const { p, g, src, snk, de, edgeIdStr, sortedT, site } = group[0];
750
+ const fId = ids.flowId(src.id, snk.id, [de.id], [p.shape, g.grade, sortedT.join(',')]);
751
+ const limitations = [...new Set([
752
+ ...g.factors.map((f) => `evidence: ${f}`),
753
+ ...group.flatMap((x) => x.unattributed),
754
+ ...group.flatMap((x) => (x.truncated ? x.truncationReasons.map((t) => `reconstruction truncated: ${t}`) : [])),
755
+ ...(g.complete ? [] : [`path incomplete: terminal reason ${p.terminal.reason}`]),
756
+ ...(site.ambiguity && site.ambiguity.resolvedBy === 'plurality'
757
+ ? [`sink category ambiguous at ${site.file}:${site.line}: also matches ${site.ambiguity.alternatives.join(', ')}`]
758
+ : []),
759
+ ])].sort();
760
+ // Milestone 2, Sub-project D, increment 1 (FR-403): computed once,
761
+ // here, from this flow's own representative reconstructed path — never
762
+ // `null` in this increment (DESIGN_HANDLING_ANALYZER.md §4's own
763
+ // disclosed simplification: every flow gets a real taxonomy label,
764
+ // including the honest `'raw'` answer, since nothing here yet
765
+ // distinguishes a sink category with no natural "handling" concept
766
+ // from one that does).
767
+ const handlingResult = classifyHandling(p, callGraph).handling;
768
+ // Milestone 2, Sub-project C, increment 1 (FR-402, application-layer
769
+ // at-rest evidence): reuses the SAME `classifyHandling` result above
770
+ // for `flow.handling` — never a second call — to also decide
771
+ // `edge.protection.atRest`. `'encrypted'` is the only `HANDLING_VALUES`
772
+ // member that is at-rest PROTECTION evidence per FR-402 (see
773
+ // DESIGN_HANDLING_ANALYZER.md's new §7); every other value (including
774
+ // `'masked'`/`'hashed'`/`'tokenized'`) leaves `atRest` at
775
+ // `emptyProtection()`'s own honest default. Gated to `snk.kind ===
776
+ // 'store'` (`CATEGORY_NODE_KIND`'s `database`/`file`/`object-storage`/
777
+ // `cache`/`client-storage`/`backup`/`export` — `queue` has its own
778
+ // distinct `kind: 'queue'` and is deliberately excluded here). FR-402's
779
+ // own anti-pattern guard ("a cipher present anywhere in the same file
780
+ // or repository cannot alone establish protection for an unrelated
781
+ // store") holds by construction, not by extra code here:
782
+ // `classifyHandling` walks THIS flow's own reconstructed path only,
783
+ // never the whole file/repo, so an unrelated `encrypt()` call
784
+ // elsewhere is structurally invisible to it.
785
+ if (handlingResult === 'encrypted' && snk.kind === 'store') {
786
+ const edge = edgesById.get(edgeIdStr);
787
+ // Defensive only — `edgeIdStr` is the SAME id this flow's own
788
+ // `edgeIds: [edgeIdStr]` uses below, minted earlier in this same
789
+ // `buildDataFlowGraph` call, so `edge` should always be found. Never
790
+ // assume, per this package's own established convention.
791
+ if (edge) edge.protection.atRest = { verdict: 'protected', evidenceGrade: 'code' };
792
+ }
793
+ // Milestone 2, Sub-project G, increment 1 (FR-408/AC-09):
794
+ // `flow.policyVerdict` — real computed logic, replacing the
795
+ // `'not_evaluated'` literal §8 shipped as a Milestone-1 honest default.
796
+ // `classes`/`sinkKind` reuse fields this loop's own earlier passes
797
+ // already read for other purposes (`de.dataClasses`, `snk.subtype` —
798
+ // the FINE-grained SINK_CATEGORIES value; `snk.kind` is the coarser
799
+ // `'store'`/`'log'`/etc. Sub-project C1's own atRest gate above uses).
800
+ // `ctx.destination` reuses Sub-project A's `site.destination
801
+ // ?.literalValue` — never fabricated when unresolved, per
802
+ // `_matchesDestination`'s own fail-closed contract in
803
+ // privacy-sink-policy.js.
804
+ const policyClasses = de.dataClasses ?? [];
805
+ const policySinkKind = snk.subtype;
806
+ const policyCtx = {
807
+ environment: opts.environment || process.env.AGENTIC_SECURITY_ENVIRONMENT || null,
808
+ destination: site.destination?.literalValue ?? null,
809
+ };
810
+ // `policyLoaded` gates on the OPTS FIELD being present (`!= null`),
811
+ // never on `opts.privacySinkPolicy.allow.length > 0` — a policy that
812
+ // genuinely exists but permits nothing yet must still read
813
+ // `'prohibited'` (deny-by-default), while an opt that was never
814
+ // supplied at all (no policy evaluation attempted) must read
815
+ // `'not_evaluated'`. `index.js` is what keeps this distinction real —
816
+ // it only sets `opts.privacySinkPolicy` when a policy file genuinely
817
+ // exists on disk, never coercing a missing file to the loader's own
818
+ // `{allow: []}` "no policy configured" default (see that file's own
819
+ // comment for why `loadPrivacySinkPolicy`'s return value alone cannot
820
+ // make this distinction).
821
+ const policyLoaded = opts.privacySinkPolicy != null;
822
+ let policyVerdict = 'not_evaluated';
823
+ const policyEvidenceRefs = [];
824
+ // A flow whose data element carries NO recognized data class has
825
+ // nothing for a policy engine to have an opinion about — `'prohibited'`
826
+ // would overstate a judgment that never happened (isSinkPermitted's own
827
+ // `if (!classes.length) return false` early return exists for a
828
+ // DIFFERENT reason, precision on the FINDING side, not this field), so
829
+ // this stays the honest `'not_evaluated'` default rather than calling
830
+ // into the policy at all.
831
+ if (policyLoaded && policyClasses.length && policySinkKind) {
832
+ if (isSinkPermitted(policyClasses, policySinkKind, opts.privacySinkPolicy, policyCtx)) {
833
+ policyVerdict = 'permitted';
834
+ const rules = permittingRules(policyClasses, policySinkKind, opts.privacySinkPolicy, policyCtx);
835
+ for (const r of rules) {
836
+ const claim = `Privacy policy permits ${r.class ?? 'any data class'} data to reach sink "${r.sink}"`
837
+ + (r.environment ? ` in environment "${r.environment}"` : '')
838
+ + (r.destination ? ` for a destination matching /${r.destination}/` : '')
839
+ + (r.reason ? `: ${r.reason}` : '');
840
+ const evId = ids.evidenceId(claim, `${site.file}:${site.line}`, [de.id, r.sink, r.class ?? '', r.environment ?? '', r.destination ?? '']);
841
+ if (!evidenceById.has(evId)) {
842
+ evidenceById.set(evId, {
843
+ id: evId, claim, evidenceType: 'policy',
844
+ location: { file: site.file, line: site.line },
845
+ producer: 'privacy-sink-policy', confidenceTier: null,
846
+ snippet: null, timestamp: null, commit: null,
847
+ limitations: [], conflict: null,
848
+ });
849
+ }
850
+ if (!policyEvidenceRefs.includes(evId)) policyEvidenceRefs.push(evId);
851
+ }
852
+ } else {
853
+ policyVerdict = 'prohibited';
854
+ }
855
+ }
856
+ // Milestone 2, Sub-project I, increment 1 (PRD line 909): replaces
857
+ // the §8 `protectionSummary: 'not_assessed'` literal — that line
858
+ // stored an "unsupported independent claim", exactly what PRD line
859
+ // 909 forbids ("must be derived from the individual edge verdicts").
860
+ // `aggregateVerdicts`'s own `_PRECEDENCE` table (protection.js) is
861
+ // documented as built for CROSS-BRANCH, same-dimension aggregation
862
+ // (PRD §8.4's own wording: "one branch protected, one branch
863
+ // unprotected") — NOT for combining one edge's own three DIFFERENT
864
+ // dimensions. Reusing it here is safe ONLY because, for every real
865
+ // edge today, at most ONE of transit/atRest/handling can ever be
866
+ // non-default: `resolveTransitProtectionForSite` is gated to
867
+ // `category === 'external-api'` (-> `kind: 'external'` only) and the
868
+ // atRest block above is gated to `snk.kind === 'store'` only — these
869
+ // two node kinds are mutually exclusive by construction, and
870
+ // `edge.protection.handling` is never written by any code at all.
871
+ // So this reduces, in practice, to "whichever single dimension
872
+ // actually applies to this edge, use its own real verdict; the
873
+ // others are honestly not_assessed and never mask it." **If a
874
+ // future analyzer ever makes TWO of these three dimensions
875
+ // genuinely co-applicable to the SAME edge, this reasoning breaks**
876
+ // — a `protected` dimension could then mask a genuinely-unassessed,
877
+ // RELEVANT other dimension, exactly the false-protected bug class
878
+ // Sub-project H's own gate exists to catch (PRD line 121: "missing
879
+ // evidence is displayed as unknown or not assessed, never as
880
+ // protected"). Revisit this call (a filter-to-evaluated-dimensions
881
+ // rule, or a dedicated cross-dimension precedence) before that
882
+ // happens — do not assume this reasoning still holds.
883
+ //
884
+ // A second, separate disclosed fragility: `PROTECTION_VERDICTS`
885
+ // includes `'not_applicable'`, which is NOT a `FLOW_SUMMARY_VALUES`
886
+ // member (schema.js) — confirmed directly. No producer today ever
887
+ // sets a dimension's verdict to `'not_applicable'`, so
888
+ // `aggregateVerdicts` can never actually return it here — but if a
889
+ // future analyzer ever does, `flow.protectionSummary` could fail
890
+ // `validateGraph`. See `test/lineage/protection-summary.test.js`'s
891
+ // `I1/5` for the currently-true, narrower claim this rests on.
892
+ const flowEdge = edgesById.get(edgeIdStr);
893
+ // Defensive only, mirroring this loop's own established convention
894
+ // (see the atRest block above) — `flowEdge` should always be found,
895
+ // the SAME id this flow's own `edgeIds: [edgeIdStr]` uses below.
896
+ const protectionSummary = flowEdge
897
+ ? aggregateVerdicts([
898
+ flowEdge.protection.transit.verdict,
899
+ flowEdge.protection.atRest.verdict,
900
+ flowEdge.protection.handling.verdict,
901
+ ])
902
+ : 'not_assessed';
903
+ flowsById.set(fId, {
904
+ id: fId, dataElementIds: [de.id], source: src.id, sink: snk.id,
905
+ edgeIds: [edgeIdStr], transformationIds: sortedT,
906
+ alternatePathCount: group.length - 1,
907
+ policyVerdict,
908
+ protectionSummary,
909
+ evidenceRefs: policyEvidenceRefs,
910
+ confidence: g.grade === 'explicit' ? { score: 0.8, tier: 'high' } : { score: 0.5, tier: 'medium' },
911
+ // Deliverable #10 (DFG-020, graph-derived DPIA/RoPA migration):
912
+ // opts.resolveGovernanceRefs(dataClasses) -> governance-field record,
913
+ // applied at this exact mint point — same additive-hook shape every
914
+ // sibling hook in this file uses (resolveSiteDecision/resolveDestination/
915
+ // resolveTransitProtection), byte-identical graph when omitted. Never
916
+ // fabricates a governance fact — the hook itself (composed by
917
+ // coverage.js's default) only ever attaches operator-supplied config or
918
+ // the MANUAL_REQUIRED sentinel dataflow/privacy-governance.js already
919
+ // establishes; this mint site has no opinion of its own.
920
+ coverageStatus: snk.coverageStatus, findingRefs: [],
921
+ governanceRefs: opts.resolveGovernanceRefs?.(de.dataClasses ?? []) ?? {},
922
+ limitations,
923
+ evidenceGrade: g.grade,
924
+ handling: handlingResult,
925
+ });
926
+ }
927
+
928
+ // Milestone 2, Sub-project D, increment 2 (FR-307): appliesToAllPaths.
929
+ // Must run AFTER groupsByFlowKey is fully populated (every flow group for
930
+ // every sink has been discovered) and BEFORE transformsById is read into
931
+ // graph.transformations — see DESIGN_HANDLING_ANALYZER.md §5 for the full
932
+ // rule and why no special-casing is needed for a truncated/incomplete path.
933
+ const coarseGroups = new Map();
934
+ for (const [, group] of groupsByFlowKey) {
935
+ const { src, snk, de, sortedT } = group[0];
936
+ const coarseKey = `${src.id}|${snk.id}|${de.id}`;
937
+ if (!coarseGroups.has(coarseKey)) coarseGroups.set(coarseKey, []);
938
+ coarseGroups.get(coarseKey).push(sortedT);
939
+ }
940
+ for (const flowsSortedT of coarseGroups.values()) {
941
+ const relevantIds = new Set(flowsSortedT.flat());
942
+ for (const tid of relevantIds) {
943
+ const appliesToAll = flowsSortedT.every((st) => st.includes(tid));
944
+ const t = transformsById.get(tid);
945
+ t.appliesToAllPaths = t.appliesToAllPaths === null ? appliesToAll : (t.appliesToAllPaths && appliesToAll);
946
+ }
947
+ }
948
+
949
+ // AC-11's coarse half, source side: every seeded source is a node and a
950
+ // data element even when nothing reached a sink from it.
951
+ for (const s of seeds) {
952
+ const n = sourceNodeFor(s);
953
+ const d = mintDataElement(s);
954
+ if (!n.dataElementIds.includes(d.id)) n.dataElementIds.push(d.id);
955
+ }
956
+
957
+ // §5's enumerator union: §16.7's degraded terminals become `unresolved`
958
+ // nodes with the vocabulary DESIGN_REGISTRIES.md already fixed.
959
+ const degraded = degradedTerminals(store);
960
+ for (const dn of degraded) {
961
+ const n = mintNode({
962
+ kind: 'unresolved', category: null, coverageStatus: 'partial', externality: 'unknown',
963
+ coverageReason: 'analysis degraded: a context-cap-degraded hop ends here, so this endpoint\'s continuation was never analyzed (DESIGN_PATH_PROVENANCE.md §16.7 Finding 2)',
964
+ subtypeKey: 'context-cap-degraded', lifecycleStages: [],
965
+ });
966
+ const seed = seedByDe.get(dn.dataElementId);
967
+ if (seed) {
968
+ const d = mintDataElement(seed);
969
+ if (!n.dataElementIds.includes(d.id)) n.dataElementIds.push(d.id);
970
+ }
971
+ }
972
+
973
+ const byId = (a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0);
974
+ graph.nodes = [...nodesById.values()].sort(byId);
975
+ graph.dataElements = [...deById.values()].sort(byId);
976
+ graph.edges = [...edgesById.values()].sort(byId);
977
+ graph.transformations = [...transformsById.values()].sort(byId);
978
+ graph.flows = [...flowsById.values()].sort(byId);
979
+ // Milestone 2, Sub-project G, increment 1: the first real populator of
980
+ // `graph.evidence[]` — every permitting policy rule minted above.
981
+ graph.evidence = [...evidenceById.values()].sort(byId);
982
+ // Milestone 4, FR-506 (Third-Party and Cross-Border Intelligence): a
983
+ // THIRD, SEPARATE, additive hook of the identical shape — composes with,
984
+ // never replaces, `resolveDestination`/`resolveSiteDecision` above.
985
+ // Deliberately NOT gated on `site.destination` being present: unlike a
986
+ // protection/policy verdict, a recipient's technical-provider match can
987
+ // resolve from `site.entry.framework` alone (e.g. a receiver-based
988
+ // `anthropic.messages.create()` SDK call, AC-07's own real fixture shape,
989
+ // whose destination frequently never resolves past 'unknown'/'dynamic' —
990
+ // there is no literal URL to resolve) — skipping every site with no
991
+ // destination would silently drop exactly that real-world case. Runs
992
+ // here, after nodes/edges/flows/dataElements are populated (not right
993
+ // after resolveDestination, where it originally ran), specifically so
994
+ // `computeGraphDigest(graph)` inside `buildRecipientProfile` hashes real
995
+ // graph content instead of the still-empty envelope (fix-round-1, B2). A
996
+ // no-op when omitted, mirroring every sibling hook's own proven
997
+ // contract.
998
+ if (typeof opts.buildRecipientProfile === 'function') {
999
+ for (const site of sites) {
1000
+ const profile = opts.buildRecipientProfile(site, graph);
1001
+ if (profile) {
1002
+ // fix-round-1, B3: `sinkNodeFor(site).id` is the real, stable
1003
+ // graph node id (`mintNode` dedups by id, so calling it again
1004
+ // here is idempotent) — `site.nodeId` is a CFG-parse-local
1005
+ // counter value (`parser-js.js`'s `_nodeIdSeq`, e.g. `"n6"`),
1006
+ // never a real graph node id, which made `--filter` a permanent
1007
+ // no-op against `contributingGraphIds`.
1008
+ const nodeId = sinkNodeFor(site).id;
1009
+ const existing = recipientProfilesById.get(profile.id);
1010
+ if (existing) {
1011
+ existing.contributingGraphIds = [...new Set([...existing.contributingGraphIds, nodeId])];
1012
+ // fix-round-1, M6: an order-dependent dedup previously kept only
1013
+ // the FIRST-seen site's whole profile, silently dropping a
1014
+ // later site's non-null facts (e.g. a `technicalEndpoint` the
1015
+ // first site never resolved). Merge any fact field the existing
1016
+ // record left empty from this later, non-empty profile.
1017
+ for (const field of RECIPIENT_FACT_FIELDS) {
1018
+ const existingEmpty = existing[field] == null || (Array.isArray(existing[field]) && existing[field].length === 0);
1019
+ const incomingPopulated = profile[field] != null && !(Array.isArray(profile[field]) && profile[field].length === 0);
1020
+ if (existingEmpty && incomingPopulated) {
1021
+ existing[field] = profile[field];
1022
+ existing.fieldEvidence[field] = profile.fieldEvidence[field];
1023
+ }
1024
+ }
1025
+ } else {
1026
+ recipientProfilesById.set(profile.id, { ...profile, contributingGraphIds: [nodeId] });
1027
+ }
1028
+ }
1029
+ }
1030
+ }
1031
+ // Milestone 4, FR-506: unlike `graph.evidence[]` immediately above
1032
+ // (which IS required/core-schema, validated by `validate.js`'s
1033
+ // `_validateEvidence`/`EVIDENCE_TYPES`), `graph.recipientProfiles` is
1034
+ // the FIRST §10.10 extension-record array ever attached directly to
1035
+ // the graph object — every prior extension contract
1036
+ // (ObligationMapping/DecisionStory/GraphSnapshot) is a wholly separate
1037
+ // artifact, never stored on the built graph itself. Never in
1038
+ // `dataflow-graph.schema.json`, never routed through `validateGraph()`.
1039
+ graph.recipientProfiles = [...recipientProfilesById.values()].sort(byId);
1040
+ // M5 deliverable #8 (FR-304 "declared" half only, §10.10): a SIXTH
1041
+ // additive hook of the identical shape — `opts.crossRepoLinks(graph) ->
1042
+ // CrossRepoLink[]`. Runs here, after nodes/edges/flows/dataElements AND
1043
+ // recipientProfiles are populated, mirroring `opts.buildRecipientProfile`'s
1044
+ // own placement exactly: the hook can validate a declared `local.nodeId`
1045
+ // against the CURRENT graph's own real node set (a stale declaration, from
1046
+ // before a node was renamed/removed in a later rescan, is DROPPED and
1047
+ // reported by the hook itself, never silently kept stale — matching
1048
+ // `applyScenario`'s own "skippedOperations, never thrown" honesty
1049
+ // precedent; see `coverage.js`'s default hook for where that drop/report
1050
+ // logic lives). `graph.crossRepoLinks` is always present (mirrors
1051
+ // `graph.recipientProfiles`'s own "always an array, possibly empty" shape,
1052
+ // not `graph.runtimeCorroboration`'s own "genuinely absent when the hook
1053
+ // is omitted" shape — a CrossRepoLink array has no not_evaluated-vs-empty
1054
+ // distinction to preserve the way runtime corroboration does). Never in
1055
+ // `dataflow-graph.schema.json`, never routed through `validateGraph()` —
1056
+ // the SECOND §10.10 extension array ever attached directly to the graph
1057
+ // object (after `graph.recipientProfiles[]`).
1058
+ graph.crossRepoLinks = typeof opts.crossRepoLinks === 'function'
1059
+ ? [...(opts.crossRepoLinks(graph) || [])].sort(byId)
1060
+ : [];
1061
+ // M5 deliverable #7 (FR-505 §7.12, AC-29): a FIFTH additive hook of the
1062
+ // identical shape — `opts.correlateObservations(graph) -> correlationResult
1063
+ // | undefined`. Runs here, after nodes/edges/flows/dataElements AND
1064
+ // recipientProfiles are populated, for the same reason
1065
+ // `opts.buildRecipientProfile` was moved here in its own fix round: the hook
1066
+ // reads the real, finished graph, never a still-empty envelope.
1067
+ //
1068
+ // Assigned ONLY when the hook returns truthy — `graph.runtimeCorroboration`
1069
+ // is genuinely ABSENT otherwise, never `null` and never `{}`. That is what
1070
+ // makes the output byte-identical to a build with no hook at all (the
1071
+ // contract `M2A1/hook-1` proved for `resolveDestination`), and it is also
1072
+ // AC-29 clause 2's `not_evaluated` state expressed at the top level: a graph
1073
+ // with no `runtimeCorroboration` key was never evaluated against any
1074
+ // observation store, which is a DIFFERENT answer from a graph that was
1075
+ // evaluated and found nothing in the window (PRD line 2098 — non-observation
1076
+ // is never non-occurrence).
1077
+ //
1078
+ // Deliberately does NOT touch `edge.provenance`, which stays `'code'` on
1079
+ // every edge. Corroboration is ADDITIVE: an observed edge is still a
1080
+ // code-derived edge that was ALSO observed. Flipping `edge.provenance` needs
1081
+ // a deliberate provenance-partitioning pass through impact-engine.js,
1082
+ // decision-story.js, obligation-predicates.js and the export family first —
1083
+ // that is M2 Sub-project F2/F3's job, not this one's. See the M5 #7 scoping
1084
+ // doc §4.5 and §3's "edge.provenance has zero consumers" finding.
1085
+ if (typeof opts.correlateObservations === 'function') {
1086
+ const corroboration = opts.correlateObservations(graph);
1087
+ if (corroboration) graph.runtimeCorroboration = corroboration;
1088
+ }
1089
+ // §10's SKETCH of the coverage ledger. E4 owns the finished contract.
1090
+ graph.coverage = {
1091
+ languages: [], parseFailures: [],
1092
+ destinationResolutionStatus: 'not-attempted',
1093
+ pathBudgetTruncation: stats.truncatedQueries > 0,
1094
+ sources: { matched: seeds.length, unseedable: unseedable.length, dataElements: deById.size },
1095
+ sinks: {
1096
+ callStatementSites: sites.length,
1097
+ connected: stats.connectedSinkSites,
1098
+ disconnected: sites.length - stats.connectedSinkSites,
1099
+ nonStatementSitesNotEnumerable: nonStatementSites.length,
1100
+ },
1101
+ degradedTerminals: degraded.length,
1102
+ paths: { enumerated: stats.pathsEnumerated, projected: stats.pathsProjected, truncatedQueries: stats.truncatedQueries },
1103
+ provenance: { hops: hops.length, pnodes: store.stats().nodes, pedges: store.stats().edges },
1104
+ };
1105
+ graph.limitations = [
1106
+ 'Protection verdicts are not assessed in Milestone 1: every edge carries not_assessed/none on all three dimensions.',
1107
+ 'Policy verdicts are not evaluated in Milestone 1.',
1108
+ 'External destinations are not resolved (FR-202 is Milestone 2); protocol.destinationResolution is "unknown" on every edge.',
1109
+ ];
1110
+ graph.scanHealth = { status: 'complete', reason: 'lineage-graph-builder' };
1111
+ graph.taxonomy = { version: '1.0.0', source: 'built-in + CONFIDENTIAL extension' };
1112
+
1113
+ return { graph, store, hops, seeds, unseedable, sites, nonStatementSites, degraded, stats, decisionsByNodeId };
1114
+ }