@clear-capabilities/agentic-security-scanner 0.144.0 → 0.147.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (209) hide show
  1. package/CHANGELOG.md +390 -0
  2. package/bin/agentic-security.js +3813 -83
  3. package/dist/1122.index.js +702 -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} +13 -4
  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/2271.index.js +165 -0
  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 +307 -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} +14 -6
  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} +14 -6
  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} +67 -3
  35. package/dist/5051.index.js +770 -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} +29 -7
  42. package/dist/{449.index.js → 5830.index.js} +78 -14
  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} +26 -8
  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} +580 -187
  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/package.json +23 -10
  72. package/src/compare.js +6 -1
  73. package/src/dataflow/CLAUDE.md +2 -2
  74. package/src/dataflow/catalog.js +42 -0
  75. package/src/dataflow/orm-write-catalog.js +175 -0
  76. package/src/engine.js +580 -30
  77. package/src/fix/apply-fix-service.js +1 -0
  78. package/src/history-scan.js +22 -5
  79. package/src/ir/CLAUDE.md +2 -1
  80. package/src/ir/chrome-probe.mjs +150 -0
  81. package/src/ir/parser-js.js +94 -7
  82. package/src/lineage/CLAUDE.md +1203 -0
  83. package/src/lineage/DESIGN_DESTINATION_RESOLVER.md +156 -0
  84. package/src/lineage/DESIGN_GRAPH_BUILDER.md +938 -0
  85. package/src/lineage/DESIGN_HANDLING_ANALYZER.md +355 -0
  86. package/src/lineage/DESIGN_INTRAPROCEDURAL.md +628 -0
  87. package/src/lineage/DESIGN_PATH_PROVENANCE.md +3451 -0
  88. package/src/lineage/DESIGN_QUEUE_DETAIL.md +120 -0
  89. package/src/lineage/DESIGN_REGISTRIES.md +880 -0
  90. package/src/lineage/DESIGN_STORE_DETAIL.md +143 -0
  91. package/src/lineage/DESIGN_TRANSIT_PROTECTION.md +245 -0
  92. package/src/lineage/classification.js +56 -0
  93. package/src/lineage/coverage.js +658 -0
  94. package/src/lineage/cross-repo-link.js +107 -0
  95. package/src/lineage/dataflow-graph.schema.json +184 -0
  96. package/src/lineage/decision-story.js +206 -0
  97. package/src/lineage/drift-policy.js +279 -0
  98. package/src/lineage/driver.js +135 -0
  99. package/src/lineage/engine.js +992 -0
  100. package/src/lineage/export-briefing.js +628 -0
  101. package/src/lineage/export-csv.js +62 -0
  102. package/src/lineage/export-json.js +238 -0
  103. package/src/lineage/export-privacy.js +258 -0
  104. package/src/lineage/federation-loader.js +111 -0
  105. package/src/lineage/field-identity.js +78 -0
  106. package/src/lineage/fixtures/build-flagship-fixture.mjs +272 -0
  107. package/src/lineage/fixtures/flagship-graph.json +1453 -0
  108. package/src/lineage/flow-grade.js +221 -0
  109. package/src/lineage/governance-edit.js +169 -0
  110. package/src/lineage/graph-builder.js +1114 -0
  111. package/src/lineage/graph-diff.js +431 -0
  112. package/src/lineage/graph-snapshot.js +180 -0
  113. package/src/lineage/handling-analyzer.js +168 -0
  114. package/src/lineage/ids.js +349 -0
  115. package/src/lineage/impact-assessment.js +76 -0
  116. package/src/lineage/impact-engine.js +268 -0
  117. package/src/lineage/index.js +281 -0
  118. package/src/lineage/language-coverage-tiers.js +58 -0
  119. package/src/lineage/obligation-mapping.js +126 -0
  120. package/src/lineage/obligation-predicates.js +235 -0
  121. package/src/lineage/observation-adapters.js +282 -0
  122. package/src/lineage/observation-correlation.js +622 -0
  123. package/src/lineage/observation-store.js +497 -0
  124. package/src/lineage/path-query.js +410 -0
  125. package/src/lineage/path-store.js +400 -0
  126. package/src/lineage/protection.js +53 -0
  127. package/src/lineage/recipient-profile.js +192 -0
  128. package/src/lineage/recipient-registry.js +394 -0
  129. package/src/lineage/redact-graph.js +224 -0
  130. package/src/lineage/remediation.js +417 -0
  131. package/src/lineage/resolve-destination.js +91 -0
  132. package/src/lineage/runtime-observation.js +464 -0
  133. package/src/lineage/scenario-diff.js +84 -0
  134. package/src/lineage/scenario-engine.js +251 -0
  135. package/src/lineage/scenario.js +101 -0
  136. package/src/lineage/schema.js +167 -0
  137. package/src/lineage/sink-registry.js +427 -0
  138. package/src/lineage/source-registry.js +357 -0
  139. package/src/lineage/source-seeding.js +212 -0
  140. package/src/lineage/summaries.js +590 -0
  141. package/src/lineage/transform-catalog.js +397 -0
  142. package/src/lineage/transit-protection.js +150 -0
  143. package/src/lineage/validate.js +285 -0
  144. package/src/lsp/server.js +49 -2
  145. package/src/mcp/CLAUDE.md +7 -1
  146. package/src/mcp/dataflow-tools.js +160 -0
  147. package/src/mcp/server.js +1 -1
  148. package/src/mcp/tools.js +22 -1
  149. package/src/pipeline/assurance-mode.js +64 -1
  150. package/src/pipeline/finding-schema.js +8 -1
  151. package/src/pipeline/scan-health.js +19 -1
  152. package/src/posture/CLAUDE.md +140 -0
  153. package/src/posture/accuracy-scorecard.js +60 -0
  154. package/src/posture/artifact-registry.js +76 -0
  155. package/src/posture/auditor-walkthrough.js +192 -13
  156. package/src/posture/compliance-frameworks/hipaa-security-rule.json +3 -2
  157. package/src/posture/compliance-policy.js +12 -2
  158. package/src/posture/cross-repo-memory.js +7 -2
  159. package/src/posture/fix-history.js +25 -2
  160. package/src/posture/fix-verify.js +9 -1
  161. package/src/posture/fleet.js +0 -0
  162. package/src/posture/git-history.js +13 -5
  163. package/src/posture/material-change.js +21 -2
  164. package/src/posture/mttr.js +75 -12
  165. package/src/posture/obligation-evidence-pack.js +202 -0
  166. package/src/posture/pre-incident-archaeology.js +39 -7
  167. package/src/posture/privacy-framework.js +14 -0
  168. package/src/posture/provenance/ai-authorship.js +68 -0
  169. package/src/posture/provenance/branch-entry.js +80 -0
  170. package/src/posture/provenance/cache.js +143 -0
  171. package/src/posture/provenance/confidence.js +36 -0
  172. package/src/posture/provenance/coordinator.js +786 -0
  173. package/src/posture/provenance/dag-walk.js +249 -0
  174. package/src/posture/provenance/evidence-attribution.js +59 -0
  175. package/src/posture/provenance/git-evidence.js +310 -0
  176. package/src/posture/provenance/lifecycle.js +208 -0
  177. package/src/posture/provenance/missing-control-resolver.js +137 -0
  178. package/src/posture/provenance/origin-resolver.js +342 -0
  179. package/src/posture/provenance/predicate-replay.js +133 -0
  180. package/src/posture/provenance/providers/config.js +39 -0
  181. package/src/posture/provenance/providers/github.js +62 -0
  182. package/src/posture/provenance/providers/gitlab.js +58 -0
  183. package/src/posture/provenance/repo-lineage.js +74 -0
  184. package/src/posture/provenance/sca-origin.js +139 -0
  185. package/src/posture/provenance/schema.js +255 -0
  186. package/src/posture/provenance/transitive-sca.js +147 -0
  187. package/src/posture/provenance/validate.js +30 -0
  188. package/src/posture/provenance-evidence-bundle.js +144 -0
  189. package/src/posture/remediation-ledger.js +337 -0
  190. package/src/posture/sbom-diff.js +15 -2
  191. package/src/posture/secret-history.js +10 -2
  192. package/src/posture/state-dir.js +38 -14
  193. package/src/posture/vuln-archaeology.js +8 -2
  194. package/src/pr-delta.js +25 -4
  195. package/src/report/index.js +197 -3
  196. package/src/runScan.js +34 -5
  197. package/src/sast/rate-limit.js +33 -3
  198. package/src/server/CLAUDE.md +47 -0
  199. package/src/server/graph-loader.js +141 -0
  200. package/src/server/http-server.js +325 -0
  201. package/src/server/routes.js +129 -0
  202. package/src/server/security.js +111 -0
  203. package/src/server/static-assets.js +139 -0
  204. package/src/util/git-hardening.js +128 -0
  205. package/dist/11.index.js +0 -353
  206. package/dist/259.index.js +0 -975
  207. package/dist/317.index.js +0 -300
  208. package/dist/609.index.js +0 -741
  209. package/dist/838.index.js +0 -152
