@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,1203 @@
1
+ # scanner/src/lineage/
2
+
3
+ Data Flow Explorer's canonical `DataFlowGraph v1` contract package
4
+ (PRD section 18.1 — an untracked root working document, per this repo's
5
+ convention of not committing in-progress PRDs; ask the maintainer for the
6
+ current copy if you need it).
7
+ Isolated by design from `scanner/src/dataflow/`'s taint engine — see that
8
+ package's own CLAUDE.md for why (D-0047's precedent: a second, independent
9
+ engine sharing only pure, stateless utilities, never mutable taint state —
10
+ e.g. `access-paths.js`'s `accessPathOf`/`pathIsCoveredByPrefix`/`isCoveredBy`,
11
+ never `engine.js`'s live taint state).
12
+
13
+ ## What's here
14
+
15
+ **Milestone 0 (contract and fixture):**
16
+
17
+ | Module | Responsibility |
18
+ |---|---|
19
+ | `schema.js` | Envelope shape, `SCHEMA_VERSION`, node/mapping/transform/coverage/policy/evidence enums |
20
+ | `ids.js` | Deterministic stable-ID functions (`nodeId`, `edgeId`, `dataElementId`, `flowId`, `transformationId`, `evidenceId`, `graphId`, `obligationId`) — sha256-over-canonicalized-material, same shape as `posture/stable-id.js`'s finding IDs |
21
+ | `protection.js` | Protection verdict model: `PROTECTION_VERDICTS` × `EVIDENCE_GRADES` per dimension (`transit`/`atRest`/`handling`), plus `aggregateVerdicts()`'s risk-precedence reduction (PRD 8.4) |
22
+ | `classification.js` | Data classes (reuses `dataflow/privacy-taxonomy.js` + adds `CONFIDENTIAL`) and the 15 AI processing contexts (PRD 9.2) — AI is modeled as orthogonal to data class, never a mutually-exclusive label |
23
+ | `validate.js` | Hand-rolled structural validator (`validateGraph`) — no new npm dependency; `dataflow-graph.schema.json` is the JSON-Schema-dialect twin, kept in parity by `test/lineage/json-schema-parity.test.js`. Checks id-prefix format (regex per entity kind), every risk-bearing enum (`node.kind`, `node.externality.value`, `node.coverageStatus`, `dataElement.dataClasses`/`.aiContexts` array *contents*, `edge.protocol.destinationResolution`, `flow.policyVerdict`/`.protectionSummary`, `transformation.kind`/`.reversibility`, `evidence.evidenceType`, `graph.scope.source`) against `schema.js`/`classification.js`, not just array/field presence — each gap was closed after an initial pass let a made-up value validate cleanly (see git history on `validate.js`). Also checks duplicate-id uniqueness across the four top-level entity arrays (nodes/edges/dataElements/flows) — a real gap until it was closed alongside the enum gaps above. |
24
+ | `dataflow-graph.schema.json` | Authoritative JSON Schema (2020-12) document for external interop/documentation |
25
+ | `fixtures/build-flagship-fixture.mjs` | Deterministic generator for the payments-platform reference fixture (PRD Appendix D.2/D.3) — re-run and re-commit `flagship-graph.json` if you change the generator; a diff test (`flagship-fixture.test.js`) enforces idempotence. Entity-id discriminators (see `ids.js`) must include every field that can vary between two otherwise-similar entities (e.g. an edge's `dataElementIds`) — two edges over the same node pair carrying different payloads collided on id once before this was tightened; `flagship-fixture-semantics.test.js` now pins uniqueness of every id array as a regression guard. |
26
+
27
+ **Milestone 1, Sub-project A (field-identity engine core — design spike + intraprocedural, single-function only):**
28
+
29
+ | Module | Responsibility |
30
+ |---|---|
31
+ | `DESIGN_INTRAPROCEDURAL.md` | Design record: the field-identity state shape (`Map<accessPath, Set<dataElementId>>`, replacing boolean taint so FR-301's multi-label requirement holds), the exact reuse boundary against `scanner/src/dataflow/` (what's pure-reusable vs. must-be-reimplemented), and per-construct handling rules (object literals attribute each property to its own sub-path; template literals/string concatenation propagate identity normally, not as a widened/implicit flow; unresolved calls ARE flagged as widened). Read this before touching `field-identity.js` or `engine.js` — it's the binding reference both were built against. |
32
+ | `field-identity.js` | Pure state module: `emptyState`/`identitiesAt`/`addIdentity`/`removeIdentitiesAt`/`joinStates`/`statesEqual`/`hashState` over the `Map<path, Set<dataElementId>>` shape. `identitiesAt` is bidirectional (corrected from an original one-directional design that shipped a real FR-301 bug — see the ADR §3's "Corrected design" note): an ancestor's identity is visible when querying a descendant path, AND querying a container as a whole aggregates every identity recorded under it — sibling paths never leak into each other, since aggregation only follows an actual prefix relationship. Imports `pathIsCoveredByPrefix` from `dataflow/access-paths.js` (the one function from that package currently reused in practice; see this file's header for the full allowed-reuse list). Unit-tested in `test/lineage/field-identity.test.js`, including the FR-301 "two distinct fields coexist without merging" case and the bidirectional-aggregation regression cases. |
33
+ | `engine.js` | `resolveExprIdentities(state, expr, ctx?)`: recursively resolves which data-element identities a parsed JS/TS expression carries. `byPath` is populated not just by object literals but by ANY path reference (`ident`/`member`) that resolves to a state path with recorded descendant structure — an alias like `const copy = user;` is structurally indistinguishable from a fresh object literal for this purpose (a round-2 fix; round 1 only handled the object-literal case, and the same coarse-merge bug survived through one level of aliasing until this closed it). A shared `residualFlat(flat, byPath)` helper is applied consistently everywhere a `flat`+`byPath` pair gets written to a target/key: every `byPath` entry is written at its own sub-path, and only the RESIDUAL (whatever in `flat` isn't already captured by `byPath`'s union) is written coarsely at the target's own root — never the full `flat` when `byPath` has structure, since that recreates the coarse-merge bug one level up. **The general invariant, restated once more per round 5's own suggested durable framing (subsumes round 4's "three hop types" framing and round 5's own finding without needing to re-enumerate constructs again): every path component the IR supplies must be either a real property name or an explicitly modeled unknown (`'*'`), and every kill (`removeIdentitiesAt`) must be justified as a strong update on a definite, uniquely-identified location — a write to an unknown/aliased location must be a weak update instead.** Check any new/changed code against all three hops (production/selection/write-out, below), don't hand-count sites (a fixed-count claim is exactly what let round 3's gap survive two rounds of review), don't limit the check to `resolveExprIdentities`'s own switch (that's exactly what let round 4's gap survive three rounds of review before it was found), and don't assume every path component reaching a hop is a real, distinct property name (that's exactly what let round 5's gap survive four rounds of review — the three hop types were correctly identified, but nothing questioned whether `node.target`/`expr.prop`/`prop.key` are always real property names rather than an explicitly-modeled unknown):
34
+ - *Production* (a switch case building a value's structure) — every case falls into one of two categories: *structure-preserving* (its result could genuinely BE an existing structured value from `state`, by reference/selection — must forward `byPath`) covers `ident` and `object` (round 1/2), `member` when its base is a pure ident/member chain (round 2), plus `union` (ternary — selects one branch verbatim), `logical` (`\|\|`/`&&`/`??` — short-circuit evaluation can return an operand verbatim, unlike `binary`) and `assign-expr` (simple pass-through of its resolved source, including its `widened` flag — round 4 fixed `assign-expr` recomputing `widened` from `flat.size > 0` instead of forwarding the source's real value, which falsely flagged a plain no-call assignment as an `unresolved-call` widening), all three added in round 3 after the same coarse-merge bug was found surviving there, confirmed via the real parser (`flag ? user : other` merging `user`'s and `other`'s fields together). *Structure-flattening, correctly and by design* (stays flat-only) covers `literal`/`unknown` (nothing to preserve), `tpl` (a template literal always produces a new string), `binary` (arithmetic/comparison operators always produce a new primitive — this is why `binary` and `logical` are separate cases, not shared, as of round 3), `array` (**not** "no index-sensitive access paths" — round 4 found that claim false, since `accessPathOf`/the parser DO extract literal computed keys and can build paths like `arr.0`; the real reason is that the parser transparently unwraps `SpreadElement`, so `[...xs, user]` and `[xs, user]` are byte-identical in the IR and naive per-index attribution would actively misattribute a spread source's contents to a literal index — worse than staying flat; fixing this properly needs the parser to distinguish spread from literal elements first, out of scope here), and `call` (an unresolved call's return is genuinely unknown structure — flat + `widened: true`, not laundered). Round 5 also found and fixed a production-side bug in `object`'s own INPUT rather than `resolveExprIdentities` itself: `scanner/src/ir/parser-js.js`'s `ObjectExpression` handling built each property's `key` as `p.key.name || ...` with no check of Babel's own `p.computed` flag, so a computed property with a non-literal key expression (`{[k]: v}`) resolved to the key EXPRESSION's own variable name (`'k'`) — colliding with an explicit, non-computed property literally named `k` on the same object. Fixed by checking `p.computed`: a computed key that is itself a resolvable literal (`{[42]: v}`, `{['literal']: v}`) still resolves to that literal's string form; a non-literal computed key resolves to the literal string `'*'`, mirroring the EXISTING computed-member-access convention this same file already uses for `obj[k]` reads/writes. `object`'s own case now folds a `key === '*'` property into the coarse residual, never a byPath entry keyed `'*'` (which would just be a differently-shaped version of the same collision bug). **Round 6 found the identical computed-key bug, unfixed, in a SECOND production site in the same file**: `lhsPath`'s `ObjectPattern` branch (the destructuring-pattern lowering, e.g. `const { [field]: value } = user`) resolved a non-literal computed key to the key expression's own variable name, same as `ObjectExpression` did pre-round-5 — silently dropping the destructured identity entirely (`returnFacts: []`), where the equivalent `return user[field]` already widened correctly per round 5. Fixed by extracting a shared `resolveObjectKey(p)` helper from `ObjectExpression`'s already-fixed logic and calling it from both sites, so this bug class can't reappear a third time in some future object-key-reading code path in this file. (Round 6 also found, and flagged as a known, deliberately-unattempted limitation, that object spread/rest — `{...user}`, `const {...rest} = user` — was silently dropped entirely by the parser; see this cell's closing "Object spread/rest fix" sentence below for the resolution.)
35
+ - *Selection* (reading a field off an already-produced structured value, round 4) — `member`'s base isn't always a pure ident/member chain (`accessPathOf` returns `null` for a ternary, a logical expression, an object literal, an assign-expr, a call). Before round 4, that fell straight to `noIdentity()`, silently dropping the identity: `(user ?? other).email` returned `[]` even though `const c = user ?? other; return c.email;` correctly returned `[email]` — same semantics, two forms, two different answers, the exact tell every prior round used to find its bug. Fixed by giving `member` a second path: resolve the base recursively and select `prop` out of the base's `byPath` (plus its residual), mirroring how `object`'s construction attributes a property to its own key. Round 5 found a further, narrower selection gap: `accessPathOf` maps a computed member access with a statically-unknown key (`user[k]`) to a path ending in the literal segment `'*'` (e.g. `'user.*'`) — an existing convention in the shared access-path layer that `member`'s path-succeeds branch never interpreted specially, so it queried `identitiesAt(state, 'user.*')`, which matches nothing, and silently returned `[]` instead of a widened flow. Fixed (both the path-succeeds branch and the non-path fallback, for e.g. `(user ?? other)[k]`) by recognizing a `'*'`-ending path/prop and conservatively resolving the CONTAINER's full aggregate, flagged `widened: true`. **Round 6 found round 5's own guard here (`path === '*' || path.endsWith('.*')`) only recognized a TRAILING wildcard, not an INTERIOR one**: `store[k1].name`/`store[k2].name` both lower to the identical access path `'store.*.name'`, which is neither exactly `'*'` nor ending in `.*'`, so the read side silently returned `[]` for `store[k].name = user.ssn; return store.a.name;` instead of conservatively widening. Fixed with a position-independent `pathHasWildcard`/`definitePrefixBeforeWildcard(path)` helper pair (near the top of the file, after `unionOfByPath`/`residualFlat`) that finds the wildcard at ANY position — `definitePrefixBeforeWildcard('bag.*')` still gives round 5's own `'bag'` (no behavior change for the trailing case), `definitePrefixBeforeWildcard('store.*.name')` now correctly gives `'store'`. `member`'s non-path fallback branch (the paragraph above) needed no change: it never parses a dotted path string at all — it resolves the base recursively via the expression tree, so an interior wildcard there is just a nested `member` node whose own `expr.prop === '*'` check fires one recursion level in.
36
+ - *Write-out* (writing a resolved value to a target path that must itself be a valid, non-fabricated path, round 4) — `step()`'s `assign` case passed `node.target` straight through to `removeIdentitiesAt`/`addIdentity` with no guard it was a string. Assignment-expression-form destructuring (`({a} = obj)`, as opposed to declaration-form `const {a} = obj`) is lowered by the real parser into one `assign` CFG node whose `target` is the raw pattern object, not a string — this implicitly stringified to the literal `"[object Object]"`, so every such destructuring anywhere in a function collided onto one fabricated key, silently merging unrelated statements' fields. Fixed by guarding `node.target` to be a string before writing anything (skip rather than fabricate a key), matching a precedent `scanner/src/dataflow/engine.js`'s own `assign` case already established that this package hadn't inherited. Round 5 found a second write-out bug in the same case, once the target IS a string: EVERY computed-key write on the SAME container lowers to the SAME literal target string (`'bag.*'`, since the real runtime key is statically unknown), so a strong update (`removeIdentitiesAt` before writing) on a second computed-key write to the same container silently deleted the FIRST write's identity — `bag[k1] = user.email; bag[k2] = user.ssn; return bag;` returned only `data:ssn`. `scanner/src/dataflow/engine.js`'s `_addPathAliasAware` had already solved exactly this for the sibling taint engine (a trailing `.*` write must be a WEAK update — add to the container, never clear it first); this package had not inherited that precedent either, the same pattern as the destructuring fix one paragraph up. Fixed by recognizing a `'*'`-ending target and performing a weak `addIdentity` onto the container path (never `removeIdentitiesAt` first), flagged as a `dynamic-property-key` widening event. **Round 6 generalized this same guard to be position-independent too** (via the same shared `pathHasWildcard`/`definitePrefixBeforeWildcard` helper pair described in the *Selection* bullet above): `store[k1].name = user.email; store[k2].name = user.ssn;` both lowered to the identical target `'store.*.name'` (an INTERIOR wildcard, not trailing), so the round-5 trailing-only check let the second write's strong update silently delete the first write's identity — round 5's own bug recurring one path segment deeper. **Deferred, not fixed this round (Finding 3, Minor, soundness-unaffected):** both this branch's `assign`-case widening push and `return`'s equivalent still label EVERY widening event with the hardcoded reason string `'unresolved-call'` when they fire via the general `resolved.widened` path (as opposed to this `'*'`-target branch's own correctly-labeled `dynamic-property-key` push) — so a widening actually caused by a dynamic property key on the READ side can be mislabeled `'unresolved-call'` in the ledger. The identity SETS themselves are unaffected either way; only the stated cause can be wrong. A real fix needs `resolveExprIdentities`'s return shape to thread a reason string through more broadly than this round's scope covers.
37
+
38
+ See the ADR §3's "The residual principle" and "The structure-preserving vs. structure-flattening invariant (round 3)" notes, the ADR's "The three hop types the invariant covers (round 4)" note, the ADR's "Statically-unknown path components (round 5)" note, and the ADR's "Two under-enforcements of the round-5 invariant (round 6)" note, for the full worked examples. `analyzeFunctionFieldIdentity(fn, entryState, ctx?)`: a forward-worklist CFG analysis over a single function, structurally mirroring `dataflow/engine.js`'s `analyzeFunction` (same fixed-point/join algorithm, entirely different state type — never imports or touches that file's taint state) — returns `{exitState, returnFacts, mutatedParams, widenings}`. `returnFacts` accumulates per-node in a `Map` during the worklist and unions on revisit before materializing to an array, so a `return` node revisited before its incoming join settles gets exactly one, fully-unioned entry (a final whole-branch review found and fixed a real duplicate-stale-entry bug here). `mutatedParams` is computed via a direct `identitiesAt` call per param, relying on `identitiesAt`'s bidirectional aggregation to see both the param's own path and any descendant a mutation wrote to. **Interprocedural resolution (Sub-project B, increment 2):** the optional `ctx` param is threaded through `step()` into every recursive `resolveExprIdentities` call it makes (`assign`/`return`/bare-call CFG nodes); when `ctx.resolveCallSummary` is present, a `call` expression consults it instead of falling back to the flat/`widened:true` unresolved-call case — see `summaries.js`'s `createCallSummaryResolver` below for how that resolver is built and how it threads itself down through multi-hop call chains. No path DAG, no registry/graph-output wiring yet — see the ADR §5 for the explicit scope line. Unit-tested against hand-built fixtures in `test/lineage/engine-expr-resolver.test.js` and `test/lineage/engine-walker.test.js`, and against real parsed JS/TS source (via `scanner/src/ir/parser-js.js`) in `test/lineage/engine-integration.test.js`. **Object spread/rest fix:** a post-implementation audit (Tasks 1–2) found and closed a long-standing parser-level gap where object spread (`{...user}`) and destructuring rest (`const {...rest} = user`) were silently dropped — see `DESIGN_INTRAPROCEDURAL.md`'s object-spread bullet for the full fix description and the array-rest cross-reference (distinct, unrelated issue, still unfixed). |
39
+
40
+ **Milestone 1, Sub-project B, increments 1-6 — COMPLETE (interprocedural summaries — cache core, real call-case wiring, real call-graph integration, project-wide driver, recursion refinement, context-sensitivity tuning):**
41
+
42
+ | Module | Responsibility |
43
+ |---|---|
44
+ | `summaries.js` | `FieldIdentitySummaryCache`: caches a per-function field-identity summary keyed by `` `${qid}::${hashState(entryState)}` `` (reuses `field-identity.js`'s `hashState` directly — the exact primitive Sub-project A built anticipating this reuse). `compute(qid, entryState, analyzeFn)` has a per-function distinct-context cap (default 16, constructor-overridable, and — since increment B6 — env-var-overridable via `AGENTIC_SECURITY_LINEAGE_MAX_CONTEXTS`, deliberately a SEPARATE env var from `dataflow/summaries.js`'s own `AGENTIC_SECURITY_KCFA_MAX_CONTEXTS`, keeping the two engines' tuning knobs decoupled per the isolation principle — an explicit constructor argument always wins over the env var, exactly the precedence every prior increment's own tests already relied on. The validation FORMULA mirrors dataflow's exactly, but `0` does NOT mean the same thing in both: dataflow exempts its empty-entry context from the cap, so `0` there is genuinely monovariant; this package's cap counts the empty-entry context too, so `0` here degrades every context, including the empty one, to no real base — resolution goes fully off, not merely monovariant, a final whole-branch review found and corrected an earlier wrong equivalence claim here) that degrades to the empty-entry-state summary past the cap rather than computing unboundedly many contexts, and a `_stack`-based recursion guard (qid-keyed, not `(qid, entryState)`-keyed) that returns an uncached `{_recursive: true}` bottom stub for a qid already being analyzed — safe from the start (never infinite-loops, including on real multi-function mutual recursion), and since increment B5 (below), genuinely precision-refined too, not just safe — mechanism-level only for real JS/TS today, see B5's own honest limitation note below before assuming this improved real-world output. A final whole-branch review found `compute()` originally lacked a `try/finally` around the stack push/pop — mirrors the exact structural precedent in `dataflow/summaries.js`; without it, a throwing `analyzeFn` would leave `qid` on `_stack` forever, silently degrading every later `compute()` call for that qid to a permanent bottom stub. `entryStateFromCall(paramNames, callArgs, callerState)` maps a call site's real argument expressions onto a fresh entry state for the callee, reusing `engine.js`'s `resolveExprIdentities`/`residualFlat` and the identical residual+byPath write pattern the `assign` transfer function already uses (confirmed side-by-side by a task review, not just by design intent) — a caller's field-identity distinctness survives into the callee's parameters exactly as rigorously as it survives an intraprocedural alias. `applyAtCallSite(summary, paramNames, callArgs)` maps a callee's summary — specifically `mutatedParams` — back onto the caller's own state at the call site; unlike `dataflow/summaries.js`'s version (ident-argument-only, silently drops a `member`-expression argument like `f(obj.field)`), this version also resolves a `member`-expression argument via `accessPathOf` — a deliberate, scoped improvement, not scope creep. **Known, disclosed limitation**: a summary's return facts are `flat`-only (`returnByPath` exists in the shape but isn't yet populated) — a callee returning a structured object gets its identity correctly resolved as a whole, but a caller immediately selecting one field off that return value doesn't yet get field-level precision from the summary alone (sound, just imprecise). Increment B2 deliberately did NOT close this — it wires the resolver into `resolveExprIdentities`'s `call` case (see below) without adding path-level return precision; closing this remains open for a later increment. Unit-tested in `test/lineage/summaries.test.js`, including a full hand-built round-trip proof (caller state → `entryStateFromCall` → real `analyzeFunctionFieldIdentity` → cached summary → `applyAtCallSite` → back onto the caller) and a regression test for the `try/finally` fix. **Increment B2 (real interprocedural wiring):** `createCallSummaryResolver(cache, lookupCallee)` builds the `resolveCallSummary(calleeExpr, callArgs, callerState)` closure that `resolveExprIdentities`'s `call` case now consults via `ctx` (see `engine.js` above) — `lookupCallee` is itself injected and opaque to this function (this increment's tests pass a hand-built name-to-function map; increment B3 (shipped — see below) passes a resolver backed by `scanner/src/ir/callgraph.js` instead, with no change needed here or in `engine.js`). Inside the resolver, the nested `analyzeFunctionFieldIdentity(fn, es, { resolveCallSummary })` call passes the resolver being defined back down as the callee's own `ctx` — without this, a chain of resolved calls stops resolving after one hop and the outer caller silently receives an over-broad summary reported as `widened: false` (a final whole-branch review found and fixed this exact gap via a real three-function call chain; regression-tested in `test/lineage/engine-integration.test.js`). The resolver unions across every return site, not just the first. **Increment B3 (real call-graph integration):** `createCallGraphLookup(callGraph, callerFile)` builds a real `lookupCallee` closure — the shape `createCallSummaryResolver` expects — backed by `scanner/src/ir/callgraph.js#resolveKnownCallee`/`functionRecord` (never `resolve()`, which is willing to guess from a dotted name's last segment; see that file's own comments on why a fabricated call edge is worse than a missed one). `callerFile` is fixed at construction time, one `lookupCallee` per analyzed function/file, so `createCallSummaryResolver`'s single-argument `lookupCallee(calleeExpr)` shape needed no change. Its own private `_resolvableCalleeName(calleeExpr)` mirrors `dataflow/engine.js`'s `_resolvableCalleeName` BASE CASE only: bare identifier callees (`helper(x)`) resolve to a name; a member-expression callee (`obj.helper(x)`) deliberately returns `null` — safely resolving that needs class-hierarchy analysis (dataflow's separate, much larger `_resolveMemberCalleeViaCHA` mechanism, gated on a `_cha` object this package has no equivalent of and is out of scope here). Proven against the real JS/TS parser across two separately-parsed files (`test/lineage/engine-integration.test.js`) — a function in one file calling a bare-identifier function defined only in another file resolves via `resolveKnownCallee`'s project-wide exact-name fallback, matching `callgraph.js`'s own same-file-first/then-project-wide precedence, deliberately not exercising import/export resolution. **Known, deliberate scope boundary, not closed by B3**: no member-expression/CHA-based resolution — a call like `obj.method()` never resolves through this increment's `lookupCallee`, even when `obj`'s concrete type is statically knowable; a future increment could add this, mirroring dataflow's own CHA mechanism, but nothing in the B1-B6 breakdown currently commits to doing so. **Increment B4:** the "raw `analyzeFunctionFieldIdentity` result → cached `FieldSummary` shape" conversion — union return facts across every return site, wrap `mutatedParams`/`widenings` — was extracted from what was previously inline-only logic inside `createCallSummaryResolver`'s own `cache.compute` callback into a standalone exported `summaryFromAnalysisResult(result)`, purely so `driver.js` (below) could reuse the exact same conversion rather than risk a second, drifting copy. Behavior-preserving; `createCallSummaryResolver` itself is externally unchanged. **Increment B5 (recursion handling — bounded fixed-point refinement):** `compute()` no longer permanently freezes a recursive function's summary at whatever its first pass produced. When a nested self/mutual-recursive call is detected mid-analysis (the `_stack` guard fires, unchanged from B1), the OUTER `compute()` call notices via a new `_hitRecursion` flag and, once its own `analyzeFn()` call returns, re-invokes it up to `FP_MAX = 3` additional times, stopping early once the summary stops changing — a genuinely **membership-based** equality check (`fieldSummaryEq`, newly exported), not size-based, deliberately avoiding the exact bug class `dataflow/summaries.js`'s own `_summaryEq` documents having shipped once ("Stage 3 correctness audit": two summaries with equal cardinality but different membership wrongly judged equal, silently caching a staler value than the one actually computed). Ported from `dataflow/summaries.js`'s own `SummaryCache.compute()` — the ordering is load-bearing and was itself the subject of a real fix round: the cache-hit check must run **before** the `_stack` guard (matching dataflow's own literal ordering exactly), not after — checking `_stack` first was tried initially (to keep a pre-existing B1 test passing unmodified) but empirically found to make refinement a structural no-op for genuine self-recursion (a nested self-call would re-hit the bottom-stub branch on every round, since the qid never leaves `_stack` until the whole outer call finishes, so round 2 is always byte-identical to round 1). The corrected ordering lets round 2+'s self-call see the PRIOR round's real, already-cached summary instead — proven, with a hand-built mock, to genuinely grow a self-referential summary across rounds (`{'data:base'}` → `{'data:base', 'nested:data:base', 'nested:nested:data:base', ...}`, bounded by `FP_MAX`). **Honest, measured limitation for REAL JS/TS code (not the hand-built mocks above): refinement's OUTPUT is currently unobservable for every real-parser recursive scenario tried** (self-recursion, 2- and 3-function mutual cycles, nested self-calls) — `resolveCallSummary` is fully synchronous, so a single `analyzeFunctionFieldIdentity` call already performs a complete DFS-style walk that resolves as much of a cycle as the `_stack` guard allows within ONE pass, and `returnFlat`/`mutatedParams` are deduplicated `Set`/`Map` structures with no per-round-distinct value the way the hand-built mock's artificially round-tagged contributions had. This was verified empirically (byte-identical `results` with `FP_MAX=3` vs a temporarily forced `FP_MAX=0`, across five real-parser shapes), not assumed — proven correct by a task review that reproduced the same comparison independently. `test/lineage/driver.test.js`'s own B5 tests therefore assert at the MECHANISM level (instrumented call-count through the real driver: 2 invocations of `analyzeFn` per recursive qid with refinement on, 1 with it off) rather than the output level, since an output-level assertion here would be vacuous. Closing this gap — giving refinement a real, observable effect on real code — most plausibly needs `returnByPath` to be populated (B1's own disclosed, still-open limitation, referenced above) or some other source of genuine per-round distinctness; nothing in the B1-B6 breakdown currently commits to that. **Increment B6 (context-sensitivity tuning — COMPLETES Sub-project B):** the per-function distinct-context cap (present since B1) gained an `AGENTIC_SECURITY_LINEAGE_MAX_CONTEXTS` env var default — see this row's opening sentence above for the exact precedence rules, including the `0` caveat. "Operator-facing" is now genuinely true: Sub-project E, increment 5 wired `buildLineageGraph`/`runFieldIdentityAnalysis` into `runFullScan` (gated on `AGENTIC_SECURITY_LINEAGE_DEEP=1`), so this env var now affects a real scan run when that flag is set — documented at `docs/reference/configuration.md`. Small and mechanical by design; no other behavior in this file changed. See `docs/superpowers/plans/2026-08-30-data-flow-explorer-m1-subproject-b-scoping.md` for the full increment breakdown. **Sub-project C, increment 3 (path provenance, interprocedural — see `DESIGN_PATH_PROVENANCE.md` §13/§13.7 below):** `entryStateFromCall` gained an optional 4th `ctx` parameter, deriving a RECORDER-ONLY ctx (`{recordHop}`, never the full ctx) before forwarding it into its own `resolveExprIdentities` call — forwarding the full ctx would hand that call a live `resolveCallSummary`, silently starting interprocedural resolution on an argument that is itself a resolvable call, changing the ANALYSIS RESULT with no recorder attached anywhere (a fix-round finding during the design task, guarded permanently by `test/lineage/engine-provenance-interprocedural.test.js`'s golden-baseline regression). `createCallSummaryResolver` gained the same optional `ctx` param, threading it through three sites: forwarding to `entryStateFromCall`; emitting one `write-out/call-arg-bind` hop per `(path, id)` of the freshly built callee entry state (stamped with the CALLER's scope/nodeId, `peerScope`/`peerContext` naming the callee, `fromPath: null` since the argument's own in-halves already carry the real contributing keys); and keeping the caller's recorder alive on the callee's own nested ctx instead of discarding it (closing the third of §7.4's three `ctx`-threading holes — a resolved multi-hop chain's own internal hops now reach a recorder attached at the top). The resolved summary is returned as a fresh `{...summary, resolvedQid, resolvedContext}` wrapper (never a mutation of the cached object), letting `engine.js`'s `case 'call'` stamp `peerScope`/`peerContext` onto its own `production/call-resolved` hop. `FieldIdentitySummaryCache.compute`'s cap-degradation branch now marks its fallback with a permanent, externally-visible `degradedReason: 'context-cap'` on a SHALLOW COPY (never mutating the shared empty-entry-context object in place, which would retroactively poison every later reader of that precise context) — `fieldSummaryEq` deliberately does not compare it, diagnostic like `widenings`. When a resolved call's summary carries `degradedReason`, `createCallSummaryResolver` emits one `production/call-resolved` loss hop per id that entered the callee (`lossReason: 'context-cap-degraded'`, `fromPath`/`toPath` both null so it reads as an annotation on the argument's own real in-half under §2.2's join rule) — closing what was previously a completely silent failure mode (a B6 cap degradation left no hop at all, since the degraded summary's empty `returnFlat` meant `engine.js`'s own loop over `flat` never fired). A B5 recursion bottom-stub round needs no equivalent marking (same empty-`returnFlat` reason — nothing to mark). Proven against real parsed JS/TS in `test/lineage/engine-provenance-interprocedural.test.js` (absorbed from increment C3's own design-task PoC file, deleted once absorption was complete per §13.7 item 15) and the extended write-only-invariant test in `test/lineage/engine-provenance.test.js`, which now also covers a fixture exercising the argument-binding, resolved multi-hop, and context-cap-degraded paths together, with and without a recorder attached. |
45
+ | `driver.js` | **Increment B4 (the project-wide two-phase driver):** `runFieldIdentityAnalysis(callGraph, opts = {})` — the orchestration loop increments B1-B3 never had: every prior increment's own test only ever analyzed ONE hand-picked caller function. This driver iterates EVERY function in `callGraph.functions` (qid-sorted for determinism), analyzing each with its own empty entry state and a `ctx` wired via `createCallGraphLookup`/`createCallSummaryResolver` for real interprocedural resolution, then explicitly seeds a shared `FieldIdentitySummaryCache` with each function's own result (via `summaryFromAnalysisResult`) so a later-visited function calling an earlier one reuses that result instead of recomputing it. Returns `{results, cache}` — `results` is a `Map<qid, rawAnalysisResult>` covering every function in the project, INCLUDING one nothing calls (dead code, unused exports): a purely lazy, call-site-triggered scheme (B1-B3 alone) would never produce a result for such a function at all, since it's only ever reached as a side effect of some caller's own call-site resolution. **Deliberately mirrors only PART of `dataflow/engine.js`'s `runTaintEngine` Phase A/B structure, not all of it**: that engine needs a 3-sub-pass Phase A (empty-entry pre-pass plus two SPECULATIVE precompute passes) specifically because some of its call-consultation points are non-computing cache *lookups* that need a pre-seeded conservative fallback; this package's one call-consultation point (`resolveCallSummary`) already lazily `cache.compute()`s on every miss, so a SINGLE pass suffices — no fixed-point loop, no fallback-on-lookup-miss machinery. At the time this driver was built (increment B4), a cyclic/recursive call graph relied entirely on B1's existing `_stack`-based bottom-stub with no refinement (a task review hand-traced this for self- and mutually-recursive functions and confirmed it terminates correctly, bounded by one extra "unguarded" level per top-level function before the guard catches it, never unbounded) — increment B5 (above, in the `summaries.js` row) has since added bounded fixed-point refinement entirely inside `FieldIdentitySummaryCache.compute()`, with ZERO changes needed here: this driver's own single-pass design automatically benefits, since refinement happens before `compute()` ever hands a value back to any caller, this driver included. Proven against the real JS/TS parser across THREE separately-parsed files (`test/lineage/driver.test.js`) — one calling into another, one nothing calls — confirming whole-project coverage and genuine cross-file resolution (not the vacuous "nothing to widen because nothing ever carried an identity" trap a first-draft test fell into; fixed by re-analyzing with the driver's own real cache/callGraph but a deliberately seeded identity). **Sub-project C, increment 3:** `runFieldIdentityAnalysis` accepts an optional `opts.recordHop`, threaded into every per-function `ctx` CONDITIONALLY (`opts.recordHop ? {resolveCallSummary, recordHop} : {resolveCallSummary}`), so a caller supplying none still gets the exact `{resolveCallSummary}` object this function constructed before this change — Decision 7.2's "true by construction" property extended to the driver. Since the driver analyzes every function from `emptyState()` with no seeding hook (still no source registry — Sub-project E), no hop content can fire from a driver call alone; `test/lineage/driver.test.js` proves the wiring reaches every function across a real multi-file project without perturbing any result, then reuses the driver's own returned `cache` (the same content-sanity pattern this file's own B4/B5 tests already use) to re-analyze one function with a genuinely seeded identity and a recorder attached, confirming hops genuinely span more than one file once identity actually flows. |
46
+
47
+ **Milestone 1, Sub-project C, increments 1-3 (path provenance — design spike + FULL intraprocedural instrumentation + interprocedural stitching) — COMPLETE:**
48
+
49
+ | Module | Responsibility |
50
+ |---|---|
51
+ | `DESIGN_PATH_PROVENANCE.md` | Design record (binding on every later Sub-project C increment, the same way `DESIGN_INTRAPROCEDURAL.md` binds Sub-project A/B): resolves HOW provenance/predecessor information is recorded as data flows through the engine, WITHOUT touching `field-identity.js`'s core `Map<path, Set<id>>` state shape. **Decision 1**: provenance is an emitted, write-only RECORD STREAM via an optional `ctx.recordHop` callback (mirroring `ctx.resolveCallSummary`'s exact precedent from Sub-project B, increment 2) — the engine never accumulates, stores, or reads back provenance of its own; with no recorder supplied, every observable output stays byte-identical to pre-C1 behavior (proven, not assumed — see below). **Decision 2**: the DAG's node granularity is `(scope, accessPath, dataElementId)` — expression-internal constructs (object literals, ternaries, resolved calls) annotate edges, they never create nodes, which is what keeps the structure compact per FR-303. **Records are HALF-EDGES** (`production`/`selection`/`write-out`, exactly `DESIGN_INTRAPROCEDURAL.md`'s already-hardened three-hop-type taxonomy), joined later by a consumer on `(scope, nodeId, dataElementId)` — the resolver knows a hop's source but never its destination, and `step()` knows the destination but never the source, so no single site can ever emit a complete edge. **Decision 4**: one record per `dataElementId`, never a Set-valued record — keeps DAG edges atomic and independently gradeable per FR-306, and avoids deferring an explosion to reconstruction time that recording can resolve once, up front. **Decision 5**: a hop's `fromPath`/`toPath` is never a syntactic/wildcard-containing path (`store.*.name`) — `step()`'s own wildcard-write branch already resolves to the definite prefix before this increment even runs (`definitePrefixBeforeWildcard`), and recording the wildcard form instead would create DAG nodes no read hop could ever reach, silently reading as "no path" rather than "path exists, not representable" (exactly the §18.4 failure mode this whole sub-project exists to prevent). **Decision 6, the design's most consequential correction to its own first draft**: `fromPath` must be the state key that actually CONTRIBUTED the identity, not the path that was QUERIED — `identitiesAt`'s bidirectional aggregation (an ancestor's identity is visible from a descendant query, and a container query aggregates every identity recorded under it) means the queried path is frequently NOT itself a key in `state`; recording it instead of the real contributing key(s) would silently disconnect the DAG at exactly the points FR-301's own multi-identity distinctness matters most. Verified empirically (16,000 randomized checks against a query's own independently-derived oracle in the final task review, 651 of which had a genuinely different contributing key than the queried path — not a vacuous edge case). A structural test (not just a comment) pins `contributingKeys` against `identitiesAt` directly, so the two can never silently drift apart. **Known, disclosed, load-bearing gap named for C3 (not C1/C2's job)**: the join key `(scope, nodeId, dataElementId)` does not distinguish entry CONTEXTS — Sub-project B (B1/B6) already computes up to 16 distinct entry contexts per function, and two contexts of the same function emit hops sharing this join key but describing different, mutually-inconsistent endpoints. Cannot manifest yet (C1/C2 only ever run one context at a time) but is a precondition C3 must close (an additive `context` field, e.g. `hashState(entryState)`) before its own hops are correct — see the design doc's §9.4 for the worked example. Also disclosed: §9.1's cross-join ambiguity (same id, ≥2 read paths AND ≥2 write paths at one node — detect-and-mark via FR-306, not prevented; confirmed NOT rare, reproduces with a plain multi-field-object alias), §9.2's flow-insensitivity at reconstruction time, §9.3's cyclic-DAG possibility (C5 must be cycle-safe by construction, never assume acyclicity), and §9.5's unrepresented `ITER_BUDGET` truncation (the one remaining un-flagged instance of the exact §18.4 constraint this whole document treats as load-bearing). §10 is a full, explicit instrumentation checklist for every `resolveExprIdentities` case and every `step()` CFG-node kind — increment C1's own proof-of-concept implemented four of them; increment C2 (below) implemented every remaining one, closing the checklist in full. One correction made DURING C2's own implementation, per this document's own stated policy of fixing rather than silently diverging: §10.1's `assign-expr` row originally specified forwarding a `widenReason` value, but `resolveExprIdentities`'s actual return shape carries only a boolean `widened`, not a reason string — resolved by matching the SAME already-documented-as-approximate convention `step()`'s pre-existing `assign`/`return` sites use (a hardcoded `'unresolved-call'` reason when widened), disclosed as inheriting that convention's known imprecision rather than claiming new precision. §10.3 (cross-file sites, the context-join fix) remains C3's job, untouched by C2. **§13 (added by increment C3's design task, then IMPLEMENTED by C3's two follow-up implementation tasks — see below) resolves all of it**: the record shape gains exactly three fields (`context` = `hashState(entryState)`, plus `peerScope`/`peerContext` naming the function on the far side of a cross-function hop, direction read off the existing `kind` rather than a fourth field); the join key becomes `(scope, nodeId, dataElementId, context)`; `resolveCallSummary` and `entryStateFromCall` each gain an optional 4th `ctx` parameter, closing all three §7.4 holes; the argument→parameter binding becomes a `write-out/call-arg-bind` hop stamped with the CALLER's scope/nodeId (§10.3's original "in the callee's scope" instruction is corrected in place — the caller's stamps are what let it join with the argument's own in-halves). §13 also records three MEASURED findings: a cache HIT records the callee's body once per entry context rather than once per call site (accepted and disclosed as §9.6 — a sharing property, not a loss, precisely because `peerContext` points every call site at that one body, so the much larger cache-replay fix is rejected); a B6 context-cap degradation is today entirely SILENT in the hop stream (no hop is emitted at all, since the degraded summary's `returnFlat` is empty — closed by a new permanent `degradedReason` field and a `lossReason: 'context-cap-degraded'` hop, and note the cap fallback is the SAME OBJECT as the empty-entry summary, so it must be shallow-copied, never marked in place); and a B5 recursion bottom stub needs no marking at all, being self-effacing for the same empty-`returnFlat` reason. §13.5 rejects §7.4's proposed `hopSite` field as redundant once the whole stamped ctx is threaded. §13.7 is the follow-up implementation task's exact file/line/signature checklist. **The single most important thing an implementer must not get wrong (§13.2a, found by this increment's own task review after §13's first draft got it wrong): `entryStateFromCall` must forward a RECORDER-ONLY derivation — `ctx?.recordHop ? { recordHop: ctx.recordHop } : undefined` — never the ctx itself.** Forwarding the full ctx hands `resolveExprIdentities` a live `resolveCallSummary`, so an argument that is itself a resolvable call (`sink(scrub(user))`) starts resolving interprocedurally where the shipped engine takes the unresolved fallback — changing the ANALYSIS RESULT with no recorder attached anywhere, and doing so in the UNSOUND direction under a tight B6 cap (the extra nested resolve eats the callee's only context slot, so a later, unrelated call degrades to an empty summary and loses an identity the shipped engine keeps). This is the same bug class C2 already shipped once and fixed in §10.2's `assign`/unsupported-target row. Note that the usual with-recorder/without-recorder guard is structurally BLIND to it (the divergence moves both arms identically) — **do NOT compare against "the shipped resolver"**: once this wiring is itself the shipped resolver, that comparison degenerates into `assert.deepEqual(result, result)`, a vacuous, always-passing test (this exact wording was in an earlier draft of this sentence and was corrected once already — see §13.2a's boxed note and §13.7 item 15b in the design doc for the full reasoning). The real guard is a hardcoded **pre-C3 golden literal**, shipped as `engine-provenance-interprocedural.test.js`'s two golden-baseline tests. Every claim in §13 was produced by running real code — via a deliberately throwaway-named design-task PoC file, engine-provenance-interprocedural-poc.test.js (not backtick-quoted here on purpose — it no longer exists in the tree, see the next sentence, and this document's own doc-drift checker flags a dangling backtick-quoted path), which prototyped the new signatures locally (shipped source was unmodified by the design task). Per §13.7 item 15, the two follow-up implementation tasks absorbed everything the PoC proved into the permanent suite (`test/lineage/engine-provenance-interprocedural.test.js`, `engine-provenance.test.js`, `driver.test.js`) and deleted the PoC file in the same commit that closed its absorption. |
52
+ | `engine.js` (extended, additively) | **Increment C1** built the mechanism and proved it against four representative sites (`resolveExprIdentities`'s `ident`/`object` production cases, `step()`'s non-wildcard `assign`/`return` write-out cases). **Increment C2 completed FULL intraprocedural coverage** — every remaining case in both switch statements now records hops: `resolveExprIdentities`'s all four `member` sub-cases (path/no-wildcard, path/wildcard via `definitePrefixBeforeWildcard` per Decision 5, non-path-base × known-prop, non-path-base × `prop==='*'`), `array`/`tpl`/`binary` (structure-flattening, no `widenReason`), `logical`/`union` (structure-preserving), both `call` branches (unresolved → `widenReason: 'unresolved-call'`; resolved via `ctx.resolveCallSummary` → `subKind: 'call-resolved'`), `assign-expr`, and explicit "emits nothing" verdicts for `literal`/`unknown`/`default`; `step()`'s `assign` target-not-a-string (a genuine **loss** site, `lossReason: 'unsupported-target'` — resolving `node.source` purely to learn its ids genuinely emits real in-half hops when a recorder is present, not suppressed computation), `assign` wildcard-target (`subKind: 'assign-weak'`, `toPath` = the definite prefix, never the raw `'*'`-path, same Decision 5 discipline), and bare `call` statements (`subKind: 'call-arg'`). `assign`'s kill (`removeIdentitiesAt`) deliberately gets NO instrumentation — §10.2's own table gives this an explicit "no row of its own, correctly" verdict, not an oversight. A single comprehensive real-parser test (`test/lineage/engine-provenance.test.js`) exercises all 15 hop-emitting `(kind, subKind)` shapes from §10.1+§10.2 together in one realistic function, asserting closed-set coverage (every expected shape present, no stray ones) — proven, not merely claimed. `ctx.recordHop`, when present, is progressively stamped with `scope`/`nodeId`/`line` inside `analyzeFunctionFieldIdentity`'s own worklist (`nodeId` sourced from the worklist's Map key, NOT `node.id` — hand-built fixtures across every prior increment's own tests set no `id` field, a documented likely mistake this increment's own tests pin against) before being threaded down through the SAME `ctx` parameter every recursive call site already carries unconditionally since Sub-project B, increment 2 — zero new plumbing beyond what already existed. Two internal helpers implement Decision 6: `contributingKeysAllIds` (not exported) does the real work as a single O(|state|)-total pass over `state`; `contributingKeys` (exported) is a thin per-id wrapper over it, so a caller asking for ONE id's contributing keys still pays that O(|state|) scan — union it across every id at a path and the total is the O(|state| × |ids|) shape the single-pass helper exists specifically to avoid internally. `contributingKeys` is exported ONLY so a structural test can pin it against `field-identity.js`'s own `identitiesAt` rather than relying on a comment to catch drift — it is not the performance-sensitive entry point production code should call per-id in a loop; that's what `contributingKeysAllIds` is for. **Backward compatibility, proven not assumed**: a differential harness (in the final task review, not shipped as a permanent fixture) ran 15 real-parser fixtures × 4 entry states × 3 `ctx` configurations (no ctx; `ctx.resolveCallSummary` only; `ctx` with a recorder attached) against the pre-C1 engine — zero behavioral mismatches across all 120 cases. The SHIPPED test suite's own "write-only invariant" test (added in a C1 fix round after a review caught the gap with a real injected mutant that passed all 217 pre-fix-round lineage tests; extended again by C2 with fixtures for every case it added, including the full comprehensive-coverage fixture) runs its whole fixture list twice each — with and without a recorder — and `deepEqual`s a canonicalized `{exitState, returnFacts, mutatedParams, widenings}` between the two runs, closing exactly the property the differential harness proved but the original shipped tests didn't actually check. `field-identity.js` is untouched, byte-for-byte, confirmed via `git diff` at every review stage — this increment adds capability entirely alongside the existing state shape, never inside it. See `docs/superpowers/plans/2026-08-30-data-flow-explorer-m1-subproject-c1.md` and `docs/superpowers/plans/2026-08-30-data-flow-explorer-m1-subproject-c-scoping.md` for the full plan and increment breakdown. **Increment C3 (Task 2) extended this file in five places, all additive**: the `field-identity.js` import gained `hashState`; `analyzeFunctionFieldIdentity` computes `context = ctx?.recordHop ? hashState(entryState) : null` once per analysis run, alongside the pre-existing `scope`; the `stepCtx` wrapper stamps `context`/`peerScope: null`/`peerContext: null` onto every hop before the per-hop `...h` spread (so a hop that needs non-null peer fields can still override the default — only the two cross-function hop sites below ever do); `resolveExprIdentities`'s `case 'call'` now passes `ctx` as `resolveCallSummary`'s 4th argument (the **only** place the full `ctx` crosses from `engine.js` into `summaries.js` — `summaries.js`'s own `entryStateFromCall` deliberately strips it back down to a recorder-only derivation before going any further, see that file's own row below); and the resolved-call branch's `call-resolved` hop gained `peerScope: summary.resolvedQid ?? null, peerContext: summary.resolvedContext ?? null`, one of exactly two hop sites in the whole package that ever set these two fields (the other is `summaries.js`'s `call-arg-bind` hop). |
53
+
54
+ **Milestone 1, Sub-project C, increment 4 (`path-store.js` — the compact provenance DAG) — COMPLETE:**
55
+
56
+ | Module | Responsibility |
57
+ |---|---|
58
+ | `path-store.js` | Implements `DESIGN_PATH_PROVENANCE.md` §14 exactly (design + PoC landed as Task 1; this is Task 2, the mechanical implementation, absorbing the design task's PoC into the permanent suite). A **pure consumer** of the 14-field hop-record stream C1-C3 ship — never imports `engine.js`/`summaries.js`/`driver.js` (§14.1), enforced by a self-checking test that reads the module's own import list, not just documented. `PathStore.addHop(hop)`/`addHops(hops)` accumulate hops into join-key groups (`(scope, nodeId, dataElementId, context)`, §13.3); nodes/edges materialize lazily on first read and are cached until the next `addHop` (§14.6's two-phase construction). Five node **kinds**, each with its own keying rule (§14.2): `path` (`(scope, context, path, id)`, the ordinary state location), `return` (`(scope, context, id)` — per function-CONTEXT, not per CFG node, since a `call-resolved` hop addresses it with only `(peerScope, peerContext)`), `escape`/`loss` (`(scope, context, siteNodeId, id)`, per CFG node, terminal), `origin` (`(scope, context, siteNodeId, id)`, a group whose only in-halves are annotations — "a value with no prior aliasing source", hand-fixture-only today, no real-parser fixture reaches it yet). `classifyIn`/`classifyOut` (both exported for direct unit-testing) implement §14.3's rules verbatim, including §14.4's corrected join rule — the single most load-bearing rule in the module: a `null`-`fromPath` in-half with a non-null `peerScope` is **peer-sourced** (a real cross-scope edge to the callee's own function-exit node) UNLESS `lossReason` is also non-null (a §13.6 context-cap-degraded hop, which names a callee whose body was never analyzed and must fall through to *annotation* rather than fabricate an origin — this is what `C4/Q2b` pins against a future refactor silently undoing). Edge construction is a per-group cross product with the peer×peer exclusion (§14.3 — a pair whose both endpoints are peer-addressed describes a transition entirely inside the callee and would manufacture a callee-return→callee-parameter cycle no program executed) and the per-pairing (not per-group) ambiguity measure (§14.7 — the group-level form measured 3-of-5 marked at a plain resolved call, including both correct call-boundary edges; per-pairing correctly narrows to the real artifact, the caller-side bypass that skips the callee). **Both** §14.6 dedup boundaries are shipped, not just one: raw-hop ingest dedup via a `Set` keyed on the explicit, FIXED-order `HOP_FIELDS` list (never `Object.keys(h)`, which is order-dependent) — collapses worklist re-emission (§8) and flags a hop missing a required field into `diagnostics().malformed` rather than silently mis-keying it; node/edge dedup via the content-hash ids from `ids.js` at materialization — collapses two non-byte-identical hops (e.g. differing only in `syntacticPath`) that describe the same logical edge, which ingest dedup alone cannot catch. `diagnostics()` returns **four** buckets, all "recorded, never thrown, never silently dropped": `malformed`, `unclassified` (an out-half matching none of §14.3's rules — empty on every fixture tried, the closed-set proof that the rules cover today's whole vocabulary), `truncations` (§9.5's reserved out-of-band channel — `markTruncated(scope, context, reason)` marks every node/edge in that `(scope, context)` `truncated: true`, since an `ITER_BUDGET` break is a whole-analysis-run truncation, not a per-hop one, and §2.2's three-kind hop taxonomy is deliberately not extended to a fourth kind for it), and `orphanedPeerSources` (added during Task 1's own review: a peer-sourced hop, `lossReason: null`, whose named `(peerScope, peerContext, ⟨return⟩, dataElementId)` node has zero real in-edges once the whole stream is ingested — detectable via the store's own `inIndex` at build-finalize time, no new input needed; reachable via a cache warmed by a no-recorder run and reused by a later recorder-attached run, exactly `driver.js`'s own returned-and-reused cache pattern — never fabricated into an origin, never dropped, proven live by a dedicated test reproducing that exact cache-reuse scenario against the real resolver). Construction is cycle-safe **by construction**, never by discipline (§9.3/§14.6): one linear pass over the offered hops plus a per-group cross product, no recursion anywhere in the file; the read API (`nodes`/`edges`/`getNode`/`getEdge`/`edgesFrom`/`edgesTo`/`hasEdge`/`nodeIdFor`/`stats`/`diagnostics`) is deliberately traversal-free, every method an O(1)/O(degree) index lookup — bounded backward reconstruction is C5's job and C5's alone. Task 2's own review also closed two carried-forward findings from Task 1's review: a real fixture (`C4/leg`) for §14.7's rejected leg-based-pruning counter-example (`const o = { r: helper(a), s: b.email }` — a group containing a peer half also contains a genuine, purely-intraprocedural non-peer×non-peer pair the rejected rule would have deleted alongside the bypass it targeted; verified against the real engine that BOTH survive, and both are in fact marked `ambiguousCorrelation: true` — a genuine §9.1-style 2×2 cross-join independent of the call boundary, matching §14.7's own "keeping a marked extra one" phrasing more precisely than a narrower reading might suggest), and explicit count assertions pinning §14.11's headline compactness numbers (§6's worked example: 14 deduplicated records → 8 nodes / 6 edges, the FR-303 proof; the mutual-recursion fixture: 34 raw → 19 dedup → 8 nodes / 11 edges) so a future refactor that silently changes them fails a test rather than leaving this file/the design doc stale. Every assertion from the design task's PoC — path-store-poc.test.js, not backtick-quoted here on purpose (it no longer exists in the tree, and this document's own doc-drift checker flags a dangling backtick-quoted path) — was kept and re-pointed at this shipped module in `test/lineage/path-store.test.js`, including `C4/Q2b` (the literal-§2.2 store with a dead-end callee exit — the only guard against §14.4's correction being silently undone). `ids.js` gained the two stable-ID functions this module needs: `provenanceNodeId`/`provenanceEdgeId`, `pnode:`/`pedge:`-prefixed (deliberately distinct from `node:`/`edge:` — a provenance node/edge is never a `DataFlowGraph v1` entity, confirmed by `json-schema-parity.test.js`/`validate.js` needing zero changes), object-argument signatures (not this file's usual positional form — `provenanceEdgeId`'s discriminator is twelve fields wide, `provenanceNodeId`'s is six, exactly the shape a positional array silently drops a field from; a final whole-branch review corrected an earlier claim here that `path-store.js` calls each "from exactly one site" — `provenanceEdgeId` does, but `provenanceNodeId` has five call sites, which if anything strengthens the case for object arguments rather than weakening it). No `DataFlowGraph v1` output, no backward-walk/reconstruction query, no FR-306 grading, no driver wiring, no library/framework node collapsing — all deliberately out of scope, per §14.9. **The final whole-branch review** independently re-derived the §2.2 correction on a fixture of its own (not the shipped test's), recomputed all seven of §14.11's measured-numbers rows against the shipped module (zero drift), and mutation-proved 5 of 6 load-bearing properties directly — the one that survived (`HOP_FIELDS`'s fixed-order key vs. a naive `Object.keys(h)` mutant) is now closed by `C4/1e`. It also found and fixed a genuine, currently-unreachable order-dependency in the `origin` branch: picking `g.annotations[0]` as the sole representative for an originating group's `kind`/`subKind`/reasons made the resulting edge's id AND its `widenReasons`/`lossReasons` depend on hop delivery order, contradicting §14.6's own "the same hops delivered in any order produce the same DAG" guarantee — fixed to union reasons across every annotation and pick the representative by content, not position (`C4/6g`, mutation-verified). |
59
+
60
+ **Milestone 1, Sub-project C, increment 5 (`path-query.js` — bounded backward reconstruction + truncation semantics) — COMPLETE:**
61
+
62
+ | Module | Responsibility |
63
+ |---|---|
64
+ | `path-query.js` | Implements `DESIGN_PATH_PROVENANCE.md` §15 exactly (design + PoC landed as Task 1, including a fix round closing one BLOCKING finding plus 8 non-blocking ones, a scoped re-review, and a final round closing 3 more nitpicks; this is Task 2, the mechanical implementation, absorbing the design task's PoC into the permanent suite). A **pure consumer** of a built `PathStore` — never sees a hop record, never imports `engine.js`/`summaries.js`/`driver.js`, and its only import is `ids.js` (§15.1), enforced by a self-checking boundary test that also asserts the import list is EXACTLY `['./ids.js']`, one step stricter than `path-store.js`'s own equivalent test. One entry point plus two helpers, deliberately named `startNodeId` rather than `sinkNodeId` (there is no sink registry yet — Sub-project D): `reconstructPaths(store, startNodeId, opts)`, `sinkCandidates(store)` (§15.9's registry stand-in — every `return`/`escape`/`loss`-kind node, no security opinion), and `isIncompleteAnswer(result)` (AC-10's five-term banner predicate, so no caller re-derives it). `comparePaths(a, b)` is also exported, not merely internal (fix round 1's own correction to the original design's export list) — the shipped tests call it directly to build the naive-global-cap contrast fixture (`C5/5f`). The walk (`reconstructPaths`) is an **iterative DFS over an explicit stack, never recursion** — the DAG can genuinely be cyclic (§9.3, `path-store.test.js`'s `C4/4` mutual-recursion fixture) — terminated by an explicit **per-path** visited set (never global: the same node can legitimately appear on two different candidate paths), not by any budget; `edgesTo` results are sorted by edge id before expansion for determinism, since the store's own indexes are `Set`-backed and carry no inherent order. **The zero-in-edges check runs BEFORE the depth check** (§15.3's exact ordering constraint) — get this backwards and a genuine, well-formed dead end gets mislabeled as depth-truncated instead of a real origin. Three independent in-walk budgets (`maxExpansions`, `maxDepth`, `maxCandidatePaths`) plus the two post-hoc `maxPaths`/`maxPathsPerTerminal` caps below — five knobs total, all in `DEFAULTS` (exported), all `opts`-overridable, documented as uncalibrated — every fixture measured against them so far is tiny (§15.11's largest row uses 35 expansions); nothing bigger is measurable until a driver run emits real hops (Sub-projects D/E). Terminal classification (`terminal.reason`, per path): `'origin'` (a genuine `origin`-kind node), `'incomplete-record'` (the node appears in `store.diagnostics().orphanedPeerSources` — §14.4's disclosed stream-completeness gap surfacing here, never silently presented as a real origin), `'cycle'` (every continuation from this node was already visited on this path), `'depth-limit'` (the per-path budget hit `maxDepth`). `'expansion-budget'`/`'candidate-cap'` are **result-level truncation reasons only** — those two branches are abandoned when hit, never emitted as a marked partial path, since a branch cut at an arbitrary global point has a meaningless prefix and emitting it would manufacture a "path" the graph does not contain; `'depth-limit'` is different, because that branch genuinely reached a stated ceiling, so it IS emitted, marked `complete: false`. The cap: `maxPathsPerTerminal` applied per `terminal.nodeId` FIRST (ordered by `comparePaths`, truncated there), then a **diversity-first round-robin** over `(terminal.nodeId, shape)` buckets for the global `maxPaths` cap (§15.7's exact prioritization rule, using only signals that exist today: `crossScopeCount`, presence of `ambiguousCorrelation`/`widenReasons`/`lossReasons`, and `complete` as `comparePaths`' first key — transformation-kind and protection-state are honestly deferred to Sub-project D / Milestone 2, never faked). **`result.terminals[].truncated`/`.droppedPathCount` are computed AFTER the global round-robin runs, never from the per-terminal cap alone — this is the single blocking finding from Task 1's own review, and the single most load-bearing correctness property in this module.** The formula is `enumeratedPathCount > returnedPathCount` (equivalently `droppedPathCount > 0`); computing it from the per-terminal cap alone would report `truncated: false` for a terminal the GLOBAL cap later starves to zero returned paths — reproduced concretely pre-fix on the §9.1 cross-join fixture (`enumerated=2 kept=2 returned=0 truncated=false`, §18.4's exact failure mode at *pair* granularity, inside the very field designed to prevent it at *call* granularity) and pinned verbatim, both directions, by `C5/5e`. A terminal's `terminalReasons` is likewise a sorted UNION over its own paths' reasons, never a positional pick (`group[0].terminal.reason`) — the same representative-picking bug class C4's own final whole-branch review found and fixed in `path-store.js`'s `origin` branch; a terminal genuinely CAN carry mixed reasons (e.g. one sink reached by both a `cycle` clip and a `depth-limit` stop at `maxDepth: 3` on the mutual-recursion fixture), pinned by `C5/5h`. `result.noPathReason` is computed **only when `truncated === false`** — this ordering IS §18.4's "never translate budget exhausted into no path" constraint expressed as a code path, not a stylistic choice: a truncated result can never acquire a `noPathReason`. §15.4's five pairwise-JSON-distinguishable answers (complete-with-paths / genuinely-empty / recording-gap / unknown-start-node / budget-truncated) are pinned as literal JSON by `C5/4b`, and the recording-gap row against a real cache-warming fixture by `C5/4c` — together the only guard against §18.4's constraint being silently undone by a future refactor. A path never changes data element (every edge joins two nodes sharing one `dataElementId` by construction, §14.3) so no filtering is needed for FR-301 field-precision to carry into FR-303 structure; a hop is a **denormalized copy** of its edge (not just the edge id) so a path can be graded (FR-306) without also carrying the store back to the caller. `ids.js` gained `pathId({ startNodeId, edgeIds }, discriminatorParts)` → `ppath:<12 hex>` (§15.6/§15.10 item 1) — the discriminator is the **edge id SEQUENCE**, never the node id sequence or a sorted set: order matters for a path (two assignments at two different program points joining the same node pair are two materially different edges, per `provenanceEdgeId`'s own `siteNodeId` discriminator, and FR-305 forbids hiding that difference by node-keyed dedup — proven on a real fixture by `C5/5`, ids pinned idempotent/order-sensitive by `C5/id-1` through `C5/id-4`). `validate.js` needed no change — `ppath:` is not a `DataFlowGraph v1` entity kind, confirmed by `json-schema-parity.test.js` staying green untouched. Every assertion from the design task's PoC — path-query-poc.test.js, not backtick-quoted here on purpose (it no longer exists in the tree, and this document's own doc-drift checker flags a dangling backtick-quoted path) — was kept and re-pointed at this shipped module in `test/lineage/path-query.test.js`, including `C5/4b`/`C5/4c` (the truncation-honesty guard named above) and `C5/5e`/`C5/5f`/`C5/5d` (the fix round's own load-bearing regression guards for the blocking finding and the two "prose stronger than proof" measurement fixes). The old PoC's hand-written function list for the private-field source scan (`C5/6`) was replaced with programmatic export enumeration (`Object.values(await import(...)).filter(v => typeof v === 'function')`) per the PoC's own fix-round-1 note, so an eighth exported function is swept in automatically rather than requiring a remembered list. No `DataFlowGraph v1` output, no FR-306 grade computation (counts only — `widenedHopCount`/`lossHopCount`/`ambiguousHopCount`/`crossScopeCount` — turning them into a grade is **C6**), no source/sink registry, no flow-sensitivity filter, no change to `path-store.js`/`engine.js`/`summaries.js`/`driver.js`, no driver wiring (a driver run still emits zero hops today, no source registry — Sub-projects D/E) — all deliberately out of scope, per §15.8. |
65
+
66
+ **Milestone 1, Sub-project C, increment 6 (`flow-grade.js` — FR-306 edge grading) — COMPLETE, closes out Sub-project C:**
67
+
68
+ | Module | Responsibility |
69
+ |---|---|
70
+ | `flow-grade.js` | Implements `DESIGN_PATH_PROVENANCE.md` §16 exactly (design + PoC landed as Task 1, including a fix round closing one BLOCKING finding plus 3 refinements and a scoped re-review; this is Task 2, the mechanical implementation, absorbing the design task's PoC into the permanent suite). A **pure function library**, not a consumer of anything: **zero imports** — one step stricter than `path-query.js`'s own `['./ids.js']` boundary, enforced by a self-checking test asserting the specifier list is EXACTLY `[]`. Grading needs neither a path nor the store, since a hop is a denormalized copy of its edge (`C6/11`: `gradeHop` returns byte-identical results for a raw `PathStore` edge and for the `Hop` denormalized from it) — so `gradeHop(hop)` accepts either shape, letting Sub-project E's graph builder or Milestone 4 grade a stored edge with no reconstruction at all. **The taxonomy** (`FLOW_EVIDENCE_GRADES`, five real tiers plus the empty-input answer, in CONFIDENCE order): `explicit` > `widened` > `implicit` (RESERVED — nothing emits a `control-dependence` reason today, §10.2; kept and hand-tested on the `origin`-node-kind precedent) > `severed` > `ambiguous` (below both `widened` and `severed` because an ambiguous pairing *may never have happened at all*, where a widened/severed hop *certainly happened*) > `unassessed` (empty-input only, never a real hop). **Deliberately a NEW vocabulary, not `protection.js`'s `EVIDENCE_GRADES`** (§16.2) — that enum grades *where a protection verdict's evidence came from*; this one grades *how explicit a recorded data movement is*, the same evidence source throughout, so reusing/extending the other enum would validate a flow grade as a protection-evidence grade on a `DataFlowGraph v1` entity, a live correctness hazard, not mere taste (`C6/0` asserts the two enums share no value). **The per-hop rule** (`gradeHop`, §16.3): precedence, worst wins within one hop — `ambiguousCorrelation` → `ambiguous`; any non-implicit `lossReason` → `severed`; any `IMPLICIT_FLOW_REASONS` reason → `implicit`; any non-implicit `widenReason` → `widened`; otherwise `explicit`. Returns an **object**, never a bare string: `{grade, rank, factors, widenReasons, lossReasons, implicitReasons, annotationOnly, ambiguousCorrelation, degraded, truncated, crossScope, incomplete}` — there is structurally no way to read only the grade, since an annotation-only marker (see next) is a field on the same object, not a separate lookup a caller can forget. **The annotation-union fix, the single most load-bearing decision in the module** (§16.5/Q10): `widenReasons`/`lossReasons` are read as the sorted UNION of the hop's own top-level arrays AND every `annotations[].widenReason`/`.lossReason` — a genuine widening measured on three real, separately-parsed fixtures (`sink(mystery(user.email))`; `{a: mystery(user.email)}`; a ternary) lives ONLY in `annotations[]`, with the edge's own `widenReasons` empty, because §2.2 classifies that in-half as an annotation (null `fromPath`, null `peerScope`) rather than an edge-forming half; a grader reading only the top-level arrays grades all three `explicit`, FR-306's own literal prohibition (`C6/5`). §13.6's context-cap-degraded marker is the same shape (`C6/6`): it grades `severed` (folded into the grade) AND raises `degraded: true` with `annotationOnly: ['loss:context-cap-degraded']` (surfaced separately, by cause — §18.4 requires "context budget exhausted" stay distinguishable from "target unrepresentable" stay distinguishable from "no flow"). `crossScope` never affects the grade (§16.6/Q9) — a sound interprocedural stitch grades identically to the same flow inlined (`C6/4`), and a demotion would invert `comparePaths`' own cross-scope-is-better ranking (`C6/4b`); it is disclosed only as a `'cross-scope'` factor. **The per-path aggregate** (`gradePath`, §16.4/Q11): the WORST grade among the hops, via a private `_PRECEDENCE` table (the reverse of the confidence order, `unassessed` last) — mirrors `protection.js`'s `aggregateVerdicts()` risk-precedence reduction, the established precedent in this package; `flowGradeRank`/`aggregateFlowGrades` throw on an unrecognized grade and return `'unassessed'` for empty input, `aggregateVerdicts`' contract verbatim (`C6/7c`). Both a per-hop AND a per-path answer are needed, not either alone — FR-306's two clauses are different requirements: "visually distinct" is per-hop (`gradePath` returns `hops[]`, the FULL `HopGrade` objects in path order, plus `worstHopIndex` — not bare grade strings, so a caller never has to re-invoke `gradeHop` to learn which factor drove a hop's grade), and "may not be displayed as the same evidence grade" is one scalar per path. `gradePath`'s counts (`widenedHopCount`/`lossHopCount`/etc.) are **recomputed from `gradeHop`**, never read off the `Path`'s own fields — grading owns this union by design and must not re-couple itself to a `Path`-shaped input that `C6/11`'s edge-only callers don't have. **No `DataFlowGraph v1` output, no change to `path-store.js`/`engine.js`/`summaries.js`/`driver.js`/`protection.js`/`schema.js`/`validate.js`, no UI** — all deliberately out of scope, per §16.10. Every assertion from the design task's PoC — flow-grade-poc.test.js, not backtick-quoted here on purpose (it no longer exists in the tree, and this document's own doc-drift checker flags a dangling backtick-quoted path) — was kept and re-pointed at this shipped module in `test/lineage/flow-grade.test.js`, including `C6/5`/`C6/6` (the annotation-union guard, the only defense against §16.5's union being silently narrowed back to the top-level arrays) and `C6/6b` (§16.7 Finding 2, unfixed and binding on Sub-project D — see below). |
71
+ | `path-query.js` (extended) | §16.8 item 7's already-measured fix: `materialize()`'s `widenedHopCount`/`lossHopCount` (and the `shape` signature derived from them) now read `hop.annotations[]` too, not just the two top-level `widenReasons`/`lossReasons` arrays — closing §16.7 Finding 1, the exact blind spot `flow-grade.js`'s own annotation union exists to correct. Confined to `materialize()`; deliberately NOT pushed into `path-store.js`'s `edge.widenReasons`/`edge.lossReasons`, which are part of `provenanceEdgeId`'s discriminator (§14.5) and would move every `pedge:`/`ppath:` id. `test:lineage` ran 330/330 immediately after this one change, with zero other assertions moved (matching fix round 1's own measurement) — `C6/5b`/`C6/5c` are the regression guard, re-pointed at the `Path`'s own corrected fields directly now that the fix has landed. |
72
+
73
+ **Milestone 1, Sub-project D, increment 1 (design spike — the catalog → schema reclassification mapping). Design only — the registry modules it specifies are D2/D3, below:**
74
+
75
+ | Module | Responsibility |
76
+ |---|---|
77
+ | `DESIGN_REGISTRIES.md` | Design record, binding on every later Sub-project D increment (same role `DESIGN_INTRAPROCEDURAL.md` plays for A/B and `DESIGN_PATH_PROVENANCE.md` for C). Resolves how `dataflow/catalog.js`'s already-proven source/sink detection is RECLASSIFIED into `schema.js`'s `NODE_KINDS`/`SOURCE_CATEGORIES`/`SINK_CATEGORIES` vocabulary — the complete mapping tables (§4/§5), the `coverageStatus` decision procedure (§6), node-kind and externality assignment (§7.1/§7.5), and D2/D3/D4's file-precise checklist (§9). Every figure in it was measured against the live catalogs. Originally re-measured on every run by the design-phase PoC, test/lineage/registry-mapping-poc.test.js (not backtick-quoted here on purpose — it no longer exists in the tree, and this document's own doc-drift checker flags a dangling backtick-quoted path) — now DELETED, its source-side half absorbed into `source-registry.test.js` (D2) and its sink/privacy half into `sink-registry.test.js` (D3, the second lander, per §9.1's protocol; both halves' absorption was confirmed complete before deletion). **The three things a reader must not miss.** (1) **`SINK_CATEGORIES` is an EGRESS taxonomy; `catalog.js`'s sinks are a VULNERABILITY taxonomy** (§3) — they only coincide where a vuln is named after its destination (SQLi→`database`, traversal→`file`, SSRF→`external-api`), and **82 of 194 sink entries (42%) have no category at all** because their destination is in-process computation (an interpreter, parser, template engine, regex engine, loader, raw memory). Those entries are NOT dropped: they become `kind: 'process'` nodes with `coverageStatus: 'unsupported'` and a reason string, because AC-11/FR-201 require a discovered sink to stay visible. `kind === 'process'` **iff** `coverageStatus === 'unsupported'` is a pinned biconditional. (2) **`manual` — the open question this increment closed** (§6.5): it means "a human asserted this", so **no registry can ever emit it**, and D2/D3 must not. Three independent bases: this codebase's own consistent precedent (`posture/privacy-framework.js`'s `engine-gap` vs `manual` split on `codeTestable`, `posture/auditor-walkthrough.js`'s "requires manual attestation", `protection.js`'s `EVIDENCE_GRADES`, `schema.js`'s `EVIDENCE_TYPES`); the PRD's own FR-101 acceptance clause, which names exactly `modeled`/`partial`/`candidate`/`unsupported` and deliberately omits `manual` while §10.3's node contract keeps it; and construction (a catalog entry is analyzer-derived by definition). It is reserved for the operator-declaration path, pairing with the `declared` category — which is itself unreachable today for exactly that reason. (3) **FR-205 has ZERO sink-catalog coverage** (§7.2): only 10 of 29 `SINK_CATEGORIES` are reachable even counting `privacy-catalog.js`, and all nine `ai-*` values are among the 19 that are not — while the SOURCE side *does* reach three `ai-*` categories via the eight MCP entries. AC-07 is therefore **not satisfiable by reclassification**; it needs new detection, out of Sub-project D's scope entirely. Two further gaps are named and deliberately NOT patched (§7.4): `catalog.js` declares no `provenance` on 82 of 180 source entries (go/java/rb/php: zero coverage each), which is why 46% of sources are `candidate` rather than `modeled` — and it is *not* a safe drive-by fix, because `provenance` reaches findings as `sourceProvenance` via `dataflow/engine.js` and is emitted by `report/index.js`, so closing it is a benchmarked change needing its own increment; and `schema.js`'s `client-storage` under-names FR-201's "browser DOM or client storage" bullet, leaving six DOM-XSS sinks correctly-but-misleadingly mapped at `partial`. §7.3 records five measured corrections to the upstream documents (scoping doc AND task brief — they differ on which said what), including that `CATALOG` is **756 entries (180 source / 194 sink / 382 sanitizer)**, not the 655 (149/124/382) that doc and `dataflow/CLAUDE.md` both still state, and that `SINK_CATEGORIES` has **29** values, not the 28 both upstream documents claim. **Two things D2-D5 must read before being briefed.** §9.2 corrects the parent scoping doc's **D5 exit criterion**, which as written ("every FR-101/FR-201 source/sink category has at least one real-code proof") **cannot pass** — 19 of 29 sink and 7 of 21 source categories are unreachable BY CONSTRUCTION, so briefing it unchanged would either fail permanently or be quietly softened. The corrected criterion: *every REACHABLE category has a real-code proof, and every UNREACHABLE category has a recorded, tested reason.* Both halves are already testable (`D1/6a`/`D1/6b` pin the unreachable sets as exact lists). And §9.1 settles what every prior sub-project settled explicitly: the 82-row override table's permanent home is D2's source-registry module — not backtick-quoted here on purpose, since it does not exist in the tree yet and this document's own doc-drift checker flags a dangling backtick-quoted path — and NOT reproduced in the ADR, because a hand-copied markdown twin would drift undetectably; and **whichever of D2/D3 lands SECOND deletes the PoC**, after confirming the other's absorption — neither may delete it unilaterally, since they run in parallel and absorb disjoint halves. |
78
+
79
+ **Milestone 1, Sub-project D, increment 2 (`source-registry.js` — reclassifying the 180 catalog source entries) — COMPLETE:**
80
+
81
+ | Module | Responsibility |
82
+ |---|---|
83
+ | `source-registry.js` | Implements `DESIGN_REGISTRIES.md` §4/§4.1/§4.2/§4.3/§6/§7.1/§9.0 exactly (design landed as D1; this is D2, the mechanical implementation, absorbing D1's PoC's source-side half into the permanent suite — per §9.1's protocol, D3/`sink-registry.js` below is the second lander and deleted the PoC after confirming both halves' absorption was complete). A **pure reclassification layer**: imports ONLY `CATALOG` from `../dataflow/catalog.js` — never `dataflow/engine.js`, never `dataflow/summaries.js`, never matcher internals — and exports `reclassifySource(entry)`, returning `{kind: 'source', category, coverageStatus, externality, reason}` for any `kind: 'source'` catalog entry. `kind` is always `'source'` (§7.1 — checked, not assumed, by this module's own tests: `boundary` needs system/zone information no catalog entry carries). Ports `PROVENANCE_MAP` (12 rows, §4), `AGENT_TOOL_REFINEMENT` (8 rows, §4.1 — `agent-tool` splits directionally between model-produced tool arguments and results/resources flowing back), the `language === 'cpp'` descriptor-generic-I/O refinement (§4.2 — `cpp-recv`/`cpp-recvfrom`/`cpp-read`/`cpp-fgets`/`cpp-fread` demote to `partial` for `FILE*`/fd ambiguity, layered ON TOP of `PROVENANCE_MAP`, not a fallback for a missing value), and `NO_PROVENANCE_OVERRIDES` (82 rows, §4.3) — per §9.1, THIS module is now the override table's permanent home; the design-phase PoC's own copy is redundant. The registry's own field is `category`, never a literal `subtype` field (§9.0 — a registry decision must never itself validate as a `DataFlowGraph v1` node without passing through Sub-project E's future graph builder, which is what writes `category` to `node.subtype`). `coverageStatus` is never `'manual'` (§6.5) and the internal `'split'` marker (used for the `agent-tool` row, refined per-entry) never escapes as a returned value — both asserted directly by the shipped tests. **Externality — a gap D1's binding design left open for this increment to resolve.** §7.5's `CATEGORY_EXTERNALITY` table is sink-only; no source-side equivalent is specified anywhere in `DESIGN_REGISTRIES.md`, and the design-phase PoC's own local `reclassifySource()` never computed one at all. Since §9.0 requires the field on every decision regardless, this module resolves it itself via a new `SOURCE_CATEGORY_EXTERNALITY` table, grounded in the one shipped precedent that exists — the flagship fixture's `web` node (`fixtures/build-flagship-fixture.mjs`): `kind: 'source'`, `subtype: 'web-app'`, `externality: 'internal'`, modelling the checkout/registration form collection point as INTERNAL even though the person submitting the form is an anonymous public user. That precedent establishes that a source's externality answers "is the collection point itself part of the analyzed system" — never "is the eventual human origin of the bytes untrusted" (every catalog source entry answers yes to that by construction, which is why it is a taint source at all; if that were what externality encoded, every source would trivially be `external` and the field would carry no information). `SOURCE_CATEGORY_EXTERNALITY` mirrors `CATEGORY_EXTERNALITY`'s own per-class reasoning: ordinary in-app collection points (HTTP shapes, CLI, env, stdin/user-input) → `internal`; genuinely third-party-origin categories (an external API's response body, a webhook payload, anything AI-model/tool/resource-sourced) → `external`; store-shaped categories (a file/object-storage read, a DB read, a queue message) → `unknown`, for the identical "could be local or third-party-managed, the entry can't tell" reason `CATEGORY_EXTERNALITY` already uses for `database`/`object-storage`/`cache`/`queue`. **The completeness guards are real, shipped, mutation-tested tests** (`source-registry.test.js`'s `completeness/1a`–`1c`): the provenance key set and the override key set are each asserted equal, in both directions, to the live catalog's actual distinct values, so a newly-added catalog entry whose classification string nobody mapped fails loudly instead of silently producing an uncategorized node — verified by temporarily removing a row from each table during this increment's own development and confirming the matching guard failed, then confirming a clean run after restoring it (see this increment's own SDD task report for the transcript, not linked here on purpose — an SDD working file, not a path this document's doc-drift checker should be asked to track; not preserved as shipped code, since a permanently-broken mutant would defeat the point of the guard being unconditionally on). **Measured coverage on the real catalog, pinned as equality, not a floor**: 180 source entries → **84 `modeled`, 14 `partial`, 82 `candidate`, 0 `unsupported`** — unchanged from D1's own PoC-measured figures, confirmed by re-running the shipped tests against the live catalog in this increment, not copied forward from the design doc. |
84
+ | `test/lineage/source-registry.test.js` | The permanent test suite for `source-registry.js` — 20 tests, ported from the PoC's source-side content and re-pointed at the shipped `reclassifySource`. Beyond the completeness guards described above: a `SOURCE_ENTRIES` live-vs-`CATALOG`-filter sanity check; totality (every real entry produces a valid, non-throwing, fully-shaped decision, with `subtype` explicitly asserted absent); representative real end-to-end reclassifications across multiple languages/frameworks/coverage tiers; the cpp refinement's own dedicated tests (the 5 demoted entries, proof the refinement is scoped to only the `{network, file-read}` buckets, and proof the two OTHER cpp entries outside those buckets are unaffected); an honest real `candidate` case and two synthetic-entry tests proving the defensive `unsupported` fallback branches (unmapped provenance; no override for a no-provenance id) behave correctly even though the live catalog never reaches them today; the pinned coverage-count equality test; the pinned unreachable-`SOURCE_CATEGORIES` list from §7.2; and a battery of externality tests covering all three tiers of `SOURCE_CATEGORY_EXTERNALITY` plus a completeness check that every category this registry can actually emit has an externality entry. |
85
+
86
+ **Milestone 1, Sub-project D, increment 3 (`sink-registry.js` — reclassifying the 194 catalog sink entries AND the 18 privacy-catalog entries; closes FR-203) — COMPLETE:**
87
+
88
+ | Module | Responsibility |
89
+ |---|---|
90
+ | `sink-registry.js` | Implements `DESIGN_REGISTRIES.md` §5.1/§5.2/§5.3/§6/§7.1/§7.5/§9.0 exactly (design landed as D1; this is D3, the mechanical implementation, absorbing D1's PoC's sink/privacy half into the permanent suite). Per §9.1's protocol, D3 is the **SECOND lander** (D2/`source-registry.js` landed first) — it confirmed D2's own absorption was complete (its tests existed and passed) before deleting test/lineage/registry-mapping-poc.test.js (not backtick-quoted here on purpose — it no longer exists in the tree, and this document's own doc-drift checker flags a dangling backtick-quoted path), removing it from `package.json`'s `test:lineage` script, and removing its row from this table, all in the same commit. A **pure reclassification layer**: imports ONLY `CATALOG` from `../dataflow/catalog.js` and `PRIVACY_SINK_CATALOG` from `../dataflow/privacy-catalog.js` — never `dataflow/engine.js`, never `dataflow/summaries.js`, never matcher internals — and exports **TWO** functions, deliberately not a unified dispatcher (§2.1: the two catalogs key on different fields, and `privacy-catalog.js` is deliberately not merged into `CATALOG` — merging it would make every already-active general source spuriously trigger privacy-leak findings, an existing architectural boundary this module respects rather than "fixes"): `reclassifySink(entry, opts)` for `CATALOG`'s `kind: 'sink'` entries (keyed on `vuln.cwe`), and `reclassifyPrivacySink(entry)` for `PRIVACY_SINK_CATALOG` entries (keyed on `category`). Both return `{kind, category, coverageStatus, externality, reason}` — `kind` is category-derived, NOT uniformly `'sink'` the way D2's source `kind` is uniformly `'source'` (§7.1): a `null` category (`unsupported`) always yields `'process'`, and `kind === 'process'` **iff** `coverageStatus === 'unsupported'` is a pinned biconditional (contingent on `ai-local-model` staying vacuously unreachable, per §7.1's own documented fragility note — not this increment's problem to fix). Ports `CWE_MAP` (20 rows, §5.1), the CWE-79 `framework` refinement (§5.2 — `dom`/`react` → `client-storage`/`partial`, 6 entries; everything else → `http-response`/`modeled`, 10 entries), `CATEGORY_NODE_KIND` and `CATEGORY_EXTERNALITY` (§7.1/§7.5, both fully specified by D1, unlike D2's source-side externality table which D2 had to build fresh), and `PRIVACY_CATEGORY_MAP` (9 rows, §5.3) — per §9.1, THIS module is now the permanent home for all three tables. **The `unsupported` → `process` preservation (§9's D3 item 3):** the 82 sink entries with no `SINK_CATEGORIES` value never disappear — each becomes a `kind: 'process'` node with a non-empty `reason`, proven by a dedicated test plus a totality sweep of all 82. **`thirdPartySdk` (§9's D3 item 4):** a KNOWN, DISCLOSED open item — resolves to `category: 'analytics'` (the plurality of its receiver alternation) with `coverageStatus: 'partial'` and a reason naming all three OTHER candidate categories (`monitoring`/`external-api`/`collaboration`) a future match-time consumer could resolve to, never a silent guess. **FR-203 (§9's D3 item 5) — CLOSED**, via an optional second parameter `reclassifySink(entry, {destinationUnresolved, blockingExpression})`: since no catalog entry carries per-call-site destination information (§7.5), and no consumer of this shape exists yet (Sub-project E), the design doc specifies FR-203's field-by-field output but not a callable signature — this parameter is D3's own resolution of that gap, a judgment call flagged for review. **`reclassifyPrivacySink` has no equivalent parameter** — a disclosed asymmetry, not an oversight: item 5's own text names only `reclassifySink`, and no consumer exists yet to prove a privacy-side signature against; closing it is deferred to whichever increment adds the first real caller. When set (and only when the underlying category is non-null — an unsupported/process sink has no category to retain, and letting this branch fire there would break the `process`/`unsupported` biconditional above), returns `kind: 'unresolved'`, `category` RETAINED, `externality: 'unknown'`, and `coverageStatus` UNCHANGED from the category mapping (destination resolution is a different axis than classification confidence) — kept structurally distinct from the UNRELATED, already-shipped §16.7 degraded-analysis `unresolved` case (`DESIGN_PATH_PROVENANCE.md` §16.7 Finding 2, produced elsewhere entirely — a future Sub-project E graph builder reading `path-store.js` diagnostics, never this registry, and always `coverageStatus: 'partial'` unconditionally, never carried over from a category mapping). A dedicated test constructs both shapes side by side and asserts they diverge on `coverageStatus` for a `modeled` base entry, the one field item 5's own text warns must not be conflated. **Measured coverage on the real catalog, pinned as equality, not a floor**: 194 sink entries → **97 `modeled`, 6 `partial`, 9 `candidate`, 82 `unsupported`** (node kinds: 71 `store`, 82 `process`, 26 `sink`, 15 `external`); 18 privacy-catalog entries → **16 `modeled`, 2 `partial`, 0 `candidate`, 0 `unsupported`** (node kinds: 4 `log`, 2 `sink`, 5 `external`, 5 `store`, 2 `queue`) — both re-measured against the live catalogs in this increment, not copied from the design doc. **The completeness guards are real, shipped, mutation-tested tests** (`sink-registry.test.js`'s `completeness/1a`/`1b`): the CWE key set and the privacy-category key set (both against the entries actually used AND against the declared `PRIVACY_SINK_CATEGORIES` vocabulary) are each asserted equal, in both directions, to the live catalogs' actual distinct values — verified by temporarily removing a row from each table during this increment's own development and confirming the matching guard failed, then confirming a clean run after restoring it (see this increment's own SDD task report for the transcript). |
91
+ | `test/lineage/sink-registry.test.js` | The permanent test suite for `sink-registry.js` — 31 tests, ported from the PoC's sink/privacy-side content and re-pointed at the shipped `reclassifySink`/`reclassifyPrivacySink`, plus 3 added by this increment's own task review: `D1/8b` and `D1/8d` (two PoC invariants the initial absorption pass missed — no sink entry carries its own `category` field or a `host`/`url`/`provider`/`destination`/`externality`/`system` field, restoring the pins `completeness/1a`'s CWE-keying and `CATEGORY_EXTERNALITY`'s category-derivation justification both rest on) and a null-safety regression test for `reclassifySink(entry, null)`. Beyond the completeness guards described above: `SINK_ENTRIES`/`PRIVACY_SINK_ENTRIES` live-vs-catalog sanity checks; totality for both functions (every real entry produces a valid, non-throwing, fully-shaped decision, `subtype` explicitly asserted absent, `manual`/`split` never escape); the `kind === 'process'` iff `coverageStatus === 'unsupported'` biconditional, with its documented AI-sink fragility noted in a comment; the CWE-79 split proven against all 16 real entries (6 DOM/React, 10 everything-else); representative real end-to-end reclassifications; the privacy reconciliation (`storage` does NOT split, proven against its 2 real mongodb entries; every declared privacy category resolves); the `thirdPartySdk` disclosed-ambiguity test, asserting the reason text names all three other candidate categories; **FR-203's own four dedicated tests** — the base case (retained category, `externality: 'unknown'`, reason naming the blocking expression), the coverageStatus-carries-over proof (both a `modeled` and a `partial` base entry), the null-category guard (an unsupported sink is never pushed into `unresolved`), and the explicit FR-203-vs-§16.7 structural-distinctness test; the pinned coverage-count equality tests for both catalogs; the pinned unreachable-`SINK_CATEGORIES` list from §7.2 (19 unreachable, all nine `ai-*` values among them); and externality tests covering all three tiers plus totality checks that `CATEGORY_NODE_KIND`/`CATEGORY_EXTERNALITY` are each total over the whole `SINK_CATEGORIES` vocabulary, not merely the subset the catalogs reach today. |
92
+
93
+ **Milestone 1, Sub-project D, increment 4 (`transform-catalog.js` — transformation-kind recognition; closes the Sub-project D `TRANSFORM_KINDS` requirement) — COMPLETE:**
94
+
95
+ | Module | Responsibility |
96
+ |---|---|
97
+ | `transform-catalog.js` | Recognizes that a CALL performs a §10.6 data transformation and classifies it into `schema.js`'s `TRANSFORM_KINDS` × `REVERSIBILITY_VALUES`. **This is the one genuinely NEW detection capability in Sub-project D, not a reclassification.** D2/D3 both read `dataflow/catalog.js`'s already-proven, corpus-backed entries and re-labelled them; D4 has no such input, and `DESIGN_REGISTRIES.md` §8.2/§8.3 measured why: the 706 sanitizer entries are keyed on `effect` plus a THREAT class ("does this neutralize XSS"), not on "what kind of transformation is this and is it reversible" — reclassifying them would be a category error — and the one genuinely transform-shaped list that exists, `privacy-catalog.js`'s `PRIVACY_TRANSFORM_CALLEES`, is a **private** Set behind a BOOLEAN predicate (`isPrivacyTransformCallee`) that tells a caller *that* a callee is a transform but never *which kind*. §8.3 left that access problem open between "a lineage-side table of its own" and "export the Set from `privacy-catalog.js`"; D4 settles it as the FORMER — this module imports **nothing** from `scanner/src/dataflow/` at all. Its only import is `./schema.js` (`TRANSFORM_KINDS`/`REVERSIBILITY_VALUES`), pinned by a self-checking test asserting the static specifier list is EXACTLY `['./schema.js']` plus a no-dynamic-import check, the same boundary-test pattern `path-query.js`/`flow-grade.js` already established. Those two imports are not decorative: they drive a **load-time integrity check** that throws if any entry's `kind`/`reversibility` falls outside the live enums (a catalog hardcoding enum strings without ever referencing the source of truth is exactly this package's documented silent-drift hazard). One entry point, `recognizeTransformation(calleeDescriptor)` — `{type:'call', callee}` (a dotted `callee` resolves identically to the member form) or `{type:'member-call', object, method}` — returning `null` for no match or `{kind, reversibility, algorithm, confidence, evidence}`, and **never throwing**: a caller that could not fully resolve a callee may legitimately hand over a partial descriptor, and that is "no match", not a crash (21 malformed shapes pinned). **The Decision 2 boundary is the single most load-bearing property here.** PRD §10.6 lists nine things a Transformation must identify; this module produces four (kind, reversibility, algorithm-when-the-callee-names-one, evidence+confidence), and must never produce **"control credit granted or denied and the reason"** — the parent Milestone 1 scoping doc reserves awarding verdict credit for Milestone 2's FR-401-405 analyzers. That is enforced structurally, not by prose: the decision's key set is asserted to be EXACTLY the five allowed keys over every entry, and every decision key, entry field, `match` field and module export is swept against a `credit\|granted\|denied\|verdict\|protected\|protection\|control\|approved\|compliant\|passes` regex (the module HEADER does discuss control credit at length, deliberately — recording the boundary — so the proof is structural rather than a source-text grep). The five things it also does NOT produce, for the different reason that they need a CALL SITE this module never sees: input/output access paths, code location, "applies on all feasible paths", key-management evidence, and any argument-borne fact (`crypto.createHash('sha256')` yields `algorithm: null`, never a guess). A future Sub-project E graph builder is what calls this once per real call site. **Measured shape, pinned as equality, not a floor — 42 entries across all 11 non-fallback kinds:** `hash` 15, `encrypt` 5, `decrypt` 5, `encode` 5, `decode` 5, `truncate` 2, and `mask`/`redact`/`tokenize`/`aggregate`/`normalize` 1 each. **No kind was left empty**, and every entry is proven matchable by a real test — each carries its own `examples[]`, swept in both descriptor forms (which must agree exactly), so there are no prose-only rows. `custom` and `unknown` are **never emitted** (pinned as data in `NEVER_EMITTED_KINDS`): they are a consumer's fallback vocabulary for a call this catalog does not recognize, and a recognizer's honest answer is `null`. **The confidence rule** (two tiers only, no `low` — a pattern that would only justify `low` belongs in the disclosed-gap list, not the catalog): `high` iff the pattern identifies both the callee AND its semantics unambiguously — a qualified library/module API (`crypto.createHash`, `bcrypt.hash`, Python's hashlib digests, `crypto.subtle.encrypt`) or a platform global whose name is effectively reserved (`encodeURIComponent`, `btoa`); `medium` when either the name is unqualified and an application could define it with unrelated semantics (`maskCard`, `tokenize`, `normalize`) or the API is general-purpose and its per-call-site meaning varies (`_.truncate` is string shortening, not necessarily a privacy control). Every naming-convention entry is asserted `medium`, never `high`. **This deviates, deliberately, from the task plan's own suggestion that `String.prototype.normalize` rates `high`** — nothing in a callee pattern can distinguish the ECMAScript builtin from a same-named application function, so `normalize` is `medium`; its `reversibility` is `unknown` (NFC/NFD round-trip, NFKC/NFKD does not, path normalization discards `..`), which is the honest value in both directions. **§10.6's "masking, hashing, tokenization and encryption must never be treated as synonyms"** is pinned twice over: the four resolve to four different `kind` values AND to different `reversibility` values (`tokenize` is `reversible` — a token is designed to be detokenized by the vault that issued it, which is precisely what makes it not-a-mask — while `mask`/`hash` are `irreversible`), so a future catalog cannot satisfy the rule with four labels that all mean the same thing. **Precision over recall, with the gaps disclosed as tests, not just prose:** the naming conventions are anchored (`^mask(?=[A-Z_0-9]\|$)`), so `applyMask`/`unmask`/`masked`/`bitmaskOf` do NOT fire — `privacy-catalog.js`'s own comment records that exact over-fire trap; `fs.truncate`/`fsPromises.truncate` are excluded by an entry-level `notObjects` list (they shorten a FILE, not a value); `JSON.stringify`/`JSON.parse` are NOT cataloged as `encode`/`decode`, because `MAPPING_TYPES` already carries `serialization`/`deserialization` as an edge's mapping type; and **`anonymize*`/`pseudonymize*` are deliberately excluded** despite being real, common names in `privacy-catalog.js`'s own private Set — neither maps onto ONE `TRANSFORM_KINDS` value (anonymization is variously mask/redact/aggregate; pseudonymization is variously tokenize/hash), so cataloging either would perform exactly the synonym collapse §10.6 forbids. That omission has its own test so nobody later "fixes" it silently. **The two thinnest kinds, disclosed rather than padded:** `aggregate` and `tokenize` have one naming-convention entry each — aggregation is normally an unnamed data-shape operation (`.reduce()`/`.groupBy()`) with no callee to key on, tokenization vaults are overwhelmingly vendor-proprietary (no non-invented library entry was available), and `tokenize*` additionally collides with NLP/lexer tokenization, which its evidence string says out loud. `encodeURIComponent` is also a SANITIZER in `dataflow/catalog.js`; that is not a conflict — "neutralizes XSS" and "is an `encode` transformation" are two axes for two consumers, and this module never consults the other one. |
98
+ | `test/lineage/transform-catalog.test.js` | The permanent test suite — 32 tests, `D4/1` through `D4/10`. **AC-02's own worked example is `D4/1a`-`D4/1d`**, executable, not documented intent: `maskCard()` classifies `kind: 'mask'`, while the raw-log half (`logger.info`, `console.log`, a bare `log`) returns `null` — the masked and raw call sites are distinguishable by this module ALONE. This module deliberately has no opinion that logging a card number is an issue; that is a sink registry plus Milestone 2's analyzers, and "may receive handling-control credit" is Milestone 2's alone. Beyond that: the never-synonyms pair (`D4/2a`/`D4/2b`, kind AND reversibility); totality over every entry's `examples[]` in both descriptor forms (`D4/3b`/`D4/3c`); enum-validity sweeps against the LIVE `schema.js` enums plus the "every non-fallback kind is reachable" equality (`D4/4a`-`D4/4d`); the four control-credit sweeps (`D4/5a`-`D4/5d`); the precision battery (`D4/6a`-`D4/6e` — uncataloged callees, inflected/containing names, the `fs.truncate` exclusion, the `anonymize`/`pseudonymize` omission, and a platform-global entry not firing on an arbitrary receiver); algorithm-only-when-named (`D4/7`); the confidence rule (`D4/8`); 23 malformed inputs that must return `null` and never throw (`D4/9a`); and the isolation + pinned-shape + frozen checks (`D4/10`). Three of these guards were **mutation-tested during this increment's development** — collapsing `mask` into `hash` failed 5 tests, adding a `controlCredit` field to the returned decision failed 2, and widening the naming convention to a case-insensitive substring match failed `D4/6b` — then a clean run was confirmed after restoring; see this increment's own SDD task report for the transcript (an SDD working file, deliberately not linked here — not a path this document's doc-drift checker should track). |
99
+
100
+ **Milestone 1, Sub-project D, increment 5 (exit-gate closure — proving D2/D3/D4's registries against real parsed code) — COMPLETE. This closes out Sub-project D (D1-D5) in full.**
101
+
102
+ D2's and D3's own test suites already proved, exhaustively, that every `CATALOG`/`PRIVACY_SINK_CATALOG` entry reclassifies correctly — but always against the catalog's own static entries directly, never against a call site extracted from actually-parsed source code. `test/lineage/registry-real-code.test.js` closes that specific, narrow gap, per `DESIGN_REGISTRIES.md` §9.2's corrected exit criterion (*"every REACHABLE category has at least one real-code proof, and every UNREACHABLE category has a recorded, tested reason"* — the unreachable half is D1/6a's and D1/6b's, inherited unchanged, not re-proven here). For all **24 currently-reachable categories** (14 of `SOURCE_CATEGORIES`, 10 of `SINK_CATEGORIES` — re-derived from the live `source-registry.test.js`/`sink-registry.test.js` assertions during this increment, confirming zero drift from D1's own §7.2 measurement) and for AC-02's masked/raw distinction, this file writes a small JS/TS snippet, parses it with the real `parseJsFile` (never `dataflow/engine.js`, never a matcher — the same Layer-1-IR-only reuse boundary every Sub-project A-D module respects), independently confirms via a small purpose-built structural comparison (NOT the real matcher) that the chosen catalog entry's `match` field is consistent with what the parser actually produced, then feeds the CATALOG ENTRY into `reclassifySource`/`reclassifySink`/`reclassifyPrivacySink` (or, for the AC-02 half, a descriptor built from the parsed callee into `recognizeTransformation`, which consumes a descriptor directly rather than a catalog entry) and asserts the expected category/kind. Both the structural checks and the closing count assertion were mutation-tested during this increment (a corrupted snippet, a corrupted callee, and a dropped table row were each confirmed to fail loudly, then reverted) — this is a genuine, narrow proof, not a vacuous one.
103
+
104
+ Two Step-1 empirical findings worth a future reader's attention, since they contradict what a surface reading of `parser-js.js`'s own header comment would suggest: a call used as an assignment RHS (`const x = open(path);`) never reaches `fn.calls[]` at all — only a call used as a bare STATEMENT does; the RHS form is only observable nested inside that `assign` CFG node's own `source` exprDesc. And `fn.cfg.nodes` is a plain `Object` at runtime, not the header's stated `Map`. Four of the fourteen source categories (`http-upload`, `cli-argument`, `storage-read`, `user-input`) have no `language: 'js'`-tagged catalog entry at all — their only catalog representatives are Python-tagged (`py-django-request-FILES`, `py-sys-argv`, `py-open-read`, `py-input`); this increment proves them anyway by writing JS/TS SYNTAX that mirrors those entries' `match` fields' object/prop/callee strings verbatim (not real JS APIs), since the registries' own reclassification is entirely language-agnostic and `parseJsFile` cares only about syntax shape, not real-world API accuracy — the same disclosed-gap precedent D4 already established for its own ahead-of-scope `py`/`java` entries. **These four are weaker proofs than the other twenty, precisely named as such (task review MF-1):** the real matcher applies `_languageAllowed` (`dataflow/catalog.js`) BEFORE ever consulting `match`, so a `py-*`-tagged entry is discarded outright for a file the real engine treats as JS, regardless of how well its `match` shape corresponds to parsed code — these four proofs establish extraction-shape correspondence only, never "real parsed JS/TS code would reach this entry through the real engine." Reading `dataflow/catalog.js`'s real matcher (never imported, only read) also corrected an assumption this increment's structural check would otherwise have gotten wrong: `match.receiverTypeIn` is not a textual receiver-name gate at all — it gates on a CHA-resolved class TYPE, a signal this isolated file has no way to compute and deliberately does not attempt to fake.
105
+
106
+ **Milestone 1, Sub-project E, increment 1 (design spike — the seeding contract + the projection model) — COMPLETE. E2, E3, E4 and E5 are now also COMPLETE (see below) — this closes out Sub-project E in full.**
107
+
108
+ | Module | Responsibility |
109
+ |---|---|
110
+ | `DESIGN_GRAPH_BUILDER.md` | Design record, binding on every later Sub-project E increment (same role `DESIGN_INTRAPROCEDURAL.md` plays for A/B, `DESIGN_PATH_PROVENANCE.md` for C, `DESIGN_REGISTRIES.md` for D). Resolves the two mechanisms E2-E5 could not be written without, both proven by running real code: **SEEDING** (§3 — how a matched call site becomes a field-identity entry state) and **PROJECTION** (§6 — how a variable-granular provenance DAG becomes a system-granular `DataFlowGraph v1`). **The four things a reader must not miss.** (1) **The projection rule is "a graph node is a REGISTRY DECISION, not a provenance node and not a call site"** (§6.1): node identity is `(kind, subtypeKey, coverageStatus, externality, destination)`, which makes the node count bounded by the TAXONOMY rather than by repository size — measured at a constant **9 nodes** while replicating `test/fixtures/vulnerable-js` 1x -> 10x -> 50x -> 200x (8 -> 1600 functions), with edges/flows/dataElements scaling linearly over the same range. Per-call-site node minting was measured and REJECTED: it gives a 42-line fixture the same node count as the whole flagship platform. (2) **Seeding lands on the FIELD, not the container** (§3.2): `matchSource` matches `req.body`, and the seed path is extended outward through every enclosing pure-member access to `req.body.password`, which is the only reason `classifyDataElementName` can return `CREDENTIALS` rather than nothing. (3) **`driver.js`'s disclosed cache hazard had a second, worse half nobody had named — the KEY** (§3.6): writing a summary under `emptyState()` claims "this is what `fn` does when nothing flows in", which a seed makes false, so a clean call site was handed the DRIVER's own seed. Measured on a two-function real-parser fixture: pre-fix the caller's local carries `data:SEEDED`, post-fix it correctly carries nothing. (4) **§16.7 Finding 2's enumerator is CONFIRMED computable from `path-store.js`'s public read API alone** (§5) — `edgesFrom(id).length === 0` plus a `context-cap-degraded` check over `edgesTo(id)`'s `lossReasons` AND `annotations[]` — so the union mechanism was chosen over adding a sixth node kind, and `path-store.js` is untouched. **§11 escalates five things E cannot fix**, of which one is load-bearing for Sub-project F: **`engine.js`'s `case 'call'` unresolved branch unions only `expr.args`, never the RECEIVER**, so `pan.slice(0, 4)` loses the identity that `pan + 'x'` and `String(pan)` keep — which produces **zero flows** for the lineage corpus's own mask-then-log fixture, AC-02's worked example. `dataflow/engine.js` solved this with `_calleeReceiverTainted`; this package never inherited it and no design doc disclosed it. |
111
+ | `driver.js` (extended, additively) | Gained `opts.seedEntryState(fn) -> state \| falsy`, the source-seeding hook, plus the one-identifier cache-key fix that hook makes load-bearing (`cache.set(fn.qid, entryState, ...)` instead of `cache.set(fn.qid, emptyState(), ...)`). Both are no-ops when the hook is omitted — `entryState` IS `emptyState()` then — and `test/lineage/driver.test.js`'s `E1/driver-1` pins results AND cache keys against a **hardcoded golden literal captured from the pre-hook file**, never against the shipped implementation (C3 §13.2a's vacuous-test trap). `E1/driver-3` proves the cache-key fix in BOTH directions, using a local replica of the pre-fix loop as the mutant. Nothing else in this file changed at E1. **Sub-project E, increment 2 (task review M-2): `source-seeding.js` is now this hook's first real consumer**, and **increment 3's `graph-builder.js` (below) is now this package's real end-to-end caller** — see those modules' own rows below. **Increment 5** ships `lineage/index.js` and wires it into `runFullScan` (`engine.js`) — see that row below for the full wiring story. |
112
+
113
+ **Milestone 1, Sub-project E, increment 2 (`source-seeding.js` — shipping the seeding mechanism proven by E1; closes the "0 hops on real code" gap for real) — COMPLETE. E3-E5 are NOT started.**
114
+
115
+ | Module | Responsibility |
116
+ |---|---|
117
+ | `source-seeding.js` | Extracts E1's already-proven seeding mechanism (`DESIGN_GRAPH_BUILDER.md` §3) out of the throwaway PoC into a real, permanent, shipped module — mechanical porting of an already-reviewed design, not a redesign. Exports `planSeeds(callGraph, {repository})` → `{seeds, unseedable}` (walks every function's CFG expressions via the same roots `engine.js`'s own `step()` switch reads — `assign`→`source`, `call`→`callee`+`args`, `return`→`value` — matches sources via `matchSource`, extends the match outward through every enclosing pure-member access to the FIELD per §3.2's rule, mints a `dataElementId` per §3.4, and records a matched expression with no access path in `unseedable[]` rather than dropping it, per §3.3) and `seedEntryStateFactory(seeds)` → `(fn) => state \| undefined` (the exact shape `driver.js`'s `opts.seedEntryState` hook expects — confirmed by wiring it into a real `runFieldIdentityAnalysis` call in this increment's own tests, not just by shape-matching on paper). Also exports `exprRoots`/`walkExpr`, the shared CFG-node expression-walking primitives — increment 3's shipped `graph-builder.js` (below) imports these from here for its own projection code (sink enumeration, transformation attribution) rather than keeping a second, independently-drifting copy. Imports ONLY `matchSource` from `../dataflow/catalog.js` and `accessPathOf` from `../dataflow/access-paths.js` from the `dataflow/` package (§12's confirmed reuse boundary) — never `dataflow/engine.js`, never `dataflow/summaries.js`; the rest of its imports (`emptyState`/`addIdentity` from `field-identity.js`, `reclassifySource` from `source-registry.js`, `classifyDataElementName` from `classification.js`, `dataElementId` from `ids.js`) are already-shipped `src/lineage/` modules, none of them modified by this increment. **Re-measured against the live tree in this increment (2026-08-31, after the `lineage-engine-receiver-identity-hotfix` and the schema/validator hotfix), not copied from E1's own comments:** seeding `test/fixtures/vulnerable-js` through the shipped module + the shipped `driver.js` produces **23 hops / 15 pnodes / 9 pedges** — confirmed current, matching `DESIGN_GRAPH_BUILDER.md` §3.7's own post-hotfix table. |
118
+ | `test/lineage/source-seeding.test.js` | The permanent test suite — 8 tests. Absorbs `E1/1`–`E1/5` and `E1/14` from the PoC, re-pointed at the shipped `source-seeding.js` + the shipped `driver.js`: the core "0 becomes 23" proof on `vulnerable-js` (`E2/1a`/`E2/1b`, absorbing `E1/1`/`E1/4`); the field-vs-container seed-path rule, proving `req.body.password` seeds distinctly from `req.body`, that `classifyDataElementName` succeeds on the field-level name (`password` → `['CREDENTIALS']`), AND (task review M-1, the negative half the base port had left as a comment only) that the container-level seed itself (`body`) classifies as NOTHING — the entire reason the rule exists (`E2/2`, absorbing `E1/2`); the `dataElementId` discriminator's four directions — same name in two files → two ids, two fields in one file → two ids, the same field read twice → one id, function scope (`qid`) NOT in the discriminator so two handlers in one file reading the same field → one id, not two (`E2/3`, absorbing `E1/3` and adding the fourth direction explicitly) — plus a fifth own-added check confirming the two handlers really are in two different functions before asserting they collapse to one data element; an `unseedable[]` test proving a matched expression with no access path (the CALL-shaped `js-fetch-json` entry matching `res.json()` assigned directly to a variable, which has no enclosing pure-member access to extend through) is recorded, never silently dropped (`E2/4`, new — the PoC never had a real-parsed-code case exercising this path, since `vulnerable-js`'s own 9 matches are all zero-unseedable); FR-301 field precision surviving seeding + the field-identity analysis end to end — two distinct fields of one container reaching two distinct sink call sites without merging (`E2/5`, adapted from `E1/5`: E1/5's own version additionally proved this through the PROJECTION half via `buildDataFlowGraph`, which is E3's module, not this increment's to ship — this adaptation proves the SAME property using only modules already shipped before this task, `PathStore`/`path-query.js`/`flow-grade.js`, reconstructing backward from each sink's own `escape` node rather than minting a graph; task review M-3 found the first cut of this adaptation dropped two of E1/5's own halves — the `dataClasses` classification on each seed, and WHICH field reached WHICH sink rather than just "each site gets exactly one" — both restored here, the direction check recovering the real callee at each site directly from `fn.cfg.nodes[siteNodeId].callee`, no projection needed); and the escalated engine limitation, confirmed resolved post-hotfix — the masked-log fixture now produces two reconstructed paths graded `ambiguous`/`widened` rather than the pre-hotfix zero, with a receiver-free control fixture producing `ambiguous`/`explicit` (`E2/6a`/`E2/6b`, absorbing `E1/14`, re-verified rather than copied forward). `irOf`/`vulnerableJs`, the PoC's own shared fixture helpers, are duplicated here (not extracted into a shared module) — two one-line functions, small enough that a shared test-fixture module would add import indirection without saving meaningful duplication; the PoC keeps its own copies for `E1/6`–`E1/13`. |
119
+
120
+ **Milestone 1, Sub-project E, increment 3 (`graph-builder.js` — shipping the projection mechanism proven by E1; produces the first real `DataFlowGraph v1` document from real code) — COMPLETE. E4 and E5 are also now COMPLETE (see below).**
121
+
122
+ | Module | Responsibility |
123
+ |---|---|
124
+ | `graph-builder.js` | Extracts E1's already-proven projection mechanism (`DESIGN_GRAPH_BUILDER.md` §4-§8) out of the throwaway PoC into a real, permanent, shipped module — mechanical porting of an already-reviewed design, not a redesign. Exports `buildDataFlowGraph(callGraph, opts)` — **two arguments, not three**; see the signature-discrepancy note below — plus `enumerateSinkSites(callGraph)` and `degradedTerminals(store)`, both exported so the shipped test suite can exercise the registry-backed sink enumeration and the §16.7 Finding 2 enumerator directly, the same way `path-query.js` exports `comparePaths` for its own tests. Seeds and drives `runFieldIdentityAnalysis` (via `source-seeding.js`'s `planSeeds`/`seedEntryStateFactory`), builds a `PathStore`, enumerates registry-backed sink candidates per §4 (replacing `sinkCandidates()`'s security-blind stand-in), resolves multi-candidate matches per §4.3 (promote by the receiver that actually matched; else unanimous category; else plurality at `coverageStatus: 'partial'` with every alternative named, never a silent pick), reconstructs paths per sink via `reconstructPaths`, and projects the result into `nodes`/`edges`/`dataElements`/`transformations`/`flows` per §6's rule that **a node is a registry decision, never a provenance node, never a call site** — node identity is `nodeId(kind, [repository, subtypeKey ?? category ?? '', coverageStatus, externality, ''])`. Ships the four assertions `validate.js` structurally cannot make (§9.3 item 3): every `subtype` is in the registry vocabulary or `null`; every `node.dataElementIds` entry is referentially sound; no two DIFFERENT registry decisions collided onto one node id (`decisionsByNodeId`, returned alongside the graph, is the raw material a caller/test checks this against); no `pedge:`/`ppath:` id ever appears in `flow.edgeIds`. Determinism (§9.3 item 4): every entity array is sorted by id before emit, and `generatedAt` is injectable via `opts.generatedAt`, defaulting to a fixed `1970-01-01T00:00:00.000Z` literal rather than `emptyGraphEnvelope()`'s own `new Date().toISOString()` fallback. **Signature discrepancy, resolved per `DESIGN_GRAPH_BUILDER.md` §9.1's own stated policy** ("where this document and that PoC disagree, the PoC is right and this document is stale — fix it here, do not fork it"): §9.3 item 1 originally stated `buildDataFlowGraph(perFileIR, callGraph, opts)` (three arguments); the PoC's own shipped, tested implementation was always `buildDataFlowGraph(callGraph, opts)` (two arguments — everything it needs comes from `callGraph.functions[*].cfg`, and no `perFileIR` parameter was ever read). This module ships the PoC's real two-argument signature; §9.3 item 1's own prose is corrected to match in the same commit. Imports ONLY `matchSinkOrSanitizer` from `../dataflow/catalog.js` and `matchPrivacySink` from `../dataflow/privacy-catalog.js` from the `dataflow/` package (§12's confirmed reuse boundary, pinned by the shipped test's own boundary check) — never `dataflow/engine.js`, never `dataflow/summaries.js`, never `dataflow/index.js`'s `runDeepAnalysis` (this module mirrors that entry point's SHAPE — an opt-in, best-effort orchestrator — and imports nothing from it). `accessPathOf` is not directly imported here (only `source-seeding.js` needs it, to extend a seed match outward to the field). **Re-measured against the live tree in this increment (2026-08-31, after the `lineage-engine-receiver-identity-hotfix` and the schema/validator hotfix), not copied from the PoC's own comments:** `buildDataFlowGraph` on `test/fixtures/vulnerable-js` produces a `validateGraph()`-clean graph with **9 nodes, 6 edges, 6 dataElements, 6 flows, 0 transformations** (this fixture has no recognizable transform call), **23 hops / 15 pnodes / 9 pedges** in the underlying provenance store, and a coverage ledger of 11 sink call-statement sites (6 connected, 5 disconnected, 1 non-statement site not enumerable) over 9 matched sources (0 unseedable) — every number matches the PoC's own last-measured figures exactly, confirming no drift between the PoC and the shipped port. **Sub-project E, increment 4 (E4) additions, all additive, zero behavior change for any caller that omits them:** `enumerateSinkSites`'s statement sites gain `args` (the CFG call node's own `args` array) and, once `buildDataFlowGraph`'s main loop runs, `connected` (whether a real flow reached that site); `buildDataFlowGraph(callGraph, opts)` gains `opts.resolveSiteDecision(site) -> decision \| undefined`, applied once right after `enumerateSinkSites` runs and before anything else reads `site.decision` — `coverage.js`'s `resolveSiteDecision` (below) is its first real consumer, closing FR-203. |
125
+ | `test/lineage/graph-builder.test.js` | The permanent test suite — 8 tests, `E1/6`-`E1/13`, ported from the PoC's projection-half content and re-pointed at the shipped `graph-builder.js`. A validated, flagship-scale graph on real code (`E1/6`); the node-count invariance proof — 9 nodes held constant while `vulnerable-js` is replicated 1x/10x/50x and edges/flows/dataElements scale linearly (`E1/7`, the 200x row from the design spike is not re-run here, matching the PoC's own choice to keep `test:lineage` fast); the four validator-blind-spot assertions (`E1/8`); multi-candidate sink resolution on real code — `res.send(x)` promotes via its receiver, bare `ctx.send(x)`/`send(x)` demote to `partial` with alternatives named (`E1/9`); the §16.7 enumerator firing on real degraded code at a tight context cap and correctly not firing at a generous one (`E1/10`); transformation recognition end to end — a recognized `mask`, an honest `unknown`, no control-credit key anywhere (`E1/11`); the reuse boundary, now reading `graph-builder.js`'s own shipped source rather than a test file's, mirroring `source-seeding.test.js`'s own boundary-test convention (`E1/12`); AC-11's coarse half plus the ledger counts (`E1/13`). **Absorption + deletion, per `DESIGN_GRAPH_BUILDER.md` §9.1's protocol:** E2 (`source-seeding.js`) landed FIRST and absorbed the seeding half (`E1/1`-`E1/5`, `E1/14`) without deleting the PoC, since this increment's projection-half tests still needed it. This increment is the confirmed SECOND lander — it absorbed `E1/6`-`E1/13` into this file, then deleted test/lineage/graph-builder-poc.test.js entirely (not backtick-quoted here on purpose — it no longer exists in the tree, and this document's own doc-drift checker flags a dangling backtick-quoted path), removed it from `package.json`'s `test:lineage` script, and removed its row from this table, all in the same commit. |
126
+
127
+ **Milestone 1, Sub-project E, increment 4 (`coverage.js` — the finished coverage ledger + FR-203 closure) — COMPLETE.**
128
+
129
+ | Module | Responsibility |
130
+ |---|---|
131
+ | `coverage.js` | Implements `DESIGN_GRAPH_BUILDER.md` §9.4/§10 exactly: finishes the coverage ledger E1/E3 shipped only as a sketch, and closes **FR-203** (a recognized sink whose destination expression could not be statically resolved — `fetch(url)` with a computed `url`; an SDK client built from config). §9.4 item 3 ("ship §5's enumerator union as a real module function") was already done by E3 (`degradedTerminals`); this module reads its result via `built.degraded`, never reimplements it. **The hook-vs-post-processing-pass decision (§9.4 item 5b):** an `opts.resolveSiteDecision` hook on `buildDataFlowGraph` (E4's own additive change to `graph-builder.js`, see that module's row above), not a post-processing pass over the built graph — a post-processing pass was rejected because FR-203 changes a decision's `kind`/`externality`, part of a node's own identity discriminator (§6.1), so adjusting them after nodes are minted would mean re-minting and re-linking every reference by hand. **The FR-203 heuristic** (`detectUnresolvedDestination`/`resolveSiteDecision`) has two independent signals, each with its own eligibility set — a real, measured bug (task review MUST-FIX 1) forced this split: a RECEIVER signal (the callee's object isn't a plain identifier — "an SDK client built from config") is eligible for all of `FR203_ELIGIBLE_KINDS = ['external', 'store', 'queue']`, since the receiver is the destination handle in every one of them; an ARGUMENT signal (the first call argument isn't a literal — "fetch(url) with a computed url") is eligible ONLY for `FR203_ARG0_DESTINATION_CATEGORIES = ['external-api', 'file', 'object-storage']` — everywhere else in the eligible-kinds set (overwhelmingly `database`/`client-storage`), the first argument is the PAYLOAD being sent to an already-resolved destination (`cursor.execute(sql)`, `document.write(html)`), and the first cut of this heuristic false-positived exactly there, measured live on `test/fixtures/vulnerable-js` (`js-sql-query` spuriously flipped from `store/database` to `unresolved/database`). **`resolveSiteDecision` composes with §4.3's multi-candidate ambiguity resolution rather than silently discarding it** — a site's `coverageStatus` may already be demoted to `'partial'` by plurality resolution before FR-203 ever runs, and a bare `reclassifySink(site.entry, opts)` call would re-derive a fresh (undemoted) `coverageStatus` from `CWE_MAP`, silently erasing that demotion; `resolveSiteDecision` calls `reclassifySink` for `kind`/`category`/`externality`/`reason` but explicitly carries the site-level `coverageStatus`/reason forward when `site.ambiguity.resolvedBy === 'plurality'`. **Disclosed, not fixed:** the RECEIVER signal itself has the identical over-fire shape one axis over — `this.db.query(q)`/`ctx.services.db.query(q)` both fire even though both are ordinary, fully-static ORM/repository paths, since the plain-ident/member discriminator approximates static resolvability rather than deciding it; narrowing this to genuinely computed receivers (a `call` or computed-member object) is a natural follow-up, not attempted here. **The finished coverage ledger** (`buildCoverageLedger(built, opts)`) matches §10's sketch field-for-field: `languages`/`parseFailures` (optional `opts.perFile`/`opts.parseFailures`, honestly empty when omitted, never fabricated — `opts.parseFailures` entries must be pre-normalized through this module's own `languageForFile`, since `ir/index.js`'s own parse-failure tracking keys by raw extension, not this module's vocabulary); `sources.byCategory`/`sinks.byCategory` (worst-coverageStatus-wins per category, mirroring `protection.js`'s `aggregateVerdicts()` precedence convention); `sinks.unsupportedSites` (the null-category residual `byCategory` deliberately excludes, so `unsupportedSites + Σ byCategory[*].sites === callStatementSites` always reconciles); `unresolvedDestinations` (counts `built.sites` only) kept structurally distinct from `degradedTerminals` (counts `built.degraded` only — §16.7's unrelated case); `budgets` (path-query.js's `DEFAULTS` merged with whatever `opts.budget` was actually in effect); `provenance` (hop/pnode/pedge counts). `buildGraphWithCoverage(callGraph, opts)` is the convenience entry point — `buildDataFlowGraph` with FR-203 closed by default (composing with, never clobbering, a caller-supplied `opts.resolveSiteDecision`) and the finished ledger in place of E3's sketch; it is the only field this function changes. Imports ONLY `reclassifySink` from `./sink-registry.js`, `DEFAULTS` from `./path-query.js`, and `buildDataFlowGraph` from `./graph-builder.js` — never `dataflow/engine.js`, never `dataflow/summaries.js`. **Empty-but-valid-must-fail proof** (matching D5's own precedent): a zero-function `callGraph` produces a ledger with every count genuinely zero and every `byCategory` genuinely `{}` — paired with a real-fixture positive test whose assertions a hardcoded-zeros implementation would fail, so the empty case is a genuine proof, not vacuous. |
132
+ | `test/lineage/coverage.test.js` | The permanent test suite — 22 tests. `detectUnresolvedDestination`/`renderExpr` unit tests including the receiver-vs-arg0 signal split and a non-JS-parser flat-string-callee case; `resolveSiteDecision`'s guards (privacy-catalog entries, null-category decisions, non-eligible kinds) and its real §4.3-composition proof (`C1/3e`); the MUST-FIX 1 regression trio (`MF-1a`/`MF-1b`/`MF-1c`: `cursor.execute(sql)` and `document.write(html)` must NOT fire via arg0, `fetch(url)` still must); real end-to-end `buildGraphWithCoverage` wiring on parsed code, `validateGraph()`-clean; non-vacuous real-code `byCategory` counts (`C1/5`) plus the exact reconciliation invariant (`C1/5b`, a fixture with both a bucketed and a null-category site); the empty-graph proof (`C1/6`); `languages`/`parseFailures` honesty and arithmetic, both omitted and supplied (`C1/7`/`C1/8`); determinism including alphabetical `byCategory` key order regardless of discovery order (`C1/9`); and the reuse-boundary test (`C1/10`, using the package's established stricter import-detection regex after an earlier, weaker anchored-single-line version was found foolable by multi-line/side-effect/dynamic imports). |
133
+
134
+ **Milestone 1, Sub-project E, increment 5 (`lineage/index.js` + `runFullScan` wiring — the scan-facing entry point) — COMPLETE. This closes out Sub-project E (E1-E5) in full.**
135
+
136
+ | Module | Responsibility |
137
+ |---|---|
138
+ | `index.js` | The ONLY file under `src/lineage/` that `engine.js`/`bin/` import — the scan-facing entry point for the whole package. `buildLineageGraph(callGraph, opts) -> {status, graph, failure, elapsedMs}` wraps `coverage.js`'s `buildGraphWithCoverage` in an opt-in/best-effort/status-reporting contract, mirroring `runFullScan`'s own `_deepEnabled` block's failure-handling — **not** `dataflow/index.js`'s `AGENTIC_SECURITY_PRIVACY_DEEP` block, whose bare `catch {}` silently swallows a failure with no `scanHealth` signal at all (measured and disclosed; `DESIGN_GRAPH_BUILDER.md` §9.5 item 1's own wording named the wrong block, corrected there during this increment's fix round). Imports ONLY `./coverage.js`. `status` is `'not_available'` (malformed/empty `callGraph`, no degraded lineage mode exists to fall back to), `'complete'`, or `'failed'` (a real thrown error, recorded in `failure`, never an uncaught throw). |
139
+ | `engine.js` (extended, additively) | `runFullScan` gained a top-level gate block, structurally parallel to (and placed immediately after) `_deepEnabled`: `AGENTIC_SECURITY_LINEAGE_DEEP=1` builds and reuses the SAME `_sharedIR || (_sharedIR = await _buildIR())` memo `_deepEnabled` uses — no second IR pass, proven live (instrumented `_buildIR` call count: 1, not 2, when both flags are set together). **Deliberately gated INDEPENDENTLY of `AGENTIC_SECURITY_DEEP`** (a real design ruling, not an oversight): lineage analysis has no degraded/non-IR-backed mode the way privacy-taint does, so piggybacking on deep mode alone would make `AGENTIC_SECURITY_LINEAGE_DEEP=1` alone silently produce nothing whenever deep mode itself was off. **A real crash bug was found and fixed here** (task review MF-1, proven by live fault injection into `buildCallGraph`): the block's first cut had no `try/catch` around `_buildIR()`, so an IR-build failure the engine used to survive gracefully (as a recorded, non-fatal `_deepStatus.failure`) instead crashed the entire scan — closed by wrapping the block exactly like `_deepEnabled`'s own `catch`, regression-tested in `test/lineage-fault-injection.test.js` (proven non-vacuous: fails against the pre-fix commit, passes against the fix). Attaches `scan.lineageGraph`/`scan.lineageStatus` to `runFullScan`'s return object; `computeScanHealth` (`pipeline/scan-health.js`) gained an additive `lineageStatus` parameter, kept structurally separate from `deepStatus` (never folded in — a lineage failure must never read as an IR-taint failure). **Disclosed, undissolved side effect**: setting `AGENTIC_SECURITY_LINEAGE_DEEP=1` alone (deep mode off) also upgrades privacy-taint annotation to IR-backed, since both consult the same shared `_sharedIR` memo — a real, measured consequence, not a bug, now disclosed in the gate block's own comment. Proven backward-compatible field-by-field: with the env var genuinely absent, a scan's output differs from pre-E5 `main` in exactly the two new top-level keys plus one new `scanHealth.lineageAnalysis` sub-key — every other field, including all findings, byte-identical. Import-time cost of the new static import measured at ~9ms marginal (module resolution only — no lineage module does heavy work at import time), not a per-scan regression for the overwhelming majority of scans that never set the flag. |
140
+ | `bin/agentic-security.js` (extended, additively) | Persists `scan.lineageGraph` to `.agentic-security/lineage-graph.json` + `.sig`, mirroring `last-scan.json`'s own write+sign pattern exactly (same `signLastScan`/`verifyLastScan` from `posture/integrity.js`, confirmed fully generic — no filename baked in — so no new signing mechanism was introduced), inside the SAME `_writesOnScan()`/`_isSafeStateDir()` gate `last-scan.json` itself respects, written only when a graph actually exists. `persistedScan.lineageGraph` is explicitly `delete`d before writing `last-scan.json` (confirmed defensive, not a fix for a real leak — `toJSON`'s own hand-enumerated allowlist never included it) so the graph is never duplicated across the two artifacts. **The two new artifacts are registered in `posture/artifact-registry.js`** (`classification: 'generated'`, `retentionClass: 'scan'`, matching `last-scan.json`'s own entries) — a real gap the final whole-branch review caught: without this, `agentic-security reset` misclassified the lineage artifact as operator-authored config and never deleted it, proven live (files survived `reset --yes` pre-fix, are genuinely deleted post-fix) and pinned by a permanent regression test in `test/artifact-registry.test.js`. |
141
+
142
+ **Milestone 2, Sub-project A ("external destination resolver," Large), increment 1 (`resolve-destination.js` — the `destination` object shape + literal/dynamic resolution) — first increment; more increments follow, per `DESIGN_DESTINATION_RESOLVER.md`'s own "Explicitly deferred" list:**
143
+
144
+ | Module | Responsibility |
145
+ |---|---|
146
+ | `DESIGN_DESTINATION_RESOLVER.md` | Short design record (binding on later Sub-project A increments, MUCH shorter than `DESIGN_GRAPH_BUILDER.md` on purpose — this is a first slice, not the whole "Large" sub-project). Defines the `destination` object shape (`resolutionStatus` ∈ `schema.js`'s `DESTINATION_RESOLUTION_VALUES`, `raw`, `literalValue`, `blockingExpression`) and names every deferred fact (hostname/port/route/SDK-provider/model/cloud-resource-id/trust-zone extraction, `resolved_from_constant`, `resolved_from_config`, `resolved_from_schema`/`declared_service`, `runtime_corroborated`, AI-provider/model resolution, node-identity widening by destination, and a kind/category eligibility gate on the RECEIVER signal) with a one-line reason each. |
147
+ | `resolve-destination.js` | `resolveDestination(site) -> destination`, never `undefined`, never throws. Reuses `coverage.js`'s FR-203 primitives directly rather than re-deriving a second opinion: `detectUnresolvedDestination` for unresolvability, `renderExpr` for string rendering, and (imported, no longer module-private) `FR203_ARG0_DESTINATION_CATEGORIES` for the exact same arg0-eligibility gate MUST-FIX 1 established — applied to BOTH the literal-arg0 case and the dynamic-via-arg0 case (a real thing this increment's own test suite catches: without gating the dynamic case too, `db.query(sql)` would resolve `'dynamic'` via `detectUnresolvedDestination`'s own category-blind arg0 signal, reintroducing the exact false positive MUST-FIX 1 fixed for FR-203's ledger — `M2A1/wire-3` is the regression guard). Three outcomes only: `'literal'` (a literal arg0 on an eligible category, or a literal receiver — checked BEFORE the dynamic check, since a literal receiver is also a non-plain-ident and would otherwise misroute through `detectUnresolvedDestination`'s `'receiver'` signal), `'dynamic'` (anything FR-203 already flags, arg0-gated as above), `'unknown'` (everything else — the same answer Milestone 1 always gave). Imports `./coverage.js`; `coverage.js` imports `resolveDestination` back — a genuine, deliberate ES module cycle (both files' header comments explain why it's safe: every cross-module reference is inside a function body, never at either file's own top level, so Node's live-binding cycle handling resolves it correctly regardless of which file loads first — proven by the full suite passing, not just argued). |
148
+ | `graph-builder.js` (extended, additively) | Gained a SEPARATE `opts.resolveDestination(site) -> destination \| undefined` hook, applied at the same pipeline point as `opts.resolveSiteDecision` (E4), right after it — composes with it, never replaces it (a site can carry both an `unresolved` decision AND a `dynamic` destination at once, proven by `M2A1/hook-4`). `mintNode` gained an optional `destination` param (default `null`), passed through only by `sinkNodeFor` (source/degraded-terminal nodes never carry one); deliberately NOT added to the node identity discriminator (`ids.nodeId`'s inputs are unchanged) — two sites colliding onto one registry-decision node still collide onto one node, and that node's `destination` is whichever site's resolution landed first (mint-time only, same as every other node field), a disclosed coarsening named in the design doc, not a bug. The edge `protocol.destinationResolution` reads `site.destination?.resolutionStatus ?? 'unknown'`. Byte-identical to pre-M2 behavior when the hook is omitted, proven by `M2A1/hook-1`/`hook-3`. |
149
+ | `coverage.js` (extended, additively) | `buildGraphWithCoverage` wires `resolveDestination` in as the default `opts.resolveDestination`, composing with a caller-supplied override exactly the way `resolveSiteDecision` already does (NITPICK 4's precedent) — `opts.resolveDestination ?? resolveDestination`. `FR203_ARG0_DESTINATION_CATEGORIES` is now exported (was module-private) so `resolve-destination.js` can reuse the literal constant rather than a second, potentially-drifting copy. |
150
+ | `validate.js` (extended, additively) | `_validateNode` gained a structural check for `node.destination` when non-null: `resolutionStatus` must be a live `DESTINATION_RESOLUTION_VALUES` member (imported, not hardcoded), and `literalValue` must be `null` unless `resolutionStatus === 'literal'` — mirrors the existing `edge.protocol.destinationResolution` check's own "only checked when the parent object is present" shape. |
151
+
152
+ **One disclosed measured consequence, not a bug**: once `resolveDestination` is wired (`buildGraphWithCoverage`'s new default), EVERY sink node's `destination` becomes a real, non-null object — including the `'unknown'` case (`{resolutionStatus: 'unknown', raw: null, literalValue: null, blockingExpression: null}`), not `null` — since the resolver always returns a value, never `undefined`, and the hook only skips assignment on a falsy return. `destination: null` now means "resolution was never attempted at all" (the hook omitted, or `buildDataFlowGraph` called directly without it); `destination: {resolutionStatus: 'unknown', ...}` means "attempted, genuinely couldn't resolve" — a real, useful distinction, not an accidental one. `graph.limitations`' own "External destinations are not resolved (FR-202 is Milestone 2)" sentence is now stale for a caller using `buildGraphWithCoverage`'s default (it's still literally true of `buildDataFlowGraph` called bare, with no `opts.resolveDestination` supplied) — left as-is in this increment, the same way `resolveSiteDecision`'s own FR-203 closure never touched this static string either; making `graph.limitations` conditional on which hooks were actually supplied is real, small, deferred scope, not forgotten.
153
+
154
+ **Milestone 2, Sub-project D ("handling analyzer + FR-307 control-credit computation," Large) — COMPLETE (increments 1-2):**
155
+
156
+ | Module | Responsibility |
157
+ |---|---|
158
+ | `DESIGN_HANDLING_ANALYZER.md` | Design record covering both increments: §1-§4 (increment 1, FR-403's single-path handling taxonomy) and §5 (increment 2, FR-307's multi-path control-credit rule). See that file directly for the full mapping tables and reasoning — this row is a pointer, not a duplicate. |
159
+ | `handling-analyzer.js` (increment 1) | `classifyHandling(path, callGraph)` — walks ONE reconstructed path's hops source-to-sink, maps the first recognized `transform-catalog.js` transform onto `schema.js`'s new `HANDLING_VALUES` taxonomy (`raw`/`masked`/`redacted`/`hashed`/`tokenized`/`encrypted`/`aggregated`/`unknown`). Wired into `graph-builder.js`'s flow-construction loop as `flow.handling`, set once at mint time. Proven against the real AC-02 masked/raw fixture shape (`bench/data-lineage/fixtures/js-api-to-log-{masked,raw}/`'s own scenario, reproduced directly in `handling-analyzer.test.js`). |
160
+ | `graph-builder.js` (extended, additively, increment 2) | Gained a post-flow-construction aggregation pass computing `transformation.appliesToAllPaths` (previously always `null`) — groups every flow by a COARSER `(source, sink, dataElement)` key than `groupsByFlowKey`'s own key, then for each transformation id checks whether it appears in every flow sharing that coarse key. `true` only when a transform is present on literally every path reaching that sink for that field; `false` the moment any sibling flow — including one truncated by `path-query.js`'s own depth budget before ever reaching the transform's hop — lacks it, with no special-case code needed for the truncated case (the conservative answer falls out of the same conjunction). This is the AC-12 proof: masking on one branch can never make a transform read `appliesToAllPaths: true` when a sibling branch to the same sink skips it. Does NOT compute `flow.protectionSummary` (`mixed`/`unprotected`) — that stays `'not_assessed'`, deferred to a future protection-verdict analyzer (Sub-project B/C/G) that will consume `appliesToAllPaths` as one input, not produce it. |
161
+
162
+ **Milestone 2, Sub-project E ("DB/table/column + queue/topic mapping," Medium), increment 1 (`dataflow/orm-write-catalog.js` — isolated ORM-write sink recognition) — first increment; E2/E3 still open:**
163
+
164
+ | Module | Responsibility |
165
+ |---|---|
166
+ | `docs/superpowers/plans/2026-08-31-data-flow-explorer-m2-subproject-e-scoping.md` | Scoping CORRECTION to the parent M2 scoping doc's own Sub-project E row — found, by reading `posture/cross-lang-orm.js`/`cross-lang-queues.js` directly, that they are whole-project raw-text regex scanners with no per-call-site function to reuse (not the simple "reclassification" the parent doc assumed), that every current `database`-category sink in `dataflow/catalog.js` is a raw-SQL-string call (none are ORM method calls), and that an ORM model's receiver has no fixed alias list the way AC-07's AI-provider entries did — three findings that reshaped the increment breakdown before any code was written. |
167
+ | `dataflow/orm-write-catalog.js` (new) | `ORM_WRITE_CATALOG` (4 entries: `create`/`save`/`update`/`upsert` on a bare, capitalized-identifier receiver) + `matchOrmWrite(calleeExpr, file)`. Structurally mirrors `privacy-catalog.js`'s own isolation architecture — **never merged into `dataflow/catalog.js`'s `CATALOG`, never wired into `matchSinkOrSanitizer`/`runTaintEngine`** — for a stronger reason than privacy-catalog's own: merging in would make an already-tainted general source immediately produce a new, unreviewed SAST finding class the moment it reached an ORM write, a real side effect this Data Flow Explorer increment is not chartered to decide. Two precision signals gate a match, only one of which lives here (receiver/callee shape — the same heuristic `sast/mass-assignment.js`/`posture/cross-lang-orm.js` already use in production); the object-literal-first-argument signal lives in `graph-builder.js`'s site enumeration as a hard exclusion, since this catalog's matcher signature (mirroring `matchPrivacySink`'s) never receives the call's arguments. |
168
+ | `sink-registry.js` (extended, additively) | `reclassifyOrmWrite(entry)` mirrors `reclassifyPrivacySink`'s shape — `kind`/`externality` DERIVED via `CATEGORY_NODE_KIND`/`CATEGORY_EXTERNALITY` keyed on `category: 'database'` (reusing the existing category rather than minting a new one — an open question left to a later increment). `coverageStatus` is the literal string `'candidate'`, unconditionally, computed from nothing — even with both precision signals satisfied, an arbitrary capitalized-identifier receiver could still be a non-ORM builder pattern, so this is deliberately never `'modeled'`. |
169
+ | `graph-builder.js` (extended, additively) | `resolveOrmWriteAtCallSite` wires `matchOrmWrite` in alongside the two existing matchers in `enumerateSinkSites`, applying the object-literal-first-argument check as a hard exclusion BEFORE a site is created at all (never a downgrade the way ambiguity resolution works for the general/privacy matchers) — `User.create(req.body)` (a bare identifier argument) never becomes an ORM-write sink candidate. |
170
+ | `test/catalog-orm-write.test.js` (new, 15 tests) | Precision proof (positive shapes, the argument-shape hard exclusion, the receiver-shape gate) AND, the load-bearing property, a LIVE isolation proof: a real `runTaintEngine` call over a fixture containing `User.create({ email: req.body.email })` produces zero findings attributable to this catalog — not just an assertion in a comment. Plus a structural catalog-disjointness check and an import-boundary check confirming `dataflow/engine.js`'s own source never references the new file. |
171
+
172
+ **Sub-project E, increment 2 (`node.storeDetail` — table/column/operation extraction) — E3 (queue/topic mapping) still open:**
173
+
174
+ | Module | Responsibility |
175
+ |---|---|
176
+ | `DESIGN_STORE_DETAIL.md` | Design record for the new `node.storeDetail` field (`provider`/`host`/`database`/`schema`/`table`/`operation`/`columns`), mirroring `DESIGN_DESTINATION_RESOLVER.md`'s own scale. Only `table`/`operation`/`columns` are ever populated in this increment, and only from JS/TS ORM-write sites increment 1 recognizes — the other four fields stay `null` unconditionally, FR-204's own "unknown portions remain unknown" clause in action. |
177
+ | `graph-builder.js` (extended, additively) | `resolveOrmWriteAtCallSite` now also extracts `table` (the receiver identifier name, e.g. `'User'`), `operation` (mapped from the matched method name via a local `ORM_OPERATION_MAP` — `create`→`create`, `update`→`update`, `upsert`→`upsert`, and **`save`→`upsert`, not `create`**, a deliberate, disclosed judgment call: Mongoose's `.save()` is genuinely undecidable statically between insert and update), and `columns` (the object-literal argument's own property key names, correctly excluding spread entries and non-literal computed `'*'`-keyed entries rather than fabricating column names — grounded in `parser-js.js`'s real `ObjectExpression` IR shape). `mintNode`/`sinkNodeFor` gained a `storeDetail` param mirroring `destination`'s own wiring exactly — set once at mint time, never part of the node identity discriminator. |
178
+ | `schema.js` (extended, additively) | `STORE_OPERATION_VALUES = ['create', 'read', 'update', 'delete', 'upsert', 'unknown']`. |
179
+ | `validate.js` (extended, additively) | Structural check for `node.storeDetail` when non-null: `operation` must be `null` or a live `STORE_OPERATION_VALUES` member; `columns` must be an array of strings. |
180
+ | `test/catalog-orm-write.test.js` (extended, +6) / `test/lineage/validate.test.js` (extended, +7) | Table/operation/columns extraction on a real fixture; the `save`→`upsert` proof with its own reasoning in the test's assertion message; the spread-exclusion and `'*'`-exclusion proofs; deduplication of a repeated key; a full `buildDataFlowGraph`→`validateGraph` round trip; and the `validate.js` structural-check battery (every `STORE_OPERATION_VALUES` member valid, an unrecognized value rejected, `columns` type-checked). |
181
+
182
+ **Process note (Sub-project E, increment 2):** the dispatched implementer's own edits were correct (independently verified in full by the coordinator), but its turn repeatedly ended mid-verification without committing — each resume started a fresh background `npm test` run and never checked back, orphaning 2-3 concurrent test processes that contended with the coordinator's own verification. The coordinator killed the orphaned runs, stopped resuming the agent after the pattern repeated, verified everything independently (full gate: `REAL_EXIT:0`, 5792/5792), and committed the diff directly. See `.superpowers/sdd/m2-subproject-e2/progress.md` for the full account. **This was fixed for increment 3** by explicitly instructing the dispatched implementer to run tests in the foreground rather than backgrounding them — it worked cleanly the very next time, including correctly using the Monitor tool to wait out the harness's own automatic backgrounding of a long-running command rather than abandoning it the way increment 2's implementer had.
183
+
184
+ **Sub-project E, increment 3 (`node.queueDetail` — queue/topic identity extraction) — closes out Sub-project E (E1-E3) in full:**
185
+
186
+ | Module | Responsibility |
187
+ |---|---|
188
+ | `docs/superpowers/plans/2026-08-31-data-flow-explorer-m2-subproject-e3-scoping.md` | Answers the reachability question the E-scoping doc's own E3 row left open: queue sinks are ALREADY matched today via `dataflow/privacy-catalog.js`'s existing `privacy-js-queue-sendMessage`/`privacy-js-queue-publish` entries, already wired into `enumerateSinkSites` via `matchPrivacySink` — no new isolated catalog needed, unlike E1's database situation. What was missing was topic-identity EXTRACTION, not recognition. |
189
+ | `DESIGN_QUEUE_DETAIL.md` | Design record for `node.queueDetail` (`provider`/`topic`/`operation`) — a NEW, parallel field to `node.storeDetail`, not a reuse of it: `schema.js`'s `NODE_KINDS` keeps `'queue'` distinct from `'store'`, and a queue has no table/database/schema to name. |
190
+ | `graph-builder.js` (extended, additively) | `extractQueueDetail(args)` covers `sqs.sendMessage({QueueUrl: '...', ...})` — the identity is a literal-valued object-literal property, found via a short, disclosed alias list (`QueueUrl`/`TopicArn`/`topic`/`queueName`), reusing `ormWriteColumns`'s exact filter shape (exclude spreads, exclude `'*'`-keyed computed entries) for the property search. For `topic.publish(...)` (SNS/Kafka), the topic identity typically lives in a SEPARATE, earlier statement that constructed the receiver — a cross-statement lookup this package has no primitive for — so `topic` stays `null` unconditionally for that shape, an honest disclosed gap, not a half-attempt. Wired into `enumerateSinkSites` as a conditional post-step (`if (r.decision.category === 'queue') { site.queueDetail = extractQueueDetail(node.args ?? []) }`), not a change to `resolveSinkAtCallSite`'s own signature. `mintNode`/`sinkNodeFor` gained a `queueDetail` param mirroring `destination`/`storeDetail`'s exact wiring — mint-time only, never part of the node identity discriminator. |
191
+ | `schema.js` (extended, additively) | `QUEUE_OPERATION_VALUES = ['publish', 'unknown']` — only two values, since both real catalog entries are unambiguously a write, unlike the database case's real `create`/`update`/`upsert` spread. |
192
+ | `validate.js` (extended, additively) | Structural check for `node.queueDetail` when non-null, mirroring `node.storeDetail`'s own shape. |
193
+ | `test/lineage/queue-detail.test.js` (new, 4 tests) / `test/lineage/validate.test.js` (extended, +6) | The literal-`QueueUrl` extraction proof; the non-literal-value case (stays `null`, still a real recognized site); the `topic.publish(x)` disclosed-gap proof, with an explicit test comment so a future reader doesn't mistake the `null` for a bug; a full `buildDataFlowGraph → validateGraph` round trip; and the `validate.js` structural-check battery. |
194
+
195
+ **Milestone 2, Sub-project B ("transit protection analyzer," FR-401, Large), increment 1 (`transit-protection.js` — plumbing skeleton) — first increment; B2/B3 still open:**
196
+
197
+ | Module | Responsibility |
198
+ |---|---|
199
+ | `docs/superpowers/plans/2026-08-31-data-flow-explorer-m2-subproject-b-scoping.md` | Corrects the parent M2 scoping doc's "reused as pure evidence lookups" framing for `sast/crypto-protocol.js`: it's a whole-file, line-numbered scanner (like `cross-lang-orm.js`/`cross-lang-queues.js`), not a per-call-site matcher — but file+line correlation (the same pattern `posture/network-policy-import.js` already establishes) is a defensible reuse mechanism here, unlike the ORM-write case. Finds and resolves a genuinely new plumbing question: the lineage build pipeline had no raw file-text access before this increment. Confirmed directly against `engine.js` that `runFullScan`'s own `fileContents` parameter was already in scope, unused, at the `buildLineageGraph` call site. |
200
+ | `transit-protection.js` (extended, increment 2) | `scanTransitEvidence(fileContents) -> Map<file, findings[]>` (increment 1, unchanged) plus increment 2's real verdict function, `resolveTransitProtectionForSite(site, transitEvidenceByFile)`: a literal `http://` destination is always `{verdict: 'unprotected', evidenceGrade: 'code'}`; a `crypto-tls-no-verify`/`crypto-tls-version` finding within `TRANSIT_PROTECTION_WINDOW_LINES` (10, a disclosed, uncalibrated starting value) of the site's own line OVERRIDES a literal `https://` scheme to `unprotected` — the scheme alone is never sufficient evidence of protection (AC-04's core property); a literal `https://` with no such finding is `protected`/`code`; anything else (a dynamic destination, or a non-`external-api` category) honestly returns `undefined`, leaving `emptyProtection()`'s own `not_assessed`/`none` default untouched rather than guessing. |
201
+ | `graph-builder.js` (extended, additively, increment 2) | Gained `opts.resolveTransitProtection(site) -> {verdict, evidenceGrade} \| undefined`, applied at the exact edge-construction block that already reads `site.destination` (Sub-project A) — composes into `protection: {...emptyProtection(), transit: resolved ?? emptyProtection().transit}`, writing ONLY `.transit` (`.atRest`/`.handling` stay default). Byte-identical when the hook is omitted. |
202
+ | `coverage.js` (extended, additively, increment 2) | `buildGraphWithCoverage` gained `opts.transitEvidenceByFile` — a PRE-COMPUTED `Map<file, findings[]>`, never raw file text — wiring a default `resolveTransitProtection` that closes over it, composing with a caller override exactly like `resolveDestination`'s own precedent. |
203
+ | `index.js` (extended, additively, increment 2) | `scanTransitEvidence` is now called EXACTLY ONCE per `buildLineageGraph` call — its result feeds both the pre-existing `transitEvidence` return field (increment 1) AND the new `opts.transitEvidenceByFile` passed to `buildGraphWithCoverage`, the same `Map` reference. This single-computation discipline is load-bearing (a second call inside `coverage.js` would double-scan every file) and is proven live by a `Proxy`-based call-count test, not just architected. `graph` is **no longer** byte-identical when `opts.fileContents` is supplied — a network edge's `protection.transit` can now genuinely change with the evidence given, a real, disclosed, intended behavior change from increment 1. |
204
+ | `engine.js` (extended, additively, increment 1) | One-line addition: `runFullScan`'s own `buildLineageGraph(callGraph, {...})` call site passes its already-in-scope `fileContents` parameter through. |
205
+ | `DESIGN_TRANSIT_PROTECTION.md` | §1-§5 (increment 1): the isolation decision, the `fileContents` plumbing path, and the candidate "network category" list (`external-api`/`webhook`/`email`/`sms`/`push-notification`/`analytics`/`monitoring`/`collaboration`/`ai-*`, derived from `sink-registry.js`'s `CATEGORY_NODE_KIND`). §6 (increment 2): the CORRECTED hook point (`graph-builder.js` directly, not a pure post-pass — a node's own `location` is unconditionally `null` per `DESIGN_GRAPH_BUILDER.md` §6.1, so per-site file/line exists only on the `site` object, never on the minted graph node), the window constant and its rationale, the `external-api`-only filter (the wider list remains named, not yet applied), and the full decision table. |
206
+ | `test/lineage/transit-protection.test.js` (extended, 18 tests total) | Increment 1's 7 (real/clean fixture proofs, malformed-input handling, byte-identical `graph`, reuse boundary) plus increment 2's 11: AC-03 (`B2/1`), AC-04 — the scheme-override-by-nearby-finding proof (`B2/2`), the protected case (`B2/3`), the dynamic-destination case (`B2/4`), the non-`external-api`-category case (`B2/5`), the byte-identical-when-omitted proof (`B2/6`), override composition (`B2/7`), the live single-scan-count `Proxy` proof (`B2/8`), defensiveness (`B2/9`), the window-boundary edge case (`B2/10`), and an unrelated-`family` exclusion (`B2/11`). `test/lineage/coverage.test.js`'s own reuse-boundary test (`C1/10`) was updated to include the new `transit-protection.js` import — a necessary consequence, not a workaround. |
207
+
208
+ **Milestone 2, Sub-project C ("at-rest protection analyzer," FR-402), increment 1 (application-layer evidence, `edge.protection.atRest`) — C2/C3 (genuinely new storage/IaC and database-column detection) remain unstarted:**
209
+
210
+ | Module | Responsibility |
211
+ |---|---|
212
+ | `docs/superpowers/plans/2026-08-31-data-flow-explorer-m2-subproject-c-scoping.md` | Corrects the parent M2 scoping doc's "largest genuinely-new-detection... Very Large" framing for ONE of C's three named evidence sources: the application-layer "an `encrypt` call sits directly on the path to a store write" evidence is ALREADY computed by `handling-analyzer.js`'s `classifyHandling` (Milestone 2, Sub-project D, increment 1) as `flow.handling === 'encrypted'` — no new detection needed, only wiring. Confirms FR-402's own anti-pattern guard ("a cipher present anywhere in the same file/repository cannot alone establish protection") holds BY CONSTRUCTION, since `classifyHandling` only ever walks a flow's own real reconstructed path, never file-wide text. The storage/IaC-config and database-column evidence sources remain genuinely new, unbuilt detection (independently reconfirmed: no SAST module anywhere in this codebase emits CWE-311/312), named as separate, deferred C2/C3 increments, not attempted. |
213
+ | `graph-builder.js` (extended, additively) | The flow-construction loop's existing `classifyHandling(p, callGraph)` call (previously used only for `flow.handling`) is now reused — never called twice — to also gate a new check: `handlingResult === 'encrypted' && snk.kind === 'store'` (the `CATEGORY_NODE_KIND` set `database`/`file`/`object-storage`/`cache`/`client-storage`/`backup`/`export` — `queue` excluded, its own distinct kind) looks up the already-minted edge via `edgesById.get(edgeIdStr)` and sets `edge.protection.atRest = {verdict: 'protected', evidenceGrade: 'code'}` in place — mirroring Sub-project D increment 2's own `transformsById.get(tid).appliesToAllPaths = ...` "mutate an already-minted entity in a later pass" precedent, not a new pattern. No new module, no new opts-hook — the evidence and its target are already co-located in one loop iteration. |
214
+ | `DESIGN_HANDLING_ANALYZER.md` (extended) | New §7 documenting this second consumer of `classifyHandling`'s output. |
215
+ | `test/lineage/at-rest-protection.test.js` (new, 11 tests) | Real-parsed-code proofs via `buildGraphWithCoverage`: the positive encrypt-then-store case (`flow.handling` and `edge.protection.atRest` agree, both from the ONE `classifyHandling` call); the negative no-encryption case; **the anti-pattern-guard proof** (an unrelated `encrypt()` call elsewhere in the same function does NOT protect a different, unencrypted write to the store — this increment's single most important test, proving FR-402's own explicit requirement live); a non-`store`-kind sink case; a sweep of every OTHER reachable `HANDLING_VALUES` member (`masked`/`redacted`/`hashed`/`tokenized`/`unknown`/`raw`) on a store sink, proving only `'encrypted'` triggers this logic; and a completeness-accounting test explicitly documenting `'aggregated'` as unreachable from any real `classifyHandling` output (cross-referenced to `handling-analyzer.test.js`'s own pin), rather than silently omitting it from the sweep. |
216
+
217
+ **Milestone 2, Sub-project G ("scoped policy verdict," FR-408, closes AC-09), increment 1 (`flow.policyVerdict` — COMPLETE, single increment):**
218
+
219
+ | Module | Responsibility |
220
+ |---|---|
221
+ | `docs/superpowers/plans/2026-08-31-data-flow-explorer-m2-subproject-g-scoping.md` + `…-g1-plan.md` | CONFIRMS (does not correct) the parent M2 scoping doc's framing: `dataflow/privacy-sink-policy.js`'s `isSinkPermitted`/`permittingRules` are pure, vocabulary-agnostic functions (plain string equality on `sink`, no hardcoded vocabulary check) — reusable UNMODIFIED against this package's own `SINK_CATEGORIES`/`LINEAGE_DATA_CLASSES` vocabularies with zero translation, mirroring `dataflow/privacy-taint.js`'s own real usage precedent exactly. Resolves the one real open design question (`not_evaluated` vs `prohibited` vs `permitted`) against `privacy-sink-policy.js`'s own documented deny-by-default philosophy. |
222
+ | `index.js` (extended, additively) | Loads the operator's privacy sink policy EXACTLY ONCE per `buildLineageGraph` call (mirroring `scanTransitEvidence`'s own single-computation discipline), gated on an EXPLICIT `fs.existsSync` check against `statePath(opts.scanRoot, 'privacy-policy.json')` — never inferred from `loadPrivacySinkPolicy`'s own return value alone, since that function deliberately returns the identical `{allow: []}` shape whether the file is missing, malformed, or genuinely present-but-empty, which would otherwise collapse `not_evaluated` and `prohibited` into one state. `opts.privacySinkPolicy` therefore stays genuinely `undefined` unless a policy file is actually on disk. New `opts.scanRoot` param, distinct from the pre-existing `opts.repository` (a basename by the time it reaches this function, confirmed against `engine.js`'s call site — not reusable for policy-file resolution). |
223
+ | `coverage.js` (extended, additively) | No functional change — `buildGraphWithCoverage` already spread `...opts` through to `buildDataFlowGraph`, so `opts.privacySinkPolicy`/`opts.environment` thread through automatically; gained only JSDoc documenting the new fields and the `undefined`-means-"no evaluation attempted" contract `index.js` establishes. |
224
+ | `graph-builder.js` (extended, additively) | Real computed logic replacing the `flow.policyVerdict: 'not_evaluated'` §8 literal: `isSinkPermitted(de.dataClasses, snk.subtype, opts.privacySinkPolicy, ctx)` against `ctx = {environment: opts.environment \|\| process.env.AGENTIC_SECURITY_ENVIRONMENT \|\| null, destination: site.destination?.literalValue ?? null}` — `snk.subtype` (the FINE-grained `SINK_CATEGORIES` value; distinct from the coarser `snk.kind` Sub-project C1's own `atRest` gate uses). A flow whose data element has NO recognized data class stays `not_evaluated` even under a policy that would otherwise permit (a policy engine has nothing to say about a flow it was never asked to gate — `isSinkPermitted`'s own `!classes.length` early return exists for a different reason, finding-side precision, not this field). On `permitted`, mints one `graph.evidence[]` entry per distinct permitting rule (`evidenceType: 'policy'`, deduplicated by content hash via `ids.js`'s `evidenceId` — previously minted but never called by anything until this increment) and references it from `flow.evidenceRefs`. |
225
+ | `engine.js` (extended, additively) | One-line addition: `runFullScan`'s own `buildLineageGraph(callGraph, {...})` call site passes its already-in-scope `scanRoot` parameter through (distinct from the `repository` basename already threaded there). |
226
+ | `test/lineage/policy-verdict.test.js` (new, 12 tests) | Real-parsed-code proofs via `buildGraphWithCoverage`/`buildLineageGraph` end-to-end on a `req.body.email` → `analytics.track(...)` fixture (AC-09's own worked example verbatim): no-policy-at-all in three shapes (`opts` omitted, a genuinely missing `scanRoot` file, no `scanRoot`) all read `not_evaluated`; a matching allow rule reads `permitted` with a real `{sink,class,reason,environment,destination}`-bearing evidence entry; an empty or non-matching policy reads `prohibited` (deny-by-default); FR-408's environment fail-closed proof (wrong environment, and no environment at all); FR-408's destination fail-closed proof (an unresolved destination never satisfies a destination-scoped rule); the no-recognized-data-class case; and a real-file-on-disk end-to-end pair proving a present-but-empty `privacy-policy.json` reads `prohibited`, never `not_evaluated`. |
227
+
228
+ **Milestone 2, Sub-project F ("cross-boundary normalization," FR-304), increment 1 (`edge.provenance` — COMPLETE; F2/F3 remain genuinely new, unbuilt work):**
229
+
230
+ | Module | Responsibility |
231
+ |---|---|
232
+ | `docs/superpowers/plans/2026-08-31-data-flow-explorer-m2-subproject-f-scoping.md` | Corrects the parent M2 scoping doc's "reclassifies existing mechanism" framing: `cross-lang-openapi.js`/`cross-lang-grpc.js`/`cross-lang-graphql.js` (all in `posture/`, producing SAST chain-`Finding`s, never graph entities) each abstain entirely unless BOTH a code call site AND a schema/contract match already exist — none is a source of a genuinely schema-derived edge (one with no backing code call site). Confirms every edge `graph-builder.js` mints today is code-derived, 100%, with no exception. Splits FR-304's four provenance categories (`code-derived`/`schema-derived`/`manually-declared`/`runtime-corroborated`) into a small F1 (only `code-derived` has a real producer) and deferred, genuinely new F2 (bridge `cross-lang-*`'s parsed contract data into real schema-derived edges) / F3 (an operator-declared-service-graph ingestion mechanism, no existing precedent in this package). |
233
+ | `schema.js` (extended, additively) | New `EDGE_PROVENANCE_VALUES = Object.freeze(['code', 'schema', 'manual', 'runtime'])` — deliberately value-aligned with (but a distinct field from) `EVIDENCE_TYPES`, reusing its spellings rather than inventing a second vocabulary for the same four concepts. Only `'code'` has a real producer today. |
234
+ | `graph-builder.js` (extended, additively) | The edge-construction literal gains `provenance: 'code'` unconditionally, alongside `boundaryCrossings` — honest, since every edge minted here genuinely is code-derived (confirmed above), never a guess. |
235
+ | `validate.js` (extended, additively) | `_validateEdge` gains a top-level (not nested under `protocol`) `EDGE_PROVENANCE_VALUES.includes(edge.provenance)` check, mirroring `destinationResolution`'s own precedent. |
236
+ | `dataflow-graph.schema.json` / `test/lineage/json-schema-parity.test.js` | `provenance` added to the edge `$def`'s `properties` AND `required` (always-present, like `boundaryCrossings` — not optional like `protocol`); one more `assert.deepEqual` line added to the existing enum-parity test. |
237
+ | `fixtures/build-flagship-fixture.mjs` / `flagship-graph.json` | A necessary consequence of making `provenance` schema-required, not separately scoped: the committed flagship fixture and 3 hand-built `validate.test.js` "passes" fixtures would otherwise newly fail validation. The fixture's `edge()` helper now sets `provenance: 'code'`, and `flagship-graph.json` was regenerated via the builder script itself (never hand-edited). |
238
+ | `test/lineage/validate.test.js` / `graph-builder.test.js` | Real-fixture proof every `buildDataFlowGraph` edge reads `'code'`; a negative proof (unrecognized/missing `provenance` rejected); a completeness-accounting sweep proving all four `EDGE_PROVENANCE_VALUES` individually validate, including the three (`schema`/`manual`/`runtime`) no producer emits yet — the honest, pinned disclosure that F2/F3 have not shipped. |
239
+
240
+ **Milestone 2, Sub-project H ("false-protected release gate + supporting benchmark corpus"), increment 1 (`bench/protection-verdict/` — transit/atRest only — COMPLETE; `handling`'s own dimension remains genuinely open):**
241
+
242
+ | Module | Responsibility |
243
+ |---|---|
244
+ | `docs/superpowers/plans/2026-08-31-data-flow-explorer-m2-subproject-h-scoping.md` + `…-h1-plan.md` | Corrects Decision 2's own three-dimension framing (PRD Sec 14, the parent M2 scoping doc): `edge.protection.handling` is never written by any code today — `flow.handling` is a SEPARATE field with a SEPARATE vocabulary (`HANDLING_VALUES`, not `PROTECTION_VERDICTS`) — so the gate as literally specified has a permanently-zero denominator for `handling`. Scopes H1 to `transit`/`atRest` only, the two dimensions with real `protected`-asserting producers, mirroring `bench/mutation/runner.mjs`'s real scoring mechanism (independent per-case scoring against each case's own `expectVerdict`, no explicit base/mutant pairing at runtime — a correction from an earlier, tentative guess). |
245
+ | `bench/protection-verdict/runner.mjs` (new) | Builds a real `DataFlowGraph v1` document per case (`parseJsFile` → `buildCallGraph` → `buildGraphWithCoverage`, in-memory only — no temp directory, no `runScan`, since this corpus needs the lineage pipeline directly rather than a full SAST scan), reads `edge.protection[dimension]` off the edge whose destination node kind matches the case's own dimension (`'external'` for transit, `'store'` for atRest — the selector that makes the anti-pattern case work with no special-case code). 10 cases: transit baseline+2 metamorphic+2 adversarial (`http://`, and `https://` with a nearby TLS-verification-disabled finding); atRest baseline+2 metamorphic+2 adversarial (no encryption at all, AND — the single most important case, mirroring `at-rest-protection.test.js`'s own `C1/3` — an encrypt call present in the same function but NOT on the path to the store write, which must NOT read `protected`). `evidenceGrade`-overstatement cases (Decision 2's second numerator clause) are deliberately NOT exercised — confirmed directly that neither `transit-protection.js` nor the inline `atRest` block can ever emit anything but `evidenceGrade: 'code'` on a `protected` verdict, so that half of the gate has the same permanently-zero-denominator problem as `handling`, disclosed in the runner's own header rather than worked around with a synthetic producer. |
246
+ | `scanner/package.json`, `scripts/release-check.mjs`, `scripts/pre-push-gate.mjs` | `bench:protection-verdict:check` wired into BOTH gates alongside `mutation-gate` (measured ~0.15s, well within the pre-push budget) — a real, deliberate regression proof (temporarily disabling the transit TLS-override logic, then the atRest store-kind guard) confirmed the gate genuinely fails when either producer breaks, then both were reverted before commit. Four pre-existing pinned CHECKS-list tests (`pre-push-gate.test.js`'s ordered-id list; `release-check.test.js`'s total-check-count, slow-check-count, and slow-checks-list assertions) updated to include the new entry. |
247
+
248
+ **Milestone 2, Sub-project I ("exit-gate closure"), increments 1-3 — COMPLETE:**
249
+
250
+ | Module | Responsibility |
251
+ |---|---|
252
+ | `docs/superpowers/plans/2026-08-31-data-flow-explorer-m2-subproject-i-scoping.md` + `…-i1-plan.md` | Corrects the parent M2 scoping doc's "verification and cleanup, not new engine capability" framing: direct investigation found `flow.protectionSummary` hardcoded `'not_assessed'` unconditionally, never computed — directly violating PRD line 909 ("must be derived from the individual edge verdicts, never stored as an unsupported independent claim"), not just AC-12's own prose. AC-06/AC-07 are confirmed satisfied as-is (PRD line 1495 explicitly sanctions `not_assessed` as equivalent to `unknown` for missing evidence). AC-08 held structurally but had no dedicated proof. The i1-plan doc's own "IMPORTANT CORRECTION" section records a load-bearing design nuance found before implementation: naively aggregating one edge's `transit`/`atRest`/`handling` with `protection.js`'s `aggregateVerdicts()` (designed for CROSS-BRANCH, same-dimension aggregation, per its own PRD-§8.4-quoted comment) risks a protected dimension masking a genuinely-unassessed one — safe today only because `transit` (`external-api`-gated) and `atRest` (`store`-gated) are mutually exclusive by construction and `handling` is never written — an implicit, fragile assumption now disclosed in both the plan and the code comment, with a mandatory regression test. |
253
+ | `graph-builder.js` (extended, additively) | The `flowsById.set(fId, {...})` mint site's `protectionSummary: 'not_assessed'` literal is replaced with `aggregateVerdicts([flowEdge.protection.transit.verdict, flowEdge.protection.atRest.verdict, flowEdge.protection.handling.verdict])` — a real, honest per-flow summary, computed once the flow's own edge already carries its final transit/atRest verdicts for this iteration. Two fragilities are disclosed directly in the code comment, not just the plan: the cross-dimension-vs-cross-branch precedence mismatch above, and a second, separate one found while writing I2's own tests — `PROTECTION_VERDICTS` includes `'not_applicable'`, which is NOT a `FLOW_SUMMARY_VALUES` member, so a future analyzer that ever sets a dimension's verdict to `'not_applicable'` could make `flow.protectionSummary` fail `validateGraph` (no producer does this today, confirmed). |
254
+ | `test/lineage/protection-summary.test.js` (new, 5 tests) | The core I1 proof: a real protected transit verdict flowing into `protectionSummary`; a real unprotected one; the honest `not_assessed` default when nothing evaluates any dimension; **AC-12's own worked example** — two sibling flows to the same store, one via a real encrypt-then-store path, one bare, each correctly reporting its OWN `protectionSummary` (`protected` vs. `not_assessed`, never a shared false positive) — the achievable, per-flow slice of AC-12's property, deliberately not a cross-branch `'mixed'` aggregate (out of scope, undecided, named as such in the scoping doc); and the corrected, narrower claim about which `PROTECTION_VERDICTS` members are also valid `FLOW_SUMMARY_VALUES` members (a first-draft assumption that ALL of them were was wrong, caught by the test itself failing before being narrowed to the true claim). |
255
+ | `test/lineage/at-rest-protection.test.js` (extended, +4) | AC-06 (PHI written unencrypted to a store — `atRest` AND `protectionSummary` both stay honestly `not_assessed`, never `protected`, matching PRD line 1495's own "missing evidence → unknown OR not_assessed" sanction); AC-08 (an `ai-model-provider` sink with no classified field reaching it stays visible with zero flows claiming a PII/PHI/PCI reach — mirrors AC-11's own disconnected-node mechanism, applied to an AI sink for the first time); the mutual-exclusivity regression guard the I1 code comment requires — a hand-built `aggregateVerdicts` call proving the pessimistic verdict wins if two dimensions are ever simultaneously non-default on one edge, a shape no real producer creates today. |
256
+ | `test/lineage/ac01-multi-sink.test.js` (updated) | A necessary consequence, not separately scoped: this fixture's own `fetch('https://...')` call now correctly produces a real `protected` transit verdict once I1 lands, so its pre-existing "every flow stays `not_assessed`" assertion (a genuine Milestone-1-era fact) became stale. Updated to assert the CORRECT current behavior — the external-api flow reads `protected`, the log/database flows stay `not_assessed` (neither applicable dimension has evidence for them). |
257
+
258
+ **Milestone 2 exit-gate status.** The PRD's own Milestone 2 exit gate (line 1809): *"AC-03 through AC-09 and AC-12 pass; false-protected release gate passes."* As of 2026-09-01, every named acceptance criterion has a real, verified proof against actual `buildGraphWithCoverage` output on JS/TS:
259
+
260
+ | AC | Proof | Where |
261
+ |---|---|---|
262
+ | AC-03 (cleartext external call) | a literal `http://` destination reads `unprotected`/`code` | `test/lineage/transit-protection.test.js` (`B2/1`); `bench/protection-verdict/runner.mjs`'s `adversarial-transit-http-scheme` |
263
+ | AC-04 (HTTPS + verification disabled) | a nearby TLS-disable finding overrides a literal `https://` scheme to `unprotected` | `transit-protection.test.js` (`B2/2`); `bench/protection-verdict/runner.mjs`'s `adversarial-transit-tls-verify-disabled` |
264
+ | AC-05 (dynamic destination) | `fetch(url)` with a computed `url` produces an `unresolved`-kind node with `resolutionStatus`/blocking expression | `test/lineage/coverage.test.js` (`C1/3d`, unit-level); `test/lineage/graph-builder.test.js` (end-to-end, real fixture); `test/lineage/flagship-fixture-semantics.test.js` (the committed reference graph's own sink node) |
265
+ | AC-06 (database encryption unknown) | PHI written unencrypted to a store reads the honest `not_assessed` default (PRD line 1495 sanctions this as equivalent to `unknown` for missing evidence) — never `protected` | `test/lineage/at-rest-protection.test.js`'s new `AC-06` test |
266
+ | AC-07 (AI + regulated data) | `patient_record` (PHI) reaches `anthropic.messages.create()`; the model-provider path structurally carries a `network protection` field (present, honestly `not_assessed` for this fixture's own receiver-keyed SDK call shape — no literal URL to resolve, PRD line 1495's own sanction applies) | `bench/data-lineage/fixtures/js-ai-model-output-to-ai-model-provider-phi/` (Milestone 1's own AC-07 closure) |
267
+ | AC-08 (AI presence without sensitive flow) | an `ai-model-provider` sink with nothing classified reaching it stays visible, zero flows claim a PII/PHI/PCI reach | `test/lineage/at-rest-protection.test.js`'s new `AC-08` test |
268
+ | AC-09 (policy-permitted flow) | a matching `privacy-policy.json` allow rule reads `permitted` with real `{sink,class,reason,environment,destination}` evidence | `test/lineage/policy-verdict.test.js` (Sub-project G1, 12 cases) |
269
+ | AC-12 (alternate path control gap) | two sibling flows to the same store, one via a real encrypt-then-store path, one bare — each flow's own `protectionSummary` honestly diverges (`protected` vs. `not_assessed`), never a shared false-protected claim; a transform on one branch never grants `appliesToAllPaths: true` when a sibling flow lacks it | `test/lineage/protection-summary.test.js`'s `I1/4`; `test/lineage/at-rest-protection.test.js`'s `C1/3` anti-pattern guard; `transformation.appliesToAllPaths` (Sub-project D2) |
270
+ | False-protected release gate | transit + atRest verdict-flip corpus (10 cases: baseline/metamorphic must hold `protected`, adversarial must not) | `bench/protection-verdict/runner.mjs` (Sub-project H1), wired into both `release-check.mjs` and `pre-push-gate.mjs` |
271
+
272
+ **What this does NOT mean:** C2/C3 (storage/IaC and database-column at-rest detection) remain genuinely new, unbuilt detection — AC-06's own proof rests on the PRD's own explicit sanction for an honest `not_assessed`/`unknown` answer when that evidence is missing, not on C2/C3 having shipped. F2/F3 (schema-derived edge bridging, declared-service-graph ingestion) remain unbuilt — no exit-gate AC depends on either. The `handling` dimension of Sub-project H's own false-protected gate remains genuinely open (no code writes `edge.protection.handling` at all yet). Sub-project A's remaining destination-resolution increments (constant-folding, config-chain resolution, schema correlation, runtime corroboration, AI-provider/model resolution) are still open. A cross-branch/coarse-group-level `'mixed'` protection aggregate (as opposed to each sibling flow's own honest per-flow summary) remains undecided, deliberately not invented by I1. None of these block the Milestone 2 exit gate as literally worded (PRD line 1809) — they are real, disclosed, separately-scoped future work.
273
+
274
+ **Milestone 2 status note**: Sub-project D (FR-403 taxonomy + FR-307 control-credit) is fully complete. Sub-project E is fully complete (increments 1-3). Sub-project B (transit protection) is functionally complete for its exit-gate scope — closes AC-03/AC-04 for real. **Sub-project C (at-rest protection) has its application-layer evidence source done** (`edge.protection.atRest` now carries real `protected` verdicts for encrypted fields reaching a store) — C2 (storage/IaC encryption config detection) and C3 (database column encryption config) remain genuinely new, unbuilt detection, each needing its own dedicated scoping pass. **Sub-project G (scoped policy verdict) is fully complete** — closes AC-09 for real. **Sub-project F increment 1 (`edge.provenance`) is complete** — closes only FR-304's first clause ("identify whether an edge is code-derived...") — F2 (bridging `cross-lang-*`'s schema parsers into real schema-derived edges) and F3 (declared-service-graph ingestion) remain genuinely new, unbuilt work, each needing its own future scoping pass; no exit-gate AC depends on either. **Sub-project H increment 1 (the false-protected release gate for `transit`/`atRest`) is complete** — `handling`'s own dimension remains genuinely open (the scoping doc's own (a)/(b) fork: wait for a real `edge.protection.handling` producer, or reframe around `HANDLING_VALUES`' own vocabulary — neither decided). **Sub-project I (exit-gate closure) is fully complete** — every AC-03 through AC-09 plus AC-12 named in the Milestone 2 exit gate's own wording has a real, verified proof, and the false-protected release gate passes; see the exit-gate status table above. External destination resolution (Sub-project A) has only increment 1 done (literal/dynamic resolution only — hostname/port/route extraction, constant-folding, config-chain resolution, schema correlation, and AI-provider/model resolution are all still open, per `DESIGN_DESTINATION_RESOLVER.md`'s own deferred list). **Milestone 2's own exit gate (PRD line 1809) is satisfied as literally worded** — C2/C3, F2/F3, H's own `handling` dimension, and Sub-project A's remaining increments are real, disclosed, non-blocking future work, not part of the exit gate's own text.
275
+
276
+ ## Milestone 1 exit-gate status
277
+
278
+ The PRD's own Milestone 1 exit gate (§26, line 1796): *"AC-01, AC-02, AC-07,
279
+ AC-11, and schema completeness pass on the supported-language corpus."* As of
280
+ 2026-08-31, **all four acceptance criteria have a real, verified proof**
281
+ against actual `buildGraphWithCoverage` output on JS/TS (the only supported
282
+ language per §22.1's own recommended implementation order — Python/Java/C#/
283
+ Go/Kotlin/Ruby/PHP lineage support has not been attempted):
284
+
285
+ | AC | Proof | Where |
286
+ |---|---|---|
287
+ | AC-01 (PCI to multiple sinks) | `req.body.card_number` reaching log/database/external-api as 3 distinct flows | `test/lineage/ac01-multi-sink.test.js` — a direct unit test, NOT a corpus fixture (F1's `runner.mjs` scoring contract asserts one sourceCategory/sinkCategory pair per fixture and cannot express "one field, three sinks" — extending it is real, undecided scope, deliberately not improvised) |
288
+ | AC-02 (masked vs. raw log differ) | `maskCard()` before `logger.info()` vs. the same field logged raw | `bench/data-lineage/fixtures/js-api-to-log-masked/` + `js-api-to-log-raw/`, scored by `runner.mjs` |
289
+ | AC-07 (AI + regulated data) | `patient_record` (PHI) reaching `anthropic.messages.create()` | `bench/data-lineage/fixtures/js-ai-model-output-to-ai-model-provider-phi/`, closed by Sub-project H's catalog bridge (4 new `dataflow/catalog.js` sink entries + 1 `sink-registry.js` `CWE_MAP` row) |
290
+ | AC-11 (disconnected sources/sinks stay visible) | a matched log sink nothing reaches, still present with a `coverageReason` | `bench/data-lineage/fixtures/js-api-to-log-disconnected/` |
291
+ | Schema completeness | `validateGraph()` returns zero errors | proven repeatedly — every fixture above, every `test/lineage/*.test.js` file, D5's own 24-category real-code proof set |
292
+
293
+ **What this does NOT mean:** the PRD's separate §22.2/§22.3 "benchmark
294
+ expansion"/"release thresholds" requirement (a 100+/100+-entry corpus with
295
+ measured precision/recall ≥ 90%/85%) is a LARGER, ongoing target, not the
296
+ exit gate's own literal wording — `bench/data-lineage/` currently has 24
297
+ fixtures (17 passing, 7 honestly disclosed `capability`-tier gaps — see that
298
+ directory's own README), well short of the 100+/100+ floor. Sub-project F's
299
+ remaining increments continue growing the corpus toward that floor as
300
+ ongoing work; they are not what's blocking Milestone 1's own exit gate as
301
+ literally worded. AC-01's own per-sink "handling/transit/at-rest verdict"
302
+ clause, and AC-07's "provider, model when known" clause, are both explicitly
303
+ Milestone 2 work (protection verdicts and external destination resolution)
304
+ — proven here only as an honest `not_assessed`/absent placeholder, never a
305
+ fabricated verdict.
306
+
307
+ ## What is NOT here yet (later milestones / later sub-projects)
308
+
309
+ - **Milestone 1, Sub-projects E through H are now all complete — Milestone 1
310
+ itself is COMPLETE as of 2026-08-31.** A `DataFlowGraph v1` document IS
311
+ wired into a real scan run: `AGENTIC_SECURITY_LINEAGE_DEEP=1` makes
312
+ `runFullScan` build it via `buildGraphWithCoverage`, attach it to the scan
313
+ result, report its status on `scanHealth.lineageAnalysis`, and (through the
314
+ CLI) persist it as a signed, right-to-delete-registered artifact at
315
+ `.agentic-security/lineage-graph.json`. Env vars documented at
316
+ `docs/reference/configuration.md`. **F** (JS/TS benchmark corpus,
317
+ `bench/data-lineage/`) shipped 24 fixtures (17 passing, 7 honestly
318
+ disclosed `capability`-tier gaps), growing toward the separate §22.2
319
+ 100+/100+ floor as ongoing, non-blocking work. **G** (comparison report +
320
+ perf harness) shipped `bench/privacy-recall/compare-lineage.mjs` +
321
+ `docs/lineage/PRIVACY_COMPARISON.md`, and extended
322
+ `bench/data-lineage/perf/runner.mjs` to time `buildGraphWithCoverage`
323
+ overhead against PRD §21's "≤35% p50 over the equivalent deep scan"
324
+ target (measured well under target, negative on the reference fixture).
325
+ **H** (exit-gate closure) is this section: AC-01/AC-02/AC-07/AC-11 +
326
+ schema completeness all have real, verified proofs — see the table above.
327
+ AC-07 was NOT left unsatisfiable: the AI-model-provider catalog bridge
328
+ (4 new `dataflow/catalog.js` sink entries + 1 `sink-registry.js` `CWE_MAP`
329
+ row, with a real over-firing precision bug found and fixed via
330
+ `receiverBase` constraints — see `test/catalog-ai-model-provider-precision.test.js`)
331
+ closed it for real. See
332
+ `docs/superpowers/plans/2026-08-30-data-flow-explorer-m1-lineage-engine-scoping.md`
333
+ for the original sub-project breakdown. The plurality tie-break in
334
+ multi-candidate sink resolution remains arbitrary-but-disclosed (§4.3),
335
+ not a Milestone 1 exit-gate blocker — carried forward as a known,
336
+ disclosed limitation, not solved by anything in E-H.
337
+ **Sub-project D (D1-D5) is now fully complete.** The SOURCE registry
338
+ (D2, `source-registry.js`), the SINK registry (D3, `sink-registry.js`),
339
+ transformation-kind recognition (D4, `transform-catalog.js`), and the
340
+ exit-gate closure (D5, `test/lineage/registry-real-code.test.js` — all
341
+ 24 currently-reachable source/sink categories plus AC-02 now have a
342
+ real-parsed-code proof, per `DESIGN_REGISTRIES.md` §9.2's corrected
343
+ criterion) are all done — see the module tables above. What D4 does NOT
344
+ do, deliberately: it classifies a callee PATTERN, never a call site, so
345
+ input/output access paths, code location, "applies on all feasible
346
+ paths", key-management evidence and any argument-borne algorithm are
347
+ all left to a future Sub-project E graph builder; and it never emits a
348
+ control-credit field of any kind, granted or denied, per the parent
349
+ scoping doc's Decision 2 (awarding verdict credit from a recognized
350
+ transform stays `not_assessed` until Milestone 2's FR-401-405
351
+ analyzers). D5 does not wire any registry into `runScan`, does not
352
+ build a graph, and does not touch `path-store.js`/`path-query.js`/
353
+ `driver.js` — all still Sub-project E's job.
354
+ Interprocedural summaries (Sub-project B) are now **complete** —
355
+ see the module table above; all six increments (B1-B6: summary
356
+ cache core, real `call`-case integration, real call-graph
357
+ integration, the project-wide two-phase driver, recursion
358
+ refinement, context-sensitivity tuning) are done, per
359
+ `docs/superpowers/plans/2026-08-30-data-flow-explorer-m1-subproject-b-scoping.md`.
360
+ The path DAG (Sub-project C) increments 1-3 are now **complete** —
361
+ increment C1 (design spike + a 4-site proof-of-concept), C2 (full
362
+ intraprocedural instrumentation, every case in both
363
+ `resolveExprIdentities` and `step()`, per `DESIGN_PATH_PROVENANCE.md`
364
+ §10), and C3 (interprocedural stitching + the context-join fix C1
365
+ disclosed as its precondition — the three `ctx`-threading holes closed,
366
+ the `context`/`peerScope`/`peerContext` fields, the `call-arg-bind` hop,
367
+ and B5/B6 degradation marking, per `DESIGN_PATH_PROVENANCE.md` §13/§13.7)
368
+ are all done, above; a cross-function hop is now recorded by shipped code
369
+ whenever a recorder is attached. C4 (`path-store.js`, the compact
370
+ provenance DAG structure itself, per `DESIGN_PATH_PROVENANCE.md` §14) is
371
+ now also **complete** — see the module table above. C5 (`path-query.js`,
372
+ bounded backward reconstruction + truncation semantics, per
373
+ `DESIGN_PATH_PROVENANCE.md` §15) is now also **complete** — see the
374
+ module table above. C6 (FR-306 edge grading, `flow-grade.js`), per
375
+ `docs/superpowers/plans/2026-08-30-data-flow-explorer-m1-subproject-c-scoping.md`,
376
+ is now also **complete** — see the module table above. This closes out
377
+ Sub-project C (C1-C6) in full. Two things a reader of §16 must not miss:
378
+ a genuine `widenReason` can live ONLY in `edge.annotations[]` with the
379
+ edge's own `widenReasons` empty — measured on three ordinary fixtures,
380
+ so a grader reading only the top-level arrays violates FR-306 outright
381
+ (§16.5) — and `path-query.js`'s own
382
+ `Path.widenedHopCount`/`lossHopCount`/`shape` inherited exactly that
383
+ blind spot (§16.7 Finding 1), now **closed**: `materialize()` reads
384
+ `hop.annotations[]` too, verified to break no existing test, and
385
+ deliberately NOT pushed down into `path-store.js`, whose
386
+ `edge.widenReasons` sits in `provenanceEdgeId`'s discriminator. **A
387
+ third thing, binding on whoever scopes Sub-project D, not just a
388
+ reader of §16**: a §13.6
389
+ context-cap-degraded binding edge's target is unreachable from
390
+ `sinkCandidates()` today (its node has zero out-edges and is neither a
391
+ `return`/`escape`/`loss` kind), so a normal sink-rooted reconstruction
392
+ never surfaces it at all — §18.4's own "truncation must never look like
393
+ no-flow" constraint re-opening at the query boundary (§16.7 Finding 2).
394
+ Grading handles it correctly if reached directly; nothing in a
395
+ sink-rooted query asks. Sub-project D's own scoping doc must carry this
396
+ forward explicitly (a candidate "truncation-terminal" node shape is
397
+ sketched in §16.7), not leave it to be rediscovered.
398
+ - External destination resolution — Sub-project A, increment 1 now covers
399
+ `'literal'`/`'dynamic'` (see the module table above); constant-folding,
400
+ config-chain resolution, schema correlation, runtime corroboration, and
401
+ AI-provider/model resolution are all still NOT here — named explicitly in
402
+ `DESIGN_DESTINATION_RESOLVER.md`'s own deferred list. Database/queue field
403
+ mapping, transit/at-rest/handling ANALYZERS (this package only defines the
404
+ verdict *model*, not what decides a verdict) — still Milestone 2, not
405
+ started.
406
+ - The local API/server and any UI — Milestone 3.
407
+ - Decision-intelligence extensions (stories, scenarios, snapshots/diffs,
408
+ obligations, runtime twin, impact/remediation) — Milestones 4/5. (This
409
+ paragraph predates several since-shipped Milestone 4 sub-projects —
410
+ stories/obligations/snapshots-diffs and recipients (FR-506, see that
411
+ section above) are now COMPLETE; scenarios/runtime-twin/impact-remediation
412
+ remain genuinely not started.)
413
+
414
+ **Addendum (Sub-project G, increment G1, 2026-08-31):** this package's
415
+ `classifyDataElementName` (`source-seeding.js`) classifies by the SOURCE
416
+ EXPRESSION'S OWN field name at the seed, and never inspects a downstream
417
+ declared-variable name the way `dataflow/privacy-taint.js`'s
418
+ `classifyFieldAgainst(d.name, compiled)` does — measured live against
419
+ `bench/privacy-recall/`'s 4 fixtures via
420
+ `bench/privacy-recall/compare-lineage.mjs`. Disclosed, deliberate scope
421
+ boundary, not a bug; see `docs/lineage/PRIVACY_COMPARISON.md` for the full
422
+ measurement and root-cause write-up.
423
+
424
+ ## Milestone 4 (JSON/CSV export)
425
+
426
+ | Module | Responsibility |
427
+ |---|---|
428
+ | `redact-graph.js` | Shared redaction logic (`_redactGraph`/`_redactNode`/`_redactEvidence`), extracted so both the JSON exporter, the MCP dataflow tools, and any future exporter apply the identical secret-scrubbing pass rather than each carrying its own drifting copy. Covers `node.destination.raw`/`.literalValue`/`.blockingExpression`, `node.queueDetail.topic`, `node.storeDetail.table`/`.columns`, and `evidence[].claim`/`.snippet`/`.location.note` — the whole-branch review that shipped this Milestone-4 sub-project found `blockingExpression`/`queueDetail.topic` missing from the first cut (a real, live-reproduced redaction bypass: `resolveDestination()` sets `blockingExpression` to the identical string as `raw` for a dynamic resolution, so the first cut redacted one and left the other verbatim on the same object). Re-verify this field list against the real graph pipeline before trusting it again if you touch this module — see its own header comment for the full citation trail. |
429
+ | `export-json.js` | `exportGraphJSON(graph, opts)` / `computeGraphDigest(graph)` — deterministic JSON export of a `DataFlowGraph v1` document (default-redacted, tamper-evident sha256 digest of the SOURCE graph, `opts.filter`/`opts.redact`). See its own header comment for the full filter-narrowing rule (flows/dataElements narrow by referential soundness, not mere node membership). |
430
+ | `export-csv.js` | `exportFlowsCSV(graph)` — one CSV row per FLOW (not node, not edge). Per-dimension `transitVerdict`/`atRestVerdict`/`handlingVerdict` columns are each `aggregateVerdicts()` over that flow's own `edgeIds[]`' `protection.<dimension>.verdict`s. Multi-value `dataClasses` join with `;` (not `,`, which is CSV's own delimiter) — see the module's own header comment for the full reasoning. |
431
+ | `export-privacy.js` (M4 deliverable #10, DFG-020) | `emitGraphDpiaArtifact(graph, opts)` / `emitGraphRopaArtifact(graph, opts)` — graph-derived DPIA/RoPA Markdown export, migrating off the Layer-2 taint engine's `dataflow/privacy-taint.js#emitDpiaArtifact`/`dataflow/privacy-governance.js#emitRopaArtifact` (name-in-argument `piiFields` classification, no path/alias/field-mapping precision) onto the real `DataFlowGraph v1`'s field-identity-tracked flows. Both accept the same `opts.filter` (`{nodeIds, edgeIds}`) shape every other `dataflow export` format uses, filtering the GRAPH itself via `export-json.js`'s `_filterGraph` (now exported) BEFORE calling `computePrivacyViewModel` — never threaded into that function's own `state.filters`, a different, per-facet shape that would silently no-op. Governance fields are read off `flow.governanceRefs` (populated by `resolveGovernanceRefs`, `coverage.js`) and are never fabricated: every value is either operator-supplied (`.agentic-security/privacy-governance.json`) or the literal `MANUAL_REQUIRED` sentinel from `dataflow/privacy-governance.js`. **First live `scanner/` → `frontend/` module import in this codebase**: this module imports `computePrivacyViewModel` directly from `frontend/src/views/privacy-view.js` for row computation, rather than re-deriving lifecycle-stage/dataClass/protection-verdict logic a second time — confirmed safe because that module only touches `document`/`window` inside `renderPrivacyView`'s own function body, never at module top level, so importing it from plain Node never executes any DOM code. Wired into the CLI at `dataflow export --format dpia|ropa` (`bin/agentic-security.js`'s `cmdDataflowExport`), mirroring the `json`/`csv` branches exactly — not view-scoped, and `--no-redact` is a documented no-op for both formats (neither calls `exportGraphJSON`'s own redaction path), same precedent as `csv`. Both emit functions escape every interpolated value through `_mdInline`/`_mdCell`/`_mdCode` (this file's own local helpers) before writing it into Markdown — an unescaped `\|` or embedded newline in an operator-supplied governance value would otherwise corrupt the RoPA table's column alignment or inject arbitrary Markdown mid-document; a flow spanning more than one data class gets an explicit disclosure note rather than silently re-presenting its one merged (worst-case-wins, `coverage.js#resolveGovernanceRefs`) governance record as if it were specific to each class shown. **Packaging gap, disclosed not fixed**: the `frontend/src/views/privacy-view.js` import above resolves outside the published `scanner/` npm package (a repo-root sibling, not a subdirectory), confirmed via a real `npm pack` + install into a fresh consumer project — harmless for the documented `agentic-security`/`as` commands (ncc already inlines that content into `dist/agentic-security.mjs`'s own chunks at build time) but a real `ERR_MODULE_NOT_FOUND` if someone runs the raw, unbundled `bin/agentic-security.js` straight out of an installed package's `node_modules`; `cmdDataflowExport`'s catch block gives that one case an actionable message instead of a bare stack trace. Fixing it for real (vendoring `frontend/src/` into `scanner/` at build time, or similar) was judged out of proportion to a non-blocking, narrow-impact gap and left for a future pass if it ever bites a real user. |
432
+ | `chrome-probe.mjs` | Chrome/Chromium binary discovery, available as an optional local tool — `probeChromeAvailable()` returns `{ok:true, chrome:'/path/to/binary'}` on success or `{ok:false, reason:string}` when no binary is found (matching `parser-py-cst.js`'s own established `probePythonAvailable()` pattern for graceful degradation when an optional tool is missing). Probes PATH first, then platform-specific locations (macOS `.app`, Windows `Program Files`, etc.); respects `AGENTIC_SECURITY_CHROME_PATH` as an authoritative override, failing explicitly when an operator-specified binary doesn't work rather than silently falling back. Consumed by `export-image.mjs` to render the Data Flow Explorer HTML report to static image formats. |
433
+ | `export-image.mjs` | **Located in `scripts/` (not `src/lineage/`), consumed by a future Milestone 4 CLI wiring (sub-project #5, not yet built).** `exportPng(graph, opts)` / `exportPdf(graph, opts)` / `exportSvg(graph, opts)` — deterministic image export of a `DataFlowGraph v1` document. Each function builds on the already-shipped HTML report generator (`generate-html-report.mjs`), writes it to a temporary file (cleaned up in a `finally` on every exit path — success, Chrome failure, or thrown exception), opens it in headless Chrome via `probeChromeAvailable()`, and captures the rendered output (PNG via `--screenshot`, PDF via `--print-to-pdf`, SVG via DOM extraction — confirmed exactly one real `<svg>` element exists in the rendered page, so extraction is a plain string search, not a disambiguation problem). Returns `{ok:true, data:Buffer}` on success (the in-memory image/PDF/SVG bytes — NOT a file path; the temp file is already deleted by the time this returns) or `{ok:false, reason:string}` when Chrome is unavailable or rendering fails. Zero new npm dependencies; all rendering is delegated to the browser itself. Determinism confirmed empirically (byte-identical PNGs across repeated runs of the same graph), not merely asserted. |
434
+
435
+ ## Milestone 4, Regulatory Obligation Overlay (FR-504 §7.12), sub-project 6a (extension contract) — COMPLETE. 6b (predicate engine) — COMPLETE. 6c (evidence-pack export) — COMPLETE.
436
+
437
+ | Module | Responsibility |
438
+ |---|---|
439
+ | `obligation-mapping.js` | The `ObligationMapping` extension contract. Pure schema/validation module, zero imports (boundary-tested, mirrors `flow-grade.js`'s own precedent). Records are explicitly NOT `DataFlowGraph v1` entities — never in `dataflow-graph.schema.json`, never routed through `validate.js`'s `validateGraph()` — a separate, versioned artifact referencing the base graph by `graphId`/`graphDigest` (PRD §10.10: "associated with, but not required inside" the immutable base graph). Exports `OBLIGATION_STATES` (6 values), `OBLIGATION_FACT_TYPES` (6 values), `APPLICABILITY_INPUT_KEYS` (7 values — all pinned against hardcoded PRD literals, not just self-referentially, in `obligation-mapping.test.js`) and `validateObligationMapping(record)`. See `ids.js`'s `obligationId()` for the id scheme — discriminated by `(framework, frameworkVersion, requirementId, graphId, graphDigest)`; `graphDigest` is required in the discriminator because `graphId` alone never distinguishes two same-commit graphs with different content in the real pipeline (no real caller supplies `graphId`'s own `configHash` component). |
440
+ | `obligation-predicates.js` | The graph-fact predicate evaluator + `ObligationMapping` record builder (sub-project 6b). `evaluateGraphFlowPredicate(spec, graph)` walks a shipped graph's `flows[]`/`edges[]`/`dataElements[]`/`nodes[]` (joined by id) against a small declarative match object, mirroring `dataflow/catalog.js`'s own established match-object pattern. `buildObligationMappingFromGraphPredicate(...)` mints a real, `validateObligationMapping`-clean record. Wired into `posture/auditor-walkthrough.js`'s existing typed-predicate dispatch as a new, additive `graph:` branch — the first real case is HIPAA §164.312(e)'s transit-protection check. |
441
+ | `posture/obligation-evidence-pack.js` (sub-project 6c, lives in `posture/`, not here) | The signed, versioned evidence-pack export — a fourth sibling in the `evidence-bundle.js` Ed25519 family, reusing its `ensureKeyPair`/`keyPaths`/`canonicalJson` directly. `buildObligationEvidencePack({graph, framework, evaluation, ...})` collects a framework evaluation's `graph:` `ObligationMapping` facts plus scope/limitations/graph digest/reproducibility metadata; the one real design decision is the evidence index — since a record's own `evidence[]` is structurally always empty (`graph-builder.js` mints every edge with `edgeIds: []`), the pack instead resolves each fact's `contributingGraphIds` back against the graph's own entity arrays into a real per-flow summary. Wired into the CLI as `attest --obligations <framework-id>` / `verify-attestation` (`bin/agentic-security.js`). |
442
+
443
+ ## Milestone 4, Executive Risk Story Mode (FR-501 §14, DFG-035) — COMPLETE
444
+
445
+ | Module | Responsibility |
446
+ |---|---|
447
+ | `decision-story.js` | The `DecisionStory` §10.10 extension contract (mirrors `obligation-mapping.js`'s own shape: pure record validator, never a `DataFlowGraph v1` entity) plus a transparent 9-factor ranking engine over real flows. `scoreFlow(flow, graph, nodesById)` scores one flow on all 9 PRD-named factors (`RANKING_FACTORS`): 7 are direct graph reads (`sensitivity` via `dataflow/privacy-taxonomy.js`'s `SEVERITY_RANK`, `externality`, `controlVerdict`, `aiUse` via the exported `AI_SINK_SUBTYPES`, `evidenceConfidence`, `policyState`), `breadth` is a small new aggregation (count of other flows sharing a sink or dataElement, tiered low/medium/high), and `recipientJurisdiction`/`changeRecency` are honestly `available: false` on every flow with a named `unavailableReason` (RecipientProfile / GraphSnapshot-GraphDiff, neither built yet) — never fabricated, never dropped from the factor list. `rankFlows(graph, opts)` sorts flows via a documented, `opts.factorOrder`-configurable multi-key ordinal sort over each factor's own tier rank — never a blended single score, satisfying the PRD's own "never represents an uncalibrated score as expected financial loss or breach probability" constraint by construction. |
448
+ | `export-briefing.js` | `emitDecisionStory(graph, opts)` → `{record, markdown}` — the 5-chapter narrative (Scope & Confidence, Sensitive-Data Footprint, External Exposure, Control & Governance Gaps, Change & Decisions Needed) over `rankFlows`'s output, across 6 `AUDIENCE_MODES` that vary prose register/verbosity only, never facts/ranking/chapter order. Local `_mdInline`/`_mdCell`/`_mdCode` escaping helpers (byte-identical to `export-privacy.js`'s own twice-hardened versions) guard every governance-prose/graph-derived-label interpolation — chapter 4 reads the same `flow.governanceRefs` operator prose DPIA/RoPA does, so the same injection risk applies. Chapter 5 makes no "new/worsened flow" claims (`changeRecency` is always unavailable) — an explicit "no historical baseline" disclosure instead, plus real `manual_review_required`/`prohibited` flows as "decisions needed now." Wired into the CLI at `dataflow export --format briefing --audience <mode>` (`bin/agentic-security.js`'s `cmdDataflowExport`), mirroring the `dpia`/`ropa` branches. **Final whole-branch review found and fixed 3 BLOCKING cross-task issues** (none caught by task-level review, each spanning a seam no single task's own diff could see): (1) Chapter 3 originally filtered on `externality.tier === 'external'` alone, but FR-203's unresolved-destination path sets `externality:'unknown'` (never `'external'`) on every genuinely-unresolved sink — including every real AI-provider flow in this JS catalog — silently dropping them from the whole chapter; fixed by also including any flow whose sink `node.kind === 'unresolved'` (deliberately narrower than "any `externality:'unknown'` flow," which would also catch ordinary store-kind sinks whose externality is categorically ambiguous by design, not analysis failure). (2) Chapter 4 bucketed every non-`'permitted'` `policyVerdict` (including `not_evaluated`, the default with no `privacy-policy.json` on disk) under "policy conflict (not permitted)" — an unsupported compliance claim contradicting Chapter 5's own correct "no decision needed" treatment of the identical flows; fixed by splitting into three honestly-labeled buckets (`prohibited`/`conditionally_permitted` → "policy conflict"; `manual_review_required` → "requiring manual policy review"; `not_evaluated` → "not yet evaluated against policy (no policy configured for this scan)"). (3) `commands/dataflow.md`'s `description` frontmatter grew past `lint-command-descriptions.mjs`'s 120-char cap — invisible to `npm test` since that lint script's own tail is dropped from `run-unit-tests.mjs`'s file-discovery regex, so only the scoped `test:lifecycle` catches it. Also fixed: `--filter` narrows every chapter's own flow content but Chapter 1's coverage ledger reads the unfiltered source graph (`_filterGraph` never touches `coverage`) — now disclosed with an explicit "scoped to a filtered subset" note when a filter is active; `opts.factorOrder` is now threaded through to `rankFlows` (previously accepted internally but unreachable from `emitDecisionStory`/the CLI, leaving the PRD's own "transparent CONFIGURABLE factors" requirement unreachable by any caller — not yet exposed as its own CLI flag, see `commands/dataflow.md`); the header's graph digest is no longer truncated to 16 hex chars. **Disclosed, not fixed** (pre-existing, out of this sub-project's scope): `graph.limitations`'s hardcoded Milestone-1-era strings (already flagged stale elsewhere in this file) now sit inside an executive-facing narrative that can directly contradict them (e.g. claiming "protection verdicts are not assessed" beside a real Chapter 3 `protected`/`unprotected` table) for the first time — fixing the SOURCE string is `graph-builder.js`'s job, not this exporter's. |
449
+
450
+ ## Milestone 4, Data-Flow Time Machine foundation (FR-503 §14, DFG-022), sub-project 8a — COMPLETE. Diff computation, drift policies, and CLI comparison tooling are sub-project 8b, NOT YET BUILT.
451
+
452
+ | Module | Responsibility |
453
+ |---|---|
454
+ | `graph-snapshot.js` | The `GraphSnapshot` §10.10 extension contract (mirrors `obligation-mapping.js`'s/`decision-story.js`'s own shape: pure record validator, never a `DataFlowGraph v1` entity) plus commit-keyed persistence and a comparability check — the foundation `decision-story.js`'s own `recipientJurisdiction`/`changeRecency` factors named as `unavailableReason` before this was built. `validateGraphSnapshot(record)` checks structural presence only. `persistGraphSnapshot(graph, scanRoot, opts)` writes `.agentic-security/lineage-snapshots/<commit>.json`, keyed by the REAL git HEAD of `scanRoot` (resolved independently via a hardened `git rev-parse HEAD`, mirroring `posture/sbom-diff.js`'s own `_gitHead` precedent) — **never** `graph.graphId`'s own embedded commit component, which is the literal string `'uncommitted'` on every real scan today because `engine.js`'s call site to `buildLineageGraph` never passes `opts.commit`. Falls back to a content hash of the graph when no git repo is present, same precedent as `sbom-diff.js`. `loadSnapshots`/`loadSnapshot`/`mostRecentPriorSnapshot` read the history back (never throw on a missing/empty directory). `snapshotsComparable(a, b)` reports `{comparable, reasons}` on the one real, checkable signal today (`schemaVersion` equality) — a disclosed, not fabricated, limitation: two snapshots sharing a `schemaVersion` but a genuinely different analyzer/config are reported comparable, since `graphId`'s own `configHash` component is never populated by any real caller either. Wired additively into `bin/agentic-security.js`'s existing scan-flow persistence: every scan that writes `lineage-graph.json` also calls `persistGraphSnapshot`, inside the same `_writesOnScan()`/`_isSafeStateDir()` gate, never replacing the single-current-graph artifact. Registered in `posture/artifact-registry.js` (`lineage-snapshots`, `'generated'`/`'scan'`) so `reset` sweeps it. See `ids.js`'s `snapshotId()` for the id scheme — discriminated by `(graphId, commit, capturedAt)`. |
455
+
456
+ ## Milestone 4, watch-mode graph delta updates (sub-project 9) — COMPLETE
457
+
458
+ | Module | Responsibility |
459
+ |---|---|
460
+ | `bin/agentic-security.js`'s `cmdDataflowWatch` | `agentic-security dataflow watch <path>` — reruns a deep lineage scan (`AGENTIC_SECURITY_LINEAGE_DEEP=1`) on every debounced file-system change (`posture/watch-mode.js`'s `watchProject`, unmodified — reused as-is, no SAST-specific assumption baked in) and prints the real `computeGraphDiff` between the previous and current in-memory `GraphSnapshot` on stderr. Every `runScan` call — seed and every rescan — is wrapped in `withStateWritesDisabled` (`exceptCategories: ['provenance-cache']`, mirroring `src/lsp/server.js`'s own on-save-scan precedent): without it, every single-edit rescan during a live watch session wrote ~11 real files into `.agentic-security/` and, critically, fed `posture/provenance/lifecycle.js`'s ledger fabricated `remediated`/`reintroduced` events for transient mid-edit changes that were never real fixes — found and fixed in this sub-project's own final whole-branch review, not caught by either task-level review. Never persists a `GraphSnapshot` (the "before" state lives purely in memory for the life of the process) and never refreshes `.agentic-security/lineage-graph.json` (an already-running `explore` session has no live-reload mechanism to target). No in-flight-rescan guard on the closure-scoped `prevSnapshot` variable — a debounced batch firing while a previous rescan is still running can race; disclosed via a code comment at the call site rather than fixed, matching the pre-existing `scan --watch`'s identical unguarded shape on its own `prevFindings` variable. |
461
+ | **`scan --watch` does not actually watch — a real, pre-existing, permanent defect in that separate, already-shipped feature.** | Documented (both in `commands/scan.md` and informally) as blocking until `Ctrl-C`. Measured FALSE, twice independently, during this sub-project's own development: it prints its banner and exits in well under 1 second, because its dispatch (`process.exit(await cmdScan(args))`) tears the process down before `watchProject`'s internal `fs.watch` subscription — set up inside an un-awaited async IIFE — ever gets a chance to fire. `dataflow watch`'s own dispatch (`case 'dataflow':` → the `watch` branch in `bin/agentic-security.js`) avoids this exact trap by never wrapping `cmdDataflowWatch` in `process.exit()` on its success path, leaving the process alive on the live `fs.watch` handle instead. Fixing `scan --watch` itself is explicitly out of scope for this sub-project (`watch-mode.js`/`scan --watch` are deliberately unmodified per the plan's own Global Constraints) — recorded here so a future reader of this file does not assume the older command genuinely watches. |
462
+
463
+ ## Milestone 4, Third-Party and Cross-Border Intelligence (FR-506 §7.12), sub-project — COMPLETE (Tasks 1-3)
464
+
465
+ | Module | Responsibility |
466
+ |---|---|
467
+ | `recipient-profile.js` (Task 1) | The `RecipientProfile` §10.10 extension contract — mirrors `obligation-mapping.js`'s own STRUCTURE (a §10.10 record "associated with, but not required inside" the immutable base graph, never in `dataflow-graph.schema.json`, never routed through `validate.js`'s `validateGraph()`), with two deliberate departures explained in its own header: PER-FIELD `fieldEvidence` (not one record-level `factType`, since a profile mixes code-derived technical facts and operator-declared legal/jurisdiction facts on one record) and reuse of `OBLIGATION_FACT_TYPES` from `obligation-mapping.js` (the first cross-import between two §10.10 extension-contract modules — a deliberate reuse of an already-general vocabulary, not a new enum). Exports `RECIPIENT_FACT_FIELDS` (14 fields, PRD lines 541-548), `RECIPIENT_PROCESSOR_ROLES`/`RECIPIENT_DPA_STATUSES`/`RECIPIENT_CONFIDENCE_LEVELS`, and `validateRecipientProfile(record)` — structural-only, no graph cross-reference. |
468
+ | `recipient-registry.js` (Task 2) | `buildRecipientProfile(site, graph, opts) -> RecipientProfile \| null`, resolving a real record from the only two real sources this codebase has: a small, curated `TECHNICAL_PROVIDER_CATALOG` (code-derived — `anthropic`/`openai`/Google Cloud Pub/Sub/Amazon S3, disclosed precision-over-recall reasoning in the module's own header for what's deliberately excluded) and `loadRecipientConfig(configFilePath)` (operator-declared, `RECIPIENT_CONFIG_FILENAME = 'recipient-profiles.json'`, the same "never throws, missing file degrades to `{recipients: {}}`" contract `drift-policy.js`'s `loadDriftPolicies` already established). Returns `null` when neither source produced anything for a site — never an all-null record. |
469
+ | `graph-builder.js` (Task 3, extended additively; fix-round-1 B2/B3/M6 corrected the hook's own placement and merge logic) | Gained a THIRD hook of the identical `opts.resolveDestination`/`opts.resolveTransitProtection` shape: `opts.buildRecipientProfile(site, graph) -> RecipientProfile \| null`, applied once every site's `.destination` is resolved AND (as of fix-round-1, B2) after `graph.nodes`/`.edges`/`.flows`/`.dataElements` are populated — moved there specifically so `computeGraphDigest(graph)` inside `buildRecipientProfile` hashes real graph content instead of the still-empty envelope it was originally handed. Deliberately NOT gated on `site.destination` being present — a technical-provider match can resolve from `site.entry.framework` alone (a receiver-based `anthropic.messages.create()` SDK call, AC-07's own real fixture shape, whose destination frequently never resolves past `'unknown'`/`'dynamic'`), so skipping every site with no destination would silently drop exactly that real-world case. Collects non-null results into `graph.recipientProfiles[]`, deduplicated by the record's own `id` — two sink sites resolving to the same recipient merge their `sinkNodeFor(site).id`s (fix-round-1, B3 — the REAL stable graph node id, not the CFG-parse-local `site.nodeId` counter the original cut used, which made `--filter` a permanent no-op) into one record's `contributingGraphIds`, and (fix-round-1, M6) merge any fact field the first-seen profile left empty from a later site's non-empty profile, rather than silently dropping it. `graph.recipientProfiles` is a real, non-core-schema array attached to the graph object — UNLIKE `graph.evidence[]` immediately above it in the source (which IS required/core-schema, validated by `validate.js`'s `_validateEvidence`/`EVIDENCE_TYPES`), `graph.recipientProfiles` is the FIRST §10.10 extension-record array ever attached directly to the graph object — every prior extension contract (ObligationMapping/DecisionStory/GraphSnapshot) is a wholly separate artifact, never stored on the built graph itself. Never in `dataflow-graph.schema.json`, never routed through `validate.js`'s `validateGraph()`. Byte-identical when the hook is omitted, proven the same way `M2A1/hook-1` proved it for `resolveDestination`. |
470
+ | `coverage.js` (Task 3, extended additively) | `buildGraphWithCoverage` wires a default `opts.buildRecipientProfile` closing over `opts.recipientConfig` (a PRE-LOADED `{recipients: {...}}` object, never a raw file path — mirrors `opts.privacySinkPolicy`'s own precedent: the file read happens once, upstream, in `index.js`), composing with a caller-supplied override the same way every other hook in this file already does. |
471
+ | `index.js` (Task 3, extended additively) | Loads the operator's recipient config exactly once per `buildLineageGraph` call — a deliberate HYBRID of this file's own two existing loading precedents: the file PATH is resolved via `statePath(opts.scanRoot, RECIPIENT_CONFIG_FILENAME)`, mirroring `privacySinkPolicy`'s own path-resolution step (since `loadRecipientConfig` takes a literal file path, not a scanRoot), but the loader is then called UNCONDITIONALLY (no `fs.existsSync` gate), mirroring `privacyGovernanceConfig`'s own unconditional-call precedent — because `loadRecipientConfig`, unlike `loadPrivacySinkPolicy`, already has its own honest "missing file degrades gracefully" contract built in, so the caller-side existence gate `privacySinkPolicy` needs (to distinguish "file missing" from "file present but empty") is not needed here. |
472
+ | `bin/agentic-security.js` (Task 3, extended additively) | `'recipients'` added to `DATAFLOW_EXPORT_FORMATS`. `_renderDataflowRecipientsMarkdown(graph, opts)` — one Markdown table row per `graph.recipientProfiles[]` entry (provider/serviceType/legalEntity/processorRole/`processingCountries` as "Jurisdiction(s)"/dpaStatus/confidence — a deliberate SUBSET of `RECIPIENT_FACT_FIELDS`, matching the task brief's own named column list, not all 14 fields), plus a per-recipient "Field evidence" footer disclosing `code_inferred`/`declared`/`absent` per fact field, never fabricated. Local `_dfRecipientsMdInline`/`_dfRecipientsMdCell`/`_dfRecipientsMdCode` — byte-identical to `_dfDiffMdInline`/`_dfDiffMdCell`/`_dfDiffMdCode`'s own implementation, reimplemented locally per this codebase's established per-module-owns-its-own-escaping-helpers convention (never imported across modules). Three judgment calls, disclosed here (also in the module's own code comments): (1) `--filter` narrows `graph.recipientProfiles` by whether any of a profile's `contributingGraphIds` survive `filter.nodeIds` — `filter.edgeIds` has no effect on this format, since a recipient profile carries no edge reference at all (an edges-only filter file therefore narrows the format to nothing, an honest consequence, not a bug). (2) `--no-redact` is a no-op for `recipients`, matching `dpia`/`ropa`/`briefing`'s own precedent — as of fix-round-1 (B1), `redact-graph.js`'s `_redactGraph` DOES cover `graph.recipientProfiles[].technicalEndpoint`/`.legalEntity`/`.retentionCommitment`/`.transferMechanism`, but `--no-redact` stays a no-op for this format specifically because `_renderDataflowRecipientsMarkdown` never calls `exportGraphJSON`'s redaction path at all — a fact about this renderer's own code path, independent of whether `_redactGraph` itself has an opinion about the field. (3) `--view` is a no-op for `recipients`, joining `json`/`csv`/`html`/`dpia`/`ropa`/`briefing`'s own established set (this format has no view concept). |
473
+ | `commands/dataflow.md` (Task 3) | A new `recipients` row in the `## Formats` table, and `--view`/`--no-redact`/`--filter`'s own descriptions extended to name `recipients` alongside the sibling non-view-scoped formats — no new top-level section, since this is a new `--format` value of the existing `export` subcommand, not a new subcommand. |
474
+
475
+ **Deliberately out of scope (disclosed, real follow-up, not this sub-project's job):** no live enrichment of any kind — the technical-provider catalog and the operator config are the only two sources, forever, per the PRD's own "offline curated + user-supplied" sanction; no UI/frontend wiring; no `decision-story.js` wiring (its own `recipientJurisdiction` factor stays `available: false` — closing that is a real, separate follow-up this sub-project does not attempt); no subprocessor-chain discovery mechanism (operator-declared `subprocessorChain` field only — no code signal for this exists or is invented here).
476
+
477
+ ## Milestone 5, language coverage-tier disclosure — COMPLETE
478
+
479
+ Per the M5 top-level scoping doc's own ruling on deliverable #1: since none of
480
+ the product's 9 lineage-wired languages currently clear the PRD's own
481
+ 85%-recall "supported" bar (`docs/METRICS.md`, measured 2026-08-19 — best is
482
+ python at 66%), "every additional language that passes the common gate"
483
+ cannot mean "add languages beyond the ones that already pass." This
484
+ sub-project builds the genuinely unbuilt, Explorer-specific alternative
485
+ PRD §22.1 explicitly sanctions — a curated, honest per-language coverage
486
+ tier — rather than re-running the separate, already-executing taint-recall
487
+ improvement initiative (cited, never duplicated, here).
488
+
489
+ | Module | Responsibility |
490
+ |---|---|
491
+ | `language-coverage-tiers.js` (new) | `LANGUAGE_COVERAGE_TIERS` — 13 frozen entries, zero imports, mirroring `flow-grade.js`'s own "zero imports, pure data" precedent. 9 entries for the lineage-wired languages (`js`/`python`/`java`/`csharp`/`kotlin`/`go`/`php`/`ruby`/`cpp`), each `tier: 'partial'` with a real `irTaintRecallPct` copied VERBATIM from `docs/METRICS.md`'s own currently-committed table — never re-derived. 4 entries for the tree-sitter-pattern-only languages (`rust`/`solidity`/`swift`/`dart`, zero lineage/taint wiring), each `tier: 'pattern-only'` with `irTaintRecallPct: null` (no lineage engine ever runs against them, so there is nothing to report — never a fabricated 0%). `coverageTierForLanguage(language) -> entry \| null` never fabricates: returns `null` for anything not in the table. |
492
+ | `schema.js` (extended, additively) | `LANGUAGE_COVERAGE_TIER_VALUES = ['full', 'partial', 'pattern-only', 'unknown']` — deliberately its own enum, never a reuse of `COVERAGE_STATUS_VALUES` (which answers a different question: per-node classification confidence, not per-language product-level tier). `'full'` is real and reachable (a future `docs/METRICS.md` update crossing 85% needs no code change to report it) even though no language currently resolves to it. |
493
+ | `coverage.js` (extended, additively) | `LANGUAGE_EXT_PATTERNS` gained 4 new rows (`.rs`/`.sol`/`.swift`/`.dart` → `rust`/`solidity`/`swift`/`dart`) so a coverage-ledger `languages[]` entry can honestly attribute their files to a real language bucket, distinct from a genuinely unrecognized extension — the 9 pre-existing rows are unmodified. `buildCoverageLedger`'s `languages` computation now attaches `tier` (always present) plus `irTaintRecallPct`/`measuredAt`/`source` (genuinely ABSENT keys, never `null`-valued, when `coverageTierForLanguage` returns `null` or the entry has no recall number) to each language bucket, via `coverageTierForLanguage`. `graph.coverage` needed zero `schema.json`/`validate.js` changes — it is validated only as a plain object. **Two other real consumers of `graph.coverage.languages[]` outside this diff** — `export-briefing.js`'s "Languages analyzed" section and `graph-diff.js`'s `_coverageRegressionReasons` — read only `language`/`filesAnalyzed`/`filesExpected`, fields this change doesn't touch; they will now see up to 4 new distinct language buckets instead of those files silently collapsing into `'unknown'`, a benign, intended-in-spirit consequence the final whole-branch review flagged as untested at that specific seam (disclosed, not fixed here). |
494
+ | `bin/agentic-security.js` (extended, additively) | `'coverage'` added to `DATAFLOW_EXPORT_FORMATS`. `_renderDataflowCoverageMarkdown(graph, opts)` — one Markdown table row per `graph.coverage.languages[]` entry (language/files analyzed/files expected/tier/recall), with an explicit disclosure paragraph distinguishing real per-scan file counts from the curated, `docs/METRICS.md`-dated recall estimate — never presented as the same kind of fact. A missing `irTaintRecallPct` renders as `—`, never a fabricated number. Local `_dfCoverageMdInline`/`_dfCoverageMdCell` — byte-identical to the established `_dfRecipientsMd*`/`_dfDiffMd*` helpers' own bodies, per this codebase's per-module-owns-its-own-escaping-helpers convention. `--filter`/`--no-redact`/`--view` are documented no-ops for `coverage` (joining `csv`'s own established set for `--filter`; `dpia`/`ropa`/`briefing`/`recipients`'s set for `--no-redact`; the full non-view-scoped set for `--view`) — a per-language table has no node/edge-id-scoped meaning to narrow by, and `graph.coverage.languages[]` carries only curated static data and plain integers, nothing `redact-graph.js` has any opinion about. |
495
+ | `commands/dataflow.md` | A new `coverage` row in the `## Formats` table, and `--view`/`--no-redact`/`--filter`'s own descriptions extended to name `coverage` alongside the sibling no-op formats — no new top-level section, matching `recipients`'s own precedent. |
496
+
497
+ **Disclosed, not fixed (Minor, per the final whole-branch review):** the CLI test suite exercises the `unknown`-tier row end-to-end but not a `pattern-only` (rust/solidity/swift/dart) row through the real CLI subprocess path — only unit-tested at `coverage.js` level. Low risk, since the renderer's `pattern-only` and `unknown` cases share identical code (`typeof l.irTaintRecallPct === 'number'`, falling back to `'—'` either way).
498
+
499
+ ## Milestone 5, What-If Architecture Simulator (FR-502, sub-project 3a) — COMPLETE
500
+
501
+ A CLONE-AND-OVERRIDE engine: a `Scenario` names a set of hypothetical
502
+ graph overrides; applying one produces a NEW graph (a deep clone of the
503
+ already-scanned base graph, never a mutation of it) plus a delta report
504
+ against the base — never a re-scan, never a change to the real scan
505
+ artifact on disk.
506
+
507
+ | Module | Responsibility |
508
+ |---|---|
509
+ | `scenario.js` | The `Scenario` §10.10 extension contract — mirrors `obligation-mapping.js`'s/`decision-story.js`'s own shape (pure validator, zero graph access, never a `DataFlowGraph v1` entity). Exports `SCENARIO_OPERATION_KINDS` (the 6 in-scope kinds, below) and `validateScenario(record)`, a structural-only `{valid, errors}` check — it never confirms a `targetEdgeId`/`targetNodeId`/`targetFlowId` actually exists in any real graph, since this module has zero graph access by design; that check is `scenario-engine.js`'s job at apply time. |
510
+ | `scenario-engine.js` | `applyScenario(baseGraph, scenario, opts)` — deep-clones `baseGraph`, applies each operation, then re-runs the SAME two pure aggregators `graph-builder.js`'s own real pipeline uses (`aggregateVerdicts`, `isSinkPermitted`) over only the flows an operation actually touched — never re-running the taint/path pipeline. An operation whose target id does not exist in the graph is skipped (reported in `skippedOperations`, never thrown) — a Scenario written against an older snapshot degrades honestly. Never mutates `baseGraph`. |
511
+ | `scenario-diff.js` | `diffScenarioGraph(baseGraph, scenarioGraph)` + `WATCHED_SCENARIO_FIELDS` — a DEDICATED comparison, deliberately NOT `graph-diff.js`'s `computeGraphDiff`: that function's flow-reidentification pairing and `causeClassification` vocabulary (`'application_change'`, `'possible_coverage_regression'`, `'reidentified'`) are shaped for a REAL rescan across two commits, where the cause of a change is genuinely ambiguous. A Scenario's own delta has no such ambiguity — every difference between the base graph and a scenario clone IS the declared hypothetical operation that produced it — so this module reports only "what differs," with no cause classification and no reidentification. |
512
+
513
+ **The `'assumed'` evidence grade.** Every field a scenario operation
514
+ overrides (`edge.protection.transit`/`.atRest`/`.handling`) is written
515
+ with `evidenceGrade: 'assumed'` — a value on `protection.js`'s own
516
+ `EVIDENCE_GRADES` enum (the SAME axis every real `edge.protection.*`
517
+ verdict is graded on: `runtime`/`code_and_config`/`code`/`config`/
518
+ `declared`/`assumed`/`manual`/`none`), never `flow-grade.js`'s
519
+ `FLOW_EVIDENCE_GRADES` — that enum grades a DIFFERENT thing entirely
520
+ ("how explicit a recorded data movement is," per that module's own
521
+ header), and reusing/extending it here would validate a hypothetical
522
+ protection override as a flow-movement grade, a category error on top of
523
+ the collision. `'assumed'` exists specifically so a scenario-derived
524
+ verdict can never be mistaken for a `code`/`runtime`-graded real one —
525
+ every downstream reader (the CLI's own delta report, a future UI) can
526
+ tell "this edge is protected because the operator declared it so in a
527
+ what-if" apart from "this edge is protected because the scanner found
528
+ real evidence of it."
529
+
530
+ **`replace_recipient_fact` scope, disclosed**: it overrides only the
531
+ graph node's `destination` field — a recipient's legal/jurisdiction/
532
+ provider facts (the separate `RecipientProfile` record's own fields,
533
+ `recipient-profile.js`) are not simulated by this operation.
534
+
535
+ **Two disclosed, known limitations, found by the final whole-branch
536
+ review's own re-review round (not fixed — cheap to fix later, real
537
+ scope for a future increment, not silently papered over):**
538
+ - `_recomputeProtectionSummaryForFlow` aggregates `transit`/`atRest`/
539
+ `handling` via `aggregateVerdicts`, whose precedence ranks
540
+ `not_assessed` LAST — so a scenario that demotes an edge's `transit`
541
+ (e.g. via `replace_recipient_fact`'s own destination-change
542
+ invalidation, above) while ALSO setting that same edge's `handling`
543
+ to `'protected'` (via `apply_handling`, in the same operations list)
544
+ can mask the demotion at `flow.protectionSummary`, even though the
545
+ masking verdict itself is honestly graded `'assumed'` and the
546
+ per-edge `transit` field stays correctly demoted and visible in
547
+ `diffScenarioGraph`'s own delta. `graph-builder.js`'s own
548
+ cross-dimension `aggregateVerdicts` reuse is documented safe only
549
+ because `transit`/`atRest` are mutually exclusive by construction and
550
+ `handling` is "never written" by any real analyzer — `scenario-
551
+ engine.js` is the one exception to that last clause, which is what
552
+ makes this masking reachable here and nowhere else.
553
+ - `_applyRemoveEntity`'s `recipientProfiles[]` cascade is real graph
554
+ mutation, but `scenario-diff.js`'s `WATCHED_SCENARIO_FIELDS` has no
555
+ `recipientProfile` kind, so `diffScenarioGraph`'s own delta report
556
+ stays silent about a recipient record that disappeared or lost a
557
+ contributing id when `remove_entity` runs.
558
+
559
+ **The 6 in-scope operation kinds** (`SCENARIO_OPERATION_KINDS`):
560
+ `require_transit_protection`, `apply_handling`, `remove_entity`,
561
+ `replace_recipient_fact`, `change_storage_fact`,
562
+ `change_governance_fact`. **The 7th, deferred to sub-project 3b**:
563
+ synthetic node/edge INSERTION (modeling "what if we added a new service
564
+ to this architecture," as opposed to overriding a fact about an
565
+ EXISTING node/edge) — every kind implemented here operates on an id that
566
+ must already exist in the base graph; inventing a wholly new entity is a
567
+ materially different problem (fresh, collision-free stable ids; no real
568
+ provenance/coverage data to inherit) left for a later increment.
569
+
570
+ **CLI wiring**: `agentic-security dataflow scenario apply [path]
571
+ --operations <file.json> --output <file> [--format json|markdown]
572
+ [--privacy-sink-policy <file>] [--environment <name>]`
573
+ (`bin/agentic-security.js`'s `cmdDataflowScenarioApply`) — loads the
574
+ already-scanned, already-signed graph via the same `loadSignedGraph`
575
+ loader/error-message contract `export`/`diff` already use, validates
576
+ `--operations` via `validateScenario` before ever calling
577
+ `applyScenario`, then reports the `diffScenarioGraph` delta. Exit codes
578
+ 0/1/2 mirror `export`/`diff`'s own contract exactly. Deliberately has NO
579
+ wiring into `obligation-mapping.js`/`obligation-predicates.js`/
580
+ `decision-story.js` — a scenario's hypothetical graph must never feed a
581
+ compliance-obligation evaluation or an executive risk narrative as if it
582
+ were real, pinned by `test/lineage/scenario-no-obligation-wiring.test.js`
583
+ (a source-text import check, not a runtime assertion, since none of the
584
+ three modules ever construct a graph themselves to runtime-test against).
585
+
586
+ ## Milestone 5, Blast-Radius: Impact Assessment (FR-507, deliverable #4) — COMPLETE
587
+
588
+ A pure read/aggregate engine: given a compromised
589
+ node/edge/flow/dataElement's canonical id, answer "what is reachable
590
+ from here, per the graph's own already-scanned evidence." Unlike
591
+ Scenario (3a) it never clones, mutates, or hypothesizes — no new
592
+ operation catalog, no clone-and-override — a materially simpler shape
593
+ than 3a's own engine.
594
+
595
+ | Module | Responsibility |
596
+ |---|---|
597
+ | `impact-assessment.js` | The `ImpactAssessment` §10.10 extension contract — mirrors `scenario.js`'s/`obligation-mapping.js`'s own shape exactly (structural-only `{valid, errors}` validator, zero graph access at construction time, never a `DataFlowGraph v1` entity). Exports `IMPACT_TARGET_KINDS` (`node`/`edge`/`flow`/`dataElement`) and `IMPACT_SCOPE_VALUES` (`possible`/`observed` — see below). |
598
+ | `impact-engine.js` | `computeImpactAssessment(graph, targetId, opts)` — resolves `targetId`'s kind from its canonical-id prefix (`node:`/`edge:`/`flow:`/`data:`; throws only on an unrecognized prefix, a genuine caller error), then computes the affected set and every derived field. |
599
+
600
+ **`RecipientProfile`-only aggregation — no `ObligationMapping`
601
+ aggregation, and why.** `affectedRecipientProfileIds` is a pure
602
+ membership filter: `graph.recipientProfiles ?? []` filtered by
603
+ `contributingGraphIds` intersecting the traversal's own
604
+ `affectedNodeIds` — the identical mechanic `graph.recipientProfiles[]`
605
+ was built for, already a real, populated array on the graph
606
+ (`graph-builder.js`). `ObligationMapping` has no equivalent array to
607
+ filter: a record is built ON DEMAND per compliance-framework
608
+ requirement via `obligation-predicates.js`'s
609
+ `buildObligationMappingFromGraphPredicate`/`evaluateGraphFlowPredicate`,
610
+ keyed to one specific requirement's own declarative match spec — there
611
+ is no "all obligations for this graph" array the way
612
+ `graph.recipientProfiles[]` exists for recipients. Aggregating
613
+ "affected policies and regulatory obligations" would mean iterating
614
+ every bundled framework's own requirement specs and evaluating each
615
+ one against the affected subgraph — real, separate, meaningfully
616
+ larger integration work, not attempted here. So `ImpactAssessment` has
617
+ no `affectedObligationIds` field at all, a deliberate omission
618
+ disclosed in this sub-project's own scoping doc, not a silent gap.
619
+ `test/lineage/impact-no-obligation-wiring.test.js` pins the mirror
620
+ image of this decision — `obligation-mapping.js`/
621
+ `obligation-predicates.js`/`decision-story.js` must never import
622
+ `impact-assessment.js`/`impact-engine.js`, the same
623
+ source-text-import-check pattern
624
+ `scenario-no-obligation-wiring.test.js` already established, so a
625
+ future obligation/decision-story analyzer can never quietly start
626
+ treating a hypothetical-adjacent blast-radius traversal as a real
627
+ compliance or executive-risk fact.
628
+
629
+ **`scope` is always `'possible'`; `traceKind` discloses WHICH
630
+ traversal semantics produced the record.** No runtime-corroboration
631
+ signal exists yet (a future Digital Twin, M5 deliverable #7), so
632
+ `scope` never reports a narrower "confirmed exploited" blast radius —
633
+ only `'possible'`. But `'possible'` alone conflates two genuinely
634
+ different questions, found by the final whole-branch review (I1/I2):
635
+ a `node` target's blast radius is the pessimistic "everything
636
+ topologically reachable" — `frontend/src/lib/focus-controls.js`'s
637
+ `showAllPaths`, seeded from the node itself — because compromising a
638
+ node genuinely puts everything it can reach in the blast radius.
639
+ `edge`/`flow`/`dataElement` targets instead get a direct, flow-
640
+ restricted trace (`_flowRestrictedAffectedSet`) over only the flows
641
+ that actually carry that specific edge/flow/data element, DELIBERATELY
642
+ never `showAllPaths` — that BFS is topology-wide and unrestricted to
643
+ any particular flow, so seeding it from an edge's/flow's/data
644
+ element's own endpoint nodes would sweep in an unrelated sibling flow
645
+ that merely shares a node (reproduced live, twice: once for
646
+ `dataElement` targets in the original fix round, then again for
647
+ `edge`/`flow` targets by the final whole-branch review on the
648
+ identical bug class it had missed the first time). The record still
649
+ names that edge/flow's own two endpoint nodes — you need them to know
650
+ what the compromised channel actually touches — it just never treats
651
+ either endpoint as itself fully compromised for finding everything ELSE
652
+ reachable from it via unrelated edges; that further step is exactly
653
+ the over-inclusion class this fix removed. Both families are honestly
654
+ disclosed on the
655
+ record itself via `traceKind` (`impact-assessment.js`'s
656
+ `IMPACT_TRACE_KINDS`): `'topology_reachable'` for `node` targets,
657
+ `'flow_restricted'` for the other three — so a JSON consumer (or a
658
+ reader comparing two assessments of the same incident) can always tell
659
+ "exact carrier trace" apart from "topological presumption," never
660
+ conflated under one `scope` value. `IMPACT_SCOPE_VALUES` already
661
+ carries `'observed'` as a valid schema value today so that a later
662
+ runtime-corroboration increment needs no breaking change to this
663
+ contract — nothing emits it yet.
664
+
665
+ **Coverage limitations: whole-graph language disclosure, PLUS
666
+ per-entity `coverageStatus` — both real, both reported.** The
667
+ sub-project's own scoping doc originally specified narrowing
668
+ `coverageLimitations` to only the languages backing the affected
669
+ node/edge subset. The shipped `_coverageLimitations` (`impact-
670
+ engine.js`) still reports the WHOLE graph's own non-`full`-tier
671
+ languages, unscoped — no node in `DataFlowGraph v1` carries a
672
+ `language` field to filter by at all, so narrowing THAT figure to "the
673
+ languages of the affected subset" has no real data to compute from,
674
+ and this half of the disclosure is unchanged. What the final
675
+ whole-branch review (I3) found is a DIFFERENT, cheaper, real
676
+ alternative that the "no real data" justification did not rule out:
677
+ every node/edge/flow already carries a real, REQUIRED `coverageStatus`
678
+ field (`modeled|partial|candidate|unsupported|manual` — populated by
679
+ `graph-builder.js`, already consumed by `export-csv.js`/
680
+ `graph-diff.js`), which IS scoped per-entity and needs no language
681
+ lookup at all. `_affectedCoverageLimitations` (`impact-engine.js`)
682
+ reports a one-pass filter over the assessment's own
683
+ `affectedNodeIds`/`affectedEdgeIds` — "N of the M affected nodes/edges
684
+ have less-than-modeled coverage" — and its output is MERGED with, never
685
+ substituted for, the whole-graph language disclosure above: both are
686
+ real, complementary limitations of the same assessment, one whole-scan
687
+ and language-scoped, one per-entity and scoped to exactly what this
688
+ assessment says is affected.
689
+
690
+ **`frontend/src/lib/focus-controls.js` cross-import reuse.**
691
+ `impact-engine.js` imports `showAllPaths` directly from
692
+ `'../../../frontend/src/lib/focus-controls.js'` — the same established
693
+ `scanner/src/` → `frontend/src/` cross-import precedent
694
+ `export-privacy.js`'s `computePrivacyViewModel` import already set
695
+ (confirmed safe for the identical reason: `focus-controls.js` is a
696
+ pure graph-traversal module with no `document`/`window` access).
697
+ `showAllPaths` unconditionally seeds its own result with the start id
698
+ itself, even when that id does not exist in the graph at all — so
699
+ `computeImpactAssessment` filters every seed node id against the
700
+ graph's own real node ids BEFORE calling it, or a well-formed-but-
701
+ nonexistent `targetId` would surface as a phantom single-node
702
+ "affected" set instead of degrading honestly to empty arrays (the same
703
+ honest-degradation contract `applyScenario` established for a
704
+ stale/missing scenario target).
705
+
706
+ **CLI wiring**: `agentic-security dataflow impact assess [path]
707
+ --target <canonical-id> --output <file> [--format json|markdown]`
708
+ (`bin/agentic-security.js`'s `cmdDataflowImpactAssess`) — loads the
709
+ already-scanned, already-signed graph via the same `loadSignedGraph`
710
+ loader/error-message contract `export`/`diff`/`scenario apply` already
711
+ use. Exit codes: `0` success, `1` graph-load failure (`loadSignedGraph`'s
712
+ own 4 messages), `2` a CLI argument problem (missing
713
+ `--target`/`--output`, or a `--target` with no recognized canonical-id
714
+ prefix — `computeImpactAssessment`'s own thrown error, caught and
715
+ reported at the CLI boundary rather than crashing).
716
+
717
+ ## Milestone 5, Governance Editing Workflow (deliverable #5) — COMPLETE
718
+
719
+ A CLI-only propose/preview/write workflow for editing
720
+ `recipient-profiles.json` (the FR-506 operator-declared recipient
721
+ governance config `recipient-registry.js`'s `loadRecipientConfig` reads)
722
+ — **the real PRD correction, confirmed before either task in this
723
+ sub-project started**: nothing in the PRD's own acceptance-criterion
724
+ list gates this deliverable at all. The PRD's richer vision — an
725
+ interactive review/approve UI, a diff view, an approver workflow — is
726
+ real but unbuilt; this sub-project ships the narrower, CLI-first slice
727
+ every prior M4/M5 deliverable in this session has shipped first, not a
728
+ partial implementation of the richer vision.
729
+
730
+ | Module | Responsibility |
731
+ |---|---|
732
+ | `governance-edit.js` (Task 1; merge-patch semantics fixed in fix round 1) | `proposeGovernanceEdit(currentConfig, patch) -> {valid, errors, diff, merged}` — pure, no file I/O, never throws. `patch.recipients` is an RFC-7396-style JSON merge patch against `currentConfig.recipients`, keyed at the recipient level: a key with a non-null value REPLACES that key's entire entry (never deep-merged within itself); a key set to `null` DELETES it (the only way to remove a recipient); a key the patch never mentions is left UNTOUCHED in `merged` — fixing a real data-loss bug where the original design treated `patch.recipients` as the entire new recipients object, silently deleting every unmentioned key (live-reproduced and fixed by this task's own review). Validates every non-`null` patch entry via `isValidRecipientConfigEntry` (below) and computes a key-order-independent diff (`added`/`removed`/`changed`) via a local `_canonicalize` helper — a hand-authored patch file has no reason to preserve the stored config's own key order, so a raw `JSON.stringify` comparison would spuriously flag a semantically-unchanged recipient as "changed" (found and fixed by Task 1's own review). Arrays keep their own order (semantically meaningful, e.g. `subprocessorChain`); only object key order is normalized. `diff`/`merged` are always computed, even when `valid` is false, so an operator can see what they attempted before fixing a validation error. |
733
+ | `recipient-registry.js` (Task 1, extended) | `isValidRecipientConfigEntry` is now exported (was module-private) — a visibility change to an already-tested private function, not new validation logic. Its byte-identical behavior (same fail-closed, whole-entry-skips-on-any-defect discipline `loadRecipientConfig` already relied on) is what `governance-edit.js`'s own validation reuses, so a patch and the config file it edits are validated by the exact same rule, never a second, potentially-drifting copy. |
734
+ | `bin/agentic-security.js` (Task 2) | `cmdGovernancePropose` — the CLI handler for `agentic-security governance propose-edit [path] --patch <file.json> [--output <file>] [--yes] [--base-digest <hex>]`. `governance` is a NEW **top-level** command, not a `dataflow` subcommand — this edits operator config, never the scanned graph, the same distinction that keeps `dataflow scenario apply`'s hypothetical clone-and-override engine separate from a real write. **The version-guard/backup/audit mechanism, in real execution order** (pinned by the CLI test suite, below): (1) the version guard (`--base-digest` vs. the config file's real current SHA-256) runs BEFORE validation and BEFORE any write — a concurrent-edit rejection can never partially validate or partially write first; (2) `proposeGovernanceEdit` validates the patch — a failure exits 1, still before any write; (3) only on `--yes` AND a valid patch AND a passed version guard: the current file (if any) is backed up to `<file>.bak-<timestamp>` BEFORE the new content is written, so a failed write below that point leaves the backup intact and the original untouched; (4) the new content is written atomically; (5) `auditCall` (`src/mcp/audit.js`) appends a real, hash-chained NDJSON audit event — `tool: 'governance_propose_edit'`, `outcome: 'ok'`, `args: {file, added, removed, changedKeys}` — invoked ONLY on this real write path, never on a dry-run preview, never on a validation failure. Exit codes: `0` success (both the preview path and the real write path), `1` validation failure, `2` a usage/argument error or the version-guard rejection. |
735
+ | `commands/governance.md` (Task 2) | A NEW top-level slash-command dispatcher (`commands/` gained a 12th dispatcher), not a mode of `commands/dataflow.md` — mirrors every other dispatcher's frontmatter/Options-table/Examples/Implementation-block shape. States plainly: CLI-only, no PRD acceptance criterion gates this deliverable, the HTTP interactive write surface is explicitly deferred, and every write backs up the prior file and appends a real audit event. |
736
+
737
+ **Final whole-branch review, fix round 1— a real Blocking data-loss bug plus
738
+ 4 Important and 7 Minor findings, closed.** The review found the write's
739
+ merge base was `loadRecipientConfig`'s SANITIZED view of the current file
740
+ (dropping any per-entry-invalid recipient, any non-string/empty key, and any
741
+ top-level key other than `recipients`), not the file's own real bytes — so an
742
+ unrelated edit permanently deleted whatever the loader dropped, with the
743
+ preview and audit event both falsely reporting nothing removed (B1). Fixed by
744
+ making `cmdGovernancePropose` parse `currentRaw` directly (`JSON.parse`) as
745
+ the ONLY read of the current file — `loadRecipientConfig` is no longer
746
+ imported by this function at all — and by adding a shared container-shape
747
+ validator (`governance-edit.js`'s `_validateContainerShape`) applied to BOTH
748
+ the current config and the patch: a missing/non-object/array `recipients`, or
749
+ an empty-string/`__proto__` recipient key, is now a validation failure
750
+ (`valid: false`, `merged: null`) on either side, never a silent empty-object
751
+ fallback (this also closes I2, I3, and M1's `__proto__`-reported-but-never-
752
+ written case). `proposeGovernanceEdit`'s `merged` now also preserves every
753
+ OTHER top-level key from the current config (e.g. `$schema`, `version`)
754
+ verbatim — only `recipients` itself is merged. Four more fixes, all in
755
+ `cmdGovernancePropose`/`_writeConfigAtomic`: **I4** — `_writeConfigAtomic`
756
+ now `stat`s the target first and `chmod`s the temp file to the same mode
757
+ before renaming, so a deliberately restricted file (e.g. `chmod 600`) no
758
+ longer silently widens to the process umask on every write. **I5/M6** —
759
+ backups moved from a sibling `<file>.bak-<timestamp>` file to a dedicated
760
+ `recipient-profiles-backups/` subdirectory (mirroring `fix-history/`'s own
761
+ precedent exactly), named `<ms>-<8 hex>.bak` so two writes in the same
762
+ millisecond no longer collide; both `recipient-profiles.json`
763
+ (`operator-config`) and `recipient-profiles-backups` (`generated`/`backup`)
764
+ are now registered in `posture/artifact-registry.js`, so `reset` correctly
765
+ sweeps the backups while preserving the config itself. **M2** — the audit
766
+ event's `args` now also carries `beforeDigest`/`backupPath`, so a later
767
+ auditor can tell which bytes an event produced. **M5** — the `--yes` path now
768
+ calls `isSafeStateDir(path.dirname(configPath))` before any
769
+ mkdirSync/backup/write, refusing (exit 2) rather than littering a
770
+ non-project directory with a stray `.agentic-security/`, matching every
771
+ other write path in this file. Exit codes gained `4` (an unexpected I/O
772
+ error during the write itself — nothing was written, no audit event
773
+ recorded), documented in `commands/governance.md`. See
774
+ `.superpowers/sdd/2026-09-02-data-flow-explorer-m5-governance-editing/final-review.md`
775
+ and `final-fix-round-1-report.md` for the live repro steps and the full
776
+ before/after verification.
777
+
778
+ **The HTTP-write-surface deferral, explicit.** `scanner/src/server/` (the
779
+ `agentic-security explore` loopback server, `scanner/src/server/CLAUDE.md`)
780
+ is read-only today — serving the already-scanned, already-signed
781
+ `DataFlowGraph v1` artifact, never accepting a write. Wiring an
782
+ interactive review/approve UI onto it would need new routes, CSRF
783
+ protection, and a write-authorization mechanism beyond the existing
784
+ read-only session token — real, separately-scoped future work this
785
+ sub-project does not attempt. Nothing in `src/server/` was touched by
786
+ either task in this sub-project.
787
+
788
+ **CLI test coverage** (`test/cli/governance-propose-edit.test.js`, wired
789
+ into `test:mcp` — its own real, on-disk `.agentic-security/mcp-audit.log`
790
+ serialized-format assertion matches that script's own stated scope,
791
+ "MCP server tools + audit log," more precisely than `test:lineage`'s):
792
+ the dry-run-preview path leaves the real file untouched; `--yes` writes
793
+ atomically, creates exactly one backup, and appends a real, matching
794
+ audit event; a malformed patch entry exits 1 and creates no backup; a
795
+ stale `--base-digest` exits 2 and writes nothing; a missing `--patch`
796
+ exits 2. **One real defect found in this brief's own worked example,
797
+ fixed, and disclosed here rather than silently patched around**: the
798
+ brief's own "malformed patch entry" test used `{provider: 'x'}` as the
799
+ malformed entry — but `provider`/`serviceType` are code-derived-only
800
+ fields `isValidRecipientConfigEntry` never validates at all, and every
801
+ field it DOES validate is `undefined` on that entry, which its
802
+ `_isStringOrNull`/`!= null` guards all treat as legitimately absent —
803
+ confirmed live: `isValidRecipientConfigEntry({provider: 'x'})` returns
804
+ `true`, not `false`. The shipped test uses
805
+ `{provider: 'x', processorRole: 'not-a-real-role'}` instead — a value
806
+ outside `RECIPIENT_PROCESSOR_ROLES` (`recipient-profile.js`), which
807
+ `isValidRecipientConfigEntry` genuinely rejects.
808
+
809
+ ## Milestone 5, Blast-Radius: Remediation Command Center (deliverable #6) — implementation plan written
810
+
811
+ This section is an **initial plan note, not a completion note** — a
812
+ sub-project's own implementation, once it lands, gets its own COMPLETE
813
+ section the way deliverables #3a/#4/#5 above do; this entry documents the
814
+ plan Tasks 1-4 were built against.
815
+
816
+ **The load-bearing PRD difference from #5.** Deliverable #5 (Governance
817
+ Editing Workflow) shipped with no PRD acceptance criterion gating it at
818
+ all — "the PRD's richer vision is real but unbuilt; this sub-project
819
+ ships the narrower CLI-first slice." That argument is **not available**
820
+ here: this deliverable **is** gated by a named acceptance criterion,
821
+ AC-31, explicitly named in the Milestone 5 exit gate (PRD line 1854).
822
+ Surface area still narrows the same way #5's did (CLI-only, no HTTP write
823
+ surface, no UI, no external ticketing connector) — but AC-31's three
824
+ properties do not narrow with it; they are load-bearing on every task in
825
+ this sub-project.
826
+
827
+ **The module split**, mirroring `governance-edit.js`'s own CLI-write
828
+ boundary precedent:
829
+
830
+ | Module | Responsibility |
831
+ |---|---|
832
+ | `remediation.js` (this directory) | Pure, zero-imports: the `RemediationItem` contract, `foldRemediationItem`/`foldRemediationLedger` (event-sourced fold, never throws), `validateOpenPayload`, `validateTransition` (AC-31's own state machine — `state_changed` can NEVER reach `verified`, checked FIRST and unconditionally), and `evaluateVerificationEvidence` (a `GraphDiff` + a list of required-evidence flow ids in, `{outcome, ...}` out — never touches fs, never calls `computeGraphDiff` itself). |
833
+ | `posture/remediation-ledger.js` | All fs: `ledgerPaths`, `readLedgerEvents`/`latestEventHash` (tolerant, longest-verifying-prefix reads), and `appendLedgerEvent` — the **single** production call site of `validateTransition`, inside a ported (not imported — `provenance/lifecycle.js`'s `withLock` is module-private) file lock. |
834
+ | `bin/agentic-security.js` (the CLI) | `remediation open/update/accept-risk/list` (Task 3) plus `verify`/`reopen-check` (Task 4, this section's own focus) — the only caller of `computeGraphDiff`/`evaluateVerificationEvidence`/`drift-policy.js` in this whole sub-project. Nothing in the ledger or the pure module ever computes a diff itself. |
835
+
836
+ **Storage ruling.** Append-only JSONL + a hash chain mirroring
837
+ `mcp/audit.js`'s `prev`/`GENESIS` chain exactly (`sha256` of the previous
838
+ line's exact serialized text; the first event's `prev` is the literal
839
+ string `'GENESIS'`), under a lock ported from `provenance/lifecycle.js`.
840
+ Current item state is **always a fold**, replayed forward from the raw
841
+ event stream — never a stored, independently-mutable field — so a
842
+ tampered or torn tail is detected by the chain, not trusted by a cached
843
+ summary.
844
+
845
+ **The three AC-31 rules, each with its PRD citation:**
846
+
847
+ 1. *Fixed to the incident snapshot* (PRD line 171's non-goal framing,
848
+ restated positively): `remediation open` refuses (exit 2) when no
849
+ persisted `GraphSnapshot` exists, and the `opened` event's
850
+ `assessment.snapshotId` is always a real, resolved snapshot id — never
851
+ fabricated, never optional.
852
+ 2. *Marking work complete does not set `verified`* (PRD line 171):
853
+ `update --state` accepts only `in_progress`/`awaiting_verification`;
854
+ `validateTransition` rejects `state_changed` reaching `verified` as
855
+ the FIRST thing it does, before even checking whether an item exists —
856
+ a rejection placed after a state check would be reachable-around by
857
+ construction, so this ordering is itself part of the guarantee, not
858
+ an implementation detail.
859
+ 3. *A later regression automatically reopens it* (PRD line 1854, the
860
+ Milestone 5 exit gate's own wording): `remediation reopen-check`
861
+ evaluates every `verified` item against two independently-evaluated
862
+ mechanisms — a `--drift-policy` match (Mechanism A) and a direct
863
+ `affectedFlowIds` appearance in `diff.removed.flows`/`diff.changed.flows`
864
+ (Mechanism B, needed because `drift-policy.js`'s trigger vocabulary
865
+ has no `removed_flow` trigger at all) — and reports which mechanism
866
+ produced each hit, never an unlabelled "reopened."
867
+
868
+ **Five corrections this plan made against live code** (verified, not
869
+ guessed — the discipline this whole sub-project ran on):
870
+
871
+ 1. `ImpactAssessment` (`impact-assessment.js`) has **no `affectedFlowIds`
872
+ field of its own** — `cmdRemediationOpen` DERIVES it as the sorted,
873
+ deduplicated union of `--required-evidence` and (the assessment's own
874
+ `targetId`, when it is itself a `flow:`-prefixed id), never copies a
875
+ field that doesn't exist.
876
+ 2. `provenance/lifecycle.js`'s `withLock` is **not exported** —
877
+ `remediation-ledger.js`'s own `withLock` is a faithful local PORT, not
878
+ an import, confirmed by reading that file directly before writing this
879
+ one.
880
+ 3. The hash-chain semantics are `mcp/audit.js`'s real, verified shape
881
+ (`prev` = sha256 of the previous line's exact text, first event's
882
+ `prev` = the literal string `'GENESIS'`) — confirmed against that file
883
+ directly, not assumed from the general pattern description.
884
+ 4. **`--against` is a COMMIT KEY**, resolved via `loadSnapshot(scanRoot,
885
+ commitKey)`, mirroring `cmdDataflowDiff`'s own resolution exactly —
886
+ an earlier draft of this plan (and this task's own first-pass brief)
887
+ read it as a snapshot id, corrected after reading
888
+ `cmdDataflowDiff`'s real call site directly.
889
+ 5. **The CLI owns approver gating**, not the pure module or the ledger:
890
+ `remediation accept-risk` and `remediation verify --manual-attestation`
891
+ both call `loadApproverRegistry`/`verifyApprover`/
892
+ `checkSeparationOfDuties` (`fix/approver-registry.js`) themselves,
893
+ before ever building a payload — `remediation.js`'s own
894
+ `validateTransition` only enforces `manualAttestationPermitted` (a
895
+ flag set at `open` time), never approver identity, which has no
896
+ graph/ledger-level meaning to check against.
897
+
898
+ **Disclosed limitations, unchanged by Task 4 landing:**
899
+
900
+ - `snapshotsComparable` (`graph-snapshot.js`) is schemaVersion-only — two
901
+ snapshots from genuinely different analyzer configurations are reported
902
+ comparable. `remediation verify` inherits this exactly as `dataflow
903
+ diff` does.
904
+ - No verification is possible before a second lineage scan exists —
905
+ `remediation verify`'s default (scan-verification) path needs two real
906
+ `GraphSnapshot` records; an item opened against the only snapshot on
907
+ disk exits 2 with an honest message rather than fabricating a
908
+ comparison.
909
+ - The "flow still exists but is more protected" verification case is
910
+ **deliberately out of scope** for this first cut — `evaluateVerificationEvidence`
911
+ only recognizes a required-evidence flow as satisfied when it is
912
+ genuinely GONE from the graph (`causeClassification:
913
+ 'application_change'` in `diff.removed.flows`), never when it merely
914
+ transitions to a better `protectionSummary` while still present. See
915
+ `remediation.js`'s own header comment for the full reasoning — a future
916
+ increment could add this, but it needs its own deliberate, disclosed
917
+ heuristic for "protected enough," which this module does not attempt.
918
+
919
+ **Explicit out-of-scope list** (scoping doc §7, unchanged by
920
+ implementation): external ticketing/messaging/GRC/case-management
921
+ connectors (PRD lines 575, 2071 — `scanner/src/integrations/tickets.js`'s
922
+ existing finding-keyed sync does not satisfy PRD line 575's connector
923
+ contract); the HTTP write surface and any UI (same reasoning as
924
+ `governance propose-edit`'s own deferral); runtime-observation evidence
925
+ (FR-505 / Digital Twin — AC-31's own `or` is rescan-vs-manual-attestation,
926
+ never rescan-vs-runtime); and a regulation-derived due-date computation
927
+ (PRD line 2072 — `dueDate` stays a plain operator-supplied value).
928
+
929
+ ## Milestone 5, Runtime-Corroborated Digital Twin (FR-505, deliverable #7, 7b only) — COMPLETE
930
+
931
+ Ships the **RUNTIME-OBSERVED** half of FR-505 only — a metadata-only,
932
+ closed-world `RuntimeObservation` contract, a graph-ID-aware correlation
933
+ pass, an import-keyed observation store, one native-JSONL adapter, an
934
+ additive `graph-builder.js` hook, and the CLI/JSON proof surface
935
+ (`dataflow observations import|list`, `dataflow twin`) — such that every
936
+ clause of AC-29's own `then` is genuinely satisfied at the data/artifact
937
+ layer, without collecting a single payload value.
938
+
939
+ | Module | Responsibility |
940
+ |---|---|
941
+ | `runtime-observation.js` | **Pure, zero imports.** The §10.10 `RuntimeObservation` contract vocabulary (`RUNTIME_OBSERVATION_FIELDS`, `RUNTIME_ATTRIBUTE_KEYS`, `EVENT_COUNT_BANDS`, `RUNTIME_MATCH_METHODS`/`RUNTIME_MATCH_CONFIDENCE`, `OBSERVATION_LAYERS`) plus `validateRuntimeObservation`/`validateObservationAttributes` — the ONE **closed-world** validator in this whole package. AC-29 clause 5 rests on it entirely. |
942
+ | `observation-adapters.js` | **Pure, zero imports.** The adapter registry (`adapterFor`) plus `parseNativeJsonlObservations(text, context)` — text in, drafts out, no `fs`. Enforces its own closed wire-key set (`NATIVE_JSONL_RECORD_KEYS`), one layer BELOW the record-level closed-world sweep — the two layers together are what catch every smuggling shape in the payload-refusal test corpus. |
943
+ | `observation-correlation.js` | **Pure**, imports exactly `['./runtime-observation.js']`. `matchObservationToGraph` (the import-time match ladder: `destination_literal` → `store_table` → `queue_topic` → `unmatched`) and `correlateObservations` (the read-time environment/window filter and the three-valued per-flow layer). Never mutates or filters a graph entity — AC-29 clause 3 is true by construction. |
944
+ | `observation-store.js` | **Impure.** A directory of independently-readable, immutable, import-keyed whole files under `.agentic-security/runtime-observations/` — `persistObservationImport`/`loadObservationImports`/`loadObservationImport`/`loadObservations`/`deleteObservationImport`. Calls `maybeEncryptForWrite`/`maybeDecryptForRead` itself (the registry's `confidential: true` flag is a declaration, not an enforced control — see Correction 1 below); validates a `loadObservationImport` key's shape BEFORE any `path.join`. |
945
+ | `graph-builder.js` (extended, additively) | `buildDataFlowGraph` gained `opts.correlateObservations(graph) -> CorrelationResult`, applied once, right before the graph is returned. `graph.runtimeCorroboration` is assigned **only when the hook returns truthy** — genuinely ABSENT, never `null`/`{}`, when the hook is omitted (byte-identical-when-omitted, the same contract `resolveDestination`'s own hook established). |
946
+ | `coverage.js` / `index.js` (extended, additively) | `buildGraphWithCoverage` composes a default `correlateObservations` closure over a **pre-loaded** `opts.runtimeObservations` array (never a raw path); `index.js` performs the ONE read per `buildLineageGraph` call (`fs.existsSync(dir) ? loadObservations(scanRoot) : null` — the `null` is deliberate, see below). |
947
+ | `bin/agentic-security.js` (`dataflow observations import\|list`, `dataflow twin`) | The AC-29 proof surface — see `commands/dataflow.md`'s own `## Runtime observations (FR-505)` / `## Runtime Digital Twin layers (AC-29)` sections for the full CLI reference. `observations import` is dry-run-by-default and refuses the WHOLE import on any rejected record; `observations list` never prints an attribute key or value; `dataflow twin` is read-only and never writes into `.agentic-security/`. |
948
+
949
+ ### The five AC-29 properties a future UI increment inherits, and must not break
950
+
951
+ A future UI increment (deferred here — see the out-of-scope list below) sits
952
+ entirely on top of what this deliverable already proved at the
953
+ data/artifact layer. Breaking any one of these five at the UI layer would
954
+ reopen exactly the gap this sub-project exists to close:
955
+
956
+ 1. **`RUNTIME OBSERVED` is a real, computed layer, never a UI label
957
+ invented at render time.** It comes from `correlateObservations` setting
958
+ `byFlow[flowId].layer = 'runtime_observed'` — a UI must render that
959
+ value, never infer "observed" from the presence of an import file alone.
960
+ 2. **The three-valued layer stays three-valued.** `not_observed_in_window`
961
+ (a store was consulted, genuinely found nothing) and `not_evaluated` (no
962
+ store was ever consulted) are two DIFFERENT answers — collapsing them
963
+ into one "no runtime evidence" UI state would silently misrepresent an
964
+ unevaluated flow as one the operator actually checked (PRD line 2098).
965
+ 3. **Non-exclusion is structural.** `correlateObservations` never filters,
966
+ removes, or reorders a graph entity — a UI's own "show observed flows
967
+ only" toggle must be a client-side FILTER over the full `byFlow` map,
968
+ never a request that asks this layer to return fewer flows.
969
+ 4. **Match confidence/method/environment/window are shown per flow, not
970
+ summarized away.** Every `runtime_observed` `byFlow` entry carries all
971
+ eight fields (`matchMethod`, `matchConfidence`, `environment`,
972
+ `windowStart`, `windowEnd`, `firstObservedAt`, `lastObservedAt`,
973
+ `eventCountBand`) non-null; a UI that shows only a boolean "observed"
974
+ badge with no way to inspect these fields fails AC-29 clause 4.
975
+ 5. **No captured payload value ever reaches this artifact, at any layer.**
976
+ The closed-world validator (below) is the only thing standing between
977
+ an operator's telemetry export and this codebase's own signed graph
978
+ artifact — a UI must never grow a second ingestion path (a paste box, a
979
+ live webhook receiver) that bypasses `validateRuntimeObservation`.
980
+
981
+ ### Why the validator is closed-world when every sibling §10.10 contract is open-world
982
+
983
+ Every other extension-contract validator in this package
984
+ (`impact-assessment.js`, `recipient-profile.js`, `scenario.js`,
985
+ `obligation-mapping.js`, `graph-snapshot.js`) is open-world: it checks the
986
+ fields it cares about and is silent about anything else, because it is
987
+ validating a record this codebase itself constructed from its OWN
988
+ already-vetted graph content. A `RuntimeObservation` is different in kind —
989
+ it is built from an OPERATOR-SUPPLIED telemetry export this codebase never
990
+ generated and cannot vouch for. PRD line 983 states the requirement
991
+ directly: "Runtime records use approved metadata schemas and **reject**
992
+ fields capable of carrying payload values." AC-29 clause 5 restates it as
993
+ the acceptance bar: "no captured payload, prompt, response, record, log
994
+ message, or sensitive value exists in the observation artifact." Both are
995
+ REJECT requirements — an open-world validator (or a denylist, which fails
996
+ on the identical axis one level down) would let any attribute name nobody
997
+ thought to name in advance sail through unexamined. See
998
+ `runtime-observation.js`'s own header comment for the full reasoning; do
999
+ not widen either sweep from "reject unknown" to "ignore unknown" without
1000
+ re-reading it first — that is a silent AC-29 falsification, not a cleanup.
1001
+
1002
+ ### Why 7a (CONFIG DECLARED) is not here
1003
+
1004
+ 7a — inferring a graph edge from IaC/config rather than from executed code
1005
+ — is Milestone 2 Sub-project F2/F3's own job (already scoped as 2 × Large),
1006
+ not this deliverable's. Whoever picks up 7a should read the #7 scoping
1007
+ doc's Correction 3 (`byFlow` must be a plain object, never a `Map` — the
1008
+ graph is persisted to signed JSON) and Correction 4 (a flow's
1009
+ `matchConfidence` is demoted to `'ambiguous'` whenever its matched sink is
1010
+ shared with a sibling flow) first — both generalize beyond 7b — and, most
1011
+ importantly, the scoping doc's own finding that `edge.provenance` (Sub-
1012
+ project F increment 1, `EDGE_PROVENANCE_VALUES`) has **zero consumers**
1013
+ today: every downstream reader (`impact-engine.js`, `decision-story.js`,
1014
+ `obligation-predicates.js`, `export-csv.js`, `export-privacy.js`,
1015
+ `bench/protection-verdict/runner.mjs`) was written assuming
1016
+ `edge.provenance === 'code'` invariantly, and 7a would be the first thing
1017
+ in this codebase to ever write `'schema'`/`'manual'` to that field — a
1018
+ provenance-partitioning pass through every one of those consumers is real,
1019
+ separately-scoped work, not a drive-by addition.
1020
+
1021
+ ### The full out-of-scope list (scoping doc §7, verbatim in substance)
1022
+
1023
+ - **7a / CONFIG DECLARED entirely** — descoped to M2 F2/F3 (above).
1024
+ - **Setting `edge.provenance = 'runtime'`** — deferred until a deliberate
1025
+ provenance-partitioning pass exists through every downstream consumer.
1026
+ Corroboration is recorded additively instead, on `graph.runtimeCorroboration`.
1027
+ - **Any change to `remediation.js`/`remediation-ledger.js`/the
1028
+ `remediation` CLI verbs.** AC-31's own `or` is
1029
+ rescan-vs-manual-attestation; a `runtime_verification` event type is
1030
+ legitimate future FR-507 scope, not this deliverable's.
1031
+ - **`ImpactAssessment.scope = 'observed'` / possible-observed
1032
+ partitioning.** `IMPACT_SCOPE_VALUES` already reserves the value; #4
1033
+ (Impact Assessment) is already honest that nothing emits it yet. A
1034
+ half-built partition would be worse than the disclosed gap.
1035
+ - **The OpenTelemetry adapter** — its own attribute allowlist and its own
1036
+ review, against an unbounded, actively-evolving external vocabulary.
1037
+ - **Gateway/mesh metadata and cloud flow metadata adapters** — FR-505
1038
+ names them; nothing exists; not attempted.
1039
+ - **Any UI**: layer toggles, distinct edge treatment, an environment/window
1040
+ selector, an observation inspector, and the D.6
1041
+ `runtime-digital-twin.dark.1680x945.png` golden. Consistent with every
1042
+ decision-intelligence deliverable shipped this session — see the five
1043
+ properties above for what a future UI increment must not break.
1044
+ - **Live ingestion of any kind** — no collector, no daemon, no network
1045
+ call. FR-505's own no-egress rule and the root `CLAUDE.md`'s "no runtime
1046
+ cloud calls" convention both forbid it; external evidence arrives as a
1047
+ FILE, always.
1048
+ - **Field-level identity from runtime evidence.** FR-505 is explicit that
1049
+ runtime observation "may increase corroboration confidence but cannot
1050
+ prove field-level identity unless safe schema/trace evidence maps that
1051
+ field" — no such mapping exists; observations correlate to node/edge/flow
1052
+ ids only, never to a `dataElement`'s field identity.
1053
+ - **Encryption-at-rest implementation.** The registry entry declares
1054
+ `confidential: true`; `observation-store.js` calls
1055
+ `maybeEncryptForWrite`/`maybeDecryptForRead` itself (Correction 1 — see
1056
+ below) — no NEW mechanism is introduced.
1057
+ - **Any language beyond JS/TS** — unchanged package-wide boundary.
1058
+
1059
+ ### The two pre-existing gaps this deliverable touched, and the one it did not
1060
+
1061
+ - **Fixed.** `runtime-trace.jsonl`/`runtime.jsonl`/`ebpf-trace.jsonl`
1062
+ (`posture/runtime-correlation.js`'s own `DEFAULT_TRACE_NAMES`) are now
1063
+ ALL THREE registered in `posture/artifact-registry.js` — registering only
1064
+ one of three left the other two exactly as unregistered as before.
1065
+ - **Fixed.** `confidential: true` on an artifact-registry.js entry is a
1066
+ DECLARATION, not an enforced control — nothing walks the registry and
1067
+ encrypts on a caller's behalf. `observation-store.js` calls
1068
+ `maybeEncryptForWrite(scanRoot, 'runtime-observations', json)`/
1069
+ `maybeDecryptForRead` itself, the same explicit-call pattern
1070
+ `posture/compliance-policy.js`'s two pre-existing confidential artifacts
1071
+ already established — so the flag this module's own registry entry
1072
+ carries is backed by real behavior, not just a claim.
1073
+ - **Disclosed, NOT fixed.** `graph-snapshot.js`'s own `loadSnapshot(scanRoot,
1074
+ commitKey)` joins a caller-supplied key straight onto the history
1075
+ directory with no shape check at all — a real, pre-existing gap this
1076
+ deliverable's own `loadObservationImport` deliberately does NOT inherit
1077
+ (it validates its key against `/^obsimport:[0-9a-f]{12}$/` before any
1078
+ `path.join`), but `graph-snapshot.js` itself is untouched. Not exploited
1079
+ today (every real caller passes a git commit or a `--against` flag); out
1080
+ of scope to fix here.
1081
+
1082
+ ## Milestone 5, Cross-Repository/Federated Graph Import (deliverable #8, FR-304 "declared" half) — COMPLETE. This closes out the M5 top-level scoping doc's own 8-deliverable list in full.
1083
+
1084
+ Ships only the "declared" half of FR-304 — an operator explicitly names
1085
+ a local node and a remote node and asserts a `data_flow` relationship
1086
+ between them — never the "imported"/auto-correlated half (destination/
1087
+ schema-based automatic cross-repo edge matching), which needs
1088
+ prerequisites that do not exist yet (M2 Sub-project F2/F3's
1089
+ schema-derived edges, the remaining Sub-project A destination-resolver
1090
+ increments) and is a materially larger, separately-scoped design
1091
+ problem.
1092
+
1093
+ | Module | Responsibility |
1094
+ |---|---|
1095
+ | `docs/superpowers/plans/2026-09-02-data-flow-explorer-m5-cross-repo-scoping.md` + `…-plan.md` | The scoping investigation's own real correction against the parent M5 row: FR-304's "declared or imported" is two mechanisms with two different dependency profiles (`DFG-025`'s own `DFG-002`+`DFG-007` dependencies only make sense once this split is explicit), not one phrase to scope as a unit — this deliverable covers only the "declared" half. Also settles, via direct reading of `validate.js`'s `_validateEdge`, that a cross-repo entry can never be a core-schema `graph.edges[]` member at all (both endpoints must resolve against the ONE graph's own `nodeIds` set) — the id-collision risk the parent row named cannot actually occur under a correctly-scoped design, since a foreign node id is never looked up against a merged set. |
1096
+ | `cross-repo-link.js` | The `CrossRepoLink` §10.10 extension contract — mirrors `recipient-profile.js`'s own file shape exactly (pure module, `{valid, errors}` validator, zero graph access at construction time), with the one real, disclosed departure that module's own header discloses for itself: no per-field `fieldEvidence` map, since every field on a `CrossRepoLink` is uniformly operator-declared. `provenance` reuses `schema.js`'s already-shipped `EDGE_PROVENANCE_VALUES` — this deliverable's CLI is the first real producer of `'manual'` anywhere in this codebase (every shipped edge is `provenance: 'code'`, per Milestone 2 Sub-project F increment 1). `relationship` is fixed to `'data_flow'`, mirroring `edge.relationship`'s own single legal value. `ids.js` gained `crossRepoLinkId({localGraphId, localGraphDigest, localNodeId, remoteGraphId, remoteGraphDigest, remoteNodeId, relationship}, discriminatorParts)` — mirrors `recipientProfileId`'s own `(graphId, graphDigest, ...)` discriminator shape, doubled for both endpoints. |
1097
+ | `federation-loader.js` | `loadRemoteGraphExport(filePath) -> {ok, graph, digest, digestMatches, reason, message}` — reads an `exportGraphJSON`-shaped file (`dataflow export --format json`'s own artifact), recomputes `computeGraphDigest(parsed.graph)` and compares it to the file's own embedded `bodyDigest` (final whole-branch review, fix round 1, B1 — corrected from `digest`; see below), then runs the existing `validateGraph(parsed.graph)` before anything reads a referenced remote node id. `digest` identifies the SOURCE graph regardless of redaction/filtering; `bodyDigest` identifies the exact bytes this file actually contains, which is what a self-consistency check over a received file needs. Deliberately does NOT reuse `scanner/src/server/graph-loader.js`'s `loadSignedGraph` for the remote side — that function authenticates against a PER-INSTALL HMAC key, the wrong trust model for a file that crossed a repo/machine boundary in the common case (two repos scanned on two different machines sign under two different keys by default). Four distinct outcomes: `missing`, `malformed` (not JSON, or not an `exportGraphJSON` envelope), `invalid-graph` (fails `validateGraph()`), and `digest-mismatch` — the one outcome that is NOT a blocking failure (`ok:true, digestMatches:false`): a self-consistency check, never authentication, surfaced as a warning the CLI must show and does not by itself refuse. |
1098
+ | `graph-builder.js` (extended, additively) | Gained a SIXTH additive hook of the `opts.buildRecipientProfile`/`opts.correlateObservations` shape: `opts.crossRepoLinks(graph) -> CrossRepoLink[]`, applied once every graph array AND `recipientProfiles` are populated (the hook can validate a declared `local.nodeId` against the CURRENT graph's own real node set). `graph.crossRepoLinks` is always present — mirrors `graph.recipientProfiles[]`'s own "always an array, possibly empty" shape (unlike `graph.runtimeCorroboration`'s own "genuinely absent when the hook is omitted" shape, since a `CrossRepoLink` array has no `not_evaluated` state to preserve). Never in `dataflow-graph.schema.json`, never routed through `validateGraph()` — the SECOND §10.10 extension array ever attached directly to the graph object. |
1099
+ | `coverage.js` (extended, additively) | `buildGraphWithCoverage` composes a default `opts.crossRepoLinks` hook over a PRE-LOADED `opts.crossRepoLinkRecords` array (mirroring `opts.recipientConfig`'s own precedent) — installed only when `opts.crossRepoLinkRecords !== undefined`. The default hook DROPS any record whose `local.nodeId` no longer resolves against the graph's own current node set (a stale declaration from before a rescan renamed/removed the node), reporting every drop via `console.error` rather than silently keeping it stale — matching `applyScenario`'s own "skippedOperations, never thrown" honesty precedent. |
1100
+ | `index.js` (extended, additively) | Loads `.agentic-security/cross-repo-links.json` exactly once per `buildLineageGraph` call, gated on `fs.existsSync` — mirroring the `privacySinkPolicy` existence-gated pattern, not `recipientConfig`'s unconditional-call one, since a missing file here means "no links declared." A small, local, tolerant `_loadCrossRepoLinkRecords` reader (per-entry validated via `validateCrossRepoLink`, mirroring `loadRecipientConfig`'s own fail-closed, skip-the-whole-entry-on-any-defect discipline) lives in this file rather than in `cross-repo-link.js` (which must stay a pure, zero-fs-access module) or `federation-loader.js` (which owns only the REMOTE side). |
1101
+ | `bin/agentic-security.js` (extended, additively) | `cmdFederateDeclare`/`cmdFederateList`, dispatched via a NEW top-level `case 'federate':` (not a `dataflow` subcommand — this writes operator-declared config, never the scanned graph, the identical reasoning `commands/governance.md`/`commands/remediation.md` already establish, now a THREE-times-repeated pattern). `federate declare` reuses `governance-edit.js`'s exact 5-part write contract (version guard before any read of the remote file or validation; load+validate the remote export, a digest-mismatch printed as a warning that never blocks `--yes`; confirm `--local-node` exists in the current local graph via `loadSignedGraph` and `--remote-node` exists in the remote export's own `nodes[]`; on `--yes`, backup to `cross-repo-links-backups/` then an atomic write (via the already-shipped `_writeConfigAtomic`) then a real hash-chained `federate_declare` audit event via `auditCall`). `federate list` mirrors `dataflow observations list`'s own precedent — read-only, never fabricates "still valid" when it cannot check. Both registered in `posture/artifact-registry.js` (`cross-repo-links.json`: `operator-config`; `cross-repo-links-backups/`: `generated`/`backup`). |
1102
+ | `commands/federate.md` | New top-level dispatcher markdown (14th dispatcher, root `CLAUDE.md`'s own dispatcher count updated to match) — same frontmatter/Options-table/Examples/Implementation-block shape as `commands/governance.md`. States plainly: CLI-only, "declared" flavor of FR-304 only, every write backs up the prior file and appends a real audit event, the remote-side trust model is a self-consistency digest check, never cryptographic authentication. |
1103
+
1104
+ **Final whole-branch review, fix round 1** — 3 real bugs that survived five
1105
+ clean task-level reviews and a fully green test suite, plus one small
1106
+ exit-code alignment fix. **B1 (Blocking):** the remote self-consistency
1107
+ digest check was inverted for the DEFAULT case — `exportGraphJSON` embeds
1108
+ `digest: computeGraphDigest(graph)` (the SOURCE graph's digest, deliberately
1109
+ unaffected by redaction/filtering) but `graph: body` (the redacted/filtered
1110
+ view, redaction ON by default); `federation-loader.js` recomputed the
1111
+ digest over `body` and compared it to `digest`, so an un-tampered,
1112
+ default-redacted export permanently read `digestMatches: false`, and a
1113
+ genuine tamper was indistinguishable from routine redaction. Fixed by
1114
+ adding a SEPARATE `bodyDigest: computeGraphDigest(body)` field to the
1115
+ export envelope (`export-json.js`) and switching `federation-loader.js`'s
1116
+ self-consistency check to compare against it instead — additive, zero
1117
+ change to `digest`'s own meaning or any other consumer of it. The shipped
1118
+ test suite never caught this because `federation-loader.test.js`'s own
1119
+ fixture builder called `exportGraphJSON(graph, { redact: false })` — the
1120
+ NON-default option — for every fixture; now fixed to build via the real
1121
+ default. **B2 (Important):** `redact-graph.js`'s `_redactGraph` covered
1122
+ `nodes`/`evidence`/`recipientProfiles` but not `crossRepoLinks` — the THIRD
1123
+ time this exact gap class recurred in this file's own history (see that
1124
+ file's own header comment), and this deliverable did it again: a
1125
+ `--rationale` containing a secret pattern, and `remote.sourceFile`/
1126
+ `remote.repository`, all exported verbatim under default redaction. Fixed
1127
+ by adding `_redactCrossRepoLink` (mirrors `_redactRecipientProfile`'s own
1128
+ shape) and wiring it into `_redactGraph`. **B3 (Important):** two related
1129
+ bugs in `cmdFederateDeclare` — (1) `localGraphDigest` was computed over
1130
+ the FULL local graph, including `crossRepoLinks` (which
1131
+ `computeGraphDigest`'s `EXCLUDE_KEYS` does not exclude), creating a
1132
+ feedback loop where declare → rescan (the graph now carries the new link,
1133
+ changing its own digest) → declaring the SAME real-world fact again mints
1134
+ a DIFFERENT id, since the digest that fed the id changed for reasons
1135
+ unrelated to the application's own data-flow shape — fixed by stripping
1136
+ `crossRepoLinks` from the graph object before hashing; (2) the write path
1137
+ had no dedupe-by-id, so two identical `federate declare --yes` runs
1138
+ appended two entries with the same id — fixed by filtering out any
1139
+ existing entry with the new record's id before appending (replace in
1140
+ place, preserve every other entry untouched). **Exit-code alignment:**
1141
+ `cmdFederateDeclare`'s `loadSignedGraph` failure path returned exit `2`,
1142
+ the sole outlier among all six other `loadSignedGraph` call sites in
1143
+ `bin/agentic-security.js` (all exit `1`) — corrected to `1`.
1144
+
1145
+ **Explicitly NOT modified**: `dataflow-graph.schema.json`, `validate.js`'s
1146
+ `validateGraph()` (the new record is never routed through it, mirroring
1147
+ `RecipientProfile`); no live/network fetch of a remote graph, ever; no
1148
+ array-merge of two graphs' `nodes[]`/`edges[]` under any circumstance;
1149
+ no change to `nodeId`/`edgeId`/`flowId`/`dataElementId` or their
1150
+ discriminator shapes; `provenance: 'manual'` only (never `'schema'`,
1151
+ reserved for a future "imported"/auto-correlated producer).
1152
+
1153
+ **Deliberately out of scope (disclosed, real follow-up, not this
1154
+ deliverable's job):** FR-304's "imported"/auto-correlated flavor (needs
1155
+ M2 Sub-project F2/F3 and the remaining Sub-project A increments as real
1156
+ prerequisites); full graph merge of any kind; any cryptographic
1157
+ cross-machine authentication of a remote graph export (the existing
1158
+ `agentic-security attest`/`verify-attestation` Ed25519 mechanism is the
1159
+ right existing tool for a future increment that wants real third-party
1160
+ verifiability, judged out of proportion to a P2, narrowly-scoped first
1161
+ cut here); automatic cross-repo `RecipientProfile` consolidation (a
1162
+ `CrossRepoLink` connects two NODES and says nothing about whether two
1163
+ `RecipientProfile` records describe the same real-world vendor); a
1164
+ live-refresh/auto-resync mechanism (`federate list`'s own validity check
1165
+ is read-only and on-demand).
1166
+
1167
+ **Two Minor findings, disclosed (final whole-branch review), not fixed —
1168
+ each matches an EXISTING, already-accepted limitation this codebase
1169
+ already carries elsewhere, so fixing either here would be new,
1170
+ out-of-proportion scope for this deliverable, not a regression it
1171
+ introduced uniquely.** `_filterGraph` (`export-json.js`) narrows only
1172
+ nodes/edges/flows/dataElements — a `--filter` can produce an export where
1173
+ a `crossRepoLink.local.nodeId` is no longer present in the filtered
1174
+ `graph.nodes`; `graph.recipientProfiles` has the identical shape today
1175
+ (also unfiltered) and is not treated as a bug requiring a fix in this
1176
+ deliverable's own scope either. And `index.js`'s
1177
+ `_loadCrossRepoLinkRecords` never re-derives `crossRepoLinkId` from a
1178
+ loaded record's own fields (a hand-edited `cross-repo-links.json` could
1179
+ carry a mismatched id) and does not restrict `provenance` to `'manual'`
1180
+ on the READ path (only the CLI writer hardcodes it) — this matches the
1181
+ established operator-config trust model this whole package uses
1182
+ everywhere else (a hand-edited config file is trusted as declared, the
1183
+ way `recipient-profiles.json`/`privacy-policy.json` already are).
1184
+
1185
+ ## Conventions
1186
+
1187
+ - Every enum here is a single source of truth for its concept. If you add
1188
+ a new node kind, mapping type, transform kind, etc., you MUST update
1189
+ three places: `schema.js` (or `protection.js`/`classification.js`),
1190
+ `dataflow-graph.schema.json`'s matching `enum` array, and
1191
+ `validate.js` if the new value needs a structural check —
1192
+ `json-schema-parity.test.js` fails loudly if the first two drift apart.
1193
+ - Stable IDs are content hashes, not counters — see `ids.js`'s header.
1194
+ Never construct an id string by hand; always call the exported
1195
+ function, so a discriminator-shape change only has one call site to fix.
1196
+ Include every field that distinguishes two entities in the discriminator
1197
+ — omitting one (e.g. `dataElementIds` on an edge) produces a silent
1198
+ collision that `validateGraph()` currently cannot catch on its own.
1199
+ - The flagship fixture is the ONE place fixture-specific facts (node
1200
+ names like "Payments Service", synthetic commit hashes, etc.) are
1201
+ allowed to live. No other module in this package — and per PRD Appendix
1202
+ D.1, no UI code in a later milestone — may special-case a fixture name.
1203
+ The generic hook is `graph.scope.source === 'fixture'`.