@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,880 @@
1
+ # Source/Sink Registries — Reclassification Design Record
2
+
3
+ Scope: Sub-project D of Milestone 1 (see
4
+ `docs/superpowers/plans/2026-08-30-data-flow-explorer-m1-subproject-d-scoping.md`).
5
+ Binding on every later Sub-project D increment, the same way
6
+ `DESIGN_INTRAPROCEDURAL.md` binds Sub-projects A/B and `DESIGN_PATH_PROVENANCE.md`
7
+ binds Sub-project C.
8
+
9
+ This increment (D1) is design + proof-of-concept only. `src/` is unmodified by
10
+ it apart from this file. The mechanical registries (`source-registry.js` = D2,
11
+ `sink-registry.js` = D3) and transformation-kind recognition (D4) are follow-up
12
+ increments; §9 is their checklist.
13
+
14
+ **Every number, list and claim in this document was produced by running real
15
+ code against the live catalogs in the increment that wrote it**, via
16
+ `test/lineage/registry-mapping-poc.test.js`. Nothing here is quoted from the
17
+ scoping doc, and §7.3 records five places where the upstream documents (the
18
+ scoping doc and the D1 task brief) were measurably wrong.
19
+
20
+ ---
21
+
22
+ ## 1. What this sub-project actually is
23
+
24
+ Sub-projects A/B/C built new analysis capability. Sub-project D builds almost
25
+ none. Its job is to **reclassify** detection the scanner already performs —
26
+ `scanner/src/dataflow/catalog.js`'s 756 entries, proven across the whole
27
+ benchmark corpus — into `schema.js`'s `DataFlowGraph v1` vocabulary.
28
+
29
+ The registries are therefore **pure data consumers**. They read a catalog
30
+ entry's `provenance` / `category` / `vuln` / `label` / `framework` fields and
31
+ emit a `{kind, category, coverageStatus, externality, reason}` decision. They
32
+ **never** re-derive what a call site matches — `matchSource` /
33
+ `matchSinkOrSanitizer` / `matchPrivacySink` already own that, and duplicating
34
+ it would fork a matcher the corpus proves against one that nothing proves.
35
+
36
+ Reading `CATALOG` from `src/lineage/` is data consumption, not a taint-engine
37
+ import, and is consistent with this PRD's isolation principle (§18.1) — the
38
+ same basis on which Sub-project A reuses `access-paths.js`. Confirmed by
39
+ reading `catalog.js`: `CATALOG` is a frozen-by-convention module-level array of
40
+ plain objects with no engine state and no mutation path.
41
+
42
+ ---
43
+
44
+ ## 2. Ground truth (measured, not sampled)
45
+
46
+ | Fact | Value |
47
+ |---|---|
48
+ | `CATALOG.length` | **756** |
49
+ | — `kind: 'source'` | **180** |
50
+ | — `kind: 'sink'` | **194** |
51
+ | — `kind: 'sanitizer'` | **382** |
52
+ | `EXPANDED_SANITIZERS.length` | **324**, and **100% `kind: 'sanitizer'`** |
53
+ | `PRIVACY_SINK_CATALOG.length` | **18** |
54
+ | `PRIVACY_SINK_CATEGORIES.length` | **9** |
55
+ | Distinct source `provenance` values | **12** |
56
+ | Source entries with **no** `provenance` at all | **82** (46% of sources) |
57
+ | Distinct sink `vuln.cwe` values | **20** |
58
+ | Sink entries carrying a `category` field | **0** |
59
+
60
+ Export shapes, confirmed by reading rather than assumed:
61
+
62
+ - `catalog.js` exports `CATALOG` (plus matcher functions and
63
+ `_languageFamilyExtensions`).
64
+ - `catalog-expanded.js` exports `EXPANDED_SANITIZERS` and
65
+ `_expandedSanitizerStats` — **not** a `CATALOG`-shaped array, and it
66
+ contributes **no** source or sink entries. It is irrelevant to D2/D3 and
67
+ relevant to D4 only as a counter-example (§8.3).
68
+ - `privacy-catalog.js` exports `PRIVACY_SINK_CATALOG`,
69
+ `PRIVACY_SINK_CATEGORIES`, and four matcher/classifier functions.
70
+
71
+ ### 2.1 The classification signals are asymmetric
72
+
73
+ This is the fact that shapes everything below.
74
+
75
+ - **Sources** carry a purpose-built classification field, `provenance` — but
76
+ only 98 of 180 do.
77
+ - **Sinks** carry **none**. There is no `category` on any of the 194. The only
78
+ classification signal is `vuln.cwe`, which is a **vulnerability class**, not
79
+ a destination.
80
+
81
+ ---
82
+
83
+ ## 3. The central structural finding
84
+
85
+ `schema.js`'s `SINK_CATEGORIES` is an **egress taxonomy**: it answers *where
86
+ does the data go* (a log, a database, an object store, an email, a model
87
+ provider). `catalog.js`'s sink entries are a **vulnerability taxonomy**: they
88
+ answer *what goes wrong* (injection, traversal, deserialization, overflow).
89
+
90
+ These are orthogonal, and they only coincide where a vulnerability happens to
91
+ be named after its destination. They do for SQL (→ `database`), path traversal
92
+ (→ `file`), SSRF (→ `external-api`), response splitting and open redirect (→
93
+ `http-response`). They do **not** for command injection, code injection, SSTI,
94
+ deserialization, XXE, XPath injection, buffer overflow, ReDoS or untrusted
95
+ library load — in every one of those the "destination" is **in-process
96
+ computation**: an interpreter, a parser, a template engine, a regex engine, a
97
+ dynamic loader, or raw memory. FR-201's list models none of them, correctly,
98
+ because none of them is egress.
99
+
100
+ **Measured: 82 of 194 sink entries (42%) fall in that bucket.** They are not
101
+ mapping failures. They are a category of thing FR-201 does not model, and §6.4
102
+ keeps every one of them in the graph rather than dropping it.
103
+
104
+ `privacy-catalog.js` is the mirror image and the reason it exists: it *is*
105
+ destination-shaped (log / response / outboundHttp / fileWrite / s3Upload /
106
+ emailSend / storage / queues / thirdPartySdk), and consequently **8 of its 9
107
+ categories map to `SINK_CATEGORIES` cleanly** (§5). An 18-entry inert catalog
108
+ therefore has better *destination* coverage than the 194-entry proven one — a
109
+ real, disclosable asymmetry (§7.2).
110
+
111
+ ---
112
+
113
+ ## 4. Source mapping table (complete)
114
+
115
+ Primary key: the entry's declared `provenance`.
116
+
117
+ | `provenance` | count | → `SOURCE_CATEGORIES` | `coverageStatus` | basis |
118
+ |---|---:|---|---|---|
119
+ | `http-body` | 31 | `http-body` | `modeled` | exact vocabulary match |
120
+ | `url-param` | 14 | `http-query` | `modeled` | pure rename |
121
+ | `header` | 10 | `http-header` | `modeled` | pure rename |
122
+ | `cookie` | 7 | `http-cookie` | `modeled` | pure rename |
123
+ | `path-param` | 5 | `http-route` | `modeled` | pure rename |
124
+ | `env` | 4 | `env-value` | `modeled` | pure rename |
125
+ | `cli` | 3 | `cli-argument` | `modeled` | pure rename |
126
+ | `network` | 8 | `external-api-response` | `modeled` ×6, **`partial` ×2** | the 6 JS/Python entries read an outbound response body (`fetch`/`axios`/`requests`/`urlopen`); the 2 `cpp` entries are refined — see §4.2 |
127
+ | `file-read` | 4 | `storage-read` | `modeled` ×1, **`partial` ×3** | FR-101 groups "files and object storage reads"; `storage-read` is its only encoding. The 3 `cpp` entries are refined — see §4.2 |
128
+ | `url-fragment` | 2 | `http-query` | **`partial`** | lossy — a fragment is URL-borne but never transmitted to the server; no `http-fragment` value exists |
129
+ | `stdin` | 2 | `user-input` | **`partial`** | lossy — broadens to the generic value; no stdin/console value exists |
130
+ | `agent-tool` | 8 | *splits* | *see below* | **the one value that does not resolve on the provenance key alone** |
131
+
132
+ ### 4.1 `agent-tool` splits directionally
133
+
134
+ Its 8 entries are not one thing. Five are tool-invocation **arguments** (the
135
+ model chose them); three are **results/resources** flowing back. They belong in
136
+ different `SOURCE_CATEGORIES`, so the rule needs one documented per-entry
137
+ refinement:
138
+
139
+ | entry id | → category | status |
140
+ |---|---|---|
141
+ | `py-mcp-tool`, `py-mcp-server-tool`, `js-mcp-call-args`, `js-mcp-request-params`, `js-mcp-extra-args` | `ai-model-output` | `partial` — the arguments *were* produced by the model, but `ai-model-output` normally denotes a completion |
142
+ | `js-mcp-tool-result`, `py-mcp-tool-result` | `ai-tool-result` | `modeled` |
143
+ | `js-mcp-resource-contents` | `ai-retrieved-document` | `modeled` |
144
+
145
+ ### 4.2 C's I/O primitives are descriptor-generic — the third refinement key
146
+
147
+ The `network` and `file-read` rows above cannot claim `modeled` for their C
148
+ entries, under §6.1's own bar ("resolves 1:1 with no semantic loss"). C's I/O
149
+ primitives are **descriptor-generic**: the same call reads a file, a socket, a
150
+ pipe or stdin, and the catalog entry has no way to say which.
151
+
152
+ | entry | provenance | why not `modeled` |
153
+ |---|---|---|
154
+ | `cpp-recv`, `cpp-recvfrom` | `network` | directionally ambiguous — on a *client* socket this is an API response; on a *listening* socket it is an inbound client request, which `external-api-response` actively mis-describes |
155
+ | `cpp-read` | `file-read` | a raw fd is equally a file, socket or pipe |
156
+ | `cpp-fread`, `cpp-fgets` | `file-read` | a `FILE*` is commonly `stdin`, not a file |
157
+
158
+ The refinement key is therefore **`language === 'cpp'`** within these two
159
+ provenance buckets: category unchanged, status demoted to `partial`, caveat
160
+ stated. This is the third and last documented refinement, alongside §4.1's
161
+ per-entry-id one and §5.2's `framework` one — and it is the same *shape* as
162
+ both: a declared classification value that is correct for most of its entries
163
+ and lossy for an identifiable subset. (§4.3's 82-row override table is a
164
+ different mechanism entirely — a fallback for entries with *no* declared value,
165
+ not a refinement of one.) Note the `stdin` provenance (§4, also `cpp`-only) was
166
+ already `partial` for the adjacent reason, so the C entries are now uniformly
167
+ honest across all three buckets.
168
+
169
+ ### 4.3 The 82 entries with no `provenance`
170
+
171
+ They get a per-entry-id override table (enumerated in full in the PoC file's
172
+ `NO_PROVENANCE_OVERRIDES`), and every one of them is **`candidate`** — see
173
+ §6.3 for why. The gap is not random: **`go`, `java`, `rb` and `php` declare
174
+ `provenance` on literally zero of their source entries**, so entire language
175
+ families would otherwise be uncategorized.
176
+
177
+ Resulting source coverage: **84 `modeled`, 14 `partial`, 82 `candidate`, 0
178
+ `unsupported`.**
179
+
180
+ ---
181
+
182
+ ## 5. Sink mapping tables (complete)
183
+
184
+ ### 5.1 `catalog.js` — primary key `vuln.cwe`
185
+
186
+ | CWE | count | → `SINK_CATEGORIES` | status | basis |
187
+ |---|---:|---|---|---|
188
+ | CWE-89 | 38 | `database` | `modeled` | a SQL query call is unambiguously a database destination |
189
+ | CWE-943 | 1 | `database` | `modeled` | NoSQL `$where` |
190
+ | CWE-22 | 16 | `file` | `modeled` | traversal sinks are filesystem reads/writes |
191
+ | CWE-73 | 1 | `file` | `modeled` | arbitrary file write |
192
+ | CWE-918 | 15 | `external-api` | `modeled` | SSRF sinks are outbound HTTP client calls |
193
+ | CWE-601 | 7 | `http-response` | `modeled` | a redirect is a response header |
194
+ | CWE-113 | 9 | `http-response` | `modeled` | the sink *is* the response-header writer |
195
+ | CWE-79 | 16 | *splits* | *see 5.2* | |
196
+ | CWE-90 | 9 | `database` | **`candidate`** | an LDAP directory is a queryable store, structurally like a DB — but FR-201 never names directory services |
197
+ | CWE-78 | 29 | — | **`unsupported`** | shell/process execution |
198
+ | CWE-95 | 5 | — | **`unsupported`** | code evaluation — destination is an interpreter |
199
+ | CWE-94 | 10 | — | **`unsupported`** | code injection / template compilation |
200
+ | CWE-1336 | 2 | — | **`unsupported`** | SSTI — destination is a template engine |
201
+ | CWE-502 | 10 | — | **`unsupported`** | destination is a deserializer |
202
+ | CWE-611 | 10 | — | **`unsupported`** | XXE — destination is an XML parser |
203
+ | CWE-643 | 10 | — | **`unsupported`** | XPath — destination is an in-memory query engine |
204
+ | CWE-120 | 3 | — | **`unsupported`** | destination is raw memory |
205
+ | CWE-787 | 1 | — | **`unsupported`** | destination is raw memory |
206
+ | CWE-1333 | 1 | — | **`unsupported`** | destination is the regex engine |
207
+ | CWE-114 | 1 | — | **`unsupported`** | destination is the dynamic loader |
208
+
209
+ CWE-94 and CWE-1336 are both `unsupported` **deliberately and uniformly**,
210
+ even though a rendered template's output usually does reach a response: the
211
+ catalog entry names the *compile* call, not the response write, so a
212
+ `http-response` mapping would be an inference about a different program point.
213
+ Treating SSTI as egress while treating `eval` as non-egress would be special
214
+ pleading; the rule is "the entry's own callee decides", applied consistently.
215
+
216
+ ### 5.2 CWE-79's refinement — the sink side's one exception
217
+
218
+ CWE-79 covers two genuinely different destinations, split on `framework`:
219
+
220
+ | `framework` | entries | → category | status |
221
+ |---|---:|---|---|
222
+ | `dom`, `react` | 6 | `client-storage` | **`partial`** |
223
+ | everything else (`express`, `koa`, `servlet`, `flask`, `rails`, `ktor`, `aspnet`, `fmt`, `core`) | 10 | `http-response` | `modeled` |
224
+
225
+ The `partial` is a **naming gap in `schema.js`, disclosed not patched** (§7.4):
226
+ FR-201's bullet reads "browser DOM **or** client storage", and `schema.js`
227
+ encodes that whole bullet as the single value `client-storage`. So
228
+ `client-storage` is the *correct* target per the PRD's own text while
229
+ under-naming the DOM half. `innerHTML` is not client storage in any ordinary
230
+ reading of the phrase, and a reader of the graph will be misled unless the
231
+ status says `partial`.
232
+
233
+ Resulting sink coverage: **97 `modeled`, 6 `partial`, 9 `candidate`, 82
234
+ `unsupported`.**
235
+
236
+ ### 5.3 `privacy-catalog.js` ↔ `SINK_CATEGORIES`, entry by entry
237
+
238
+ | privacy `category` | entries | → `SINK_CATEGORIES` | status | basis |
239
+ |---|---:|---|---|---|
240
+ | `log` | 4 | `log` | `modeled` | `console.log/error`, `logger.info/warn` |
241
+ | `response` | 2 | `http-response` | `modeled` | `res.send` / `res.json` |
242
+ | `outboundHttp` | 2 | `external-api` | `modeled` | `fetch` / `axios.post` |
243
+ | `fileWrite` | 2 | `file` | `modeled` | `fs.writeFile` / `writeFileSync` |
244
+ | `s3Upload` | 1 | `object-storage` | `modeled` | `s3.putObject` |
245
+ | `emailSend` | 1 | `email` | `modeled` | nodemailer `sendMail` |
246
+ | `storage` | 2 | `database` | `modeled` | **does not split** — see below |
247
+ | `queues` | 2 | `queue` | `modeled` | `sqs.sendMessage` / `sns\|kafka publish`; FR-201's `queue` covers "queues, topics, streams, and event buses" |
248
+ | `thirdPartySdk` | 2 | `analytics` | **`partial`** | **does split** — see below |
249
+
250
+ **`storage` does NOT split**, contrary to the scoping doc's expectation that it
251
+ would need splitting across `database`/`object-storage`/`cache`. Both entries
252
+ carrying it are mongodb `insertOne`/`insertMany` with a `collection|db|mongo`
253
+ receiver. The *name* is broader than its entries; the entries are not. D1/4b
254
+ pins this so a future redis or S3 entry filed under `storage` fails loudly
255
+ instead of being silently mapped to `database`.
256
+
257
+ **`thirdPartySdk` DOES split, and cannot be resolved by a registry at all.**
258
+ Its two entries share one `receiverTypeIn` alternation:
259
+ `stripe|sentry|datadog|segment|amplitude|mixpanel|posthog|braze|intercom|analytics`.
260
+ That single regex spans four different `SINK_CATEGORIES` — `analytics`
261
+ (segment/amplitude/mixpanel/posthog), `monitoring` (sentry/datadog),
262
+ `external-api` (stripe) and `collaboration` (intercom/braze). Which one is
263
+ correct is only decidable **at match time**, from the receiver that actually
264
+ matched — information a registry reclassifying *entries* never sees. It
265
+ therefore resolves to `analytics` (the plurality) with status `partial`, and
266
+ §9's D3 checklist carries the match-time refinement forward.
267
+
268
+ ---
269
+
270
+ ## 6. The `coverageStatus` decision procedure
271
+
272
+ Given a catalog entry, apply in order. The procedure is **total**: every one of
273
+ the 756 entries gets a decision, and D1/1e proves it.
274
+
275
+ ### 6.1 `modeled`
276
+
277
+ The entry carries an **explicitly declared classification field** — a source's
278
+ `provenance`, a privacy sink's `category` — or, for a `catalog.js` sink, a
279
+ `vuln.cwe` whose vulnerability class *is* its destination; **and** that value
280
+ resolves to exactly one `SOURCE_CATEGORIES`/`SINK_CATEGORIES` value with no
281
+ semantic loss.
282
+
283
+ ### 6.2 `partial`
284
+
285
+ Same evidentiary basis as `modeled` — the classification is author-declared —
286
+ but the resolution is **lossy in a stated way**. Two loss shapes occur, and
287
+ both must be named in the entry's `reason`:
288
+
289
+ - **Broadening**: the target value is strictly more general than the source
290
+ fact (`stdin` → `user-input`).
291
+ - **Narrowing / adjacency**: the target value is the closest available but is
292
+ not the same thing (`url-fragment` → `http-query`; DOM XSS →
293
+ `client-storage`; `thirdPartySdk` → `analytics`).
294
+
295
+ `partial` is *not* "we are unsure". It is "we are sure, and the vocabulary
296
+ cannot say it exactly." That distinction is the whole reason it is separate
297
+ from `candidate`.
298
+
299
+ ### 6.3 `candidate`
300
+
301
+ The entry carries **no declared classification field**, and the category is
302
+ inferred from secondary metadata the author wrote for a different purpose —
303
+ the entry's `id`, `label`, or `framework`. The mapping is plausible and
304
+ reviewed, but nobody asserted it.
305
+
306
+ This tier has a second, operational meaning that matters more than the first:
307
+ **a `candidate` mapping does not generalize.** It comes from a per-entry
308
+ override table, so a *newly added* catalog entry of the same shape gets no
309
+ mapping at all and must fail the registry's own completeness check rather than
310
+ silently inheriting one. `modeled`/`partial` mappings are keyed on a declared
311
+ value and do generalize.
312
+
313
+ All 82 unprovenanced sources are `candidate`. Not because
314
+ `py-flask-request-cookies → http-cookie` is doubtful — it plainly is not — but
315
+ because the evidence for it is a human reading a label, and the graph should
316
+ say so.
317
+
318
+ ### 6.4 `unsupported`
319
+
320
+ **No `SOURCE_CATEGORIES`/`SINK_CATEGORIES` value models the entry's real
321
+ semantics.** Category is `null`.
322
+
323
+ Critically, `unsupported` is **not** exclusion. AC-11 requires a detected
324
+ source or sink with no resolved path to be "visible with a coverage reason",
325
+ and FR-201 requires all supported sinks to "remain discoverable". So an
326
+ `unsupported` entry still becomes a node: it keeps a `NODE_KINDS` value
327
+ (§7.1's `process`), keeps its label and location, and carries a non-empty
328
+ `reason` string. D1/1e asserts the non-empty reason on every entry precisely
329
+ so this cannot regress into a silent drop — the §18.4 failure mode this whole
330
+ PRD treats as load-bearing.
331
+
332
+ ### 6.5 `manual` — the open question, resolved
333
+
334
+ **`manual` means the classification was asserted by a human, not derived by
335
+ the analyzer. No registry can ever emit it, and D2/D3 must not.**
336
+
337
+ Three independent lines of evidence, all checked in this increment:
338
+
339
+ 1. **Codebase precedent, consistent across four modules.**
340
+ `posture/privacy-framework.js` defines `BUCKETS = ['gap', 'engine-gap',
341
+ 'manual', 'satisfied']` and selects between the middle two on
342
+ `codeTestable === 'no' ? 'manual' : 'engine-gap'` — *manual* is
343
+ "not code-testable at all", explicitly distinguished from *engine-gap*,
344
+ "code-testable but unimplemented". `posture/auditor-walkthrough.js`
345
+ documents `'manual'` as "requires manual attestation".
346
+ `lineage/protection.js`'s `EVIDENCE_GRADES` and `schema.js`'s
347
+ `EVIDENCE_TYPES` both carry `'manual'` in the same human-asserted sense.
348
+ That `engine-gap` / `manual` pair is the exact analogue of the
349
+ `unsupported` / `manual` pair here.
350
+
351
+ 2. **The PRD's own text.** FR-101's acceptance clause reads: "**All Sources**
352
+ shows both connected and disconnected sources, with `modeled`, `partial`,
353
+ `candidate`, or `unsupported` status." Four values. `manual` is deliberately
354
+ absent from the *inventory* clause while present in the §10.3 *node
355
+ contract* — i.e. a node may be `manual`; a discovered-source inventory row
356
+ may not.
357
+
358
+ 3. **Construction.** A catalog entry is analyzer-derived by definition. There
359
+ is no path by which reading one produces a human assertion.
360
+
361
+ `manual` is therefore reserved for a later increment in which an operator
362
+ **declares** a node — pairing with the `declared` value that already exists in
363
+ both `SOURCE_CATEGORIES` and `SINK_CATEGORIES`, and with
364
+ `evidenceType: 'manual'`. That is also exactly why `declared` shows up in §7.2
365
+ as unreachable: it is the operator-declaration path, and nothing implements it
366
+ yet.
367
+
368
+ D1/2a asserts no entry in any of the three catalogs yields `manual`. D1/2b
369
+ asserts the schema's five values minus the four a registry may emit is exactly
370
+ `['manual']` — so if a sixth status is ever added, this file's premise fails
371
+ loudly rather than quietly.
372
+
373
+ ---
374
+
375
+ ## 7. Node kind, externality, and the disclosed gaps
376
+
377
+ ### 7.1 Node-kind assignment rule
378
+
379
+ **Sources: always `NODE_KINDS.source`.** Checked, not assumed (D1/3b). The
380
+ alternative considered was `boundary`; it is wrong, because `boundary` models a
381
+ **trust-zone crossing**, which is a property of an edge between two systems,
382
+ and no catalog entry carries system, zone, or deployment information at all
383
+ (D1/8d).
384
+
385
+ **Sinks: derived from the resolved category, never from the entry.** The
386
+ category → kind table is in the PoC's `CATEGORY_NODE_KIND`; its shape is
387
+ `log`/`stdout` → `log`; every store-ish category (`database`, `file`,
388
+ `object-storage`, `cache`, `client-storage`, `backup`, `export`) → `store`;
389
+ `queue` → `queue`; every remote-party category (`external-api`, `webhook`,
390
+ `email`, `sms`, `push-notification`, `collaboration`, `analytics`,
391
+ `monitoring`, all `ai-*` except `ai-local-model`) → `external`;
392
+ `http-response` and `declared` → `sink`.
393
+
394
+ **The four `NODE_KINDS` values a reclassified sink never gets, and why** —
395
+ recorded so a later reader sees a decision rather than an oversight.
396
+ `api` was considered for `external-api`/`webhook` and rejected: it denotes a
397
+ *service endpoint entity*, whereas a catalog sink is the **call site** that
398
+ sends data to one, and `external` is the value that says "the data leaves to
399
+ a party outside this program". (When Sub-project E builds real destination
400
+ nodes from FR-202 resolution, *those* may well be `api`; the registry's
401
+ call-site node is not.) `transform` belongs to `TRANSFORM_KINDS`-shaped
402
+ transformation entities (§8), not to sinks. `boundary` is rejected for the
403
+ same reason it is on the source side — it needs trust-zone data no entry
404
+ carries. `unresolved` is rejected **here** but is genuinely needed one layer
405
+ out, for a *dynamic destination* rather than an unmappable category — see
406
+ §9's D3 checklist item 5 and the degraded-dead-end rule in §9's
407
+ carried-forward section.
408
+
409
+ **A `null` category (i.e. `unsupported`) → `process`.** This is not a fallback;
410
+ it is the *explanation*. Every unsupported sink is an in-process computation
411
+ destination, which is precisely why FR-201's egress taxonomy has no value for
412
+ it. D1/3c pins the biconditional: `kind === 'process'` **iff**
413
+ `coverageStatus === 'unsupported'`. Measured sink kinds across all 194:
414
+ exactly `{external, process, sink, store}`.
415
+
416
+ > **The biconditional is contingent, and whoever breaks it should know they
417
+ > are meant to.** It holds today only because the one *other* category that
418
+ > maps to `process` — `ai-local-model` — is **vacuously unreachable** (§7.2:
419
+ > no catalog entry produces any `ai-*` sink). The moment AI-destination
420
+ > detection lands — which §7.2 names as the single largest piece of future
421
+ > work here — an `ai-local-model` sink will have `kind: 'process'` with a
422
+ > non-null category and a non-`unsupported` status, and `D1/3c`'s reverse
423
+ > implication (`kind === 'process'` ⟹ `unsupported`) becomes false.
424
+ > **That is a correct consequence of new coverage, not a regression.**
425
+ > Whoever adds AI-sink detection owns relaxing the assertion to its forward
426
+ > direction only (`unsupported` ⟹ `process`), which is the half that
427
+ > actually encodes §3's claim. The assertion is written as a biconditional
428
+ > deliberately, because until then the stronger form is true and catches
429
+ > more; it is flagged here so it is not mistaken for something the AI work
430
+ > broke.
431
+
432
+ ### 7.2 Categories no catalog can reach today
433
+
434
+ Measured, and pinned as exact lists so they cannot go stale silently.
435
+
436
+ **`SOURCE_CATEGORIES`: 14 of 21 reachable.** Unreachable:
437
+ `graphql-argument`, `grpc-field`, `queue-message`, `database-read`,
438
+ `webhook-payload`, `ai-memory`, `declared`.
439
+
440
+ All seven are expected. GraphQL and gRPC have no source entries in `catalog.js`
441
+ at all; queue-message and database-read are ingress shapes the scanner does not
442
+ pattern-match; `declared` is the operator-declaration path (§6.5).
443
+
444
+ **`SINK_CATEGORIES`: only 10 of 29 reachable, as measured at D1's time of
445
+ writing** — even counting `privacy-catalog.js`. Unreachable at THAT time:
446
+ `stdout`, `cache`, `monitoring`, `sms`, `push-notification`, `collaboration`,
447
+ `webhook`, `backup`, `export`, `declared`, and **all nine `ai-*` values**
448
+ (`ai-model-provider`, `ai-local-model`, `ai-agent`, `ai-tool`,
449
+ `ai-vector-store`, `ai-memory`, `ai-training`, `ai-evaluation`,
450
+ `ai-telemetry`).
451
+
452
+ **CORRECTED (Sub-project H, AC-07 closure, 2026-08-31): `ai-model-provider`
453
+ is no longer unreachable.** `dataflow/catalog.js` gained four real sink
454
+ entries (OpenAI `chat.completions.create`/`responses.create`, Anthropic
455
+ `messages.create`, AWS Bedrock `InvokeModelCommand`) and `sink-registry.js`
456
+ gained a `CWE-201` → `ai-model-provider` `CWE_MAP` row — see
457
+ `docs/superpowers/plans/2026-08-31-data-flow-explorer-m1-subproject-h-ac07.md`.
458
+ **11 of 29 `SINK_CATEGORIES` are now reachable**, not 10; the other eight
459
+ `ai-*` values remain unreachable (`ai-local-model`/`ai-agent`/`ai-tool`/
460
+ `ai-vector-store`/`ai-memory`/`ai-training`/`ai-evaluation`/`ai-telemetry`),
461
+ and every OTHER category named unreachable above is still unreachable —
462
+ this correction is scoped to `ai-model-provider` alone. Every downstream
463
+ reference to "10 of 29" / "19 unreachable" / "all nine `ai-*`" elsewhere in
464
+ this document (§9.2's exit criterion discussion, D5's own historical
465
+ measurement) is a HISTORICAL record of what was true when D1-D5 shipped,
466
+ not a live claim — read it as "at the time," not as the current state.
467
+
468
+ **The headline gap this section originally recorded: FR-205 (AI
469
+ destinations) had zero sink-catalog coverage.** The source side *did* have
470
+ AI coverage even then — the eight MCP entries reach three `ai-*` source
471
+ categories — so the asymmetry was real and specific: this scanner could see
472
+ data arriving *from* an agent context and could not see data being sent *to*
473
+ a model provider, vector store, or agent. AC-07 ("PHI enters an
474
+ Anthropic/OpenAI/Bedrock model request") was, AT THAT TIME, **not
475
+ satisfiable** by reclassification alone — that gap is what the correction
476
+ above closes for the `ai-model-provider` slice of it (a direct API call to
477
+ a hosted model provider). `ai-agent`/`ai-tool`/the other seven `ai-*`
478
+ categories remain genuinely unreachable and are NOT closed by this
479
+ correction — they need their own, separate catalog work, deliberately out
480
+ of the AC-07 closure's scope.
481
+
482
+ **Whose problem this is: Sub-project H, inside Milestone 1 — not Milestone 2.**
483
+ AC-07 sits in **Milestone 1's own exit gate** (PRD §26, quoted verbatim in the
484
+ parent M1 scoping doc: *"AC-01, AC-02, AC-07, AC-11, and schema completeness
485
+ pass on the supported-language corpus"*), and that doc's §5 table assigns
486
+ running it to **Sub-project H** (*"Runs AC-01, AC-02, AC-07, AC-11, and schema
487
+ completeness against the real JS/TS corpus"*). So this is not a
488
+ deferred-to-a-later-milestone concern that D may hand off and forget: it is a
489
+ gap in the milestone D itself belongs to, and H will hit it as a hard exit-gate
490
+ failure unless AI-destination detection lands somewhere in D through G first.
491
+ **Whoever scopes Sub-project H must budget for that**, and should treat "AC-07
492
+ passes" as blocked on new detection work that no current increment owns.
493
+
494
+ `stdout` is likewise unreachable despite FR-201 naming it, because
495
+ `console.log` is catalogued as `log`, not split.
496
+
497
+ ### 7.3 Five places the upstream documents were measurably wrong
498
+
499
+ Recorded because later increments will otherwise re-inherit them. "Upstream"
500
+ rather than "the scoping doc" deliberately — items 3 and 5 originate in the D1
501
+ task brief, not the scoping doc, and attributing everything to one source would
502
+ send a corrector to the wrong file.
503
+
504
+ 1. **Catalog size.** The scoping doc (and `scanner/src/dataflow/CLAUDE.md`)
505
+ say **655 entries (149 source / 124 sink / 382 sanitizer)**. The live
506
+ figures are **756 (180 / 194 / 382)**. `dataflow/CLAUDE.md` itself warns
507
+ this number "has already drifted once"; it has now drifted twice.
508
+ Pinned by D1/8a.
509
+ 2. **The privacy vocabulary.** The scoping doc names eight categories —
510
+ `log`/`response`/`storage`/`queues`/`email`/`file`/`outbound`/`third-party`.
511
+ The real vocabulary is **nine**, and four of those eight names do not exist:
512
+ the real identifiers are `emailSend`, `fileWrite`, `outboundHttp`,
513
+ `thirdPartySdk`, plus `s3Upload`, which the eight-value list omits entirely.
514
+ Pinned by D1/4d.
515
+ 3. **`storage` splitting.** The **task brief** (not the scoping doc, which only
516
+ says the two vocabularies don't match) predicted `storage` would need
517
+ splitting across `database`/`object-storage`/`cache`. Measured: it does not
518
+ (§5.3). Pinned by D1/4b.
519
+ 4. **The sink classification field.** The scoping doc calls it `vuln`. That is
520
+ correct as far as it goes, but the operative sub-field is `vuln.cwe`, and
521
+ the more important fact — that **no sink entry carries a `category` field at
522
+ all** — is not recorded there. Pinned by D1/8b.
523
+ 5. **`SINK_CATEGORIES` has 29 values, not 28.** Both the scoping doc (D3's row:
524
+ *"more categories: 28 vs 21"*) and the task brief say 28. The live export
525
+ has **29** (`SOURCE_CATEGORIES`' 21 is correct). This document uses 29
526
+ throughout — §7.2's "10 of 29 reachable" and "19 unreachable" sum correctly
527
+ — but the correction was not flagged alongside the others in D1's first
528
+ draft, so it is recorded here. It matters for D3's sizing, and any
529
+ coverage-fraction computed off 28 is wrong. Pinned by D1/6c.
530
+
531
+ ### 7.4 Two genuine schema/catalog gaps — named, deliberately not patched
532
+
533
+ Per this plan's Global Constraints, both are reported rather than fixed.
534
+
535
+ **(a) `catalog.js` is missing `provenance` on 82 source entries.** This is a
536
+ *catalog* defect, not a lineage-side one, and it is the direct cause of 46% of
537
+ sources being `candidate` rather than `modeled`. It looks trivially fixable and
538
+ is not: `provenance` is **not inert**. `dataflow/engine.js` propagates it onto
539
+ findings as `sourceProvenance` and `chain[].provenance`, and
540
+ `src/report/index.js` emits it. Adding it to 82 entries would change reported
541
+ finding content for go/java/rb/php/py flows across the corpus. That is a real,
542
+ benchmarked change and must be its own increment with `bench:cve-replay:check`
543
+ and `bench:layer-recall:check` run against it — **not** a side effect of a
544
+ lineage design task. Until then, D2 carries the override table and honestly
545
+ reports `candidate`.
546
+
547
+ **(b) `schema.js`'s `client-storage` under-names FR-201's "browser DOM or
548
+ client storage".** Six DOM XSS sinks (`document.write`, `innerHTML`,
549
+ `outerHTML`, `insertAdjacentHTML`, both `dangerouslySetInnerHTML` forms) have
550
+ the rendered DOM as their destination. `client-storage` is the PRD-faithful
551
+ target and a misleading label for them. A future additive `browser-dom` value
552
+ would promote all six from `partial` to `modeled`, but adding a
553
+ `SINK_CATEGORIES` value is a **schema-version-bumping change** (`schema.js`'s
554
+ own header says so) requiring `dataflow-graph.schema.json` and
555
+ `validate.js` to move in lockstep. Out of scope for a design increment; raised
556
+ here for whoever owns the next schema bump.
557
+
558
+ Neither `catalog.js`, `catalog-expanded.js`, `privacy-catalog.js` nor
559
+ `schema.js` was modified by this increment.
560
+
561
+ ### 7.5 Externality: derivable, but only weakly
562
+
563
+ `schema.js`'s node contract requires `externality` ∈
564
+ `{internal, external, unknown}` "plus evidence" (PRD §10.3).
565
+
566
+ **No catalog entry carries any destination information** — no host, URL,
567
+ provider, system, or externality field. D1/8d asserts this across all 756
568
+ entries. Externality therefore **cannot** be entry-derived and must be
569
+ **category-derived**, per the `CATEGORY_EXTERNALITY` table: `log`, `stdout`,
570
+ `http-response`, `file`, `client-storage`, `ai-local-model` and every
571
+ `unsupported`/`process` sink → `internal`; every remote-party category →
572
+ `external`; every store-or-queue category (`database`, `object-storage`,
573
+ `cache`, `queue`, `backup`, `export`, `declared`) → **`unknown`**, because a
574
+ database may equally be a local container or a managed cloud service and the
575
+ entry gives no way to tell.
576
+
577
+ The consequence D2/D3 must respect: the accompanying evidence grade is at best
578
+ `declared` (it comes from a registry table), **never** `code`. Real externality
579
+ resolution is FR-202's job and lands in Milestone 2. Emitting `external` with a
580
+ `code` grade off this table would be an unsupported claim.
581
+
582
+ **2026-08-31 implementation note (D2, Task 1):** §9.0's decision shape —
583
+ `{kind, category, coverageStatus, externality, reason}` — requires
584
+ `externality` on BOTH registries' output, but the `CATEGORY_EXTERNALITY`
585
+ table above only ever covers `SINK_CATEGORIES`; no source-side twin
586
+ existed anywhere in this document. This went undetected through D1's own
587
+ review, fix round, and scoped re-review because the design-task PoC's own
588
+ totality test (`D1/1e`) asserted the `externality` enum only inside its
589
+ SINKS loop, never its SOURCES loop — the field was simply never exercised
590
+ for a source, so the gap was invisible to every check that ran.
591
+
592
+ D2 closed it by building **`SOURCE_CATEGORY_EXTERNALITY`**, in
593
+ `source-registry.js`, deriving all 21 `SOURCE_CATEGORIES` values via the
594
+ SAME rule this section's own reasoning already states for sinks — read
595
+ symmetrically: *is the counterparty the data came from a party outside
596
+ this program?* An end user hitting your own HTTP/GraphQL/gRPC/CLI
597
+ endpoint, or your own process's env/argv, is your front door → `internal`
598
+ (the flagship fixture's own `source`/`web-app` node pins exactly this:
599
+ `externality: 'internal'`, `fixtures/build-flagship-fixture.mjs:119`).
600
+ Another service's response, an AI provider's output, or an inbound
601
+ webhook payload is a genuine outside party → `external`. A store/queue
602
+ read where the entry gives no way to tell what's on the other end →
603
+ `unknown` (mirroring the sink table's own reasoning for `database`/
604
+ `object-storage`/etc.).
605
+
606
+ Two deliberate asymmetries with the sink table, so a reader doesn't mistake
607
+ either for drift: (1) the null-category (`unsupported`) fallback is
608
+ `externality: 'unknown'` on the source side, not `internal` as the sink
609
+ table gives it — a sink's `unsupported` still means "the call site is in
610
+ YOUR process," which is genuinely `internal`; a source's `unsupported`
611
+ means "we don't know what this construct even is," which has no honest
612
+ `internal` claim to make. (2) `storage-read` (D1's own merge of what would,
613
+ on the sink side, be two distinct categories — `file` and
614
+ `object-storage`, `internal` and `unknown` respectively) resolves to
615
+ `unknown`, the more conservative of the two, since the merged category
616
+ genuinely cannot distinguish which case a given entry is — never
617
+ `internal`-by-default.
618
+
619
+ The evidence-grade consequence above (`declared`, never `code`) applies
620
+ identically to `SOURCE_CATEGORY_EXTERNALITY` — nothing about deriving
621
+ externality from a table rather than an entry changes which grade that
622
+ derivation earns.
623
+
624
+ ---
625
+
626
+ ## 8. Confirmations for D4 (transformation-kind recognition)
627
+
628
+ D4 is not designed here. Three facts it depends on were checked so it starts
629
+ from a settled base.
630
+
631
+ **8.1 `TRANSFORM_KINDS` is correct and complete.** It matches PRD §10.6's
632
+ prose exactly, in order, 13 values, no duplicates:
633
+ `mask, redact, tokenize, hash, encrypt, decrypt, encode, decode, aggregate,
634
+ truncate, normalize, custom, unknown`. `REVERSIBILITY_VALUES` likewise matches
635
+ "reversible, irreversible, unknown". **No change is needed** — D4 can treat the
636
+ vocabulary as fixed. Pinned by D1/7a, which also asserts `mask`/`hash`/
637
+ `tokenize`/`encrypt` remain four distinct values, per §10.6's explicit "must
638
+ never be treated as synonyms".
639
+
640
+ **8.2 The 706 sanitizer entries are the WRONG input for D4.** `catalog.js`'s
641
+ 382 sanitizers and `catalog-expanded.js`'s 324 are keyed on `effect`
642
+ (`strip`/`taintNever`/`taintIf-not-pinned`) and `appliesTo` (a *threat class*,
643
+ e.g. XSS vs SQLi). They answer "does this neutralize this vulnerability
644
+ family", not "what kind of transformation is this and is it reversible".
645
+ Reclassifying them into `TRANSFORM_KINDS` would be a category error — an HTML
646
+ escaper is `encode`, but 380-odd others carry no signal that maps at all.
647
+
648
+ **8.3 The one genuinely transform-shaped list is private.**
649
+ `privacy-catalog.js`'s `PRIVACY_TRANSFORM_CALLEES` is a hand-curated set
650
+ already grouped along `TRANSFORM_KINDS` lines — hashes (`createHash`, `sha256`,
651
+ `md5`), KDFs (`bcrypt`, `scrypt`, `argon2`), ciphers (`createCipheriv`,
652
+ `encrypt`, `seal`), and masking (`mask`, `redact`, `anonymize`,
653
+ `pseudonymize`, `tokenize`). It is **not exported**; it is reachable only
654
+ through the `isPrivacyTransformCallee(calleeExpr)` boolean predicate, which
655
+ tells a caller *that* a callee is a transform but never *which kind*. D4 must
656
+ resolve that access problem — the conservative option is a lineage-side table
657
+ of its own, mirroring §6.3's per-entry precedent; exporting the set from
658
+ `privacy-catalog.js` is an additive alternative. D1 records the constraint and
659
+ does not decide it.
660
+
661
+ ---
662
+
663
+ ## 9. Checklist for the follow-up increments
664
+
665
+ ### 9.0 The decision shape, and how `category` reaches a node
666
+
667
+ Both registries return the same object:
668
+ `{kind, category, coverageStatus, externality, reason}`.
669
+
670
+ **`category` is the REGISTRY's own field name. On a `DataFlowGraph v1` node it
671
+ becomes `subtype`.** The PRD's §10.3 node contract has no `category` field at
672
+ all — the vocabulary lands in `subtype` ("Framework/provider-specific type such
673
+ as `express-route`, `postgres-table`, `stdout`"), and `schema.js`'s own comment
674
+ on `SOURCE_CATEGORIES`/`SINK_CATEGORIES` says so explicitly: *"the fixed
675
+ vocabulary a node's `subtype`/an inventory row's `category` field draws from."*
676
+ So D2/D3 emit `category`, and Sub-project E's graph builder writes it to
677
+ `node.subtype`. Neither side should invent a third name, and a registry must
678
+ not emit a field literally called `subtype` — the two are the same vocabulary
679
+ at different layers, and conflating them would let a registry decision be
680
+ validated as a node without ever passing through the builder.
681
+
682
+ A `null` `category` therefore becomes a `null`/absent `subtype`, which is
683
+ exactly what an `unsupported` node needs (§6.4) — its `kind` and `reason` carry
684
+ the meaning instead.
685
+
686
+ ### D2 — `src/lineage/source-registry.js`
687
+
688
+ 1. Export `reclassifySource(entry)` returning the §9.0 shape. `kind` is always
689
+ `'source'` (§7.1). `externality` is derived via `SOURCE_CATEGORY_EXTERNALITY`
690
+ — see §7.5's 2026-08-31 implementation note for the table and the
691
+ counterparty-rule derivation this section's own reasoning did not
692
+ originally cover for the source side.
693
+ 2. Port `PROVENANCE_MAP` (12 rows, §4), `AGENT_TOOL_REFINEMENT` (8 rows, §4.1),
694
+ the `language === 'cpp'` descriptor-generic refinement (§4.2) and
695
+ `NO_PROVENANCE_OVERRIDES` (82 rows, §4.3) from
696
+ `test/lineage/registry-mapping-poc.test.js` — see §9.1 for why that file is
697
+ the source of truth for the 82-row table and what happens to it afterwards.
698
+ 3. Import `CATALOG` from `../dataflow/catalog.js`. Import nothing else from
699
+ `dataflow/` — no matcher, no engine (§1).
700
+ 4. Keep the completeness guards as **shipped** tests, not PoC-only: the
701
+ provenance key set and the override key set must each be asserted equal to
702
+ the live catalog's, both directions. They are the only thing standing
703
+ between a new catalog entry and a silently uncategorized node. All three
704
+ were mutation-proven to fail in D1.
705
+ 5. Never emit `manual` (§6.5).
706
+
707
+ ### D3 — `src/lineage/sink-registry.js`
708
+
709
+ 1. Export `reclassifySink(entry)` and `reclassifyPrivacySink(entry)` — two
710
+ functions, because the two catalogs key on different fields (§2.1) and
711
+ `privacy-catalog.js` is deliberately not merged into `CATALOG`.
712
+ 2. Port `CWE_MAP` (20 rows, §5.1), the CWE-79 `framework` refinement (§5.2),
713
+ `PRIVACY_CATEGORY_MAP` (9 rows, §5.3), `CATEGORY_NODE_KIND` and
714
+ `CATEGORY_EXTERNALITY` (§7.1, §7.5).
715
+ 3. **Preserve the `unsupported` → `process` node, with its reason string.**
716
+ Dropping those 82 entries would be the single easiest way to violate AC-11
717
+ and FR-201, and it would look like an optimization. D1/3c's biconditional
718
+ and D1/1e's non-empty-reason assertion must both ship.
719
+ 4. Carry forward the `thirdPartySdk` match-time refinement as a **known open
720
+ item, not a silent `analytics`** (§5.3): a registry cannot resolve it, but a
721
+ later match-time consumer that knows which receiver matched can, and should
722
+ promote `partial` → `modeled` when it does.
723
+ 5. **Close FR-203 — a dynamic destination still produces a node.** The parent
724
+ scoping doc assigns FR-203 to D3 explicitly, and nothing else in this
725
+ checklist covers it, because it is a *different axis* from everything above:
726
+ §5's tables answer "which category is this sink", FR-203 answers "we know
727
+ the call, but not where it points". A recognized sink whose destination
728
+ cannot be resolved (`fetch(url)` with a computed `url`, an SDK client built
729
+ from config) must emit a node with **`kind: 'unresolved'`** — the
730
+ `NODE_KINDS` value that exists for exactly this and is used nowhere else in
731
+ this design — its resolved `category` retained (the *category* is known even
732
+ when the *destination* is not), `externality: 'unknown'`, and a `reason`
733
+ naming the expression that prevented resolution, since FR-203 requires the
734
+ evidence panel to show it. **`coverageStatus`** carries over unchanged from
735
+ whatever the category mapping already assigned the underlying catalog entry
736
+ (§4/§5) — destination resolution is a different axis from classification
737
+ confidence (this item's own opening sentence), so a `modeled` sink whose
738
+ destination happens to be dynamic stays `modeled`; it does NOT demote to
739
+ `partial` the way §16.7 half 2's degraded-dead-end node does (that node
740
+ carries `partial` for an unrelated reason — the analysis itself is
741
+ incomplete, not the classification, per §9.2's own routing above — do not
742
+ conflate the two `unresolved`-kind cases). **Never** drop the node, and never let it
743
+ degrade into the `process`/`unsupported` bucket, which means something
744
+ entirely different (§3: no category exists at all). Note D3 can only mark
745
+ the *shape*; actually resolving destinations is FR-202 and lands in
746
+ Milestone 2 (§7.5).
747
+ 6. Ship the measured-count pins (§4.3, §5.1, §5.2) as tests. `bench/layer-recall`
748
+ has already demonstrated in this repo that a floor-only gate lets a stale
749
+ published number survive for weeks; these are equality pins for that reason.
750
+
751
+ ### 9.1 Who owns the 82-row override table, and who deletes the PoC
752
+
753
+ Two questions every prior sub-project answered explicitly and this one must
754
+ too (the precedent is `DESIGN_PATH_PROVENANCE.md` §13.7 item 15, where a single
755
+ absorbing task carried an explicit deletion instruction).
756
+
757
+ **The override table's permanent home is `source-registry.js`.** It is code,
758
+ not prose, and it belongs in the module that executes it. This ADR
759
+ deliberately does **not** reproduce all 82 rows: a hand-copied 82-row table in
760
+ markdown would drift from the executable one with nothing able to detect the
761
+ drift, which is strictly worse than a single authoritative copy plus a pointer.
762
+ What this document owns instead is the *derivation rule* (§4.3 + §6.3) and the
763
+ *completeness guarantee* (D2 item 4) — both of which survive any move of the
764
+ table itself. Until D2 lands, the PoC is the table's interim home; after D2
765
+ lands, `source-registry.js` is, and the PoC's copy is redundant.
766
+
767
+ **Deletion: whichever of D2/D3 lands SECOND deletes
768
+ `test/lineage/registry-mapping-poc.test.js`**, in its own commit, after
769
+ confirming the other increment's absorption is complete. D2 and D3 run in
770
+ parallel (§9.2) and each absorbs a *disjoint* half of the PoC — D2 the source
771
+ tables and guards, D3 the sink and privacy ones — so neither may delete it
772
+ unilaterally while the other is still in flight. The second-lander must verify
773
+ both halves are present as shipped tests before removing it, and must also
774
+ remove the file from `package.json`'s `test:lineage` script and from this
775
+ package's `CLAUDE.md` table in the same commit. If D4 somehow lands before
776
+ either, it changes nothing: D4 absorbs no part of this file (§8 is
777
+ confirmations only, `D1/7a`/`D1/7b` stay until the second registry lands).
778
+
779
+ ### 9.2 Sequencing and D5's exit criterion
780
+
781
+ D2 and D3 are independent and can run in parallel — they share only this
782
+ document and `schema.js`, and touch disjoint files. D4 depends on §8 but not on
783
+ D2/D3.
784
+
785
+ **D5's exit criterion, as the parent scoping doc states it, cannot pass — and
786
+ must be corrected before D5 is briefed.** That doc's D5 row reads: *"every
787
+ FR-101/FR-201 source/sink category has at least one real-code proof."* §7.2
788
+ measures why that is unachievable: **19 of 29 sink categories and 7 of 21
789
+ source categories have zero catalog entries mapping to them**, by construction
790
+ rather than by any implementation gap. No quality of D2/D3/D4 execution can
791
+ change that, because the detection those categories would need does not exist
792
+ anywhere in the scanner (§7.2's headline: all nine `ai-*` sink categories are
793
+ among them). Briefed as written, D5 would either fail permanently or be quietly
794
+ softened — and quietly softening a coverage criterion is precisely the failure
795
+ mode this whole PRD treats as load-bearing.
796
+
797
+ This is a defect in D5's *paraphrase*, not in FR-201. FR-201's own text says
798
+ **"all *supported* sinks must remain discoverable"**, and the `unsupported`-
799
+ with-reason design satisfies that honestly: every one of the 756 entries
800
+ produces a node carrying a non-empty reason (`D1/1e`), and every unreachable
801
+ category is named in a test that fails if the set changes (`D1/6a`, `D1/6b`).
802
+
803
+ **The corrected criterion D2–D5 should be built and judged against:**
804
+
805
+ > Every **reachable** category has at least one real-code proof, **and** every
806
+ > **unreachable** category has a recorded, tested reason — never silent
807
+ > absence.
808
+
809
+ Both halves are testable today and already have their shape fixed by `D1/6a`
810
+ and `D1/6b`, which pin the unreachable sets as exact lists. D5's job is to
811
+ extend that from "the registry maps it" to "real parsed code exercises it",
812
+ for the reachable half only, while inheriting the unreachable half's existing
813
+ assertions unchanged. A category moving from unreachable to reachable is then
814
+ a *deliberate* event that fails a test and forces both lists to be updated —
815
+ which is the behavior wanted, and the opposite of a floor-only gate.
816
+
817
+ ### Carried forward from Sub-project C: §16.7 Finding 2
818
+
819
+ `DESIGN_PATH_PROVENANCE.md` §16.7 left one finding explicitly **unfixed and
820
+ binding on whoever scopes Sub-project D**, with an instruction that D's own
821
+ documents must carry it forward rather than let it be rediscovered. Recording
822
+ it here discharges that obligation; it is **not** resolved by D1, and it is not
823
+ D2's or D3's to resolve either.
824
+
825
+ The finding: a §13.6 context-cap-degraded binding edge's target node is
826
+ unreachable from `path-query.js`'s `sinkCandidates()`. That node has zero
827
+ out-edges and is none of the `return`/`escape`/`loss` kinds `sinkCandidates()`
828
+ enumerates, so a normal **sink-rooted** reconstruction never surfaces it at
829
+ all. `flow-grade.js` grades it correctly if a caller reaches it directly;
830
+ nothing in a sink-rooted query asks. This is §18.4's "truncation must never
831
+ look like no-flow" constraint re-opening at the *query* boundary rather than
832
+ the recording boundary.
833
+
834
+ §16.7 offers two fixes, and they are **not alternatives — they are two halves,
835
+ one of which is D's**. Both are settled here.
836
+
837
+ **Half 2 (the taxonomy) IS D's, and is decided now.** §16.7's second option —
838
+ "Sub-project D's registry deciding a degraded dead end is a reportable
839
+ endpoint" — is a vocabulary question in §6/§7.1's remit and needs no
840
+ `PathStore` at all, so leaving it open would force Sub-project E to invent the
841
+ node's vocabulary while implementing the enumerator. **A truncation-terminal —
842
+ a `path` node with zero out-edges whose in-edges carry a
843
+ `context-cap-degraded` annotation — is a reportable endpoint, and it carries
844
+ `kind: 'unresolved'`, `coverageStatus: 'partial'`, `externality: 'unknown'`,
845
+ with a `reason` naming the context-cap degradation.** The reasoning, in the
846
+ terms §6 and §7.1 already set: `unresolved` is right for the same reason it is
847
+ right for FR-203 (§9's D3 item 5) — the flow is known to exist and its endpoint
848
+ is not knowable, which is exactly what that kind means, and it must **not** be
849
+ `process`/`unsupported`, which asserts the different and false claim that no
850
+ category models it; `partial` is right because the analyzer genuinely observed
851
+ this flow and lost only its continuation, which is §6.2's "sure, and lossy in a
852
+ stated way", not §6.3's "inferred" or §6.4's "unmodellable"; `externality:
853
+ 'unknown'` follows §7.5 directly, since the callee's body was never analyzed
854
+ and nothing is known about where the data goes next. This is the one place in
855
+ this design where a node's `coverageStatus` comes from the *analysis* rather
856
+ than from a catalog entry — consistent, because a truncation-terminal has no
857
+ catalog entry behind it at all, which is exactly why §4/§5's tables could not
858
+ settle it.
859
+
860
+ **Half 1 (the enumerator) is Sub-project E's, not D2's or D3's.**
861
+ `sinkCandidates()` is documented as a
862
+ **registry stand-in** — "there is no sink registry yet — Sub-project D" — so
863
+ the obvious reading is that D3 replaces it and inherits the bug. That reading
864
+ is wrong. D3 produces a *classification for a catalog entry*; it does not
865
+ enumerate graph nodes, and it never sees a `PathStore`. The consumer that
866
+ eventually replaces `sinkCandidates()` with real registry-backed sink
867
+ enumeration is **Sub-project E's graph builder**, and that is where the fix
868
+ belongs — either by adding the candidate "truncation-terminal" node shape §16.7
869
+ sketches, or by having the enumerator union `diagnostics()`'s degraded targets
870
+ into its candidate set. Whoever scopes Sub-project E must pick one up front,
871
+ because a registry-backed enumerator that simply mirrors `sinkCandidates()`'
872
+ current kind list reproduces the gap exactly.
873
+
874
+ ### Not in scope for any of D2/D3/D4
875
+
876
+ Modifying `catalog.js` / `catalog-expanded.js` / `privacy-catalog.js` /
877
+ `schema.js` (§7.4); FR-202 destination resolution; any `DataFlowGraph v1`
878
+ envelope output or graph builder (Sub-project E); wiring any registry into
879
+ `runScan`; new *detection* of any kind — in particular the AI-destination gap
880
+ (§7.2), which reclassification cannot close.