@@ -0,0 +1,938 @@
1
+ # DESIGN_GRAPH_BUILDER.md — Sub-project E's binding design record
2
+
3
+ **Status:** landed as Sub-project E, increment **E1** (design spike). Binding
4
+ on E2, E3, E4 and E5, the same way `DESIGN_INTRAPROCEDURAL.md` binds
5
+ Sub-projects A/B, `DESIGN_PATH_PROVENANCE.md` binds C, and
6
+ `DESIGN_REGISTRIES.md` binds D.
7
+
8
+ Every number in this document was produced by running real code against the
9
+ live tree in the increment that wrote it — never quoted from an upstream
10
+ scoping document. The proofs live in `test/lineage/graph-builder-poc.test.js`
11
+ (deliberately throwaway; see §9.1) and `test/lineage/driver.test.js`'s three
12
+ `E1/driver-*` tests. Where this document and that PoC disagree, the PoC is
13
+ right and this document is stale — fix it here, do not fork it.
14
+
15
+ ---
16
+
17
+ ## 1. What this sub-project actually is
18
+
19
+ A-D built machinery. **Nothing calls it.** `grep -rn "lineage/" src bin` from
20
+ `scanner/`, excluding `src/lineage/` itself, returns exactly one hit and it is
21
+ a comment. Every one of the fifteen shipped modules is reachable only from
22
+ `test/lineage/*`.
23
+
24
+ Sub-project E is the first increment family whose deliverable is an
25
+ **artifact** rather than a capability: a `DataFlowGraph v1` document, produced
26
+ from a real repository, that `validateGraph()` accepts and that a human can
27
+ read without being lied to.
28
+
29
+ Two mechanisms had to be settled before any of E2-E5 could be written, and
30
+ both are settled here:
31
+
32
+ 1. **Seeding** (§3) — how a real call site becomes a field-identity entry
33
+ state. Today the shipped driver hardcodes `emptyState()` and produces
34
+ **exactly zero hops** on real code. Measured, twice, in `E1/1`.
35
+ 2. **Projection** (§6) — how a provenance DAG that is *variable*-granular
36
+ (494 pnodes for a 33-file project) becomes a `DataFlowGraph v1` document
37
+ that is *system*-granular (14 nodes for a whole synthetic platform).
38
+
39
+ ---
40
+
41
+ ## 2. Ground truth, re-measured in this increment
42
+
43
+ ### 2.1 The shipped driver, unchanged, on real code
44
+
45
+ | Project | files | functions | **hops** | pnodes / pedges | `sinkCandidates()` |
46
+ |---|---|---|---|---|---|
47
+ | `test/fixtures/vulnerable-js` | 1 | 8 | **0** | 0 / 0 | 0 |
48
+ | `frontend/` | 33 | 441 | **0** | 0 / 0 | 0 |
49
+
50
+ Reproduced exactly. `E1/1` pins it as a shipped test so the gap cannot close
51
+ by accident.
52
+
53
+ ### 2.2 The registry matchers, at real call sites in `vulnerable-js`
54
+
55
+ - `matchSource` — **9 call-site matches, 3 distinct entries**
56
+ (`js-req-body`, `js-req-params`, `js-req-query`). Reproduced exactly.
57
+ - `matchSinkOrSanitizer` (sink-kind hits only) — **12 matches, 6 distinct
58
+ entries**, *when counted once per CFG `call` statement node*. Counting every
59
+ call sub-expression instead gives **13**, because `res.send(eval(x))` and
60
+ the separate CFG node for `eval(x)` are the same call twice. The scoping
61
+ doc's 12 is the per-CFG-node count, and **the per-CFG-node count is the one
62
+ the design uses** (§4.1) — not for agreement's sake, but because a CFG
63
+ `call` statement node is the only shape that produces an `escape`
64
+ provenance node to reconstruct from.
65
+
66
+ ### 2.3 The synthetic-seed comparison, and one honest non-reproduction
67
+
68
+ `vulnerable-js` reproduces the scoping doc's synthetic-seed table **in every
69
+ column**: 7 functions seeded, 21/21 hops offered/accepted, 9 join groups,
70
+ 16 pnodes / 9 pedges, 9 sink candidates (all `escape`), 9 paths, all complete.
71
+
72
+ `frontend/` does **not** reproduce (doc: 1253/1048 hops, 369 groups, 494/418,
73
+ 234 candidates). The IR is provably identical — 33 files, 441 functions, both
74
+ runs — so the divergence is entirely in the scoping doc's own unspecified
75
+ seeding harness. Six variants were tried (per-parameter vs. one shared
76
+ identity × `cache.set` keyed on the seeded state / on `emptyState()` / not at
77
+ all); the closest was per-parameter + seeded key at **1305/1133**. This is
78
+ recorded rather than chased: the synthetic seed is exactly what §3 replaces,
79
+ and the fixture the design is actually proven against reproduces to the digit.
80
+
81
+ **One variant pair is a real finding, not harness noise:** on `frontend/`,
82
+ keying `cache.set` on `emptyState()` (the shipped line) rather than on the
83
+ state actually analyzed changes accepted hops from **1133 to 1448** and sink
84
+ candidates from **247 to 348** — a 27% swing. §3.4 is why.
85
+
86
+ ---
87
+
88
+ ## 3. Seeding
89
+
90
+ ### 3.1 Where the expressions come from
91
+
92
+ Walk `fn.cfg.nodes` and, per node, the **same expression roots
93
+ `engine.js`'s own `step()` switch reads**: `assign` → `source`, `call` →
94
+ `callee` + `args`, `return` → `value`. Then recurse into every
95
+ sub-expression.
96
+
97
+ Deliberately **not** `fn.reads` / `fn.calls`: D5 already measured that a call
98
+ used as an assignment RHS never reaches `fn.calls[]` at all. Those
99
+ side-channels are incomplete for this purpose and using them would silently
100
+ lose sources. `fn.cfg.nodes` is a plain `Object` at runtime, not a `Map`
101
+ (also D5's finding — `ir/CLAUDE.md`'s header is wrong about this).
102
+
103
+ ### 3.2 The seed-path rule — seed the FIELD, not the container
104
+
105
+ `matchSource` matches `req.body`. The thing that has a field identity is
106
+ `req.body.card_number`. **Extend the matched expression outward through every
107
+ enclosing pure-member access, then take `accessPathOf` of the outermost
108
+ node.** Fall back to the matched expression's own path when it is not the
109
+ object of a member access (`User.create(req.body)`) — that container-level
110
+ seed is the honest answer for that shape.
111
+
112
+ This is not cosmetic. On `vulnerable-js` it is the difference between six
113
+ data elements named `body`/`params`/`query` and six named
114
+ `id`/`password`/`name`/`expr`/`body`/`host` — and
115
+ `classifyDataElementName('password')` returns `['CREDENTIALS']` while
116
+ `classifyDataElementName('body')` returns `[]`. **Classification is
117
+ impossible without this rule.** `E1/2` pins both halves.
118
+
119
+ Seeding at the deeper path is sound: `identitiesAt` aggregates
120
+ bidirectionally, so a later read of `req.body` as a whole still sees every
121
+ field seeded under it, while a read of an *unmatched* sibling correctly sees
122
+ nothing.
123
+
124
+ ### 3.3 The seed record
125
+
126
+ One record per matched call site:
127
+
128
+ ```
129
+ { file, qid, nodeId, line, entryId, seedPath, canonicalName,
130
+ category, coverageStatus, externality, reason, // <- reclassifySource(entry)
131
+ dataElementId, dataClasses } // <- ids.js + classification.js
132
+ ```
133
+
134
+ A matched expression with **no** access path (`accessPathOf` → `null`) is
135
+ recorded in an `unseedable[]` list and counted in the coverage ledger — never
136
+ dropped. Measured on `vulnerable-js`: zero unseedable.
137
+
138
+ ### 3.4 `dataElementId` minting (E1 item **(b)**)
139
+
140
+ ```
141
+ dataElementId(canonicalName, [repository, file, seedPath, category ?? ''])
142
+ ```
143
+
144
+ - `canonicalName` is the **last segment of the seed path** (`card_number`),
145
+ which is what `classifyDataElementName` needs.
146
+ - PRD §10.4 forbids an identifier based only on the field name. The
147
+ discriminator carries a **system proxy** (`repository` + `file`), the
148
+ **access path**, and the **category**.
149
+ - `file` is Milestone 1's only available proxy for §10.4's "service or
150
+ schema". When a real service/module boundary signal exists (Milestone 2's
151
+ system attribution), it belongs in the discriminator *ahead of* `file`, not
152
+ instead of it.
153
+ - **Function scope (`qid`) is deliberately NOT in the discriminator.** Two
154
+ handlers in one file reading `req.body.email` are one logical field far more
155
+ often than two; §10.4's rule is about not merging across *services*, and it
156
+ is already satisfied. Over-splitting is not free — it fragments the graph
157
+ without evidence in either direction.
158
+
159
+ `E1/3` pins all four directions: same name in two files → two ids; two fields
160
+ in one file → two ids; the same field read twice in one file → one id; and
161
+ `dataElementId(name, [])` never equals the real id.
162
+
163
+ ### 3.5 Where seeding lives (E1 item **(a)**) — `driver.js` gets an additive hook
164
+
165
+ **Decision: `opts.seedEntryState(fn) -> state | falsy`, on
166
+ `runFieldIdentityAnalysis`. Implemented in this increment.**
167
+
168
+ The alternative — a wrapper that calls `driver.js`'s lower-level pieces —
169
+ was rejected for three measured reasons:
170
+
171
+ 1. The wrapper would have to duplicate the per-function
172
+ `createCallGraphLookup` / `createCallSummaryResolver` construction
173
+ verbatim. That is a fork that will drift. B4 extracted
174
+ `summaryFromAnalysisResult` precisely to avoid a second, drifting copy of
175
+ a smaller piece of logic than this.
176
+ 2. **The cache-overwrite hazard is inside `driver.js` and a wrapper cannot
177
+ fix it** — it would have to reimplement the loop anyway, i.e. become the
178
+ fork in point 1.
179
+ 3. `opts.recordHop` already established exactly this additive-hook contract
180
+ for exactly this file.
181
+
182
+ **Backward compatibility is proven, not asserted.** With the hook omitted,
183
+ `entryState` *is* `emptyState()`, so both the `analyzeFunctionFieldIdentity`
184
+ call and the `cache.set` below it are byte-identical to the pre-hook line.
185
+ `E1/driver-1` pins the results and the cache keys against a **hardcoded
186
+ golden literal captured from the pre-hook `driver.js`** — never against the
187
+ shipped implementation, which is C3 §13.2a's vacuous-test trap.
188
+
189
+ ### 3.6 The cache-overwrite hazard, and the half nobody had named
190
+
191
+ `driver.js`'s own comment disclosed that `cache.set(fn.qid, emptyState(), …)`
192
+ can overwrite a lazily-computed entry with a *different* value, and that this
193
+ "becomes load-bearing the moment entry states carry real identities."
194
+
195
+ It named the wrong half. The worse half is the **KEY**. Writing a summary
196
+ under `emptyState()` claims "this is what `fn` does when nothing flows in."
197
+ Once a seed puts real identities into `fn`'s entry state that claim is
198
+ **false**, and a later call site resolving `fn` with clean arguments builds an
199
+ empty callee entry state, hits that key, and is handed return facts that exist
200
+ only because the driver seeded them.
201
+
202
+ Measured on a two-function real-parser fixture (`E1/driver-3`):
203
+
204
+ | keying | `caller`'s local `v` after `const v = helper(o)` |
205
+ |---|---|
206
+ | `cache.set(qid, emptyState(), …)` — pre-fix | `['data:SEEDED']` — **fabricated** |
207
+ | `cache.set(qid, entryState, …)` — shipped | `[]` — correct |
208
+
209
+ The fix is one identifier, and it is a no-op when no hook is supplied. It also
210
+ improves `FieldIdentitySummaryCache.compute`'s cap-degradation fallback
211
+ (`this._cache.get(_key(qid, emptyState())) ?? emptyFieldSummary()`), which
212
+ previously degraded to a *seeded* summary and now degrades to an honestly
213
+ empty one.
214
+
215
+ `E1/driver-3` proves **both** directions: a local replica of the pre-fix loop
216
+ (the mutant) fabricates; the shipped driver does not.
217
+
218
+ ### 3.7 What real seeding measures
219
+
220
+ | Project | seeds | hops | pnodes / pedges | node kinds reached |
221
+ |---|---|---|---|---|
222
+ | `vulnerable-js` | 9 | **23** | 15 / 9 | `path`, `escape` |
223
+ | `frontend/` | **0** | 0 | 0 / 0 | — |
224
+
225
+ (Re-measured after the `lineage-engine-receiver-identity-hotfix` — task
226
+ review MF-2. The receiver-identity fix adds real intraprocedural hops that
227
+ were previously silently dropped, so these numbers moved from the design
228
+ task's own original measurement: **19** hops / 14 pnodes / 8 pedges. See
229
+ §11 item 1's RESOLVED note.)
230
+
231
+ Two things follow, and E2/F must not be surprised by either:
232
+
233
+ - **A real seed's `pnodes` count is narrower than the synthetic
234
+ per-parameter seed's, though `hops` and `pedges` are not** (task review
235
+ MF-3 corrected this claim's original overreach): post-fix, `pnodes` is
236
+ 15 vs the synthetic seed's 16 (narrower, by 1); `hops` is 23 vs 21
237
+ (WIDER, since the receiver fix surfaces real hops the synthetic seed's
238
+ own per-parameter approach never modeled); `pedges` is 9 vs 9 (tied). The
239
+ real seed still seeds only what a registry matched — that property
240
+ holds — but "strictly narrower on every dimension" no longer does, and
241
+ this table states the true per-dimension comparison rather than the
242
+ original, now-false blanket claim.
243
+ - **`frontend/` produces zero seeds and therefore zero hops** — not a bug. It
244
+ is a browser prototype with no HTTP-request-shaped source in the catalog.
245
+ Every non-zero number the scoping doc measured for `frontend/` came from the
246
+ synthetic seed. **`frontend/` is not a usable Sub-project E benchmark
247
+ target**; F must pick corpora that contain real sources.
248
+ - Still **zero `loss`, zero `origin`, zero `return`** node kinds on real code,
249
+ matching §2.1's own finding. A "complete" path terminates at an ordinary
250
+ `path` node; §6.3 is how the projection knows which of those is a source.
251
+
252
+ ---
253
+
254
+ ## 4. Registry-backed sink enumeration
255
+
256
+ ### 4.1 The enumeration unit is a CFG `call` STATEMENT node
257
+
258
+ `engine.js`'s `step()` emits `write-out`/`call-arg` — the hop that becomes an
259
+ `escape` provenance node — **only** for a bare `call` CFG node. So that is the
260
+ only shape a sink-rooted `reconstructPaths` can start from.
261
+
262
+ Every other call expression (an assignment RHS, a return value, a nested
263
+ argument) that matches a sink entry is **counted into
264
+ `coverage.sinks.nonStatementSitesNotEnumerable`, never silently dropped**.
265
+ Measured on `vulnerable-js`: 11 statement sites, 1 non-statement site. This is
266
+ a real, named gap for E2/E3 (§11, item 3), not a design choice.
267
+
268
+ Locating a site's escape nodes needs no scan of the store's internals:
269
+ `escape` nodes carry `(scope, siteNodeId)` and are keyed by `(fn.qid,
270
+ cfgNodeId)`. Multiple contexts of one function simply yield multiple escapes
271
+ at the same key, which is correct.
272
+
273
+ ### 4.2 Both catalogs, and one precision cost
274
+
275
+ The enumerator consults `matchSinkOrSanitizer` **and** `matchPrivacySink`.
276
+ Dropping the privacy catalog would drop `logger.info`, i.e. AC-02's own worked
277
+ example.
278
+
279
+ The cost is real and must be in the ledger: `matchPrivacySink` supports no
280
+ receiver constraint at all, so `privacy-js-axios-post` matches
281
+ `app.post('/ping', handler)` on the bare callee name. **3 of `vulnerable-js`'s
282
+ 11 sink sites are that false match**, and they surface as a disconnected
283
+ `external`/`external-api` node with zero data elements. That is honest
284
+ behaviour (a matched sink nothing reached), but E4 must count it and Sub-project
285
+ F must not treat it as recall.
286
+
287
+ ### 4.3 Multi-candidate resolution (E1 item **(d)**) — represent, never pick
288
+
289
+ Three steps, in order:
290
+
291
+ 1. **Promote via the receiver that actually matched.** Keep the candidates
292
+ whose entry declares `match.receiver` or `match.receiverBase` — the
293
+ matcher already verified those textually. If exactly one survives, use it,
294
+ **coverageStatus unchanged**.
295
+ - `match.receiverTypeIn` deliberately does **not** count: it gates on a
296
+ CHA-resolved class type and is vacuously allowed whenever no
297
+ `receiverType` is supplied, which is always here. D5 already measured
298
+ this exact trap; treating it as specificity would promote on evidence
299
+ that was never checked.
300
+ - This reads a returned candidate's own declared constraint. It does not
301
+ re-run any matching, so the Global Constraint against re-deriving
302
+ `catalog.js`'s work holds.
303
+ 2. **Unanimous category** among the survivors → use it.
304
+ 3. **Plurality**, otherwise: one node at the plurality category,
305
+ `coverageStatus: 'partial'`, and a `reason` naming every alternative
306
+ category *and* every candidate entry id — `sink-registry.js`'s own
307
+ `thirdPartySdk` convention, reused rather than a second convention
308
+ invented.
309
+
310
+ Measured (`E1/9`):
311
+
312
+ | call site | candidates | resolution |
313
+ |---|---|---|
314
+ | `res.send(x)` | `js-express-res-send`, `js-koa-send`, `privacy-js-res-send` | **receiver** → `http-response`, `modeled` |
315
+ | `ctx.send(x)` | `js-koa-send`, `privacy-js-res-send` — task review MF-3: `js-express-res-send` is filtered out here by `matchSinkOrSanitizer`'s own `_receiverAllowed`, since the receiver isn't `res`; only TWO candidates, not three | **plurality** → `file`, `partial`, alternatives named |
316
+ | bare `send(x)` | same two as `ctx.send(x)` | **plurality** → `file`, `partial`, alternatives named |
317
+
318
+ **Disclosed weakness:** step 3's "plurality" is frequently a 1-1 tie, and the
319
+ tie is broken by lexicographic category order. That is deterministic but
320
+ arbitrary. It is mitigated, not solved, by the mandatory demotion to `partial`
321
+ and by naming every alternative in the reason — a reader is never shown a
322
+ confident wrong answer. A real fix needs per-call-site type information the
323
+ lineage package does not have.
324
+
325
+ ---
326
+
327
+ ## 5. §16.7 Finding 2's enumerator (E1 item **(e)**) — RESOLVED, `diagnostics()`-union
328
+
329
+ `DESIGN_PATH_PROVENANCE.md` §16.7 offers two mechanisms. **E uses the union
330
+ mechanism**, and the question it left open — "is this genuinely computable
331
+ from `path-store.js`'s public read API alone?" — is answered **yes**,
332
+ measured, not argued:
333
+
334
+ ```js
335
+ store.nodes()
336
+ .filter((n) => store.edgesFrom(n.id).length === 0)
337
+ .filter((n) => store.edgesTo(n.id).length > 0)
338
+ .filter((n) => store.edgesTo(n.id).some((e) =>
339
+ e.lossReasons.some((r) => DEGRADED_LOSS_REASONS.includes(r))
340
+ || e.annotations.some((a) => DEGRADED_LOSS_REASONS.includes(a.lossReason))))
341
+ ```
342
+
343
+ Three public methods, one exported constant from `flow-grade.js`. **No change
344
+ to `path-store.js`, and no sixth node kind** (which would have moved every
345
+ `pnode:`/`pedge:`/`ppath:` id in the tree).
346
+
347
+ The `annotations[]` half of the check is not optional: §16.5 measured that a
348
+ genuine degradation reason can live *only* there, with the edge's own
349
+ `lossReasons` empty.
350
+
351
+ Proven live in `E1/10` on real parsed code, in both directions: at
352
+ `maxContextsPerFn: 16` there is no degradation and the enumerator finds
353
+ **zero** terminals (it does not over-fire); at `maxContextsPerFn: 2` a real
354
+ `context-cap-degraded` hop appears and the enumerator finds **exactly one**
355
+ `path` node with zero out-edges — a node `sinkCandidates()` cannot return,
356
+ because its kind is not `return`/`escape`/`loss`. That is §16.7 Finding 2
357
+ exactly, closed.
358
+
359
+ The node minted for it uses **the vocabulary already fixed by
360
+ `DESIGN_REGISTRIES.md`'s closing section**, re-derived nowhere:
361
+ `kind: 'unresolved'`, `coverageStatus: 'partial'`, `externality: 'unknown'`,
362
+ with a `reason` naming the context-cap degradation.
363
+
364
+ **Keep it structurally distinct from FR-203's `unresolved`.** FR-203 (E4's
365
+ other closure) is `reclassifySink(entry, {destinationUnresolved,
366
+ blockingExpression})`: classification *succeeded*, only the destination is
367
+ dynamic, so `coverageStatus` carries over **unchanged**. §16.7's is
368
+ `partial` **unconditionally**, because the analysis itself is incomplete.
369
+ `sink-registry.js`'s header warns about conflating them at length; do not.
370
+
371
+ ---
372
+
373
+ ## 6. The projection (E1 item **(c)**)
374
+
375
+ ### 6.1 The rule, in one sentence
376
+
377
+ **A `DataFlowGraph v1` node is a REGISTRY DECISION. An edge and a flow are a
378
+ PATH.**
379
+
380
+ Node identity is
381
+ `nodeId(kind, [repository, subtypeKey, coverageStatus, externality, destination])`
382
+ where `subtypeKey` is the registry `category`, or — for a null-category
383
+ decision — `unsupported-sink:<CWE>` / `unsupported-source:<entryId>` so
384
+ `js-exec` (CWE-78) and `js-eval` (CWE-95) stay two nodes rather than
385
+ collapsing into one anonymous `process`.
386
+
387
+ `destination` is in the discriminator and is **always `''` in Milestone 1**.
388
+ It is there so that when FR-202 resolves destinations in Milestone 2,
389
+ "PostgreSQL prod" and "PostgreSQL analytics" split into two nodes without a
390
+ discriminator change — the same way the flagship fixture already distinguishes
391
+ Payment API from Analytics API.
392
+
393
+ ### 6.2 Why not per-provenance-node, and why not per-call-site
394
+
395
+ - **Per-pnode** is what §2.1 already rejected: 494 nodes for one 33-file
396
+ project.
397
+ - **Per-call-site** was measured and rejected here: `vulnerable-js` alone
398
+ would produce ~14 nodes — the *same count as the entire flagship platform*,
399
+ for a 42-line file.
400
+ - **Per-registry-decision** gives a node count bounded by the **taxonomy**,
401
+ not by the repository: at most `|SOURCE_CATEGORIES| + |SINK_CATEGORIES| +
402
+ (distinct unsupported CWEs) + 1`, i.e. under ~60 for any repository of any
403
+ size.
404
+
405
+ Measured (`E1/7`), replicating `vulnerable-js` 1× / 10× / 50×:
406
+
407
+ | copies | functions | **nodes** | edges | flows | dataElements |
408
+ |---|---|---|---|---|---|
409
+ | 1 | 8 | **9** | 6 | 6 | 6 |
410
+ | 10 | 80 | **9** | 60 | 60 | 60 |
411
+ | 50 | 400 | **9** | 300 | 300 | 300 |
412
+ | 200 | 1600 | **9** | 1200 | 1200 | 1200 |
413
+
414
+ (the 200× row was measured during the spike at 104 ms; the shipped test stops
415
+ at 50× to keep `test:lineage` fast.)
416
+
417
+ Nodes are invariant; everything field- or path-granular scales linearly. That
418
+ invariance **is** the projection rule.
419
+
420
+ ### 6.3 Source attachment
421
+
422
+ A reconstructed `Path` carries a `dataElementId`, and every data element id in
423
+ the store came from exactly one seed. So `seedByDataElementId.get(p.dataElementId)`
424
+ is the source join — an O(1) map lookup, no path-shape heuristics. A path
425
+ whose `dataElementId` has no seed is skipped and would be a diagnostic (none
426
+ observed).
427
+
428
+ This is what §2.1's "on real code every complete path terminates at an
429
+ ordinary `path` node, and nothing in the store says which of those is a
430
+ source" needed: the store never needs to know. The seed ledger knows.
431
+
432
+ ### 6.4 Edges and flows, and FR-305
433
+
434
+ **One graph edge per `(sourceNode, sinkNode, fromPath, toPath, dataElementId,
435
+ mappingType, transformationIds)`.** `toPath` is `null` — a call argument is
436
+ not an access path, and fabricating one is Decision 5's forbidden bug class.
437
+
438
+ **One flow per `(sourceNode, sinkNode, dataElementId, Path.shape,
439
+ gradePath().grade, ordered transformation ids)`**, with
440
+ `alternatePathCount = collapsedPaths - 1`.
441
+
442
+ That key is FR-305 (*"deduplication may collapse identical internal segments
443
+ but cannot hide materially different transformations or controls"*) expressed
444
+ as data. Two paths differing in transformation, in evidence grade, or in
445
+ `shape` (complete/partial × boundary/local × widened/explicit × lossy/intact ×
446
+ ambiguous/correlated) are two flows. Everything else collapses.
447
+
448
+ **The `context` dimension collapses for free.** `hashState(entryState)` appears
449
+ nowhere in the flow key, and data element ids are context-independent by
450
+ construction (§3.4), so two entry contexts of one function producing the same
451
+ projection are one flow with a higher `alternatePathCount` — never two nodes,
452
+ never two flows.
453
+
454
+ A worked example this actually produces (`E1/14`): one masked-log flow yields
455
+ **two** flows, graded `explicit` (the real cross-scope path through
456
+ `maskCard`) and `ambiguous` (the caller-side bypass §14.7 marks
457
+ `ambiguousCorrelation`). Collapsing them would hide exactly what FR-305/FR-306
458
+ forbid hiding. This is the rule working, not a defect.
459
+
460
+ Since only source and sink nodes exist, a flow's `edgeIds` has length 1. That
461
+ is honest: the projection does not invent intermediate system nodes it has not
462
+ identified. Cross-scope hops are disclosed in `flow.limitations` via
463
+ `gradePath().factors` (`evidence: cross-scope`), never silently dropped.
464
+
465
+ ### 6.5 AC-11, and a flagged unresolved question for E4/H
466
+
467
+ - **Coarse half, satisfied here:** a matched source or sink is a `nodes[]`
468
+ entry whether or not any flow touches it, carrying its registry
469
+ `coverageStatus` and a non-empty `coverageReason`. Measured on
470
+ `vulnerable-js`: 6 of 11 sink sites connected, and the 5 disconnected ones
471
+ are still nodes (`E1/13`).
472
+ - **Fine half, NOT satisfied by `nodes[]`:** because nodes are
473
+ category-granular, a category with 9 connected and 1 disconnected call site
474
+ reads as connected. The disconnected *call site* is visible only in
475
+ `graph.coverage` (§10).
476
+
477
+ **Flagged for E4 and H, honestly unresolved here:** whether AC-11's acceptance
478
+ test (*"the user opens the corresponding inventory … it is visible with a
479
+ coverage reason"*) is satisfied by a coverage-ledger row rather than a
480
+ `nodes[]` row is a product judgment E1 cannot settle from the code. Two
481
+ options were considered and one was **rejected outright**: minting a per-site
482
+ node *only when it is disconnected* makes node identity depend on
483
+ connectivity, so a node would appear and disappear as the analysis improves,
484
+ breaking id stability across runs. Do not do that. If a per-site inventory row
485
+ is required, it must be an additional, always-present entity (a ledger row, or
486
+ an `evidence` entity), never a conditional node.
487
+
488
+ ### 6.6 `node.subtype: null` — an escalated schema divergence — RESOLVED
489
+
490
+ **RESOLVED** by the `2026-08-31-lineage-schema-subtype-nullability-hotfix`
491
+ Milestone-0 hotfix — this file is committed in the same commit as the fix
492
+ itself, so it cannot self-reference its own hash; find the exact commit via
493
+ `git log --grep='relax node.subtype schema nullability'` (the commit message
494
+ starts `fix(lineage): relax node.subtype schema nullability`).
495
+
496
+ Decision 1 (inherited, `DESIGN_REGISTRIES.md` §9.0) says a `null` category
497
+ becomes a `null`/absent `subtype`. `validate.js` accepted that already — it
498
+ never looked at `subtype` at all, which was itself half the bug: it was also
499
+ silently accepting genuinely wrong values (e.g. a bare number) with no check.
500
+
501
+ **`dataflow-graph.schema.json` did not.** Its node definition had `subtype` in
502
+ `required` with `"type": "string"`, so a null-category node was invalid
503
+ against the JSON-Schema twin while being valid against the runtime validator,
504
+ and `json-schema-parity.test.js` could not see the difference (it only ever
505
+ compared enum arrays and the top-level envelope's `required` keys, never a
506
+ per-entity `$defs.<entity>.required` array).
507
+
508
+ **The fix, applied outside Sub-project E** (this file's own node contract
509
+ description above is unaffected — E still emits `subtype: null` per Decision
510
+ 1, exactly as originally written): `dataflow-graph.schema.json`'s `$defs.node`
511
+ now declares `"subtype": { "type": ["string", "null"] }` and no longer lists
512
+ `subtype` in `required` (both `null` and a fully absent field are legal, per
513
+ `DESIGN_REGISTRIES.md` §9.0's own "null/absent" phrasing, which never
514
+ committed to one representation over the other); `validate.js` gained an
515
+ active check — present-and-non-null-and-non-string (a number, object, array,
516
+ etc.) is now a validation error, present-and-null or absent are not;
517
+ `json-schema-parity.test.js` gained a new block comparing every
518
+ `$defs.<entity>.required` array against the fields `validate.js` actually
519
+ enforces as required, per entity, closing the exact blind spot that let this
520
+ divergence go undetected. The two validators no longer disagree on any of
521
+ `vulnerable-js`'s 9 nodes (nor on the flagship fixture's 14, which all already
522
+ carried string subtypes and needed no change).
523
+
524
+ ---
525
+
526
+ ## 7. Transformations
527
+
528
+ ### 7.1 Attribution
529
+
530
+ For each hop on a projected path, resolve the CFG node at
531
+ `(hop.scope, hop.siteNodeId)` and build a `recognizeTransformation`
532
+ descriptor from its callee (`{type:'member-call', object, method}` for a
533
+ member callee, `{type:'call', callee}` for an identifier).
534
+
535
+ `inputPath`/`outputPath` come from the hop's `fromPath`/`toPath`;
536
+ `location` from the CFG node's file and the hop's line; `kind`,
537
+ `reversibility`, `algorithm`, `confidence`, `evidence` from D4's recognizer
538
+ verbatim.
539
+
540
+ ### 7.2 `null` from the recognizer → `kind: 'unknown'`, and only when attributable
541
+
542
+ `NEVER_EMITTED_KINDS = ['custom', 'unknown']` forces a choice. **It is
543
+ `'unknown'`.** `'custom'` asserts "this is a real transform we simply cannot
544
+ name", which is a stronger claim than a `null` supports.
545
+
546
+ An `unknown` entity is emitted only when **the hop's edge carries
547
+ `widenReason: 'unresolved-call'`** (the hop record itself says a call
548
+ transformed this value) **and the CFG node carries exactly one call
549
+ expression**, so the callee attribution cannot be a guess. Otherwise a
550
+ `flow.limitations` string records the site and says why attribution was
551
+ refused.
552
+
553
+ That scoping was added because the naive rule got it wrong on real code:
554
+ `res.send(eval(req.body.expr))` has two call expressions at one CFG node, and
555
+ the naive rule attributed the widening to `res.send` when `eval` caused it.
556
+
557
+ Both cases are proven reachable on real parsed code in `E1/11`: `maskCard` →
558
+ `kind: 'mask'`, `reversibility: 'irreversible'`, `confidence: 'medium'`;
559
+ `reshapeForVendor` → `kind: 'unknown'`, `reversibility: 'unknown'`,
560
+ `algorithm: null`, evidence naming the `unresolved-call` hop.
561
+
562
+ ### 7.3 The two §10.6 fields with no honest source
563
+
564
+ - **`appliesToAllPaths: null`** — never `true`/`false`. That is FR-307's
565
+ all-path proof and nothing in `src/lineage/` does path-feasibility
566
+ reasoning.
567
+ - **No control-credit field of any kind — not even `false`.** `false` reads as
568
+ "credit was considered and denied", a claim E has no basis for. Decision 2
569
+ reserves awarding credit for Milestone 2's FR-401-405. `E1/11` sweeps every
570
+ transformation's key set against `/credit|granted|denied|verdict|protected/i`
571
+ — the same structural enforcement D4 already uses on its own decisions.
572
+
573
+ ---
574
+
575
+ ## 8. Flow and edge defaults
576
+
577
+ - `flow.protectionSummary: 'not_assessed'` on every flow.
578
+ - `flow.policyVerdict: 'not_evaluated'` on every flow.
579
+ - `edge.protection: emptyProtection()` on every edge — all three dimensions
580
+ `{verdict: 'not_assessed', evidenceGrade: 'none'}`.
581
+ - `edge.protocol.destinationResolution: 'unknown'` on every edge (FR-202 is
582
+ Milestone 2; `'dynamic'` would be a claim that resolution was attempted and
583
+ failed).
584
+
585
+ §10.7's *"the end-to-end summary must be derived from the individual edge
586
+ verdicts, never stored as an unsupported independent claim"* is satisfied
587
+ **trivially and correctly**: every edge verdict is `not_assessed`, so the only
588
+ derivable summary is `not_assessed`. E3 must keep it derived, not hardcoded —
589
+ the moment Milestone 2 gives an edge a real verdict, the flow summary must
590
+ follow it.
591
+
592
+ `flow.limitations[]` is where the honesty budget goes, and it is **sourced
593
+ from `gradePath().factors`**, never re-derived: widening reasons, loss
594
+ reasons, ambiguous correlation, analysis truncation, cross-scope, plus
595
+ reconstruction truncation reasons and the multi-candidate ambiguity note.
596
+
597
+ ---
598
+
599
+ ## 9. Checklist for E2-E5
600
+
601
+ ### 9.0 What already shipped in E1
602
+
603
+ - `src/lineage/driver.js`: `opts.seedEntryState` + the `cache.set` key fix.
604
+ **Nothing else.** No graph-builder.js, no source-seeding.js, no
605
+ lineage/index.js, no `runFullScan` wiring (none of the three exists in the
606
+ tree, so none is backtick-quoted here).
607
+ - `test/lineage/driver.test.js`: `E1/driver-1` … `E1/driver-3`, permanent.
608
+ - `test/lineage/graph-builder-poc.test.js`: throwaway, §9.1.
609
+
610
+ ### 9.1 The PoC absorption protocol — stated, not implicit
611
+
612
+ D1 left D2/D3's protocol implicit until §9.1 had to spell it out. Stating it
613
+ up front here:
614
+
615
+ - **E2 absorbs the seeding half** — `E1/1` … `E1/5` — into
616
+ test/lineage/source-seeding.test.js (not backtick-quoted: it does not exist in the tree yet, and this repo's doc-drift checker flags a dangling backtick-quoted path), re-pointed at the shipped
617
+ source-seeding.js. It also inherits `E1/14`, the escalated engine
618
+ limitation (§11 item 1), because that test is about seeding reaching a sink.
619
+ - **E3 absorbs the projection half** — `E1/6` … `E1/13` — into
620
+ test/lineage/graph-builder.test.js (same reason — not yet in the tree), re-pointed at the shipped
621
+ graph-builder.js.
622
+ - **Whichever of E2/E3 lands SECOND deletes
623
+ `test/lineage/graph-builder-poc.test.js`**, removes it from
624
+ `package.json`'s `test:lineage` script, and removes its row from
625
+ `src/lineage/CLAUDE.md` — after confirming the other's absorption is
626
+ complete. Neither may delete it unilaterally. (D1 §9.1's two-lander rule.)
627
+ - `E1/driver-1` … `E1/driver-3` are **permanent** and are absorbed by nobody.
628
+ They guard `driver.js`, which is shipped code.
629
+
630
+ ### 9.2 E2 — src/lineage/source-seeding.js (not yet in the tree)
631
+
632
+ 1. Export `planSeeds(callGraph, {repository})` and
633
+ `seedEntryStateFactory(seeds)` exactly as §3.1-§3.5 specify.
634
+ 2. Import list must be exactly
635
+ `['../dataflow/catalog.js', '../dataflow/access-paths.js', './source-registry.js', './classification.js', './ids.js', './field-identity.js']`
636
+ — pin it with the self-checking boundary test `path-query.js` established.
637
+ 3. Ship the `unseedable[]` accounting from day one; an unseedable match that
638
+ is merely absent is the failure mode this whole PRD exists to prevent.
639
+ 4. Re-measure `path-query.js`'s five uncalibrated `DEFAULTS` against real
640
+ seeded stores — that is E2's own deliverable per the scoping doc, and §2.3
641
+ above shows how easily an unspecified harness makes such numbers
642
+ irreproducible. Record the harness, not just the number.
643
+ 5. Do **not** pick `frontend/` as a measurement target (§3.7).
644
+
645
+ ### 9.3 E3 — src/lineage/graph-builder.js — SHIPPED, see src/lineage/CLAUDE.md
646
+
647
+ 1. `buildDataFlowGraph(callGraph, opts)` — **corrected by E3, per this
648
+ file's own §9.1 policy** ("where this document and that PoC disagree,
649
+ the PoC is right and this document is stale — fix it here, do not fork
650
+ it"): this item originally stated the signature as
651
+ `buildDataFlowGraph(perFileIR, callGraph, opts)` (three arguments), but
652
+ the PoC's own shipped, tested implementation was always the
653
+ two-argument form above — it reads everything E3 itself needs from
654
+ `callGraph.functions[*].cfg` and never used a separate `perFileIR`
655
+ parameter. E3 shipped the PoC's real two-argument signature rather than
656
+ forking it to match this document's stale prose. **This is scoped to
657
+ E3, not a claim that `perFileIR` is never needed anywhere in this
658
+ sub-project (task review, E3's own follow-up review)**: §10's ledger —
659
+ E4's job — requires `languages: [{language, filesExpected,
660
+ filesAnalyzed}]` and `parseFailures: []`, and a parse failure is
661
+ structurally invisible to a `callGraph`-only builder (a file that fails
662
+ to parse contributes zero functions, so nothing in `callGraph` records
663
+ its absence). E4 will need a per-file input of its own — either
664
+ `opts.perFile` or an equivalent — to populate those two fields
665
+ honestly; it should not assume E3's two-argument precedent means it can
666
+ do the same. Still mirror `dataflow/index.js`'s `runDeepAnalysis`
667
+ **shape** and import nothing from it.
668
+ 2. Node minting per §6.1; edge/flow keys per §6.4; `subtype` per §6.6, with
669
+ the schema divergence escalated in the PR, not silently emitted.
670
+ 3. Ship the four assertions `validate.js` cannot make (`E1/8`): every
671
+ `subtype` in the registry vocabulary or null; every `node.dataElementIds`
672
+ entry referentially sound; no two *different* registry decisions collided
673
+ onto one node id; no `pedge:`/`ppath:` id in `flow.edgeIds`.
674
+ 4. Determinism: sort every entity array by id before emit, and take
675
+ `generatedAt` from `opts` — `emptyGraphEnvelope()` defaults it to
676
+ `new Date().toISOString()`, which breaks `--deterministic`. E5 settles the
677
+ policy (§9.5); E3 must at minimum make it injectable.
678
+
679
+ ### 9.4 E4 — src/lineage/coverage.js (not yet in the tree)
680
+
681
+ 1. Finish §10's ledger sketch.
682
+ 2. Close **FR-203**: call `reclassifySink(entry, {destinationUnresolved,
683
+ blockingExpression})` at real call sites whose destination expression is
684
+ not statically resolvable. Keep it structurally distinct from §5's case.
685
+ 3. Ship §5's enumerator union as a real module function.
686
+ 4. Prove it the way D5 was proven: an empty-but-valid graph must **fail**
687
+ these tests.
688
+ 5. **Two things E3's own review confirmed E4 needs, named here so neither
689
+ is rediscovered mid-implementation.** (a) FR-203 needs no new export
690
+ from `graph-builder.js` — `enumerateSinkSites` already returns, per
691
+ site, `{file, qid, nodeId, line, calleeExpr, entry, decision,
692
+ ambiguity}`, and the raw `entry` is exactly what `reclassifySink(entry,
693
+ {destinationUnresolved, blockingExpression})` (item 2 above) needs;
694
+ confirmed live, this signature composes cleanly with no change to E3's
695
+ shipped module. (b) `buildDataFlowGraph` currently mints sink nodes
696
+ from `site.decision` internally with no hook to substitute an
697
+ FR-203-adjusted decision, and hardcodes `coverage.languages`/
698
+ `parseFailures` to `[]` with no per-file input to populate them
699
+ honestly (a parse failure is structurally invisible to a
700
+ `callGraph`-only builder — see §9.3 item 1's own note above). E4 will
701
+ need either an `opts` hook on `buildDataFlowGraph` (e.g.
702
+ `opts.resolveSiteDecision`, `opts.perFile`) or a post-processing pass
703
+ over the built graph — decide which explicitly, don't default to
704
+ whichever is easiest to hack in.
705
+
706
+ ### 9.5 E5 — src/lineage/index.js (not yet in the tree) + `runFullScan`
707
+
708
+ 1. Copy the `AGENTIC_SECURITY_PRIVACY_DEEP` block's shape in
709
+ `dataflow/index.js`: opt-in, best-effort, failure **recorded** in
710
+ `scanHealth`, never swallowed, never able to fail a scan.
711
+ **Correction (final whole-branch review, N-6): this item's own wording
712
+ is wrong about which block's failure-handling to copy, and was never
713
+ amended when item 5 below and `src/lineage/index.js`'s own header
714
+ comment already corrected it.** The `AGENTIC_SECURITY_PRIVACY_DEEP`
715
+ block's actual failure-handling is a bare `catch {}` that silently
716
+ swallows — the opposite of "recorded in scanHealth, never swallowed"
717
+ this item asks for in the same sentence. What E5 actually shipped
718
+ copies `_deepEnabled`'s own failure-handling instead (status
719
+ `'complete'`/`'failed'`/`'not_available'`, `failure` folded into
720
+ `scanHealth.lineageAnalysis`, never a bare `catch {}`) — see item 5's
721
+ own "Disclosed, deliberate difference" note for the full reasoning and
722
+ `src/lineage/index.js`'s header for where this is stated at the
723
+ implementation site.
724
+ 2. Consume `runScan`'s existing `_sharedIR` `{perFile, callGraph}` memo. No
725
+ new IR pass.
726
+ 3. Settle `generatedAt` under `--deterministic` (§2.7 of the scoping doc) and
727
+ where the artifact is written.
728
+ 4. `AGENTIC_SECURITY_LINEAGE_MAX_CONTEXTS` becomes genuinely operator-facing
729
+ here for the first time — and per §5 it now has an observable effect on
730
+ output (degraded terminals), so document it.
731
+ 5. **Disclosed, deliberate difference from `_deepEnabled`: no CI auto-disable.**
732
+ `_deepEnabled`'s own gate additionally checks `inCi`/`ciOverrideAllowed`
733
+ (deep mode defaults OFF in CI unless `AGENTIC_SECURITY_DEEP_IN_CI=1` is
734
+ also set) — the lineage gate has no equivalent. `AGENTIC_SECURITY_LINEAGE_DEEP=1`
735
+ runs unconditionally in CI once an operator sets it, with no separate
736
+ "and also allow it in CI" flag. This is deliberate, not an omission:
737
+ deep mode's CI caution exists because it is sometimes enabled implicitly
738
+ by other default-on machinery, where an operator might not have
739
+ consciously chosen "run this in CI"; lineage is reachable only via this
740
+ one explicit opt-in env var, so setting it already IS the operator's own
741
+ CI decision — there is nothing implicit to guard against.
742
+
743
+ ---
744
+
745
+ ## 10. The coverage ledger, sketched (E1 item **(f)**)
746
+
747
+ E4 owns the finished contract. This is the shape E1 proved out, grounded in
748
+ the flagship fixture's own four keys plus what AC-11, FR-203 and §16.7
749
+ require:
750
+
751
+ ```js
752
+ graph.coverage = {
753
+ // --- the flagship fixture's own four keys, kept ---
754
+ languages: [{ language, filesExpected, filesAnalyzed }],
755
+ parseFailures: [],
756
+ destinationResolutionStatus: 'not-attempted', // FR-202 is Milestone 2
757
+ pathBudgetTruncation: <bool>,
758
+
759
+ // --- AC-11's fine half ---
760
+ sources: { matched, unseedable, dataElements,
761
+ byCategory: { <category>: { sites, coverageStatus } } },
762
+ sinks: { callStatementSites, connected, disconnected,
763
+ nonStatementSitesNotEnumerable, // §4.1's named gap
764
+ byCategory: { <category>: { sites, connected, coverageStatus } } },
765
+
766
+ // --- the two "must never disappear" obligations ---
767
+ degradedTerminals: <count>, // §16.7 Finding 2
768
+ unresolvedDestinations: <count>, // FR-203
769
+
770
+ // --- §18.4: budget exhaustion is never "no path" ---
771
+ paths: { enumerated, projected, truncatedQueries },
772
+ budgets: { ...path-query.js DEFAULTS actually used },
773
+
774
+ // --- provenance scale, for G's comparison report ---
775
+ provenance: { hops, pnodes, pedges },
776
+ };
777
+ ```
778
+
779
+ **The one rule to design against explicitly:** a node E *dropped* and a node
780
+ with *no path* must be distinguishable in the output, and neither may look
781
+ like the other. `byCategory` is what makes "this category was never matched"
782
+ distinguishable from "this category matched and connected nothing".
783
+
784
+ Measured on `vulnerable-js`, the sketch reports: 9 sources matched, 0
785
+ unseedable, 6 data elements; 11 sink statement sites, 6 connected, 5
786
+ disconnected, 1 non-statement site not enumerable; 0 degraded terminals; 23
787
+ hops / 15 pnodes / 9 pedges (re-measured by E3, task review N-2 — the
788
+ `lineage-engine-receiver-identity-hotfix` moved this from the pre-hotfix
789
+ 19/14/8 §3.7 already corrected; this section had not been).
790
+
791
+ ---
792
+
793
+ ## 11. Escalations — things E cannot fix, named here rather than discovered later
794
+
795
+ 1. **`lineage/engine.js` drops RECEIVER-borne identity through a method
796
+ call. RESOLVED**, by a dedicated hotfix task (2026-08-31, "Lineage Engine
797
+ Hotfix: Unresolved-Call Receiver Identity" — see this file's git history
798
+ for the exact commit that landed this change). `case 'call'`'s unresolved
799
+ branch used to union only `expr.args`, never `expr.callee.object`.
800
+ Measured: `pan + 'x'` and `String(pan)` always kept the identity;
801
+ **`pan.slice(0, 4)` lost it**. `dataflow/engine.js` had already solved
802
+ exactly this with `_calleeReceiverTainted`; this package had not
803
+ inherited it, and no lineage design document disclosed the gap until this
804
+ escalation entry.
805
+
806
+ **The fix's exact mechanism, BOTH callee shapes (task review MF-1: the
807
+ first cut of this fix ported only half of `_calleeReceiverTainted`'s own
808
+ precedent and was found to still lose the identity for every non-JS
809
+ parser's callee shape — closed in the same hotfix, not left as a second
810
+ escalation):**
811
+ - `parser-js.js`'s structured `{kind:'member', object, prop}` callee:
812
+ `expr.callee.object` (the receiver) is recursively resolved via
813
+ `resolveExprIdentities` and its `flat` identities are unioned into the
814
+ branch's own `flat` result, the same way each argument's `flat`
815
+ identities already were.
816
+ - Every OTHER language parser's flat, dot-joined STRING callee
817
+ (`"pan.slice"`, not an exprDesc — `parser-py.js`/`parser-java.js`/
818
+ `parser-go.js`/`parser-php.js`/`parser-rb.js`/`parser-cs.js`/
819
+ `parser-kt.js`/`parser-cpp.js` all emit this shape): the receiver is
820
+ recovered by slicing the string after its LAST `.` and querying that
821
+ prefix directly via `identitiesAt`, mirroring
822
+ `_calleeReceiverTainted`'s own string branch (`isCoveredBy`) — this
823
+ one needs the actual identity SET, not a boolean.
824
+ - Both match `DESIGN_INTRAPROCEDURAL.md`'s structure-flattening rule for
825
+ `call` (an unresolved call's return is genuinely unknown structure, so
826
+ only `flat` participates, never `byPath`). Proven permanently in
827
+ `test/lineage/engine-receiver-identity.test.js` against both real
828
+ JS/TS (`parser-js.js`) and real Python (`parser-py.js`) parsed IR —
829
+ not reachable from any shipped caller today (this package's only real
830
+ caller, Sub-project E, is wired against `parser-js.js`'s JS/TS output
831
+ only), but `resolveExprIdentities` has no JS-only gate of its own, and
832
+ shipping the fix for one callee shape while leaving the other
833
+ half-inherited would have reproduced this exact bug class one
834
+ language over.
835
+
836
+ **The consequence was not theoretical:** it produced **zero flows** for
837
+ `bench/data-lineage/fixtures/js-api-to-log-masked` — the mask-then-log
838
+ fixture that is AC-02's own worked example and one of only three entries in
839
+ the lineage accuracy corpus. `E1/14` (now updated to assert the FIX, not
840
+ the bug) pins both halves: the real fixture now yields 2 flows (the real
841
+ cross-scope path through `maskCard`, graded `widened` because the call
842
+ itself stays unresolved, plus the caller-side bypass FR-305/§14.7 marks
843
+ `ambiguousCorrelation`); the same structure with a receiver-free transform
844
+ already yielded 2 flows (graded `explicit`/`ambiguous`) and still does.
845
+
846
+ Sub-project E's own E1 PoC tests that pinned the pre-fix (buggy) numbers —
847
+ `E1/4`, `E1/6`, `E1/13`'s hop counts (19 -> 23 on `vulnerable-js`; node
848
+ count 14 -> 15, edge count 8 -> 9) and `E1/14` itself — were updated as a
849
+ direct, disclosed consequence of this fix, per that PoC file's own
850
+ comments at each changed assertion, not silently left stale.
851
+
852
+ 2. **RESOLVED.** ~~`node.subtype: null` is invalid against
853
+ `dataflow-graph.schema.json` while valid against `validate.js`~~ — §6.6.
854
+ Fixed by the `2026-08-31-lineage-schema-subtype-nullability-hotfix`
855
+ Milestone-0 hotfix: the schema now declares `subtype`'s type as
856
+ `["string", "null"]` and drops it from `required`; `validate.js` gained an
857
+ active check accepting `string | null | absent` and rejecting anything
858
+ else (a bare number, previously silently accepted); the parity test now
859
+ compares `$defs.<entity>.required` arrays, not just enums. Find the exact
860
+ commit via `git log --grep='relax node.subtype schema nullability'`.
861
+
862
+ 3. **A sink in an assignment RHS, a return value, or a nested argument has no
863
+ `escape` provenance node**, so no sink-rooted reconstruction can start
864
+ there — §4.1. Counted in the ledger; closing it needs either a new hop kind
865
+ (a `path-store.js`/`engine.js` change, out of E's scope) or a
866
+ forward-from-source query `path-query.js` does not have.
867
+
868
+ 4. **`matchPrivacySink` has no receiver constraint**, so bare-name over-match
869
+ is structural — §4.2. 3 of 11 sink sites in a 42-line fixture. Out of E's
870
+ scope (`privacy-catalog.js` is on the must-not-modify list); F must not
871
+ count these as recall.
872
+
873
+ 5. **The plurality tie-break is arbitrary** — §4.3. Mitigated by `partial` +
874
+ named alternatives, not solved.
875
+
876
+ 6. **`validate.js` does not enforce every field `dataflow-graph.schema.json`
877
+ declares `required`** — found and escalated by the
878
+ `2026-08-31-lineage-schema-subtype-nullability-hotfix` while closing
879
+ item 2. `test/lineage/json-schema-parity.test.js` now derives, live
880
+ against the flagship fixture, exactly which schema-required fields
881
+ `validate.js` actually enforces, and pins the rest as a documented
882
+ `KNOWN_REQUIRED_GAPS` allowlist so a NEW gap of this shape fails loudly.
883
+ Two of these are **risk-bearing, not merely opaque object bags**:
884
+ `edge.coverageStatus` and `flow.coverageStatus` are the SAME enum
885
+ `node.coverageStatus` is already checked for — AC-11's "a discovered
886
+ sink stays visible with a reason" rests on this field, and `validate.js`
887
+ checks it on a node but not on the edge or flow carrying that node's own
888
+ flow — and `evidence.claim` (the evidence contract's own free-text
889
+ assertion) is never checked at all. The full current set: `node` —
890
+ `system`/`externality`/`lifecycleStages`/`governanceRefs`/`confidence`;
891
+ `fieldMapping` — `fromPath`/`toPath`/`dataElementIds`/
892
+ `transformationIds`; `edge` — `protocol`/`boundaryCrossings`/
893
+ `evidenceRefs`/`coverageStatus`; `dataElement` —
894
+ `aliases`/`sourceLocations`/`classificationEvidence`/`manualOverride`;
895
+ `flow` — `dataElementIds`/`edgeIds`/`evidenceRefs`/`coverageStatus`;
896
+ `evidence` — `claim`. Out of scope for that hotfix (its own Global
897
+ Constraints forbade tightening any validation beyond `subtype`) and out
898
+ of Sub-project E's scope too (`validate.js` is Milestone-0 frozen
899
+ contract code) — named here so a future Milestone-0 hardening pass finds
900
+ it named, not rediscovered. Whoever closes this should prioritize
901
+ `edge.coverageStatus`/`flow.coverageStatus` first, given AC-11's own
902
+ stake in the property.
903
+
904
+ ---
905
+
906
+ ## 12. The reuse boundary (E1 item **(g)**) — confirmed against the source
907
+
908
+ `src/lineage/` may import **`matchSource`** and **`matchSinkOrSanitizer`**
909
+ from `../dataflow/catalog.js`, **`matchPrivacySink`** from
910
+ `../dataflow/privacy-catalog.js`, and **`accessPathOf`** from
911
+ `../dataflow/access-paths.js`.
912
+
913
+ Confirmed by reading `catalog.js`'s own module boundary, not by citing the
914
+ scoping doc's paraphrase:
915
+
916
+ - All four are **top-level `export function` declarations**, reachable
917
+ directly from the module. None is reached through `dataflow/engine.js`, and
918
+ none needs it.
919
+ - All four are **pure with respect to lineage state**. `catalog.js` holds
920
+ index maps and a provenance-filter memo built at module load from a frozen
921
+ catalog; nothing a caller passes mutates them, and nothing they return is a
922
+ live taint object. `accessPathOf` is a pure recursive function over an
923
+ exprDesc — `field-identity.js` already imports its sibling
924
+ `pathIsCoveredByPrefix` from the same file, which is the in-package
925
+ precedent.
926
+ - `DESIGN_REGISTRIES.md` §1's argument holds and is the reason this is the
927
+ right call rather than merely a permitted one: *"they never re-derive what a
928
+ call site matches … duplicating it would fork a matcher the corpus proves
929
+ against one that nothing proves."*
930
+
931
+ Still forbidden, unchanged (PRD §18.1): `dataflow/engine.js`'s live taint
932
+ state, `dataflow/summaries.js`'s `SummaryCache`, and `dataflow/index.js`'s
933
+ `runDeepAnalysis` (E **mirrors** its shape; E imports nothing from it).
934
+
935
+ `E1/12` enforces this mechanically — it reads the PoC's own source, extracts
936
+ every module specifier, and asserts the `dataflow/` subset is exactly those
937
+ three files, in the shape `path-query.js`'s own `['./ids.js']` boundary test
938
+ established.