@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,3451 @@
1
+ # Path Provenance Recording — Design Record
2
+
3
+ Scope: Sub-project C of Milestone 1, increment C1 (see
4
+ `docs/superpowers/plans/2026-08-30-data-flow-explorer-m1-subproject-c-scoping.md`).
5
+ This document decides **how provenance is recorded** as data flows through
6
+ the field-identity engine. It does **not** decide the storage structure
7
+ (`path-store.js`, C4), the reconstruction query (C5), or the FR-306 edge
8
+ grading (C6) — those consume what is decided here.
9
+
10
+ Binding on every later Sub-project C increment, the same way
11
+ `DESIGN_INTRAPROCEDURAL.md` is binding on `field-identity.js` / `engine.js`.
12
+ If an implementation increment finds a decision here to be wrong or
13
+ incomplete once real code is attempted, **fix this document with a dated
14
+ note explaining what changed and why** — do not silently diverge code from
15
+ doc. That is exactly what Sub-project A's own six-round history shows is
16
+ the expensive failure mode.
17
+
18
+ ---
19
+
20
+ ## 0. The requirements this must satisfy
21
+
22
+ Verbatim from `AGENTIC_SECURITY_DATA_FLOW_EXPLORER_PRD.md` (repo root,
23
+ untracked per this repo's convention):
24
+
25
+ - **FR-303:** *"The engine must retain a compact predecessor/provenance DAG
26
+ from which ordered paths can be reconstructed. It must not eagerly
27
+ materialize every possible path during scanning because that creates path
28
+ explosion."*
29
+ - **FR-305:** *"When several paths connect the same source and sink, the UI
30
+ must show a path count and allow the user to inspect each path.
31
+ Deduplication may collapse identical internal segments but cannot hide
32
+ materially different transformations or controls."*
33
+ - **FR-306:** *"Implicit/control-dependent and unknown-field widened flows
34
+ must be visually distinct and lower-confidence."*
35
+ - **§18.4**, the load-bearing constraint: *"Never translate 'path budget
36
+ exhausted' into 'no path.'"*
37
+
38
+ And two hard local constraints, inherited:
39
+
40
+ - **`field-identity.js` is read-only.** Its `Map<accessPath,
41
+ Set<dataElementId>>` shape and all seven exports stay byte-for-byte
42
+ unmodified. Provenance may not become part of that Map's value type.
43
+ Re-opening FR-301's correctness surface under a richer shape is a bad
44
+ trade against six rounds of adversarial review.
45
+ - **Backward compatibility is the acceptance bar.** With no recorder
46
+ supplied, every observable output of `resolveExprIdentities` /
47
+ `analyzeFunctionFieldIdentity` / `summaries.js` / `driver.js` must be
48
+ byte-identical to today's.
49
+
50
+ ---
51
+
52
+ ## 1. Decision 1 — provenance is an emitted RECORD STREAM, not analysis state
53
+
54
+ **Decided:** the engine emits *hop records* through an optional, write-only
55
+ callback supplied on the existing `ctx` object. It does not accumulate,
56
+ store, join, or read back any provenance structure of its own.
57
+
58
+ Three alternatives were considered and rejected:
59
+
60
+ - **(a) Provenance in the state value type** (`Map<path, Map<id,
61
+ Set<ProvenanceEdge>>>`). Rejected: forbidden by the constraint above, and
62
+ for good reason — every `identitiesAt` / `joinStates` / `removeIdentitiesAt`
63
+ / `statesEqual` / `hashState` invariant would need re-deriving under a new
64
+ shape, and `hashState` (the summary cache key, Sub-project B) would start
65
+ discriminating contexts by *provenance* rather than by *facts*, silently
66
+ exploding B6's per-function context cap.
67
+ - **(b) A second, parallel dataflow state** ("last writer per path"), joined
68
+ at CFG merge points alongside `state`. Rejected: it would have to
69
+ participate in the worklist's fixed point, which means real changes to
70
+ `analyzeFunctionFieldIdentity`'s convergence logic, a second `statesEqual`,
71
+ and a second termination argument. That is a rewrite of A's core in
72
+ everything but name.
73
+ - **(c) Enriching `resolveExprIdentities`'s return shape** with an `origins`
74
+ map, so write-out sites emit complete, self-contained edges. Rejected —
75
+ though it is the closest call. It would force every switch case to build a
76
+ nested origin structure, which is a provenance DAG re-implemented *inside*
77
+ the resolver, one increment early and without the deduplication C4 exists
78
+ to provide. It also cannot represent a hop that has a source but no
79
+ destination (a bare `call` argument; a return) without inventing a second
80
+ channel anyway.
81
+
82
+ **Why a callback rather than an array on `ctx`.** The consumer chooses
83
+ storage: an array for a test, a deduplicating set for C4, a budget-capped
84
+ sink for C5. It also mirrors `ctx.resolveCallSummary`, the precedent
85
+ Sub-project B's increment 2 already established for extending this engine
86
+ additively.
87
+
88
+ **The write-only invariant** (this is what makes "zero behavior change"
89
+ provable rather than merely hoped for):
90
+
91
+ > The engine must never read from the recorder, never branch on anything it
92
+ > returns, and never let a recorder's presence change `state`, `returnFacts`,
93
+ > `mutatedParams`, `widenings`, or `exitState`. The single permitted
94
+ > `ctx.recordHop`-conditional behavior is **extra computation whose result is
95
+ > discarded** (e.g. resolving a source expression purely to learn which
96
+ > identities a skipped write would have carried). `recordHop`'s return value
97
+ > is ignored.
98
+
99
+ A corollary worth stating because it is easy to get wrong: a recorder may be
100
+ **lossy** (drop records past a budget) without affecting analysis results at
101
+ all. That is what lets C5's path budget live entirely in the consumer.
102
+
103
+ **`recordHop` must not throw.** Deliberately un-guarded, matching
104
+ `resolveCallSummary`'s existing precedent — swallowing a consumer's exception
105
+ would hide a real bug in exchange for nothing. A throwing recorder aborts the
106
+ analysis; that is a consumer defect.
107
+
108
+ ---
109
+
110
+ ## 2. Decision 2 — the DAG's node granularity is `(scope, accessPath, dataElementId)`; hop records are HALF-EDGES
111
+
112
+ This is the decision the rest of the shape follows from, so it is stated
113
+ before the shape.
114
+
115
+ ### 2.1 What a DAG node is
116
+
117
+ A reconstructed path that a human can read is a sequence of **state
118
+ locations**: `user.email` → `u.email` → `o.email` → *(returned)*. Every step
119
+ in that sequence is a place the engine actually recorded an identity. The
120
+ things that happen *between* two such locations — an object literal, a
121
+ template literal, a ternary, a resolved call — are **not additional nodes**.
122
+ They are properties of the edge.
123
+
124
+ **Decided:** the DAG's node is `(scope, accessPath, dataElementId)`.
125
+ Expression-internal constructs annotate edges; they never create nodes.
126
+
127
+ This is what makes the structure *compact* in FR-303's sense: `user.email`
128
+ read in forty places is one node, and the forty reads are forty edges out of
129
+ it. A node-per-expression design would be a materialized-path structure in
130
+ disguise.
131
+
132
+ ### 2.2 Why records are half-edges, and how they join
133
+
134
+ A complete edge needs both endpoints. The engine never has both at once:
135
+
136
+ - `resolveExprIdentities` knows the **source** path (it reads `state`) but
137
+ has no idea where the value will eventually land — it is deep inside an
138
+ expression tree and its caller may be an `assign`, a `return`, a call
139
+ argument, or nothing at all.
140
+ - `step()`'s `assign` knows the **destination** path but receives only
141
+ `{flat, byPath, widened}` from the resolver — no source-path information
142
+ whatsoever.
143
+
144
+ So a record is a half-edge, and its `kind` says which half:
145
+
146
+ | Hop type | Half | `fromPath` | `toPath` | Emitted by |
147
+ |---|---|---|---|---|
148
+ | `production` | inbound | the contributing state key, or `null` for a construct that reads no state | `null` | `resolveExprIdentities` cases |
149
+ | `selection` | inbound | the contributing state key, or `null` | `null` | `member`'s two resolution branches |
150
+ | `write-out` | outbound | `null` | the exact path passed to `addIdentity`, or `null` for a hop with no landing path | `step()` cases |
151
+
152
+ **The join rule (binding on C4):** in-half and out-half records join into an
153
+ edge when they share `(scope, nodeId, dataElementId)`.
154
+ **Superseded by §13.3 (increment C3): the key is now the four-part
155
+ `(scope, nodeId, dataElementId, context)`** — see §13.0/§13.3. Every use of
156
+ the three-part form in this document predates C3 and should be read with
157
+ `context` appended.
158
+ Either form is sound only because
159
+ of an invariant that must be re-checked, not assumed:
160
+
161
+ > **Every CFG node kind, in one `step()` invocation, has at most one *write
162
+ > destination expression* — one `target`, one `return value`, one argument
163
+ > list with no destination at all.** A future CFG node kind with two
164
+ > independent destinations (e.g. a lowered swap, or a multi-assignment node)
165
+ > breaks the join rule and needs an explicit correlation token before it can
166
+ > be instrumented.
167
+
168
+ That is an *enumeration principle*, deliberately not a count of today's node
169
+ kinds — see §10.
170
+
171
+ **A gap a design review found in this rule as originally written, closed
172
+ here:** the rule above says WHEN two records join; it did not say what a
173
+ `null`-`fromPath` in-half means when it joins alongside a non-null one at
174
+ the same key. This matters concretely: `const o = {email: u.email}` at one
175
+ CFG node produces, for `data:email`, TWO in-halves (a `selection` hop from
176
+ `u.email`, AND a `production/object` hop with `fromPath: null`) and ONE
177
+ out-half (`write-out` to `o.email`). Read literally, the join rule would
178
+ emit a spurious extra edge with no real source. The intended semantics,
179
+ already implicit in §2.1's worked examples but not stated as a rule: **a
180
+ `null`-`fromPath` in-half is an ANNOTATION on the edges formed by any
181
+ non-null in-half at the same key, not an edge-forming half-edge of its
182
+ own.** It forms a real edge only when NO non-null in-half exists at that
183
+ key — which is precisely the "value with no prior aliasing source" case
184
+ (a literal, or — the one case that matters for interprocedural stitching —
185
+ a resolved call's return value, which is C3's join point, not C1/C2's).
186
+
187
+ > **CORRECTED 2026-08-30 by increment C4 — see §14.4.** The parenthesis
188
+ > above names the resolved-call return value, and the rule then EXCLUDES
189
+ > it: in the real C3 stream the argument expression's own non-null in-half
190
+ > sits at the *same* join key as the `production/call-resolved` hop, so
191
+ > "only when NO non-null in-half exists" demotes the return stitch to an
192
+ > annotation and silently deletes it (measured — a store built that way
193
+ > leaves the callee's exit node with zero outgoing edges). **The
194
+ > discriminator is `peerScope`, not `fromPath`:** a null `fromPath` with a
195
+ > non-null `peerScope` is PEER-ADDRESSED — its source is the callee's own
196
+ > function-exit node — and is always edge-forming, except when it also
197
+ > carries a `lossReason` (§13.6's context-cap marker, whose callee has no
198
+ > recorded body, so an edge from it would fabricate an origin). The rule as
199
+ > written above remains correct, unchanged, for the genuinely source-less
200
+ > case: `peerScope === null`.
201
+
202
+ The three hop types are exactly `DESIGN_INTRAPROCEDURAL.md`'s already-hardened
203
+ production / selection / write-out taxonomy. That is not a coincidence to be
204
+ grateful for; it is the point. Every place structure can be *lost* is a place
205
+ provenance must be *emitted*, because they are the same set of places. A
206
+ provenance site that is not one of those three is a sign the taxonomy is
207
+ being extended without the review the taxonomy earned.
208
+
209
+ ---
210
+
211
+ ## 3. Decision 3 — the hop record shape
212
+
213
+ ```js
214
+ /**
215
+ * One hop record = one half-edge for ONE data element at ONE CFG node.
216
+ * Every field is always present; nullable fields carry `null`, never
217
+ * `undefined` and never an omitted key (a stable shape is what lets C4
218
+ * hash a record for deduplication without a canonicalization step).
219
+ *
220
+ * Increment C3 adds three more always-present fields to this shape —
221
+ * `context`, `peerScope`, `peerContext` — and moves the join key to
222
+ * `(scope, nodeId, dataElementId, context)`. See §13.0 and §13.3; this
223
+ * block is otherwise unchanged.
224
+ *
225
+ * This completeness guarantee is delivered by `analyzeFunctionFieldIdentity`'s
226
+ * OWN worklist wrapper (§7.2's "progressive stamping"), not by
227
+ * `resolveExprIdentities`/`step()` individually — those only ever emit the
228
+ * SEMANTIC fields (kind/subKind/fromPath/toPath/dataElementId/
229
+ * syntacticPath/widenReason/lossReason). `scope`/`nodeId`/`line` are
230
+ * stamped onto every record by the wrapper as it flows through `ctx`,
231
+ * BEFORE any site ever sees it. A whole-branch review confirmed this
232
+ * empirically: calling `resolveExprIdentities` directly with a bare
233
+ * `{recordHop}` (bypassing `analyzeFunctionFieldIdentity`) emits records
234
+ * missing `scope`/`nodeId`/`line` entirely — not `null`, ABSENT. Unreachable
235
+ * in shipped code today (the sole caller, `summaries.js`'s `resolveCallSummary`,
236
+ * passes no `ctx` at all — see §7.4's ctx holes), but §7.4 tells C3 to wire a
237
+ * recorder in at exactly that site. **Binding on C3:** any new emission path
238
+ * that does not route through `analyzeFunctionFieldIdentity`'s own wrapper
239
+ * must independently stamp all three progressive fields itself, or this
240
+ * completeness guarantee — and the no-canonicalization contract C4 is being
241
+ * designed around — silently breaks.
242
+ */
243
+ {
244
+ kind: 'production' | 'selection' | 'write-out',
245
+ subKind: string, // the specific case: 'ident' | 'member' | 'object' |
246
+ // 'array' | 'tpl' | 'binary' | 'logical' | 'union' |
247
+ // 'call' | 'call-resolved' | 'assign-expr' |
248
+ // 'assign' | 'assign-weak' | 'return' | 'call-arg'
249
+ scope: string | null, // the analyzed function's qid; null when unknown
250
+ dataElementId: string, // ALWAYS a non-null id — see Decision 4
251
+ fromPath: string | null, // a REAL state key — see Decision 5 and 6
252
+ toPath: string | null, // the exact path handed to addIdentity — see Decision 5
253
+ syntacticPath: string | null, // the path the IR supplied / the analysis queried,
254
+ // when it differs from the endpoint actually used;
255
+ // null when they are the same
256
+ nodeId: string, // the CFG node id (the worklist key — see Decision 7)
257
+ line: number | null, // node.line, for display
258
+ widenReason: string | null, // null = explicit flow; otherwise the reason
259
+ // ('unresolved-call' | 'dynamic-property-key' | …)
260
+ lossReason: string | null, // null = the identity propagated; otherwise
261
+ // WHY this hop is a dead end ('unsupported-target' | …)
262
+ }
263
+ ```
264
+
265
+ ### Deviations from the plan's suggested starting shape, and why
266
+
267
+ - **`widened: boolean` → `widenReason: string | null`.** The boolean and a
268
+ reason string side by side are redundant, and redundant fields drift — the
269
+ exact failure this codebase punishes elsewhere. `widened` is recoverable as
270
+ `widenReason !== null`. The upgrade also **closes, at the hop layer,
271
+ `DESIGN_INTRAPROCEDURAL.md`'s explicitly-deferred Finding 3** (round 6):
272
+ the `widenings` ledger mislabels a dynamic-property-key widening as
273
+ `'unresolved-call'` because the reason is stamped at `assign`/`return`,
274
+ far from where the cause is known. A hop is recorded *at the site that knows
275
+ its own cause*, so it can label correctly for free. C6 should read
276
+ `widenReason` from hops, not from `widenings`. (The `widenings` ledger's own
277
+ mislabel is untouched and remains open; this does not fix it, it routes
278
+ around it.)
279
+ - **`lossReason` added.** §18.4's most load-bearing constraint is that a
280
+ truncated or unfollowable path must never look like the absence of a path.
281
+ A dead end that is *recorded as a dead end* is the data-layer form of that
282
+ guarantee; an unrecorded drop is invisible and indistinguishable from "no
283
+ flow exists." Without this field, C5 physically cannot satisfy AC-10 from
284
+ the data alone.
285
+ - **`scope` added.** C3 stitches across functions; a hop with no owner cannot
286
+ be stitched. Adding it now costs one field; adding it in C3 means
287
+ re-instrumenting everything C2 wrote.
288
+ - **`syntacticPath` added.** Decision 5 forces endpoints to be real state
289
+ paths, which discards the IR's own framing (`store.*.name` becomes `store`).
290
+ That framing is the only human-readable trace of *why* a hop widened; it
291
+ belongs somewhere, and it must not be in the endpoint.
292
+ - **`nodeId` kept, but sourced differently.** See Decision 7 — hand-built test
293
+ fixtures do not set `node.id`, so it must come from the worklist key.
294
+
295
+ ---
296
+
297
+ ## 4. Decision 4 — one record per `dataElementId`, never a Set-valued record
298
+
299
+ **Confirmed** (the plan's own proposal), with stronger reasoning than the
300
+ plan gave, plus one argument the plan did not have.
301
+
302
+ The plan's argument was that per-identity records let a later query
303
+ distinguish "two elements took the same route" from "one route carries N
304
+ elements." True, but weak on its own — a Set could be exploded at query time.
305
+
306
+ The two decisive arguments:
307
+
308
+ **(1) The half-edge join is only well-defined per identity.** With
309
+ `(scope, nodeId, dataElementId)` as the join key, a Set-valued half-edge
310
+ would have to be exploded before joining anyway, so the Set buys nothing and
311
+ costs a normalization step. Worse, a Set-valued in-half `{ids: {A, B}}`
312
+ paired with a Set-valued out-half `{ids: {A}}` makes B's fate **ambiguous**:
313
+ was B read and deliberately not written (folded into a residual, dropped at
314
+ an unsupported target), or is the out-half merely under-reporting? Per
315
+ identity, "B has an in-half and no out-half at this node" is an unambiguous,
316
+ readable fact — and it is exactly the fact `lossReason` exists to explain.
317
+
318
+ **(2) A worked example where a Set-valued record is outright wrong.** With
319
+ state `{ user: {data:blob}, user.email: {data:email} }` (an ancestor and a
320
+ descendant both carrying identity — a shape §3 of
321
+ `DESIGN_INTRAPROCEDURAL.md` explicitly says must be allowed to coexist),
322
+ `const u = user;` resolves `flat = {data:blob, data:email}` and
323
+ `byPath = {email: {data:email}}`, residual `{data:blob}`. The `assign` then
324
+ writes **two different paths**: `u` ← `data:blob` and `u.email` ←
325
+ `data:email`. Per identity, the records come out exactly right:
326
+
327
+ ```
328
+ in: production/ident from 'user' id data:blob syntacticPath 'user'
329
+ in: production/ident from 'user.email' id data:email syntacticPath 'user'
330
+ out: write-out/assign to 'u' id data:blob
331
+ out: write-out/assign to 'u.email' id data:email
332
+ → edges: user → u (data:blob), user.email → u.email (data:email)
333
+ ```
334
+
335
+ A Set-valued record — one in-half `{from: 'user', ids: {blob, email}}`, one
336
+ out-half `{to: 'u', ids: {blob, email}}` — would report `user → u` carrying
337
+ both, losing `user.email → u.email` entirely. That is FR-301's own merge bug,
338
+ recreated one layer up in the provenance representation, in exactly the
339
+ ancestor/descendant shape Sub-project A's round 1 was fixed to handle.
340
+
341
+ **The cost, disclosed:** record volume is O(identities × hops), and the
342
+ worklist re-emits on every revisit (Decision 8). Mitigations: the recorder is
343
+ opt-in, so a scan that does not want provenance pays literally nothing; and
344
+ C4 deduplicates by record content, which collapses the revisit multiplicity
345
+ and all repeated reads of the same location.
346
+
347
+ ---
348
+
349
+ ## 5. Decision 5 — endpoints are paths the engine actually read from or wrote to; never a syntactic path
350
+
351
+ > **Invariant (the Sub-project C analogue of round 5's own):** every non-null
352
+ > `fromPath` / `toPath` on a hop record must be a path the engine actually
353
+ > **queried against** or **passed to `addIdentity`**. A wildcard-bearing
354
+ > syntactic path is never an endpoint. The wildcard is an *attribute* of the
355
+ > hop (`widenReason: 'dynamic-property-key'`, `syntacticPath` carrying the
356
+ > raw form), never part of its endpoint.
357
+
358
+ ### This answers the plan's Step 5 question, and the answer is "no"
359
+
360
+ The plan asked whether `toPath` "naturally carries the `'*'`-suffixed path
361
+ already, same as the existing write-out logic." **It does not, and it must
362
+ not.** Reading the actual code settles it: `step()`'s wildcard branch does
363
+ not write to `node.target` at all —
364
+
365
+ ```js
366
+ const containerPath = definitePrefixBeforeWildcard(node.target); // 'store.*.name' → 'store'
367
+ for (const id of allIds) wState = addIdentity(wState, containerPath, id);
368
+ ```
369
+
370
+ — it writes at the **definite prefix**. So the only correct `toPath` for
371
+ `store[k].name = user.ssn` is `'store'`, with `syntacticPath: 'store.*.name'`
372
+ and `widenReason: 'dynamic-property-key'`. The read side is symmetric:
373
+ `member`'s wildcard branch queries `identitiesAt(state, basePath)` where
374
+ `basePath` is the same definite prefix, so `fromPath` is `'store'`, not
375
+ `'store.*.name'`.
376
+
377
+ ### Why getting this wrong would be expensive
378
+
379
+ If C2 recorded `toPath: 'store.*.name'`, the DAG would grow a node
380
+ `(store.*.name, id)` that **no read hop anywhere can ever match** — because
381
+ no read hop ever names that path either, for the same reason. Backward
382
+ reconstruction from a sink would hit that node and find zero predecessors,
383
+ and would report *no path* for every flow that passed through a dynamic
384
+ property key. That is §18.4's single most load-bearing constraint —
385
+ "never translate 'path budget exhausted' into 'no path'" — violated in a
386
+ new disguise: not budget exhaustion, but a fabricated endpoint, producing
387
+ the same silent, confident, wrong "there is no flow here."
388
+
389
+ This is the same bug class as round 4's `"[object Object]"` target and round
390
+ 5's `'*'` object key: **a path component that is not a real, resolved
391
+ location, used as an identity key.** The rule above is the general form.
392
+
393
+ ### Two sub-cases the rule already covers
394
+
395
+ - **`typeof node.target !== 'string'`** (assignment-expression destructuring,
396
+ `({a} = obj)`): the engine deliberately skips rather than fabricating a key.
397
+ Provenance must not fabricate one either. This is a **loss** site — see the
398
+ checklist in §10.2 for how to record it.
399
+ - **`prop.key === '*'`** in an `object` literal: the engine folds it into the
400
+ coarse residual and writes it at the container root. So the eventual
401
+ `toPath` is the container, from the residual write — already correct under
402
+ this rule, with `widenReason: 'dynamic-property-key'` carried on the
403
+ production hop.
404
+
405
+ ---
406
+
407
+ ## 6. Decision 6 — `fromPath` is the CONTRIBUTING state key, not the queried path
408
+
409
+ This decision does not appear in the plan at all, and it is the one most
410
+ likely to have been discovered late and expensively.
411
+
412
+ `identitiesAt(state, path)` aggregates **bidirectionally** (round 1 of
413
+ Sub-project A): querying `u.email` sees an identity recorded at the ancestor
414
+ `u`, and querying `u` sees identities recorded at descendants `u.email` /
415
+ `u.ssn`. So the path a read hop *queried* is very often **not** a key in
416
+ `state` at all.
417
+
418
+ If `fromPath` were the queried path, the DAG would contain nodes with no
419
+ incoming edges that nonetheless are not sources — e.g. a node `(u.email, id)`
420
+ created by a read, when the identity actually lives at `(u, id)`. Backward
421
+ reconstruction would terminate there and report a path that begins in the
422
+ middle of the program. Every flow that passes through ancestor or descendant
423
+ coverage — which is most of them, because that is how object structure is
424
+ modeled here — would silently truncate.
425
+
426
+ **Decided:** at a state-backed read, emit **one record per (contributing
427
+ state key, dataElementId)** pair, where a contributing key is a key of
428
+ `state` that (a) contains that id and (b) stands in a prefix-coverage
429
+ relation to the queried path, in either direction — i.e. exactly the keys
430
+ `identitiesAt` unioned to produce the answer. The queried path goes in
431
+ `syntacticPath`.
432
+
433
+ Mechanically this needs one new pure helper in `engine.js` (not in
434
+ `field-identity.js` — no change there), roughly:
435
+
436
+ ```js
437
+ // Which state keys actually contributed `id` to identitiesAt(state, path)?
438
+ // Mirrors identitiesAt's own bidirectional coverage test exactly; if that
439
+ // test ever changes, this must change with it or the DAG silently
440
+ // disconnects. Pure, allocation-only-when-recording.
441
+ function contributingKeys(state, path, id) { /* … */ }
442
+ ```
443
+
444
+ **Cost, corrected — a design review caught this understated:** naively
445
+ calling `contributingKeys(state, path, id)` once per id (as the signature
446
+ above suggests) is O(|state| × |ids at path|), not O(|state|) — the state
447
+ scan repeats per id. The fix is a single pass: scan `state` once against
448
+ `path` (the same prefix-coverage test `identitiesAt` already does), and for
449
+ each covered key partition ITS ids by contributing key in one step, rather
450
+ than re-scanning per id. That is O(|state|) total for all ids at a path, the
451
+ cost this section originally claimed. C2's implementer should build the
452
+ single-pass version, not the naive per-id loop the signature above implies
453
+ — and it must be called **only when `ctx?.recordHop` is present**, squarely
454
+ within Decision 1's "extra discarded computation" allowance.
455
+
456
+ ### Worked example — the plan's own Task 2 fixture, traced end to end
457
+
458
+ ```js
459
+ function f(user) {
460
+ const u = user; // n1
461
+ const o = { email: u.email, ssn: u.ssn }; // n2
462
+ return o; // n3
463
+ }
464
+ // entry state: user.email → data:email, user.ssn → data:ssn
465
+ ```
466
+
467
+ | node | records emitted |
468
+ |---|---|
469
+ | n1 | `production/ident from 'user.email' id data:email syntacticPath 'user'`<br>`production/ident from 'user.ssn' id data:ssn syntacticPath 'user'`<br>`write-out/assign to 'u.email' id data:email`<br>`write-out/assign to 'u.ssn' id data:ssn` |
470
+ | n2 | `selection/member from 'u.email' id data:email`<br>`selection/member from 'u.ssn' id data:ssn`<br>`production/object from null id data:email`<br>`production/object from null id data:ssn`<br>`write-out/assign to 'o.email' id data:email`<br>`write-out/assign to 'o.ssn' id data:ssn` |
471
+ | n3 | `production/ident from 'o.email' id data:email syntacticPath 'o'`<br>`production/ident from 'o.ssn' id data:ssn syntacticPath 'o'`<br>`write-out/return to null id data:email`<br>`write-out/return to null id data:ssn` |
472
+
473
+ Joining by `(scope, nodeId, dataElementId)` yields exactly:
474
+
475
+ ```
476
+ data:email : user.email → u.email → o.email → ⟨return⟩ (via: object at n2)
477
+ data:ssn : user.ssn → u.ssn → o.ssn → ⟨return⟩ (via: object at n2)
478
+ ```
479
+
480
+ Two ordered paths, field-distinct end to end, from a structure containing
481
+ fourteen deduplicated records (the table above: 4 at n1 + 6 at n2 + 4 at n3)
482
+ and zero materialized paths. Note that the two elements never touch, at any
483
+ hop — which is FR-301's requirement carried into FR-303's structure.
484
+
485
+ **Correction found during Task 2's real implementation (this document
486
+ previously said "twelve" here, which was simply a miscount of the table
487
+ two paragraphs above — a genuine defect, not a deliberate simplification):**
488
+ fourteen is the count once EVERY hop type in the table is instrumented,
489
+ which requires `member`'s selection case (the two `selection/member` rows
490
+ at n2) — that is increment C2's job, not C1's. Task 2's own four-site POC
491
+ (§11 — `ident`, `object`, `assign`, `return`; deliberately NOT `member`)
492
+ was run against this exact fixture and produces exactly **twelve**
493
+ deduplicated records for it (4 at n1 + 4 at n2, since the two
494
+ `selection/member` rows never fire + 4 at n3) — confirmed by
495
+ `scanner/test/lineage/engine-provenance.test.js`, which pins this count.
496
+ So "twelve" was a real number, just attached to the wrong scope (it
497
+ describes Task 2's own POC output, not this section's full-coverage
498
+ worked example) — both twelve and fourteen are now stated explicitly,
499
+ against the scope each actually belongs to, so a future reader doesn't
500
+ have to re-derive which is which by hand.
501
+
502
+ Had `fromPath` been the queried path, n1 would have emitted
503
+ `from 'user'` for both ids and n3 `from 'o'` for both, and the reconstruction
504
+ would have been `user → u.email` / `user → u.ssn` — two paths that begin at a
505
+ container the engine never recorded anything at, merging the two elements'
506
+ first hop. The plan's own POC fixture is therefore already sufficient to
507
+ expose this; Task 2 should assert the *contributing-key* form explicitly.
508
+
509
+ ---
510
+
511
+ ## 7. Decision 7 — the injection mechanism, and the exact threading audit
512
+
513
+ ### 7.1 The parameter
514
+
515
+ `ctx.recordHop(record)` — an optional callback on the **existing** `ctx`
516
+ object, mirroring `ctx.resolveCallSummary` exactly (Sub-project B, increment
517
+ 2). No signature change to `resolveExprIdentities`,
518
+ `analyzeFunctionFieldIdentity`, `step()`, or any exported function.
519
+
520
+ ### 7.2 Progressive stamping — call sites supply only semantic fields
521
+
522
+ `resolveExprIdentities` does **not** know `nodeId`, `line`, or `scope`. It is
523
+ called from `step(node, …)` and never receives the node. Rather than thread
524
+ three more arguments through fifteen call sites (and rely on nobody ever
525
+ forgetting one), the recorder is **enriched as it descends**:
526
+
527
+ - `analyzeFunctionFieldIdentity` stamps `scope` (from `fn.qid ?? null`) once,
528
+ for the whole analysis.
529
+ - `step` stamps `nodeId` and `line` once per node visit.
530
+
531
+ ```js
532
+ // in analyzeFunctionFieldIdentity, before the worklist:
533
+ const scope = fn.qid ?? null;
534
+ // in the worklist, per node (nid is the map KEY — see the warning below):
535
+ const stepCtx = ctx?.recordHop
536
+ ? { ...ctx, recordHop: (h) => ctx.recordHop({ scope, nodeId: nid, line: node.line ?? null, ...h }) }
537
+ : ctx;
538
+ ```
539
+
540
+ Two properties this buys:
541
+
542
+ - **A call site cannot forget or mis-supply `nodeId`/`line`/`scope`.** That
543
+ entire bug class is structurally impossible.
544
+ - **With no recorder, `ctx` is passed through byte-identically** — no
545
+ allocation, no new object, nothing for a backward-compatibility test to
546
+ catch. The `? :` above is not an optimization; it is the mechanism by
547
+ which "zero behavior change when absent" is true by construction.
548
+
549
+ Spread order (`{scope, nodeId, line, ...h}`) puts the stamped fields first so
550
+ a record may deliberately override them — needed in C3, where a hop written
551
+ into a *callee's* entry state must carry the callee's `scope`, not the
552
+ caller's.
553
+
554
+ > **`nodeId` must come from the worklist's map key (`nid`), not `node.id`.**
555
+ > Verified by reading `test/lineage/engine-walker.test.js`: hand-built CFG
556
+ > fixtures set no `id` field on their nodes at all, only the map key. The
557
+ > real parser (`parser-js.js`) does set `node.id`, and sets it equal to the
558
+ > key — so `nid` is correct for both and `node.id` is correct for only one.
559
+ > A hop stamped `nodeId: undefined` would collapse every node in a
560
+ > hand-built-fixture function onto one join key, merging unrelated hops.
561
+
562
+ ### 7.3 Threading audit — inside `engine.js`, nothing further is needed
563
+
564
+ Verified by enumerating every call site rather than counting them:
565
+
566
+ ```
567
+ $ grep -n "resolveExprIdentities(" src/lineage/engine.js
568
+ ```
569
+
570
+ **Every call site in `engine.js` already passes `ctx` unconditionally** —
571
+ `member`'s recursive base, `object`, `array`, `tpl`, `binary` (×2), `logical`
572
+ (×2), `union`, `call`'s args, `assign-expr`, and `step()`'s three call sites
573
+ (`assign`, `call`, `return`). The enumeration principle, not the count:
574
+ *every* `resolveExprIdentities` call in this file threads `ctx`, and any new
575
+ one must. So instrumenting one case is sufficient to prove the mechanism; no
576
+ new plumbing exists anywhere in `engine.js`.
577
+
578
+ ### 7.4 Threading audit — `summaries.js` and `driver.js` have three real holes
579
+
580
+ The same audit run across the package finds **three places where `ctx` is
581
+ dropped or reconstructed**, all outside `engine.js`. None blocks C2; all
582
+ three block C3, and they are named here so C3 does not rediscover them the
583
+ expensive way:
584
+
585
+ | Site | Problem |
586
+ |---|---|
587
+ | `summaries.js:291`, in `entryStateFromCall` | `resolveExprIdentities(callerState, callArgs[i])` is called with **no `ctx` at all**. Argument→parameter binding is the single most important interprocedural hop, and it is currently unrecordable. `entryStateFromCall`'s signature must gain a way to receive one. |
588
+ | `summaries.js:357`, in `createCallSummaryResolver` | Constructs a **fresh** `{ resolveCallSummary }` ctx for the callee's `analyzeFunctionFieldIdentity`, discarding any `recordHop` the caller had. A resolved call chain therefore records the caller's hops and none of the callee's. |
589
+ | `driver.js:65` | Same fresh-`{ resolveCallSummary }` construction for every top-level function. The project-wide entry point cannot pass a recorder in at all today. |
590
+
591
+ There is also a **missing call-site identity** problem C3 must solve:
592
+ `resolveCallSummary(calleeExpr, callArgs, callerState)` has no access to the
593
+ calling CFG node, so a cross-function hop cannot be stamped with the call
594
+ site it crossed at. Recommended fix, consistent with §7.2: `step`'s derived
595
+ ctx already carries the stamped `recordHop`; extend the same derived ctx with
596
+ the plain values (`ctx.hopSite = {scope, nodeId, line}`) so `summaries.js` can
597
+ read them without any signature change. Decide this in C3, not now — but do
598
+ not design C3's hop shape as though the call site were available for free.
599
+
600
+ This entire subsection exists because of Sub-project A's round-4 lesson,
601
+ generalized: *the check must not stop at the file you are editing.* Round 4's
602
+ gap survived three reviews because everyone checked
603
+ `resolveExprIdentities`'s switch and nobody checked `step()`. The Sub-project
604
+ C version of that mistake is checking `engine.js` and not checking
605
+ `summaries.js` / `driver.js`.
606
+
607
+ ---
608
+
609
+ ## 8. Decision 8 — the worklist re-emits; deduplicate at the consumer
610
+
611
+ `analyzeFunctionFieldIdentity` is a fixed-point worklist: a node is
612
+ re-`step()`ed whenever an incoming state changes. So **every hop is emitted
613
+ once per node visit**, not once per program point.
614
+
615
+ **Decided:** the engine does *not* suppress duplicates. Doing so would
616
+ require a second per-node memo — i.e. Decision 1's rejected option (b) in
617
+ miniature. C4 deduplicates by record content.
618
+
619
+ **Why duplicates are harmless and no early-iteration hop is ever wrong:**
620
+ the state visible at a node is **monotone** across worklist iterations.
621
+ `inStates`/`outStates` are only ever updated through `joinStates`, which
622
+ unions; `step` is monotone in its input (each case only unions more into what
623
+ it produces, and `assign`'s kill removes a *fixed*, content-independent set of
624
+ paths). Therefore the identities resolvable at a node on iteration *k+1* are a
625
+ superset of those on iteration *k*, so the hops emitted on iteration *k* are a
626
+ **subset** of those emitted on the final iteration. Duplicates are exact
627
+ repeats; nothing recorded early is later invalidated. Content-deduplication
628
+ is therefore both safe and complete.
629
+
630
+ **Inherited imprecision, disclosed:** because `outStates.set(nid, joinStates(prevOut, out))`
631
+ joins a node's *own* successive outputs, a strong update inside a loop is
632
+ weakened across iterations — a pre-existing property of Sub-project A's
633
+ worklist, not something provenance introduces. Hop records inherit it exactly:
634
+ inside a loop, a re-assigned variable may retain in-halves from a prior
635
+ iteration's source. Recording it faithfully is correct; "fixing" it here would
636
+ mean disagreeing with the analysis the hops are supposed to explain.
637
+
638
+ ---
639
+
640
+ ## 9. Known imprecisions of this design, disclosed up front
641
+
642
+ None of these is a soundness hole — each over-approximates *paths*, never
643
+ under-reports *identities*. They are listed so a later increment measures
644
+ them rather than rediscovering them.
645
+
646
+ ### 9.1 The half-edge cross-join can invent a path
647
+
648
+ Within one CFG node, all in-halves for an id join with all out-halves for
649
+ that id. When the **same** `dataElementId` is read from ≥2 distinct paths
650
+ **and** written to ≥2 distinct paths at the same node, the cross product
651
+ contains edges that never really happened:
652
+
653
+ ```js
654
+ const x = { a: p.email, b: q.email }; // both p.email and q.email carry data:email
655
+ // in-halves : p.email, q.email out-halves: x.a, x.b
656
+ // joined : 4 edges — of which p.email→x.b and q.email→x.a are phantom
657
+ ```
658
+
659
+ **Decided: detect and mark, do not prevent.** C4 can identify this exactly —
660
+ at a `(scope, nodeId, dataElementId)` group, `distinctInPaths ≥ 2 &&
661
+ distinctOutPaths ≥ 2` — and mark the resulting edges
662
+ `ambiguousCorrelation: true`. FR-306 already requires a lower-confidence
663
+ grade for less-certain hops; this feeds it directly, and FR-305's constraint
664
+ is about not *hiding* differences, which marking satisfies and silence would
665
+ not.
666
+
667
+ > **CORRECTED by increment C4 — see §14.7.** The group-level measure above
668
+ > (`distinctInPaths ≥ 2 && distinctOutPaths ≥ 2` on the WHOLE group) is too
669
+ > coarse: applied to a plain resolved call, it marks every edge in the
670
+ > group — including both edges of the correct call-boundary chain — because
671
+ > the annotation-only `call-resolved` in-half (`fromPath: null`) counts as
672
+ > a second "distinct" in-path even though it never forms its own edge
673
+ > (§2.2's correction). §14.7 replaces this with a per-*pairing* measure
674
+ > (ambiguous only when the SPECIFIC in-half/out-half pair being joined has
675
+ > a same-key sibling on both sides), verified against a real resolved-call
676
+ > fixture where the group-level form over-marks 3 of 5 edges and the
677
+ > per-pairing form marks exactly the 1 genuinely ambiguous one. The
678
+ > `slot`-field discussion below is unaffected by this correction.
679
+
680
+ **The known fix, if C4 measures this as a real problem:** add a `slot` field
681
+ — the sub-path *within the value under construction* that a hop contributes
682
+ to — and join on `(scope, nodeId, dataElementId, slot)`. It is deferred, not
683
+ overlooked, because it requires threading a slot prefix down through
684
+ `resolveExprIdentities`'s recursion for the **in-expression** case (the
685
+ resolver does not know its own position in its parent). The cost is lower
686
+ than that framing suggests for a real, non-exotic shape a design review
687
+ confirmed by execution: a plain alias of a multi-field object —
688
+ `const u = user;` where `state` already has `user.a: X, user.b: X` — trips
689
+ this exact condition (`distinctInPaths=2, distinctOutPaths=2` for the same
690
+ id) with **neither** half needing anything threaded through, since both
691
+ in-halves (`user.a`, `user.b`) and both out-halves (`u.a`, `u.b`) already
692
+ carry their own sub-path as their own `fromPath`/`toPath`. A `slot` for
693
+ THIS shape is a same-node string transform, not a signature change — only
694
+ the in-expression case (`{a: p.email, b: q.email}`, where the resolver
695
+ itself must know it's building the `a` vs `b` slot) needs the recursion
696
+ change. Do not adopt either half speculatively; measure first — but do not
697
+ assume both halves cost the same when deciding whether to.
698
+
699
+ **Frequency, corrected:** the paragraph above previously called this "a
700
+ narrow case that has not yet been shown to occur." A design review
701
+ reproduced it with the plain-alias shape by running the real walker — it is
702
+ not exotic, and any object carrying the same identity at two fields, then
703
+ aliased or passed through untouched, hits it. This does not change the
704
+ decision (detect-and-mark is still correct, and still sound — nothing is
705
+ silently wrong), but `ambiguousCorrelation: true` should be expected to
706
+ appear on a real, non-trivial share of ordinary aliasing edges, not treated
707
+ as a rare corner case when C4 is scoping how much weight to give it in
708
+ FR-306's confidence grading.
709
+
710
+ ### 9.2 The DAG is flow-insensitive at reconstruction time
711
+
712
+ The forward analysis is flow-sensitive; the DAG is not. `a = user.email; a = other.email;`
713
+ — where both carry `data:email` — leaves two predecessors for `(a, data:email)`,
714
+ and reconstruction will report both, though only the second reaches a later
715
+ sink.
716
+
717
+ Note the narrowness: this needs the **same** id written to the **same** path
718
+ from **different** sources. When the ids differ, the killed identity simply
719
+ has no outgoing hop past the kill point and backward reconstruction never
720
+ visits it — the dead branch is naturally unreachable, at no cost.
721
+
722
+ **Lever available to C5, not mandated here:** every edge carries `nodeId` and
723
+ `line`, so a reconstruction can require hop ordering to be non-decreasing
724
+ along a path, or de-prioritize paths that are not. Whether that is worth its
725
+ false-negative risk on loops and back-edges is C5's call, made against real
726
+ measurements.
727
+
728
+ ### 9.3 Cyclic DAGs are possible
729
+
730
+ `x = y; y = x;` produces a two-cycle for a shared id. Reconstruction (C5) must
731
+ be cycle-safe by construction — a visited set per path, plus the hop budget —
732
+ and a path truncated by cycle-breaking must be reported as truncated, per
733
+ §18.4. Stated here so C5 does not treat acyclicity as an inherited guarantee:
734
+ **the "DAG" in FR-303 names the intent, not a property this recording
735
+ mechanism enforces.**
736
+
737
+ ### 9.4 The join key does not distinguish entry contexts — a real gap for C3, named now
738
+
739
+ > **CLOSED by increment C3 — see §13.3**, which adds the `context` field
740
+ > proposed at the end of this section and moves the join key to
741
+ > `(scope, nodeId, dataElementId, context)`. The worked example below was
742
+ > reproduced, and both phantoms shown excluded, in this design's own
743
+ > now-deleted PoC file at design time, and again in the permanent suite
744
+ > (`test/lineage/engine-provenance-interprocedural.test.js`) once shipped.
745
+ > The section is kept as written because it is the reasoning §13.3 rests on.
746
+
747
+ `(scope, nodeId, dataElementId)` is the join key throughout this document
748
+ (§2.2). It is sufficient for ONE analysis run of ONE function under ONE
749
+ entry state. It is NOT sufficient once `FieldIdentitySummaryCache` (Sub-
750
+ project B, B1/B6) computes up to 16 distinct entry contexts for the SAME
751
+ qid — which `driver.js` already triggers today, independent of anything
752
+ Sub-project C adds. Two contexts of the same function emit hops that share
753
+ `(scope, nodeId, dataElementId)` but describe DIFFERENT endpoints:
754
+
755
+ ```js
756
+ function g(x) { const y = x; return y; }
757
+ // context A (entry: x.email → data:email): in-half from 'x.email', out-half to 'y.email'
758
+ // context B (entry: x → data:email): in-half from 'x', out-half to 'y'
759
+ // joined at n1 for data:email: 2 in-halves × 2 out-halves = 4 edges,
760
+ // 2 of which (x.email→y, x→y.email) never actually happened in either context
761
+ ```
762
+
763
+ §8's monotonicity argument does not cover this — it is scoped to worklist
764
+ revisits WITHIN one analysis run, and neither context's hop set is a subset
765
+ of the other's. This is a different failure from §9.1's cross-join (that one
766
+ is real ambiguity within a single, real execution; this one mixes hops from
767
+ executions that never coexisted).
768
+
769
+ **Not this increment's or C2's problem to fix** — C1/C2 only ever run one
770
+ entry state at a time, so it cannot manifest yet. **Named here, now,
771
+ specifically so C3 (which is what actually turns on multi-context analysis
772
+ for provenance) does not rediscover it expensively**, matching this
773
+ document's own stated purpose. The fix is additive and cheap when C3 gets
774
+ there: add a `context` field (e.g. `hashState(entryState)`, reusing the
775
+ exact primitive `FieldIdentitySummaryCache` already keys on) to the record
776
+ and fold it into the join key — no change to the half-edge model, the node
777
+ granularity, or anything C1/C2 build.
778
+
779
+ ### 9.5 The worklist's own iteration budget is an unrepresented truncation
780
+
781
+ `analyzeFunctionFieldIdentity`'s `ITER_BUDGET` (5000) is a defensive backstop
782
+ against a malformed/generated CFG — on a real, well-formed CFG the fixed
783
+ point is reached in finitely many steps and the budget is never hit (see
784
+ `engine.js`'s own comment on `ITER_BUDGET`). But if it ever IS hit, the
785
+ worklist `break`s and returns a silently partial fixed point — exactly the
786
+ "path budget exhausted" class §18.4 requires never be presented as "no
787
+ path." This design has no `lossReason` value for it today (`§3`'s value set
788
+ covers per-hop losses like `unsupported-target`/`dynamic-property-key`, not
789
+ a whole-analysis-run truncation). Defensive-only, not expected to fire on
790
+ real code — but §18.4 treats exactly this constraint as load-bearing, so it
791
+ should not be the one gap this document leaves unnamed. **For whichever
792
+ increment first surfaces analysis-level (not per-hop) truncation to a
793
+ consumer** (plausibly C5's reconstruction-result shape, per the scoping
794
+ doc's own §3): a function whose `analyzeFunctionFieldIdentity` run hit
795
+ `ITER_BUDGET` should mark its ENTIRE result set as budget-truncated, not
796
+ leave individual hops looking complete.
797
+
798
+ ### 9.6 A callee's body is recorded once per ENTRY CONTEXT, not once per call site
799
+
800
+ **Added by increment C3, from a measurement, not a prediction** — see §13.4
801
+ for the run that produced it.
802
+
803
+ `FieldIdentitySummaryCache.compute(qid, entryState, analyzeFn)` memoizes:
804
+ `analyzeFn` — the thing that transitively emits the callee's own hops —
805
+ runs only on a cache MISS. So when two call sites bind the *same*
806
+ `(qid, entryState)`, the callee's internal hops are emitted **once**, for
807
+ whichever call site missed first. The later, cache-hitting call site emits
808
+ its own `call-arg-bind` and `call-resolved` hops (neither goes through the
809
+ cache) and nothing else.
810
+
811
+ **Accepted as a disclosed property, and it is not a loss** — *provided*
812
+ `peerContext` is recorded (§13.2). Both call sites' `call-arg-bind` hops
813
+ carry the same `peerContext`, which is exactly the `context` stamped on the
814
+ single recorded copy of the callee's body, so a consumer walking forward
815
+ from either call site reaches that body. The body is **shared, not
816
+ orphaned**; sharing one recorded body across N call sites that produce the
817
+ same callee behaviour *is* FR-303's compactness requirement, not a gap in
818
+ it. Proven by execution, not argued: the PoC asserts, for the two-call-site
819
+ fixture, that every bind hop's `peerContext` is a context whose body is
820
+ present in the record stream.
821
+
822
+ Two caveats, stated so a later increment does not rediscover them:
823
+
824
+ - **Without `peerContext` this WOULD be a silent loss**, and the naive
825
+ wiring is precisely the one that omits it. That is what makes
826
+ `peerContext` load-bearing rather than decorative, and it is why §13.4
827
+ does not need the much larger "cache stores and replays a per-summary hop
828
+ list" fix that was the alternative on the table.
829
+ - **The one genuinely orphaned case is a B6 cap degradation**, where the
830
+ bound context's body was never computed at all. That is a real §18.4
831
+ violation, it is separate from this section, and §13.6 handles it.
832
+
833
+ ---
834
+
835
+ ## 10. Full instrumentation checklist for increment C2
836
+
837
+ **Not to be implemented by this increment.** Increment C1's proof-of-concept
838
+ covers four sites only (§11). This table is C2's work order, written now while
839
+ the design is fresh, matching `DESIGN_INTRAPROCEDURAL.md`'s own precedent of
840
+ naming what is deferred rather than being silent about it.
841
+
842
+ **The enumeration principle, not a count:** *every* `case` in
843
+ `resolveExprIdentities`'s switch and *every* `case` in `step()`'s switch — plus
844
+ the `default` of each — must be visited and given a verdict, including a
845
+ verdict of "emits nothing, because …". A hand-counted "these N sites" claim is
846
+ precisely what let Sub-project A's rounds 3, 4 and 5 each ship an unexamined
847
+ site (see `DESIGN_INTRAPROCEDURAL.md`'s own note that "any future 'N sites'
848
+ phrasing in this document is a bug in the document"). If the switch grows a
849
+ case, this table is stale until it grows a row.
850
+
851
+ ### 10.1 `resolveExprIdentities` — every case
852
+
853
+ | case | hop type(s) | `fromPath` | `toPath` | notes |
854
+ |---|---|---|---|---|
855
+ | `ident` | production | contributing state key per id (Decision 6) | `null` | `syntacticPath` = the resolved path. `byPath` construction reads the same keys — do not double-emit. |
856
+ | `member` (path branch, no wildcard) | selection | contributing state key per id | `null` | `syntacticPath` = the resolved dotted path. |
857
+ | `member` (path branch, wildcard) | selection | `definitePrefixBeforeWildcard(path)` — **never** the `'*'` path | `null` | `widenReason: 'dynamic-property-key'`, `syntacticPath` = the raw `'…*…'` path. Decision 5. |
858
+ | `member` (non-path base, `prop !== '*'`) | selection | `null` (base is an in-flight value, not a state key) | `null` | The base's own recursion already emitted the state-backed in-halves; this hop only annotates the selection. |
859
+ | `member` (non-path base, `prop === '*'`) | selection | `null` | `null` | `widenReason: 'dynamic-property-key'`. |
860
+ | `literal` | — | — | — | Emits nothing: no identity exists to have provenance. |
861
+ | `unknown` / `default` | — | — | — | Emits nothing. **Consider** a `lossReason: 'unmodelled-expression'` marker for the coverage ledger — but only when an identity is demonstrably being dropped, which for these cases it is not (nothing was resolved). Decide in C2 with evidence, do not add speculatively. |
862
+ | `object` (plain property) | production | `null` | `null` | Per id in the property's contribution. Edge annotation (`via: 'object'`). Structure is *preserved* here — no `lossReason`. |
863
+ | `object` (spread property) | production | `null` | `null` | `subKind: 'object'`, but distinguish spread in a note if C4 needs it — the byPath merge is structurally different (top-level siblings). |
864
+ | `object` (`prop.key === '*'`) | production | `null` | `null` | `widenReason: 'dynamic-property-key'`. Folds into the residual, so the eventual `toPath` is the container root — already correct under Decision 5. |
865
+ | `array` | production | `null` | `null` | Structure-flattening **by design** (spread ambiguity, ADR §4). **No `lossReason`** — identity propagates fully; only per-index distinction is lost, which is a precision fact, not an identity loss. Getting this wrong would flood the coverage ledger with false gaps. |
866
+ | `tpl` | production | `null` | `null` | Transformation-bearing: the identity is embedded in a new string. Prime FR-307 / Sub-project D raw material. No `widenReason` — ADR §4 is explicit that this is an *explicit* flow, not a widened one. |
867
+ | `binary` | production | `null` | `null` | Same as `tpl`. Deliberately separate from `logical`. |
868
+ | `logical` | production | `null` | `null` | Structure-preserving (short-circuit returns an operand verbatim). |
869
+ | `union` (ternary) | production | `null` | `null` | Structure-preserving. Both branches emit; the resulting multiple in-halves are *correct* — this is FR-305's genuine multiple-path case, not §9.1's phantom. |
870
+ | `call` (unresolved) | production | `null` | `null` | `widenReason: 'unresolved-call'`. |
871
+ | `call` (resolved via `ctx.resolveCallSummary`) | production | `null` | `null` | `subKind: 'call-resolved'`, `widenReason: null`. The cross-function stitching itself is **C3**, not C2 — C2 records only that a resolved call contributed. |
872
+ | `assign-expr` | production | `null` | `null` | Pure pass-through; forwards the source's `widened`, so forward its `widenReason` too. Note the known limitation: it does **not** write to state, so there is no write-out hop — an in-half with no out-half that is *not* a loss. |
873
+
874
+ **2026-08-30 implementation note (C2, Task 1):** the `assign-expr` row's
875
+ "forward its `widenReason` too" instruction assumed a real reason string was
876
+ available to forward. It isn't — `resolveExprIdentities`'s return shape is
877
+ `{flat, byPath, widened}`, a boolean, not a reason string (that's exactly
878
+ Decision 3's own already-disclosed gap: only a *hop*, not the general
879
+ return value, carries `widenReason`). Resolved by applying the SAME
880
+ documented-approximate `'unresolved-call'` convention `step()`'s
881
+ `assign`/`return` cases already use when forwarding a bare `widened` flag
882
+ (`r.widened && r.flat.size > 0 ? 'unresolved-call' : null`) — not a new
883
+ mechanism, just the existing one, applied consistently. This inherits
884
+ those cases' already-disclosed mislabeling risk (a widening actually
885
+ caused by a dynamic property key can read as `'unresolved-call'`); it does
886
+ not worsen it. A real fix still needs `resolveExprIdentities` to thread an
887
+ actual reason string through its return value, out of C2's scope.
888
+
889
+ ### 10.2 `step()` — every CFG node kind
890
+
891
+ | case | hop type | `fromPath` | `toPath` | notes |
892
+ |---|---|---|---|---|
893
+ | `assign`, target not a string | write-out | `null` | `null` | **Loss site.** `lossReason: 'unsupported-target'`. Requires resolving `node.source` purely to learn the ids. **Not merely discarded computation, a design review flagged this understated:** resolving `node.source` recursively runs the FULL `resolveExprIdentities` tree for that expression, which — when `ctx?.recordHop` is present — genuinely EMITS real in-half (`production`/`selection`) hops for whatever `node.source` reads, exactly as it would for any other resolved expression. These are not spurious: they correctly join with THIS row's `lossReason` write-out to show "this data was read here, then lost, because the target couldn't be represented" — arguably necessary for §18.4's transparency requirement, not incidental. If no identity resolves from `node.source`, none of that fires and there is nothing to lose. **CORRECTION (a later final whole-branch review, increment C2): "permitted (Decision 1) but must be guarded on `ctx?.recordHop`" — this document's own earlier wording — is WRONG, and describes a real bug increment C2 shipped and then fixed.** `resolveExprIdentities` is not side-effect-free when `ctx.resolveCallSummary` is present: its `call` case can trigger `FieldIdentitySummaryCache.compute()` for a callee, which registers a context against that function's distinct-context cap (`summaries.js`). Gating the resolve itself (not just the hop emission) on `ctx?.recordHop` meant a recorder's mere PRESENCE could consume cap budget a no-recorder run never would, silently changing a LATER, unrelated call site's own resolution once the cap was hit — reproduced with real parsed source, in the unsound direction (attaching a recorder made the analysis LOSE an identity a no-recorder run kept). Decision 1's "extra, discarded computation" allowance covers computation that is genuinely inert to skip; this resolve never qualified, because it can mutate cache state a sibling call site later reads. **The resolve itself must always run unconditionally; only the HOP EMISSION may be gated on `ctx?.recordHop`** — exactly the pattern the sibling `assign` (normal) branch already used, which is why that branch never exhibited this bug. Regression-tested with a real `FieldIdentitySummaryCache` at a low cap in `test/lineage/engine-provenance.test.js`. |
894
+ | `assign`, wildcard target | write-out | `null` | `definitePrefixBeforeWildcard(node.target)` | `subKind: 'assign-weak'`, `widenReason: 'dynamic-property-key'`, `syntacticPath` = raw target. **Weak update** — no kill. One record per `(containerPath, id)`. |
895
+ | `assign`, normal — residual write | write-out | `null` | `node.target` | One record per id in the residual. |
896
+ | `assign`, normal — `byPath` writes | write-out | `null` | `` `${node.target}.${subPath}` `` | **One record per `addIdentity` call**, at the exact sub-path written. Recording `node.target` here instead is the most likely C2 mistake: it would claim `o` where the identity is really at `o.email`, mismatching the granularity every read hop uses and disconnecting the DAG. |
897
+ | `assign`, normal — the kill (`removeIdentitiesAt(stateIn, node.target)`) | — | — | — | **No row of its own, and that is the correct answer, not an oversight** (§10's own rule requires every case get a verdict, including "emits nothing, because…" — this is that verdict, made explicit per a design review's request). §9.2 already covers this from the reconstruction side: a killed identity simply has no outgoing hop past the kill point, so backward reconstruction never visits the dead branch — at no representation cost. Nothing to emit here beyond what the surrounding residual/byPath writes above already record. |
898
+ | `call` (bare call statement) | write-out | `null` | `null` | `subKind: 'call-arg'`. The value leaves the analysis via an argument — not a loss, an escape. This is the natural sink-attachment point for Sub-project D. |
899
+ | `return` | write-out | `null` | `null` | `subKind: 'return'`. Deliberately **not** a pseudo-path like `'@return'` — mixing a fabricated token into the endpoint namespace is the bug class of Decision 5. C3/C4 identify a function exit by `kind === 'write-out' && subKind === 'return' && toPath === null`, scoped by `scope`. |
900
+ | `throw` | — | — | — | Currently a no-op in `step()`. Emits nothing. Revisit only if `throw` ever becomes a real transfer function. |
901
+ | `entry` / `exit` / `noop` / `loop-header` / `if` / `unknown` / `default` | — | — | — | No transfer, nothing to record. `if` is where **implicit/control-dependent** flow would eventually be recorded for FR-306's first half — the engine models no implicit flow today, so there is nothing to emit; do not invent one in C2. |
902
+
903
+ ### 10.3 Cross-file sites C3 must add (not C2)
904
+
905
+ `summaries.js`'s `entryStateFromCall` (argument→parameter write-outs, in the
906
+ **callee's** scope), `applyAtCallSite` (callee mutation→caller write-outs), and
907
+ `summaryFromAnalysisResult`'s return-fact union (the callee-exit→caller-value
908
+ hop). Plus the three ctx holes and the missing call-site identity in §7.4. C3
909
+ must also mark hops recorded during a B5 bottom-stub round and hops from a
910
+ B6 context-capped, degraded summary — §3 of the scoping doc names both, and
911
+ neither is representable in today's shape without a new `subKind` or
912
+ `lossReason` value. That is C3's call to make, and it is additive.
913
+
914
+ > **2026-08-30 correction (increment C3, per this document's own
915
+ > fix-rather-than-diverge policy).** Three of this paragraph's instructions
916
+ > were wrong once real code was attempted, and §13 supersedes them:
917
+ >
918
+ > - *"argument→parameter write-outs, in the **callee's** scope"* — **no.**
919
+ > The binding out-half must be stamped with the **caller's**
920
+ > `scope`/`nodeId`/`context`, because that is the only way it joins with
921
+ > the in-halves the argument expression's own resolution emits (which run
922
+ > under the caller's `stepCtx`). The callee's identity is carried on the
923
+ > new `peerScope`/`peerContext` fields instead. See §13.2.
924
+ > - *"C3 must also mark hops recorded during a B5 bottom-stub round"* —
925
+ > there is **nothing to mark**: a bottom stub's `returnFlat` is empty, so
926
+ > `case 'call'` emits no `call-resolved` hop at all on that round. See
927
+ > §13.6, which measured this.
928
+ > - *"`applyAtCallSite` (callee mutation→caller write-outs)"* — **deferred,
929
+ > not done.** `applyAtCallSite` is exported and unit-tested but is wired
930
+ > into nothing: `engine.js` never calls it (a fact `test/lineage/driver
931
+ > .test.js` already records). Instrumenting a mechanism that never runs
932
+ > would ship untested-by-construction hop code. See §13.7's exclusions.
933
+
934
+ **Also C3's, and load-bearing, not optional:** §9.4's `context` field. C3 is
935
+ what actually exercises multiple entry contexts for the same qid through
936
+ this recording mechanism — closing §9.4 is a precondition for C3's own hops
937
+ being correct, not a nice-to-have alongside them.
938
+
939
+ ---
940
+
941
+ ## 11. What increment C1's proof-of-concept (Task 2) covers
942
+
943
+ Four sites only: `resolveExprIdentities`'s `ident` and `object`, and
944
+ `step()`'s `assign` (non-wildcard branch) and `return`. Enough to prove one
945
+ in-half, one annotation-only hop, one out-half, and one exit marker join into
946
+ a real, ordered path against real parsed JS/TS — and nothing more. Everything
947
+ in §10 beyond those four is C2's.
948
+
949
+ Task 2 should assert, specifically:
950
+
951
+ 1. **Opt-out is genuinely zero-cost.** An existing scenario, run with no
952
+ `recordHop`, produces byte-identical output.
953
+ 2. **Contributing-key semantics** (Decision 6), not queried-path semantics —
954
+ the §6 worked example distinguishes them and is the natural fixture.
955
+ 3. **Per-identity records** (Decision 4) — two ids through one construct give
956
+ two records, never one carrying a Set.
957
+ 4. **`nodeId` comes from the worklist key** (Decision 7) — assert distinct
958
+ `nodeId`s on a hand-built fixture whose nodes set no `id` field.
959
+
960
+ ---
961
+
962
+ ## 12. What this document deliberately does NOT decide
963
+
964
+ The storage structure and its stable ID (`path-store.js`, `ids.js`'s `pathId`
965
+ — C4); the backward-walk query, the alternate-path cap, the prioritization
966
+ rule, and the truncation-is-never-silent result shape (C5); how `widenReason`
967
+ maps onto FR-306's evidence grades (C6); any `DataFlowGraph v1` output
968
+ (Sub-project E); source/sink registries and transformation-kind recognition
969
+ (Sub-project D); collapsing repeated library/framework nodes into typed
970
+ summary hops (§18.4 — home undecided, plausibly D or C4).
971
+
972
+ **Updated 2026-08-30 by increment C3.** Nothing needed removing from this
973
+ list — the questions §13 closes were open in §7.4 / §9.4 / §10.3, never in
974
+ this section's own prose. For the record, §13 now decides: the `context` field and the join-key extension (§9.4 →
975
+ §13.3); the `resolveCallSummary` / `entryStateFromCall` signature changes
976
+ and the three §7.4 ctx holes (§13.1); the argument→parameter binding hop's
977
+ shape (§13.2); whether call-site identity needs its own `hopSite` field
978
+ (§13.5 — it does not); and how a B5/B6-degraded resolution is represented
979
+ (§13.6). Added to this list by §13, i.e. deliberately punted further:
980
+
981
+ - **How C4 materializes a cross-scope half-edge.** §13.2 records the peer
982
+ endpoint's `(scope, context)`; it does not decide whether C4 renders the
983
+ caller→callee transition as one edge, two, or a typed "call" segment.
984
+ - **Instrumenting `applyAtCallSite`** — deferred until it is actually wired
985
+ into `engine.js` (see §10.3's correction and §13.7).
986
+ - **Interprocedural provenance for the hand-rolled-parser languages.**
987
+ `createCallGraphLookup` resolves nothing for Python/Ruby/PHP/Go/Java/C#/
988
+ Kotlin IR (their `callee` is a flat dotted string, deliberately not
989
+ resolved — see `summaries.js`'s `_resolvableCalleeName`), and no
990
+ member-expression callee resolves anywhere without CHA. Every such call
991
+ takes the unresolved fallback, so it gets `production/call` and no
992
+ cross-function hops at all. C3 does not change that boundary; it inherits
993
+ it from B3, and it is a coverage fact a consumer must not read as "no
994
+ flow crosses this call."
995
+
996
+ Two levers are named but **not** adopted here, with their trigger conditions,
997
+ so a later increment adopts them on evidence rather than on taste: the `slot`
998
+ correlation field (§9.1) and hop-order filtering during reconstruction (§9.2).
999
+
1000
+ **Updated 2026-08-30 by increment C4.** The first item on this list — "the
1001
+ storage structure and its stable ID (`path-store.js`, `ids.js`'s `pathId` —
1002
+ C4)" — is now **decided in §14**, including the two things this section
1003
+ could not have anticipated: cross-function node addressing (§14.3) and the
1004
+ correction §2.2's annotation rule needed before the resolved-call return
1005
+ value could be stitched at all (§14.4). §14 also settles the "one edge, two,
1006
+ or a typed call segment" question this section punted (two cross-scope
1007
+ edges — §14.3/§14.4), declines the library/framework-collapse item as
1008
+ Sub-project D's rather than C4's (§14.9), and supplies part of the evidence
1009
+ §9.1's `slot` lever was waiting on (§14.7). Everything else on this list is
1010
+ still open, and `pathId` itself is now deliberately left unclaimed for C5
1011
+ (§14.5).
1012
+
1013
+ **Updated 2026-08-30 by increment C6.** This list's third item — "how
1014
+ `widenReason` maps onto FR-306's evidence grades (C6)" — is now **decided
1015
+ in §16**, and the answer is wider than the item's own wording: `widenReason`
1016
+ is one of *four* grading inputs (`ambiguousCorrelation`, `lossReason`,
1017
+ `widenReason`, and — the one §14.9's own correction warned about, measured
1018
+ larger than that warning states — reasons carried in `annotations[]`
1019
+ rather than on the edge's top-level arrays, §16.5). §16 also declines to
1020
+ reuse `protection.js`'s `EVIDENCE_GRADES` for it, with the reason stated
1021
+ (§16.2), and keeps the `implicit` half of FR-306 as a **reserved,
1022
+ unreachable** tier rather than inventing an implicit-flow analysis §10.2
1023
+ explicitly forbids (§16.3). Everything else on this list — the backward-walk
1024
+ question aside, which §15 closed — is still open.
1025
+
1026
+ ---
1027
+
1028
+ ## 13. Interprocedural hop recording (Sub-project C, increment 3)
1029
+
1030
+ **Implemented.** Design landed as `a2d42695` (fix round `fa27354e`, correction
1031
+ `a8846463`); shipped by Task 2 (`5ee9143d`) and Task 3 (`119cff02`), whole-branch
1032
+ reviewed clean at `ac6ed4c2`. The section below is kept in its original,
1033
+ design-time voice (future tense, "the follow-up task must…") as the historical
1034
+ record of what was decided and why — read `summaries.js`/`engine.js`/`driver.js`
1035
+ themselves, or `scanner/src/lineage/CLAUDE.md`'s own module-table rows, for the
1036
+ current, as-shipped description. The design-time PoC file this section
1037
+ originally shipped alongside it (`engine-provenance-interprocedural-poc.test.js`)
1038
+ no longer exists — Task 3 absorbed everything it proved into the permanent
1039
+ suite (`engine-provenance-interprocedural.test.js`, `engine-provenance.test.js`,
1040
+ `driver.test.js`) and deleted it, per item 15 below.
1041
+
1042
+ Added 2026-08-30. Everything in this section is **decided**, not proposed,
1043
+ and every behavioural claim in it was produced by running code in
1044
+ `scanner/test/lineage/engine-provenance-interprocedural-poc.test.js` — a
1045
+ throwaway-named PoC committed alongside this section, which the follow-up
1046
+ implementation task should re-point at the shipped functions and then fold
1047
+ into `engine-provenance.test.js`.
1048
+
1049
+ > **Fix round 1 (2026-08-30), from this increment's own task review.** One
1050
+ > BLOCKING defect and three disclosure gaps were found in §13's first
1051
+ > draft and are corrected in place, each marked where it applies: §13.2a
1052
+ > now forwards a RECORDER-ONLY ctx at the hole-2 site (the original
1053
+ > full-ctx forwarding changed analysis results with no recorder attached,
1054
+ > in the unsound direction under a tight cap); §13.2 discloses the
1055
+ > multi-argument cross-join; §13.6 is now prototyped rather than only
1056
+ > designed, and pins how C4 must read its loss hop under §2.2's annotation
1057
+ > rule. Recorded here rather than silently rewritten, per this document's
1058
+ > own policy.
1059
+
1060
+ **What C3 does NOT touch:** `field-identity.js` (unchanged, byte-for-byte,
1061
+ as in every prior increment), and the isolation rule (`src/lineage/` may
1062
+ import pure utilities from `src/dataflow/`, never `dataflow/engine.js` or
1063
+ `dataflow/summaries.js`). Every change below is additive and inert when
1064
+ `ctx.recordHop` is absent, matching Decision 1.
1065
+
1066
+ ### 13.0 The record shape gains exactly three fields
1067
+
1068
+ Extending §3's shape. Same contract as every existing field: **always
1069
+ present, `null` when inapplicable, never `undefined`, never an omitted
1070
+ key**, so C4 can still hash a record without a canonicalization step.
1071
+
1072
+ ```js
1073
+ context: string | null, // hashState(entryState) of the analysis run
1074
+ // this hop was emitted in. null only when a
1075
+ // hop is emitted outside analyzeFunctionFieldIdentity.
1076
+ peerScope: string | null, // the OTHER function's qid, on a cross-function hop
1077
+ peerContext: string | null, // that function's entry-context hash
1078
+ ```
1079
+
1080
+ `peerScope`/`peerContext` name the function on the far side of a
1081
+ cross-function hop. **The direction is read off the existing `kind` field,
1082
+ not off a fourth new field** — §2.2 already defines `kind` as "which half
1083
+ this is", so a `write-out` hop's peer is its *destination* and a
1084
+ `production` hop's peer is its *source*. That is why this is two fields and
1085
+ not four (`fromScope`/`fromContext`/`toScope`/`toContext`): the direction is
1086
+ already in the record.
1087
+
1088
+ Only two hop shapes ever set them: `write-out/call-arg-bind` (§13.2) and
1089
+ `production/call-resolved` (§13.2's return half). Every other hop carries
1090
+ `null` for both, stamped by the wrapper in §13.3.
1091
+
1092
+ **Additivity, verified rather than assumed.** No test in
1093
+ `engine-provenance.test.js` compares a hop object by `deepEqual` or asserts
1094
+ a closed set of keys. Its two shape guards (lines ~595 and ~1383) are
1095
+ `hasOwnProperty` + `!== undefined` over a REQUIRED list, and its
1096
+ count assertions (`hops.length === 14`, and the per-shape counts) run on
1097
+ `dedupeHops`, whose key is `JSON.stringify(h, Object.keys(h).sort())`.
1098
+ `context` is constant within a single analysis run, so it cannot split a
1099
+ dedupe group. The PoC re-runs the §6 fixture with all three fields stamped
1100
+ on and still gets exactly 14 deduplicated records. **No existing assertion
1101
+ needs updating.**
1102
+
1103
+ ### 13.1 `resolveCallSummary` gains a 4th parameter; `case 'call'` is its one call site
1104
+
1105
+ **Decided:**
1106
+
1107
+ ```js
1108
+ resolveCallSummary(calleeExpr, callArgs, callerState, ctx) // ctx: NEW, 4th, optional
1109
+ ```
1110
+
1111
+ and `engine.js`'s `case 'call'` (today engine.js:505) becomes
1112
+
1113
+ ```js
1114
+ const summary = ctx.resolveCallSummary(expr.callee, expr.args ?? [], state, ctx);
1115
+ ```
1116
+
1117
+ That is the whole of hole 1. `ctx` there is already the *stamped* `stepCtx`
1118
+ (§7.2), threaded down unchanged through every recursive
1119
+ `resolveExprIdentities` call (§7.3), so passing it hands `summaries.js`
1120
+ both the caller's recorder **and** the caller's `scope`/`nodeId`/`line`/
1121
+ `context` stamping in one object. Fourth position keeps every existing
1122
+ 3-parameter resolver stub — including the hand-built ones in
1123
+ `engine-provenance.test.js` and `engine-integration.test.js` — working
1124
+ untouched.
1125
+
1126
+ **Why an explicit parameter and not `this`.** Today `case 'call'` invokes
1127
+ the resolver as a *method* (`ctx.resolveCallSummary(...)`), so `this` is
1128
+ already the stamped `stepCtx`. The PoC exploits exactly that to prove
1129
+ reachability without modifying `engine.js`, and pins it with an assertion.
1130
+ It is **not** the shipped fix: `createCallSummaryResolver` passes the
1131
+ resolver down as `{ resolveCallSummary }` and `driver.js` builds a fresh
1132
+ ctx object per function, so any caller that destructures
1133
+ (`const { resolveCallSummary } = ctx`) silently gets `this === undefined`
1134
+ and the recorder vanishes with no error. A load-bearing channel must not
1135
+ depend on call syntax.
1136
+
1137
+ **Measured, before the change:** the resolver receives `arguments.length
1138
+ === 3` and `arguments[3] === undefined`.
1139
+
1140
+ ### 13.2 The argument→parameter binding hop
1141
+
1142
+ Two changes, one at each end of a resolved call.
1143
+
1144
+ **(a) `entryStateFromCall` gains `ctx` as an optional 4th parameter — and
1145
+ forwards a RECORDER-ONLY derivation of it, never the ctx itself:**
1146
+
1147
+ ```js
1148
+ entryStateFromCall(paramNames, callArgs, callerState, ctx) // ctx: NEW, 4th, optional
1149
+ // inside, once, before the loop:
1150
+ const argCtx = ctx?.recordHop ? { recordHop: ctx.recordHop } : undefined;
1151
+ // ... then, per argument:
1152
+ const resolved = resolveExprIdentities(callerState, callArgs[i], argCtx);
1153
+ ```
1154
+
1155
+ Return shape unchanged (`summaries.test.js`'s existing calls keep working).
1156
+ It has exactly **one** call site, `summaries.js:338` inside
1157
+ `createCallSummaryResolver`; `driver.js` does not call it at all.
1158
+
1159
+ That forwarding is what makes the argument's **in-halves** exist: ordinary
1160
+ `production`/`selection` hops with correct contributing-key `fromPath`s
1161
+ (Decision 6), stamped with the caller's `scope`/`nodeId`/`context` because
1162
+ `ctx.recordHop` is the caller's already-stamped recorder (§7.2).
1163
+
1164
+ > **Why `resolveCallSummary` MUST be stripped here (2026-08-30, fix round 1
1165
+ > — this section's first draft forwarded the full `ctx` and was wrong).**
1166
+ > `resolveExprIdentities` branches on `ctx?.resolveCallSummary`. Handing it
1167
+ > a live one at *this* site makes an argument that is itself a resolvable
1168
+ > call — `sink(scrub(user))` — start resolving interprocedurally, where the
1169
+ > shipped engine takes the unresolved-call fallback. That changes the
1170
+ > ANALYSIS RESULT with **no recorder attached anywhere**, breaking this
1171
+ > sub-project's flat "byte-identical without a recorder" bar. Reproduced
1172
+ > two ways, both recorder-free, both in the PoC:
1173
+ >
1174
+ > - `function scrub(u){return {safe:1}} function sink(p){return p}
1175
+ > function caller(user){const out = sink(scrub(user)); return out;}`
1176
+ > seeded `user.email → data:email`: shipped keeps `data:email` in `out`;
1177
+ > full-ctx forwarding **drops** it.
1178
+ > - Two call sites sharing a cap-1 cache: the extra nested resolve consumes
1179
+ > the callee's only context slot, so a later, unrelated call degrades to
1180
+ > an empty summary — an identity the shipped engine KEEPS is **lost**.
1181
+ > That is the unsound direction, and it is the same class of bug C2
1182
+ > already shipped once and fixed (§10.2's `assign`/unsupported-target
1183
+ > correction: a recorder's mere presence must never consume cap budget).
1184
+ >
1185
+ > The recorder-only derivation adds hop RECORDING without adding
1186
+ > RESOLUTION. With a recorder attached, the argument's in-half is then
1187
+ > recorded **honestly against the path the analysis actually took** —
1188
+ > `production/call` with `widenReason: 'unresolved-call'`, not a
1189
+ > `call-resolved` that never happened — which §8 requires anyway ("fixing"
1190
+ > it here would mean disagreeing with the analysis the hops exist to
1191
+ > explain).
1192
+ >
1193
+ > **Note for whoever writes the guard:** §13.7 item 16's own
1194
+ > with-recorder/without-recorder comparison is structurally BLIND to this,
1195
+ > because the divergence moves both arms identically. The guard that
1196
+ > catches it must compare against a **hardcoded pre-C3 golden literal**
1197
+ > (§13.7 item 15b), never "the shipped resolver" by name — once this task's
1198
+ > own wiring lands, the shipped resolver IS that wiring, so a live
1199
+ > comparison degenerates into `assert.deepEqual(result, result)`. The PoC's
1200
+ > two regression tests (three arms: shipped / fixed / the hazard, the last
1201
+ > pinned so the test cannot go vacuous) prove this NOW, while "shipped"
1202
+ > still means something distinct — the golden values they hardcode are
1203
+ > what item 15b's follow-up test must carry forward.
1204
+
1205
+ **(b) The binding out-half is emitted in `createCallSummaryResolver`,
1206
+ not in `entryStateFromCall`:**
1207
+
1208
+ ```js
1209
+ {
1210
+ kind: 'write-out', subKind: 'call-arg-bind',
1211
+ fromPath: null,
1212
+ toPath: <paramName> | `${paramName}.${subPath}`,
1213
+ dataElementId: <id>,
1214
+ syntacticPath: null, widenReason: null, lossReason: null,
1215
+ peerScope: <callee qid>, peerContext: hashState(<callee entryState>),
1216
+ }
1217
+ ```
1218
+
1219
+ emitted once per `(path, id)` entry of the freshly built `entryState`, and
1220
+ guarded on `ctx?.recordHop`.
1221
+
1222
+ Four decisions, each with its reason:
1223
+
1224
+ - **`kind: 'write-out'`, not `'production'`.** §2.2's taxonomy is
1225
+ directional: `production`/`selection` are *inbound* halves carrying a
1226
+ `fromPath`; `write-out` is the *outbound* half carrying a `toPath`. The
1227
+ binding has a real destination path and joins with the argument
1228
+ expression's inbound halves at the caller's node — it is structurally the
1229
+ same event as `assign`, which is exactly how `summaries.js` already
1230
+ describes `entryStateFromCall` in its own header. `production/call-resolved`
1231
+ is `kind: 'production'` for the opposite reason: it is the call's *output*
1232
+ side, an inbound half at the caller.
1233
+ - **`fromPath: null`**, not `contributingKeys(...)` against the argument's
1234
+ `accessPathOf`. Every write-out in §10.2 carries `fromPath: null` and
1235
+ relies on the resolver's own in-halves for the source; because (a)
1236
+ already forwards `ctx`, those in-halves are emitted with correct
1237
+ contributing keys for free. Computing them a second time here would
1238
+ emit a duplicate, differently-shaped source for the same edge. The PoC
1239
+ asserts the argument's `production/ident from 'a.email'` in-half and the
1240
+ bind out-half share `(scope, nodeId, dataElementId, context)` — i.e. the
1241
+ edge joins under §2.2's existing rule with no special case.
1242
+ - **`toPath` is the exact path written**, `u` or `u.email`, never the
1243
+ coarse `u` when a sub-path was written — the same granularity rule §10.2
1244
+ flags as "the most likely C2 mistake".
1245
+ - **`peerScope`/`peerContext` are mandatory here, not decorative.**
1246
+ `toPath: 'u'` is a path in the *callee's* namespace, recorded on a hop
1247
+ stamped with the *caller's* scope. Without `peerScope`, C4 would create
1248
+ the DAG node `(callerScope, 'u', id)` — colliding with any caller-local
1249
+ variable named `u`. That is Decision 5's bug class (an endpoint that is
1250
+ not the location it names) in a new disguise. `peerContext` is what makes
1251
+ the binding land in the *right* context of the callee, i.e. §9.4's own
1252
+ failure one level up, and it is also what makes §9.6 a sharing property
1253
+ rather than a loss.
1254
+
1255
+ **Arguments that are not path-shaped.** A literal argument resolves to no
1256
+ identity, so no entry appears in `entryState` and **no hop is emitted at
1257
+ all** — the same verdict §10.1 gives `literal`. An argument that is itself
1258
+ an unresolved call *does* carry ids: it emits a bind hop with
1259
+ `fromPath: null` and `widenReason: null`, because the widening is already
1260
+ carried on its own `production/call` in-half (`widenReason: 'unresolved-call'`)
1261
+ which joins with this out-half at the same key. Duplicating it would
1262
+ double-grade the edge. Both cases are pinned in the PoC.
1263
+
1264
+ **Disclosed consequence of `fromPath: null` at a MULTI-ARGUMENT call site
1265
+ (2026-08-30, fix round 1).** When two arguments at the same call site carry
1266
+ the **same** `dataElementId`, the 4-part join key does **not** separate them
1267
+ — both in-halves and both bind out-halves share
1268
+ `(scope, nodeId, dataElementId, context)`, so the cross product names the
1269
+ wrong parameter half the time:
1270
+
1271
+ ```js
1272
+ function two(p, q) { … }
1273
+ two(m, n); // m.email and n.email both carry data:email
1274
+ // in-halves : m.email, n.email out-halves: p.email, q.email
1275
+ // joined : 4 edges — m.email→q.email and n.email→p.email are phantom
1276
+ ```
1277
+
1278
+ This is **§9.1's already-disclosed cross-join, not a new bug class** — the
1279
+ same shape at a call boundary instead of inside an object literal — and it
1280
+ is handled the same way: C4 detects it exactly
1281
+ (`distinctInPaths ≥ 2 && distinctOutPaths ≥ 2` at a group) and marks
1282
+ `ambiguousCorrelation: true`. Reproduced in the PoC, so it is a measured
1283
+ property rather than a hypothetical. Two notes for whoever revisits it:
1284
+
1285
+ - The bind hop is the **cheapest possible instance of §9.1's own `slot`
1286
+ lever**. §9.1 defers `slot` because the in-expression case needs a slot
1287
+ prefix threaded down through `resolveExprIdentities`'s recursion — but
1288
+ here the **parameter index is known for free** at the emission site
1289
+ (`fn.params[i]`), on both halves, with nothing to thread. If §9.1's
1290
+ evidence threshold for adopting `slot` is ever met, this case closes for
1291
+ the price of one field.
1292
+ - Do **not** try to close it by putting the argument's path in `fromPath`
1293
+ instead: that reintroduces the double-emission this section rejects, and
1294
+ it still would not correlate the halves — it would only make the phantom
1295
+ edges harder to detect.
1296
+
1297
+ **(c) The return direction.** `case 'call'`'s existing
1298
+ `production/call-resolved` hop (engine.js:512-520) gains
1299
+ `peerScope`/`peerContext`, so C4 can connect it to the callee's own
1300
+ `write-out/return` hops (`kind === 'write-out' && subKind === 'return' &&
1301
+ toPath === null`, scoped by `scope` **and now `context`**). The information
1302
+ comes from `createCallSummaryResolver` returning a **fresh wrapper**:
1303
+
1304
+ ```js
1305
+ return summary ? { ...summary, resolvedQid: qid, resolvedContext: calleeContext } : summary;
1306
+ ```
1307
+
1308
+ A fresh object every call — the cached summary is never mutated, so
1309
+ `fieldSummaryEq` and the B5 refinement loop are untouched. `engine.js`
1310
+ reads only `returnFlat`/`returnByPath`, so a resolver that does *not*
1311
+ supply these (every existing hand-built test stub) must yield
1312
+ `peerScope: null`, not `undefined` — read them as `summary.resolvedQid ?? null`.
1313
+ The PoC proves both halves: the identity is available at the site, and the
1314
+ augmented return leaves the analysis result identical to the shipped
1315
+ resolver's.
1316
+
1317
+ ### 13.3 The `context` field and the join-key extension
1318
+
1319
+ **Decided:** `context = hashState(entryState)` — the exact primitive
1320
+ `FieldIdentitySummaryCache` already keys on (`summaries.js`'s `_key`), so
1321
+ two hops share a `context` iff the cache would consider them the same
1322
+ context. Reusing it rather than inventing a second notion is what keeps
1323
+ "which body does this call site's `peerContext` point at" answerable by
1324
+ string equality.
1325
+
1326
+ **Stamped in exactly one place:** `analyzeFunctionFieldIdentity`'s existing
1327
+ per-node `stepCtx` wrapper (engine.js:864-866), computed once per analysis
1328
+ alongside `scope`:
1329
+
1330
+ ```js
1331
+ const scope = fn.qid ?? null;
1332
+ const context = ctx?.recordHop ? hashState(entryState) : null; // only when recording
1333
+ ...
1334
+ const stepCtx = ctx?.recordHop
1335
+ ? { ...ctx, recordHop: (h) => ctx.recordHop({
1336
+ scope, nodeId: nid, line: node.line ?? null,
1337
+ context, peerScope: null, peerContext: null,
1338
+ ...h,
1339
+ }) }
1340
+ : ctx;
1341
+ ```
1342
+
1343
+ `hashState` is added to `engine.js`'s existing `./field-identity.js` import
1344
+ — no new dependency, no isolation-rule concern. Cost is one O(|state|) hash
1345
+ per analysis, and only when a recorder is attached.
1346
+
1347
+ Two properties this buys, both already relied on by §7.2 and now
1348
+ load-bearing for C3:
1349
+
1350
+ - **Zero change at the 15 existing `recordHop` call sites.** The three new
1351
+ fields are stamped by the wrapper, before `...h`, so a site that supplies
1352
+ none gets the correct nulls and a site that supplies `peerScope`/
1353
+ `peerContext` overrides them — §7.2's spread-order rule, used for the
1354
+ purpose it was written for.
1355
+ - **A nested analysis's stamps win.** When a callee's hops flow out through
1356
+ the caller's already-stamped `recordHop` (§13.4's wiring), the callee's
1357
+ own wrapper has already put `scope`/`nodeId`/`line`/`context` into `h`,
1358
+ so the caller's outer stamp cannot overwrite them. Proven in the PoC: a
1359
+ hop recorded two resolved hops deep carries `inner`'s qid and `inner`'s
1360
+ own context, not `outer`'s.
1361
+
1362
+ **The join key becomes `(scope, nodeId, dataElementId, context)`**,
1363
+ superseding §2.2's three-part key everywhere in this document. §9.4's
1364
+ worked example, reconstructed by running the real cache over
1365
+ `function g(x) { const y = x; return y; }` under two contexts:
1366
+
1367
+ | join key | joinable pairs | phantoms |
1368
+ |---|---|---|
1369
+ | `(scope, nodeId, dataElementId)` | `x→y`, `x→y.email`, `x.email→y`, `x.email→y.email` | 2 |
1370
+ | `(scope, nodeId, dataElementId, context)` | `x→y`, `x.email→y.email` | 0 |
1371
+
1372
+ Exactly the failure §9.4 predicted, and exactly the fix it proposed.
1373
+
1374
+ **Existing tests broken by this: none** — see §13.0 for the evidence.
1375
+
1376
+ ### 13.4 The cache-hit finding, and why it does NOT need the big fix
1377
+
1378
+ The question was whether `FieldIdentitySummaryCache.compute`'s memoization
1379
+ silently suppresses a callee's own internal hops on a cache HIT, and
1380
+ whether that forces the cache to store and replay a per-summary hop list.
1381
+
1382
+ **Measured** (two call sites to the same callee, deliberately seeded so
1383
+ both produce the identical entry state and `hashState` collides):
1384
+
1385
+ - The callee's internal hops appear for **one** entry context, not two —
1386
+ the second call site is a cache hit and `analyzeFn` never runs again.
1387
+ Transitively true for its own callee too (`inner`, two hops deep).
1388
+ - Both call sites *do* emit their own `call-arg-bind` and
1389
+ `production/call-resolved` hops — those are emitted outside `compute`.
1390
+ - Control run, two *distinct* entry contexts: two distinct sets of
1391
+ callee-internal hops, at both depths. So the suppression is genuinely the
1392
+ cache key doing its job, not a wiring bug.
1393
+
1394
+ **Decided: (a), accept and disclose — recorded as §9.6.** The big fix (the
1395
+ cache storing a hop list per summary and replaying it on every hit) is
1396
+ **rejected**, for a reason the measurement makes concrete rather than for
1397
+ cost: because both call sites' bind hops carry the same `peerContext`, and
1398
+ that `peerContext` equals the `context` stamped on the one recorded body,
1399
+ **nothing is orphaned** — a consumer walking forward from either call site
1400
+ reaches the same, correctly-contexted body. Replaying would emit N
1401
+ byte-identical copies of a body that C4 deduplicates by content anyway
1402
+ (Decision 8's mechanism), i.e. cost with no information gained, and it
1403
+ would make record volume O(call sites × callee size) — the opposite of
1404
+ FR-303.
1405
+
1406
+ **Sizing, since the plan asked for it honestly:** were it ever needed, the
1407
+ replay fix is roughly *(i)* a `Map<cacheKey, hopRecord[]>` alongside
1408
+ `_cache`, *(ii)* wrapping `analyzeFn`'s recorder to tee into that list,
1409
+ *(iii)* replaying on the hit path in `compute` — but *(iv)* the replayed
1410
+ records would need the *hitting* call site's identity re-stamped onto them
1411
+ to be worth anything, which is a per-record rewrite, and *(v)* it interacts
1412
+ with B5's refinement loop, which re-invokes `analyzeFn` and would need the
1413
+ list reset per round. Non-trivial, and unnecessary. Do not do it without
1414
+ evidence from C4/C5 that shared bodies are actually a reconstruction
1415
+ problem.
1416
+
1417
+ ### 13.5 Call-site identity: no `hopSite` field — §7.4's recommendation is superseded
1418
+
1419
+ §7.4 proposed `ctx.hopSite = {scope, nodeId, line}` as plain values on the
1420
+ derived ctx, so `summaries.js` could stamp a cross-function hop with the
1421
+ call site it crossed at.
1422
+
1423
+ **Decided: not needed, and not added.** §13.1 threads the entire stamped
1424
+ `stepCtx` into `resolveCallSummary`, and that ctx's `recordHop` *already*
1425
+ applies `{scope, nodeId, line, context}` to everything emitted through it.
1426
+ The `call-arg-bind` hop therefore carries the call site's full identity
1427
+ with no new field, no new plumbing, and — decisively — **the same values
1428
+ the argument's own in-halves carry**, which is the property that makes the
1429
+ edge join at all. A separate `hopSite` field would be a second, parallel
1430
+ copy of information the stamping mechanism already delivers, i.e. exactly
1431
+ the redundant-fields-drift failure mode §3 rejected `widened` for.
1432
+
1433
+ C4/C5 get call-site identity as: the caller-side `(scope, nodeId, line,
1434
+ context)` on a `call-arg-bind` / `call-resolved` hop, plus
1435
+ `(peerScope, peerContext)` for the function on the other side. That is
1436
+ strictly more than `hopSite` would have carried.
1437
+
1438
+ ### 13.6 B5/B6 degradation marking: the mechanism is decided here, the emission ships with §13.7
1439
+
1440
+ Three things were measured before deciding.
1441
+
1442
+ **Finding 1 — a B6 cap degradation is, today, completely silent.** With
1443
+ `maxContextsPerFn: 1` and two call sites requesting genuinely different
1444
+ contexts of the same callee, the second resolution degrades to the
1445
+ empty-entry fallback. Its `returnFlat` is empty, so `case 'call'`'s
1446
+ `for (const id of flat)` loop emits **nothing** — there is not even a hop
1447
+ present to carry a marker. The call site's `call-arg-bind` hop points at a
1448
+ `peerContext` that has no body anywhere in the record stream, and nothing
1449
+ says why. That is §18.4's constraint violated in the interprocedural
1450
+ dimension: "context budget exhausted" is indistinguishable from "no flow
1451
+ crosses this call."
1452
+
1453
+ **Finding 2 — the fallback is the *same object* as the empty-entry
1454
+ summary.** `compute`'s cap branch returns
1455
+ `this._cache.get(this._key(qid, emptyState())) ?? emptyFieldSummary()`;
1456
+ object identity, not a copy (asserted in the PoC). Setting a flag on it in
1457
+ place would retroactively mark the **precise** empty-entry summary as
1458
+ degraded for every later reader — a real bug, avoided only by knowing
1459
+ about it in advance.
1460
+
1461
+ **Finding 3 — B5's bottom stub needs no marking.** The stub is
1462
+ `{...emptyFieldSummary(), _recursive: true}`; empty `returnFlat`, so no
1463
+ `call-resolved` hop is emitted on that round either. And B5's refinement
1464
+ re-invokes `analyzeFn`, re-emitting the callee's hops with the *better*
1465
+ summary; per Decision 8's monotonicity argument the stub round's output is
1466
+ a subset of the refined round's, so the duplicate-tolerant record stream
1467
+ absorbs it. Nothing to mark, nothing to strip.
1468
+
1469
+ **Decided — B6 marking is IN SCOPE for C3, B5 marking is not (there is
1470
+ nothing to mark).** Not deferred to C4/C6: C4 grades edges that exist, and
1471
+ this is about an edge that does *not* exist. Only the site that performed
1472
+ the degradation knows it happened; by C4 the information is gone. The shape:
1473
+
1474
+ 1. `FieldIdentitySummaryCache.compute`'s cap-degradation branch returns a
1475
+ **shallow copy** — `const degraded = { ...fallback, degradedReason: 'context-cap' };`
1476
+ — and caches the copy. Never mutates `fallback` (Finding 2).
1477
+ `degradedReason` is a **permanent, documented, externally-visible
1478
+ field**, deliberately unlike `_recursive`: `_recursive` is stripped
1479
+ because it is a transient recursion-in-progress marker, whereas a
1480
+ degraded summary stays degraded for the life of the cache entry. A
1481
+ string, not a boolean, so a later increment can add reasons without a
1482
+ shape change. `fieldSummaryEq` deliberately does **not** compare it —
1483
+ it is diagnostic, exactly like `widenings`.
1484
+ 2. `createCallSummaryResolver`, when `summary.degradedReason` is set and
1485
+ `ctx?.recordHop` is present, emits **one loss hop per id that entered
1486
+ the callee** (i.e. per `(path, id)` of the entry state it just built):
1487
+
1488
+ ```js
1489
+ {
1490
+ kind: 'production', subKind: 'call-resolved',
1491
+ fromPath: null, toPath: null, dataElementId: <id>,
1492
+ syntacticPath: null, widenReason: null,
1493
+ lossReason: 'context-cap-degraded',
1494
+ peerScope: <callee qid>, peerContext: <callee context>,
1495
+ }
1496
+ ```
1497
+
1498
+ Emitted at the resolver, not in `engine.js`, precisely because
1499
+ `engine.js`'s loop over an empty `returnFlat` cannot fire. The ids come
1500
+ from the *argument* side because those are exactly the identities whose
1501
+ downstream fate is now unrepresented — Decision 4's "always a real,
1502
+ non-null `dataElementId`" is satisfied without inventing one.
1503
+ `lossReason`, not `widenReason`, because §3 defines `lossReason` as "why
1504
+ this hop is a dead end", which is precisely what it is.
1505
+
1506
+ **How C4 must read this hop, pinned so §2.2 cannot silently swallow it
1507
+ (2026-08-30, fix round 1).** The loss hop has `fromPath: null` *and*
1508
+ `toPath: null`, so under §2.2's annotation rule it is an **annotation on
1509
+ whatever edges the non-null in-halves form at the same join key**, not an
1510
+ edge-forming half of its own — and once (a) records the argument's own
1511
+ in-half, that is the common case at a degraded call site with a
1512
+ path-shaped argument. **That is the intended reading, deliberately**: the
1513
+ marker belongs *on* the real `argument → parameter` edge, saying "this
1514
+ data was bound into a callee whose summary the engine honestly degraded;
1515
+ its downstream is unrepresented." It is the same relationship
1516
+ §10.2's `unsupported-target` write-out already has with the in-halves it
1517
+ joins. When there is no non-null in-half (a literal or otherwise
1518
+ path-less argument), §2.2's rule makes it edge-forming instead, which is
1519
+ also correct — the "value with no prior aliasing source" case. **C4 must
1520
+ surface it under both readings; what it must never do is drop it**,
1521
+ because dropping it restores exactly the §18.4 silence Finding 1 measured.
1522
+
1523
+ **Prototyped, not just designed (2026-08-30, fix round 1).** Round 0 left
1524
+ this the one mechanism in §13 that was specified without being run, which
1525
+ contradicted this section's own opening claim. It is now executed in the
1526
+ PoC via a `MarkingSummaryCache` subclass that overrides the cap branch
1527
+ exactly as item 1 above specifies. Confirmed by running: the degraded call
1528
+ site's hop carries `lossReason: 'context-cap-degraded'` and a
1529
+ `peerContext` that provably has no recorded body; the precisely-resolved
1530
+ call site's `call-resolved` hop carries no marker; the shallow copy leaves
1531
+ the precise empty-entry summary unmarked (Finding 2, in both directions);
1532
+ and a recorder-free run is unaffected by marking, which is what makes
1533
+ `degradedReason` diagnostic rather than a fact.
1534
+
1535
+ Wiring the emission into shipped `summaries.js` is the follow-up task's job
1536
+ (§13.7), like every other site here.
1537
+
1538
+ ### 13.7 What the follow-up implementation task must do
1539
+
1540
+ Written the way §10.1/§10.2 were written for C2, so the next brief needs no
1541
+ re-derivation. Files, in dependency order.
1542
+
1543
+ **`scanner/src/lineage/engine.js`**
1544
+
1545
+ | # | Site | Change |
1546
+ |---|---|---|
1547
+ | 1 | imports (line 2) | add `hashState` to the existing `./field-identity.js` import |
1548
+ | 2 | `analyzeFunctionFieldIdentity`, ~line 848 | `const context = ctx?.recordHop ? hashState(entryState) : null;` beside the existing `scope` |
1549
+ | 3 | `stepCtx` wrapper, ~line 864-866 | stamp `context, peerScope: null, peerContext: null` before `...h` (§13.3's snippet, verbatim) |
1550
+ | 4 | `case 'call'`, line 505 | pass `ctx` as the 4th argument to `ctx.resolveCallSummary`. **This is the ONLY place the full ctx crosses into `summaries.js`** — items 6/7 below must not let `resolveCallSummary` reach `resolveExprIdentities` from there (§13.2a's boxed warning). |
1551
+ | 5 | `case 'call'` resolved branch, ~line 512-520 | add `peerScope: summary.resolvedQid ?? null, peerContext: summary.resolvedContext ?? null` to the `call-resolved` hop (`?? null`, never bare — a 3-arg test stub supplies neither) |
1552
+
1553
+ **`scanner/src/lineage/summaries.js`**
1554
+
1555
+ | # | Site | Change |
1556
+ |---|---|---|
1557
+ | 6 | `entryStateFromCall`, line 286 | add optional 4th param `ctx`, then derive `const argCtx = ctx?.recordHop ? { recordHop: ctx.recordHop } : undefined;` ONCE before the loop and pass **`argCtx`, never `ctx`**, to `resolveExprIdentities` (line 291). Stripping `resolveCallSummary` is load-bearing, not tidiness — forwarding the full ctx changes the analysis result with no recorder attached, in the unsound direction under a tight cap. See §13.2a. Return shape unchanged. |
1558
+ | 7 | `createCallSummaryResolver`'s closure, line 334 | add optional 4th param `ctx`; pass it to `entryStateFromCall` (which does the stripping in item 6 — keep the derivation inside `entryStateFromCall`, so the hazard cannot reappear via a future second caller) |
1559
+ | 8 | same, after `entryStateFromCall` | compute `const calleeContext = hashState(entryState);` and, when `ctx?.recordHop`, emit one `write-out/call-arg-bind` per `(path, id)` of `entryState` (§13.2b's exact shape) |
1560
+ | 9 | same, inside `cache.compute`'s callback, line 357 | build the callee ctx as `ctx?.recordHop ? { resolveCallSummary, recordHop: ctx.recordHop } : { resolveCallSummary }` — **hole 3**. Do not re-stamp `context` here; the callee's own `analyzeFunctionFieldIdentity` (change #2/#3) does it, and its stamps win by spread order. |
1561
+ | 10 | same, at return | wrap: `{ ...summary, resolvedQid: qid, resolvedContext: calleeContext }` — a fresh object, never a mutation |
1562
+ | 11 | `FieldIdentitySummaryCache.compute`, cap branch, lines 115-123 | return/cache a **shallow copy** carrying `degradedReason: 'context-cap'` (§13.6, Finding 2). Prototyped in the PoC as `MarkingSummaryCache` — but that subclass re-derives `willDegrade` by re-testing the cap (`!seen.has(hash) && seen.size >= this._maxContextsPerFn`) *outside* `compute`, purely because a subclass cannot see which branch `super.compute` took. Do NOT carry that re-derivation inline: mark `fallback` at its one real call site, inside the existing cap-branch `if` (lines 115-123), right where the branch is already decided — `const fallback = { ...base, degradedReason: 'context-cap' };` before `this.set(...)`/`return fallback;`, never a mutation of the shared object in place (the same fallback can be cached under multiple keys, e.g. the function's own empty-entry summary — mutating it in place would leak the marking there too), and never a second cap test. |
1563
+ | 12 | `createCallSummaryResolver` | when `summary.degradedReason` and `ctx?.recordHop`, emit the §13.6 loss hop per entry-state id |
1564
+ | 13 | `fieldSummaryEq` comment | note that `degradedReason` is deliberately not compared (diagnostic, like `widenings`) |
1565
+
1566
+ **`scanner/src/lineage/driver.js`**
1567
+
1568
+ | # | Site | Change |
1569
+ |---|---|---|
1570
+ | 14 | `runFieldIdentityAnalysis`, line 65 | accept `opts.recordHop` and spread it into the per-function ctx **conditionally** (`...(opts.recordHop ? { recordHop: opts.recordHop } : {})`), so a caller that supplies none gets a byte-identical `{ resolveCallSummary }` — Decision 7.2's "true by construction" |
1571
+
1572
+ **Tests**
1573
+
1574
+ | # | Change |
1575
+ |---|---|
1576
+ | 15 | Re-point `engine-provenance-interprocedural-poc.test.js`'s local prototypes at the shipped functions, delete the `this`-binding stand-in and the "hole is real" tests (they will correctly start failing), and fold what remains into `engine-provenance.test.js`. Drop the PoC file and its `package.json` `test:lineage` entry in the same commit. |
1577
+ | 15b | Add the **golden-baseline** regression the PoC now carries: for a fixture whose call ARGUMENT is itself a resolvable call, and again for a two-call-site cap-1 cache, assert the new wiring's recorder-free result equals a **hardcoded pre-C3 golden literal** (`['data:email']` and `['data:other-email']` respectively — the exact values the PoC pins), comparing the full canonicalized `{exitState, returnFacts, mutatedParams, widenings}` shape (matching item 16's own canonicalization), not just `returnFacts`' identities. **Do NOT compare against "the shipped resolver"** — after this task lands, the shipped resolver IS the new wiring, so a live comparison degenerates into `assert.deepEqual(result, result)`, a vacuous, always-passing test. Only a value fixed independently of whichever implementation is live stays meaningful. Item 16's with/without-recorder comparison cannot catch this class either way (the divergence moves both arms identically) — the guard must be a fixed golden, not a relative comparison. |
1578
+ | 16 | Extend the existing **write-only invariant** test (`engine-provenance.test.js`, ~line 245) with at least one multi-function fixture driven through a real `FieldIdentitySummaryCache`, run with and without a recorder. This is the guard that catches the C2-era class of bug where a recorder's presence perturbed cache-cap accounting; C3 adds three new recorder-conditional branches inside `summaries.js`, so it must cover them. |
1579
+ | 17 | Add a `driver.js` test proving `opts.recordHop` reaches every function in a multi-file project AND that omitting it leaves `runFieldIdentityAnalysis`'s `results`/`cache` unchanged. |
1580
+
1581
+ **Deliberately NOT in the follow-up's scope:** instrumenting
1582
+ `applyAtCallSite` (not wired into `engine.js`; see §10.3's correction), the
1583
+ cache hop-replay fix (§13.4), a `hopSite` field (§13.5), and any change to
1584
+ `field-identity.js` (never).
1585
+
1586
+ ---
1587
+
1588
+ ## 14. `path-store.js`: the compact DAG (Sub-project C, increment 4)
1589
+
1590
+ Added 2026-08-30 by increment C4's design task. Everything in this section
1591
+ is **decided**, not proposed, and every behavioural claim and every number
1592
+ in it was produced by running code in
1593
+ `scanner/test/lineage/path-store-poc.test.js` — a throwaway-named PoC
1594
+ committed alongside this section, which prototypes `path-store.js` and the
1595
+ two new `ids.js` functions LOCALLY (shipped source is unmodified by this
1596
+ design task, exactly as C3's own design task did). §14.10 is the follow-up
1597
+ implementation task's file/line checklist.
1598
+
1599
+ Two questions were open when this increment was scoped, and neither is
1600
+ answerable on paper. Both are now answered by execution:
1601
+
1602
+ - **Q1 — cross-function node addressing.** A `write-out/call-arg-bind`
1603
+ hop's destination is `(peerScope, peerContext, toPath, dataElementId)`,
1604
+ **not** `(scope, context, …)`. §14.3.
1605
+ - **Q2 — does `call-resolved`'s `fromPath: null` ever form a real edge?**
1606
+ **Yes** — it is the caller-side half of the return stitch, and its source
1607
+ is the callee's own function-exit node. But §2.2's annotation rule, read
1608
+ literally, demotes exactly this hop to an annotation and silently deletes
1609
+ the stitch. §14.4 corrects §2.2 in place.
1610
+
1611
+ ### 14.1 What `path-store.js` is, and the isolation boundary it introduces
1612
+
1613
+ `path-store.js` is a **pure consumer of a hop-record stream**. It takes
1614
+ hop records — the exact 14-field shape §3 + §13.0 define — and builds a
1615
+ deduplicated DAG. It does not run analysis, and:
1616
+
1617
+ > **`path-store.js` must NEVER import `engine.js`, `summaries.js`, or
1618
+ > `driver.js`.** It consumes their OUTPUT, never their internals. This is a
1619
+ > stronger, additional boundary on top of the existing "`src/lineage/` may
1620
+ > import pure utilities from `src/dataflow/`, never that package's
1621
+ > `engine.js`/`summaries.js`" rule.
1622
+
1623
+ This is not tidiness. It is what makes the store testable at all right now:
1624
+ there is still no source registry (Sub-projects D/E), so
1625
+ `runFieldIdentityAnalysis` analyzes every function from `emptyState()` and a
1626
+ real project-wide driver run emits **zero** hops today. A store that could
1627
+ only be exercised through the driver would be untestable by construction. A
1628
+ store fed a hand-built or hand-seeded array is testable immediately, and
1629
+ every fixture below does exactly that.
1630
+
1631
+ It also means `path-store.js`'s only dependency is `ids.js`.
1632
+
1633
+ ### 14.2 The node: `(scope, context, kind, path | siteNodeId, dataElementId)`
1634
+
1635
+ §2.1 decided the node is `(scope, accessPath, dataElementId)`. C4 makes two
1636
+ additions, both forced by evidence rather than taste.
1637
+
1638
+ **(a) `context` is part of node identity.** §13.3 already moved the *join
1639
+ key* to four parts. The node must follow, for a reason §13.2 states
1640
+ directly: `peerContext` "is what makes the binding land in the *right*
1641
+ context of the callee." If node identity ignored `context`, `peerContext`
1642
+ would be decorative and §9.4's phantom would return one level up — two entry
1643
+ contexts of one function would share a node, and a backward walk in
1644
+ context B could leave through an edge that only ever existed in context A.
1645
+ Cost, measured: the two-context `function g(x) { const y = x; return y; }`
1646
+ fixture builds 6 nodes with `context` in the identity and would collapse to
1647
+ 5 without it. The blow-up is bounded by the B6 per-function context cap
1648
+ (default 16), and correctness wins.
1649
+
1650
+ **(b) Terminal endpoints get their own node `kind`, never a fabricated
1651
+ path.** §10.2 is explicit that a `return` must not be given a pseudo-path
1652
+ like `'@return'`, and that C3/C4 identify a function exit structurally
1653
+ (`kind === 'write-out' && subKind === 'return' && toPath === null`). C4 needs
1654
+ those endpoints to *be* nodes, so it keeps them in a separate namespace
1655
+ rather than inventing a path string that could collide with a real one:
1656
+
1657
+ | node `kind` | created by | keyed on | why |
1658
+ |---|---|---|---|
1659
+ | `path` | any half with a non-null `fromPath`/`toPath` | `(scope, context, path, id)` | the ordinary state location |
1660
+ | `return` | `write-out/return`; also *addressed* by `production/call-resolved` via `(peerScope, peerContext)` | `(scope, context, id)` — **per function-context, not per CFG node** | forced: a `call-resolved` hop names only `(peerScope, peerContext)`, with no node id, so every return site of a context must aggregate into one exit node |
1661
+ | `escape` | `write-out/call-arg` (a bare call statement) | `(scope, context, siteNodeId, id)` | the value leaves the analysis; §10.2 calls this the natural sink-attachment point for Sub-project D. Nothing addresses it from elsewhere, so per-CFG-node precision is free |
1662
+ | `loss` | any `write-out` with `toPath === null` and a non-null `lossReason` (today: `assign`/`unsupported-target`) | `(scope, context, siteNodeId, id)` | §18.4: a dead end that is *recorded as a dead end* is the data-layer form of "never present a truncation as an absence" |
1663
+ | `origin` | §2.2's surviving half — a group whose ONLY in-halves are annotations | `(scope, context, siteNodeId, id)` | "a value with no prior aliasing source" |
1664
+
1665
+ `origin` is exercised only by a hand-built hop pair in this task's PoC — no
1666
+ real-parser fixture in C1-C3's own instrumentation reaches it today, since
1667
+ every construct that carries a `dataElementId` currently inherits it from
1668
+ somewhere already in `state` (a contributing key `production`/`selection`
1669
+ hop always accompanies it). This is a real, honestly-disclosed gap, but not
1670
+ a dead branch: it is the exact shape a Sub-project D source registry will
1671
+ produce the first time it seeds an identity at a source site with no prior
1672
+ state to point to (a null-`fromPath`, null-`peerScope` in-half is precisely
1673
+ "this value originates here, not upstream"). Kept, tested with the hand-built
1674
+ pair, and left for D's own real-parser coverage rather than removed.
1675
+
1676
+ Expression-internal constructs still create **no** nodes (§2.1). An object
1677
+ literal, a ternary, a template literal survive as `annotations[]` on the
1678
+ edges they helped form — pinned by a test asserting the `production/object`
1679
+ hop is present as an annotation and that no node was created for it.
1680
+
1681
+ ### 14.3 Half-edge classification — the exact rules, and the answer to Q1
1682
+
1683
+ Grouped by §13.3's four-part join key `(scope, nodeId, dataElementId,
1684
+ context)`. Within a group:
1685
+
1686
+ **In-halves** (`kind` is `production` or `selection`):
1687
+
1688
+ 1. `fromPath !== null` → **sourced**, at `(scope, context, path, id)`.
1689
+ 2. `fromPath === null && peerScope !== null && lossReason === null` →
1690
+ **peer-sourced**, at `(peerScope, peerContext, ⟨return⟩, id)`. See §14.4.
1691
+ 3. otherwise → **annotation**.
1692
+
1693
+ **Out-halves** (`kind === 'write-out'`), in this order:
1694
+
1695
+ 1. `toPath !== null && peerScope !== null` → **peer-targeted**, at
1696
+ `(peerScope, peerContext, toPath, id)`. **This is Q1's answer.**
1697
+ 2. `toPath !== null` → **targeted**, at `(scope, context, toPath, id)`.
1698
+ 3. `subKind === 'return'` → the `return` terminal.
1699
+ 4. `subKind === 'call-arg'` → the `escape` terminal.
1700
+ 5. `lossReason !== null` → the `loss` terminal.
1701
+ 6. otherwise → **unclassified**, recorded in `diagnostics().unclassified`
1702
+ and never silently dropped. Empty for every fixture in the PoC — which
1703
+ is the closed-set proof that rules 1-5 cover today's whole out-half
1704
+ vocabulary (`assign`, `assign-weak`, `call-arg`, `call-arg-bind`,
1705
+ `return`, and `assign`-with-`unsupported-target`).
1706
+
1707
+ **Q1, proven rather than argued.** For
1708
+ `function helper(u) { return u.email; } function caller(a) { const out = helper(a); return out; }`
1709
+ seeded `a.email → data:email`, the bind hop is stamped `scope: caller`,
1710
+ `peerScope: helper`, `toPath: 'u.email'`. The PoC asserts that the node id
1711
+ computed from `(peerScope, peerContext, 'u.email', id)` is **byte-identical**
1712
+ to the node id computed from the callee's OWN
1713
+ `selection/member from 'u.email'` hop (`scope: helper`, its own `context`) —
1714
+ i.e. peer addressing lands exactly on a node the callee independently
1715
+ created. The naive alternative computes a different id, and
1716
+ `store.getNode(thatId)` is `null`: it is an orphan no hop anywhere can reach.
1717
+
1718
+ And the hazard is not hypothetical. With a caller that also has a local
1719
+ variable named `u`, the PoC shows own-scope addressing of the callee's
1720
+ parameter produces the **same id** as the caller's own local `u` — Decision
1721
+ 5's bug class ("an endpoint that is not the location it names") in a new
1722
+ disguise, exactly as §13.2 predicted. Peer addressing keeps them apart, and
1723
+ no binding edge lands on the caller-local `u`.
1724
+
1725
+ **A pair whose BOTH endpoints are peer-addressed is excluded.** At any
1726
+ resolved call, the `call-resolved` in-half and the `call-arg-bind` out-half
1727
+ share a join key (asserted in the PoC), so a naive full cross product pairs
1728
+ them and manufactures a `callee ⟨return⟩ → callee parameter` edge that no
1729
+ program ever executed — a fabricated cycle, created by C4 rather than by the
1730
+ code. Such a pair always describes a transition *entirely inside* the
1731
+ callee, which the callee's own hops already record, so excluding it can
1732
+ never drop a real caller-side fact. Nothing else is pruned; see §14.7.
1733
+
1734
+ ### 14.4 Q2 — the return stitch, and a correction to §2.2
1735
+
1736
+ Running the two-function fixture above with a recorder produces, at the
1737
+ caller's `const out = helper(a)` CFG node, **four** hops sharing one join
1738
+ key:
1739
+
1740
+ ```
1741
+ production/ident fromPath 'a.email' (the argument's own in-half)
1742
+ write-out/call-arg-bind toPath 'u.email' peer=(helper, Ch)
1743
+ production/call-resolved fromPath null peer=(helper, Ch)
1744
+ write-out/assign toPath 'out'
1745
+ ```
1746
+
1747
+ and, inside `helper` under context `Ch`, `selection/member from 'u.email'`
1748
+ plus `write-out/return toPath null`.
1749
+
1750
+ **The stitch exists, and it goes through the callee.** The
1751
+ `production/call-resolved` hop is `kind: 'production'`, so per §13.0 its
1752
+ peer is its *source* — and that source is the callee's function-exit node
1753
+ `(helper, Ch, ⟨return⟩, id)`, which the callee's own `write-out/return` hop
1754
+ independently creates. `peerContext` is byte-equal to the `context` the
1755
+ callee's body was recorded under (asserted). The PoC walks the whole chain:
1756
+
1757
+ ```
1758
+ (caller, Ca, a.email) → (helper, Ch, u.email) → (helper, Ch, ⟨return⟩)
1759
+ → (caller, Ca, out) → (caller, Ca, ⟨return⟩)
1760
+ ```
1761
+
1762
+ Four edges, two of them cross-scope, and no step is asserted rather than
1763
+ built.
1764
+
1765
+ > **Correction to §2.2, per this document's own fix-rather-than-diverge
1766
+ > policy.** §2.2 says a `null`-`fromPath` in-half "forms a real edge only
1767
+ > when NO non-null in-half exists at that key — which is precisely the
1768
+ > 'value with no prior aliasing source' case (a literal, or — the one case
1769
+ > that matters for interprocedural stitching — a resolved call's return
1770
+ > value, which is C3's join point…)". That parenthesis names the right case
1771
+ > and the rule then **excludes** it: in the real C3 stream the argument
1772
+ > expression's own `production/ident from 'a.email'` in-half sits at the
1773
+ > *same* join key as the `call-resolved` hop (measured — the PoC asserts
1774
+ > it), so the literal rule demotes `call-resolved` to an annotation. A
1775
+ > `PathStore` built that way leaves the callee's exit node with **zero**
1776
+ > outgoing edges: reconstruction from `out` reports the argument as its
1777
+ > immediate predecessor and the callee body is unreachable. The PoC builds
1778
+ > exactly that store and asserts the dead end, so the defect cannot be
1779
+ > re-argued away.
1780
+ >
1781
+ > **The corrected discriminator is `peerScope`, not `fromPath`.** A null
1782
+ > `fromPath` with a non-null `peerScope` is not source-less; it is
1783
+ > **peer-addressed**. §2.2's annotation rule survives unchanged for the
1784
+ > genuinely source-less case (`peerScope === null`), which is what the
1785
+ > `origin` node kind covers.
1786
+
1787
+ **The one exception, and it is `lossReason`.** §13.6's context-cap
1788
+ degradation hop is also `production/call-resolved` with `fromPath: null`
1789
+ and a non-null `peerScope` — but it names a callee whose body was **never
1790
+ analyzed**, so `(peerScope, peerContext, ⟨return⟩, id)` does not exist in
1791
+ the stream. Treating it as peer-sourced would fabricate an origin node with
1792
+ no predecessors and report a path that begins in the middle of nothing —
1793
+ Decision 5's bug class again. `lossReason === null` is therefore part of
1794
+ rule 2, and a degraded hop falls through to *annotation*, which is exactly
1795
+ the reading §13.6 asked for ("the marker belongs *on* the real
1796
+ `argument → parameter` edge"). Verified against the shipped resolver under
1797
+ `new FieldIdentitySummaryCache(1)`: no exit node is fabricated for the
1798
+ degraded callee, and the `lossReason: 'context-cap-degraded'` marker is
1799
+ present on the real `call-arg-bind` edge. It is never dropped — which is
1800
+ the half §13.6 says matters most.
1801
+
1802
+ > **Disclosed precondition, found by task review, not closed this
1803
+ > increment.** The `lossReason === null` guard correctly distinguishes "the
1804
+ > callee's body was analyzed" from "it was degraded away" WITHIN one fully
1805
+ > recorded analysis run. It does not, on its own, guarantee the callee's
1806
+ > exit hops are actually PRESENT in the stream `path-store.js` was fed —
1807
+ > that additionally requires the stream to be complete for
1808
+ > `(peerScope, peerContext)`. A reachable counter-shape: analyze `callerA`
1809
+ > against a shared `FieldIdentitySummaryCache` with NO recorder attached
1810
+ > (warming the cache with `helper`'s summary), then analyze `callerB`
1811
+ > against that SAME cache with a recorder attached. `helper` is now a cache
1812
+ > HIT for `callerB` — `resolveCallSummary`'s `cache.compute()` never
1813
+ > re-invokes `analyzeFn`, so `helper`'s own body hops never fire a second
1814
+ > time — yet the resolved summary still carries `lossReason: null` (it was
1815
+ > genuinely, precisely resolved; it just wasn't resolved *this run*). Fed
1816
+ > into `path-store.js`, `callerB`'s `call-resolved` hop is peer-sourced at
1817
+ > `(helper, Ch, ⟨return⟩)`, a node the store never otherwise creates —
1818
+ > exactly the fabricated-origin failure mode the `lossReason` guard exists
1819
+ > to prevent, reached by a different door. Not reachable within a SINGLE
1820
+ > fully-recorded run (checked across 6 fixtures: 2-fn, 3-fn/2-site, mutual
1821
+ > recursion, self recursion, mutated-param return, §9.6's own same-context
1822
+ > cache hit — zero orphaned exit nodes in any of them), but directly
1823
+ > reachable through `driver.js`'s own returned-and-reused `cache`, which
1824
+ > `driver.test.js` already exercises in this exact shape. **Left for the
1825
+ > follow-up implementation task (§14.10 item 10):** `path-store.js` must
1826
+ > treat this as a build-time DIAGNOSTIC, not a silent fabrication — a
1827
+ > `return` node with zero in-edges that nonetheless sources a real
1828
+ > cross-scope edge is detectable with the same `inIndex`/`outIndex` the
1829
+ > store already builds, and must be recorded via `diagnostics()`, never
1830
+ > thrown and never dropped, per this document's own established §9 culture
1831
+ > and §14.9's "recorded, never silent" framing.
1832
+
1833
+ ### 14.5 The two new `ids.js` functions
1834
+
1835
+ `DESIGN_PATH_PROVENANCE.md` §12 and the C-scoping doc both anticipated a
1836
+ single `pathId`. Two functions are needed, and neither is called `pathId`:
1837
+
1838
+ ```js
1839
+ provenanceNodeId({ kind, scope, context, path, siteNodeId, dataElementId },
1840
+ discriminatorParts = []) // -> `pnode:<kind>:<12 hex>`
1841
+ provenanceEdgeId({ fromNodeId, toNodeId, dataElementId,
1842
+ scope, context, siteNodeId,
1843
+ inKind, inSubKind, outKind, outSubKind,
1844
+ widenReasons = [], lossReasons = [] },
1845
+ discriminatorParts = []) // -> `pedge:<12 hex>`
1846
+ ```
1847
+
1848
+ Both use `ids.js`'s existing `_hash`/`_canon` helpers unchanged: sha256 over
1849
+ a canonicalized, pipe-joined material string, truncated to `ID_HEX_LEN`,
1850
+ prefixed by the entity kind. Never a counter.
1851
+
1852
+ - **Distinct `pnode:`/`pedge:` prefixes, not `node:`/`edge:`.** A provenance
1853
+ node is not a `DataFlowGraph v1` node; `validate.js` regex-checks the
1854
+ `node:`/`edge:` prefixes for graph entities, and making the two
1855
+ indistinguishable would be a latent contract bug. Sub-project E maps
1856
+ between the two namespaces; it must not confuse them.
1857
+ - **`pathId` is deliberately left unused.** The thing C5 reconstructs *is* a
1858
+ path, and it will plausibly want that name for its own entity. Calling an
1859
+ edge a path now would cost C5 the obvious name.
1860
+ - **The edge discriminator carries the SITE** (`scope`, `context`,
1861
+ `siteNodeId`) as well as both endpoint ids. `fromNodeId`/`toNodeId` already
1862
+ embed each side's own scope/context, but not the CFG node the pair was
1863
+ observed at — and two structurally identical hops at two different program
1864
+ points are two materially different edges (FR-305), each needing its own
1865
+ `line` for display and for §9.2's hop-ordering lever. Omit `siteNodeId` and
1866
+ they silently collide into one edge carrying one arbitrary line.
1867
+ - **It also carries both halves' `kind`/`subKind` and their reason strings.**
1868
+ This is the `flagship-fixture.mjs` lesson applied deliberately: that
1869
+ module's edge ids once collided because `dataElementIds` was left out of
1870
+ the discriminator (see this package's own CLAUDE.md row). Over-specifying a
1871
+ content hash costs nothing; under-specifying it is a silent merge.
1872
+ - **Object arguments, not `ids.js`'s usual positional form.** A deliberate,
1873
+ narrow divergence (`graphId` is the in-file precedent). `provenanceEdgeId`'s
1874
+ discriminator is twelve fields wide, `provenanceNodeId`'s is six, and a
1875
+ positional `discriminatorParts` array is exactly the shape from which a
1876
+ field gets omitted.
1877
+
1878
+ > **Corrected by the final whole-branch review (finding 2).** The
1879
+ > sentence above originally said "`path-store.js` calls each of these
1880
+ > from ONE place" as the justification. `provenanceEdgeId` genuinely has
1881
+ > one call site; `provenanceNodeId` has FIVE (`intern`, `sourcesFor`,
1882
+ > `targetsFor`, the `orphanedPeerSources` check, and `nodeIdFor`). The
1883
+ > object-argument choice is still correct — arguably more so at five call
1884
+ > sites than at one, since a positional array is exactly as easy to get
1885
+ > wrong the second, third, fourth, and fifth time as the first — but the
1886
+ > stated reason was wrong. `scanner/src/lineage/CLAUDE.md`'s own
1887
+ > `path-store.js` row repeated the same error and has been corrected too.
1888
+ - **Not in the discriminator:** `syntacticPath` and `line` (display
1889
+ material — pinned by a test where two hop records differing only in
1890
+ `syntacticPath` collapse to one edge), edge `annotations[]`, and
1891
+ `ambiguousCorrelation` (both are functions of the group and of the
1892
+ endpoints already in the id, so they cannot distinguish two edges).
1893
+
1894
+ The PoC pins idempotence (two independent analysis runs of the same fixture
1895
+ produce identical edge ids; re-delivering a stream changes nothing) and
1896
+ non-collision (every discriminator field, changed alone, moves the id;
1897
+ reason arrays are order-independent sets; 5000 distinct node descriptors
1898
+ produce 5000 distinct ids).
1899
+
1900
+ ### 14.6 Deduplication — two boundaries, and they are not interchangeable
1901
+
1902
+ §8 pushes worklist re-emission onto the consumer. The consumer needs **both**
1903
+ of these, and the plan's own framing of them as alternatives is wrong:
1904
+
1905
+ 1. **Raw-hop dedup, at ingest.** A `Set` keyed on the 14 fields in a fixed
1906
+ order. This is the volume control: it collapses the re-visit
1907
+ multiplicity §8 describes. Measured on a `while`-loop fixture: 12 records
1908
+ offered, 8 accepted. It is safe precisely because of §8's monotonicity
1909
+ argument — duplicates are exact repeats, never stale facts.
1910
+ The key is built from an **explicit field list**, not `Object.keys(h)`,
1911
+ so a hop with an ABSENT key (§3 warns this is reachable for any emission
1912
+ path bypassing `analyzeFunctionFieldIdentity`'s progressive stamping)
1913
+ is recorded in `diagnostics().malformed` instead of silently hashing to a
1914
+ different key than its fully-stamped twin. C4 is where §3's completeness
1915
+ guarantee becomes checkable rather than merely asserted.
1916
+ 2. **Node/edge dedup, at materialization.** Content-hash ids are Map keys,
1917
+ so two structurally identical edges collapse. This is the
1918
+ **correctness-bearing** one: two hop records that are NOT byte-identical
1919
+ can still describe the same logical edge (the PoC pins a pair differing
1920
+ only in `syntacticPath`), and (1) keeps both. (2) collapses them to one.
1921
+
1922
+ Dedup (1) alone leaves duplicate edges. Dedup (2) alone would be correct but
1923
+ would let group membership grow unboundedly on a hot loop, and the per-group
1924
+ cross product is quadratic in group size. Ship both.
1925
+
1926
+ **Where `context`'s memory cost actually lands, unmeasured but named.**
1927
+ `context` (§13.3's `hashState(entryState)`) is `hashState`'s full canonical
1928
+ string — bounded by the entry state's size, not the function's — computed
1929
+ once per analysis run and held by reference on every node/edge record
1930
+ sharing it, so the per-record field cost is one pointer, not N copies of the
1931
+ string. The real, unmeasured cost is in the DERIVED key strings this
1932
+ increment builds from it: dedup (1)'s ingest key and (2)'s node/edge id
1933
+ discriminator each concatenate the full `context` text once per hop/group,
1934
+ and those concatenated strings are retained for the store's lifetime in
1935
+ `_seen`/the group index. This is what C5/Sub-project E should profile at
1936
+ real project scale, not the record fields themselves.
1937
+
1938
+ **Construction is two-phase**: `addHop`/`addHops` accumulate into groups;
1939
+ nodes and edges are materialized lazily on the first read and cached until
1940
+ the next `addHop`. This is what lets an edge's annotation set be complete
1941
+ before its id is computed, and it makes the store order-insensitive — the
1942
+ same hops delivered in any order produce the same DAG.
1943
+
1944
+ **Cycle safety (§9.3).** Construction is one linear pass over the hop stream
1945
+ plus a per-group cross product; it never walks the graph, so it cannot
1946
+ recurse into a cycle. The read API is deliberately **traversal-free** —
1947
+ `nodes()`, `edges()`, `getNode`, `getEdge`, `edgesFrom`, `edgesTo`,
1948
+ `hasEdge`, `nodeIdFor`, `stats`, `diagnostics` are all O(1) or O(degree)
1949
+ index lookups. There is no recursion anywhere in this increment's code, by
1950
+ construction rather than by discipline. Bounded backward reconstruction is
1951
+ C5's job and C5's alone. Proven on a mutual-recursion fixture
1952
+ (`ping`/`pong`/`top`): the store builds 8 nodes and 11 edges from 34 raw
1953
+ records without recursing, and an explicitly budgeted walk *in the test*
1954
+ confirms a genuine cycle really is present — §9.3 is not hypothetical.
1955
+
1956
+ ### 14.7 Correlation ambiguity is measured per pairing, not per group
1957
+
1958
+ §9.1 marks an edge `ambiguousCorrelation: true` when, at a group,
1959
+ `distinctInPaths ≥ 2 && distinctOutPaths ≥ 2`. Applied verbatim to a C3
1960
+ stream this is far too coarse, and the measurement is the argument: at the
1961
+ two-function resolved-call fixture it marks **3 of 5** edges — including
1962
+ both genuinely correct call-boundary edges — because the argument's in-half
1963
+ and the return's in-half share one join key by construction (§14.4). A
1964
+ marker that fires on the right answers is not usable input to FR-306's
1965
+ confidence grading.
1966
+
1967
+ **Decided: count only the pairings the store would actually form.** For an
1968
+ edge `(s, o)`, ambiguity is `|{s' : pairable(s', o)}| ≥ 2 && |{o' :
1969
+ pairable(s, o')}| ≥ 2`, where `pairable` is §14.3's peer×peer exclusion.
1970
+ Measured effect, same fixtures: the resolved-call fixture drops from 3
1971
+ marked edges to **1**; a 3-function/2-call-site fixture from 6 to 2; the
1972
+ mutual-recursion fixture from 6 to 2. §9.1's own genuine intraprocedural
1973
+ case (`const x = { a: p.email, b: q.email }`, both carrying the same id) is
1974
+ **unchanged at 4** — the refinement removes only the marks that the call
1975
+ boundary's own structure introduced, not §9.1's real ambiguity. No new hop
1976
+ field, no threading, no `slot`.
1977
+
1978
+ **The one artefact that survives, disclosed rather than pruned.** The
1979
+ remaining marked edge at a resolved call is the **bypass**: `a.email → out`,
1980
+ which skips the callee. It is real data flow (the identity genuinely reaches
1981
+ `out`) but it is not the route the program takes, and it lets a
1982
+ reconstruction report a path that never enters the callee. Pruning it would
1983
+ require a leg-based rule ("in a group containing a peer half, a non-peer ×
1984
+ non-peer pair is not an edge") and that rule was tried and **rejected on a
1985
+ counter-example**: at `const o = { r: helper(a), s: b.email }` the group also
1986
+ contains a legitimate non-peer × non-peer pair (`b.email → o.s`), which the
1987
+ rule deletes. Losing a real edge is a worse failure than keeping a marked
1988
+ extra one — §9.1's own "detect and mark, do not prevent" verdict, reached
1989
+ here for the second time on independent evidence.
1990
+
1991
+ **The cheap closure remains on the table, unchanged from §13.2's own note:**
1992
+ the parameter index is known for free at the bind emission site, so a `slot`
1993
+ field would separate the legs exactly. §9.1's evidence-first policy still
1994
+ applies; this section now supplies part of that evidence.
1995
+
1996
+ ### 14.8 §9.5's analysis-level truncation: a reserved out-of-band channel
1997
+
1998
+ **Decided: reserve it in C4, do not invent a hop for it.** An `ITER_BUDGET`
1999
+ break in `analyzeFunctionFieldIdentity` is a whole-analysis-run truncation,
2000
+ and §9.5 says the entire result set for that function must be marked, not
2001
+ individual hops. Representing it as a hop would mean a fourth hop `kind` —
2002
+ which §2.2 explicitly guards against ("a provenance site that is not one of
2003
+ those three is a sign the taxonomy is being extended without the review the
2004
+ taxonomy earned"). So it arrives out of band:
2005
+
2006
+ ```js
2007
+ store.markTruncated(scope, context, reason) // e.g. reason: 'iter-budget'
2008
+ ```
2009
+
2010
+ Every node and edge in that `(scope, context)` then carries
2011
+ `truncated: true`, and `diagnostics().truncations` lists them. Prototyped
2012
+ and pinned: before the call nothing is marked, after it everything in that
2013
+ scope-context is. **Reserving costs one method and one Map now; retrofitting
2014
+ it in C5 would mean revisiting the node and edge shape after C5 has been
2015
+ built on them.** The *producer* side — engine or driver actually calling it
2016
+ when the budget breaks — is deliberately NOT in C4's scope: it is an
2017
+ `engine.js` change, and C4 changes no existing file.
2018
+
2019
+ ### 14.9 What C4 deliberately does not do
2020
+
2021
+ - **No backward walk, no reconstruction, no path budget, no prioritization.**
2022
+ C5's, entirely. C4 ships the structure and the minimum read API needed to
2023
+ prove that structure correct.
2024
+ - **No `DataFlowGraph v1` output.** Sub-project E.
2025
+ - **No FR-306 grade computation.** C6 reads `widenReasons`/`lossReasons`/
2026
+ `ambiguousCorrelation`/`truncated`/**`annotations[]`** off the edges; C4
2027
+ only carries them.
2028
+
2029
+ > **Corrected by the final whole-branch review (finding 5): `annotations[]`
2030
+ > was missing from this list, and its absence would reintroduce exactly
2031
+ > the silence §13.6 exists to prevent.** A §13.6 context-cap-degraded
2032
+ > marker (`lossReason: 'context-cap-degraded'`) is classified as an
2033
+ > ANNOTATION, not a source (correctly, per §14.4's `lossReason` exception
2034
+ > — the peer was never analyzed, so it cannot be peer-sourced) — it
2035
+ > therefore never reaches `edge.lossReasons`, only `edge.annotations[]`.
2036
+ > `C4/Q2c` proves this is exactly where it lives (it finds the marker via
2037
+ > `e.annotations.some(a => a.lossReason === 'context-cap-degraded')`, not
2038
+ > via `e.lossReasons`). A C6 implementer reading only "reads
2039
+ > `widenReasons`/`lossReasons`/…" off the edges would drop the marker
2040
+ > silently — the precise §18.4 failure mode §13.6 was written to close.
2041
+ > C6 must read `annotations[]` too, not only the edge's own top-level
2042
+ > reason arrays.
2043
+ - **No collapsing of repeated library/framework nodes into typed summary
2044
+ hops.** §12 and the C-scoping doc both left this "plausibly D or C4". It is
2045
+ **not** C4: deciding that a node is a library node needs a registry that
2046
+ does not exist yet (Sub-project D). C4 has no way to tell a framework
2047
+ function from an application one, and guessing would be the same class of
2048
+ error as a fabricated endpoint.
2049
+ - **No wiring into `runFieldIdentityAnalysis`.** A driver run emits zero hops
2050
+ today (§14.1). Wiring is a follow-up task's item, and it is a `driver.js`
2051
+ change, not a `path-store.js` one.
2052
+ - **No change to `field-identity.js`** (never), and no change to any existing
2053
+ `src/lineage/*.js` file in the design task itself.
2054
+
2055
+ ### 14.10 What the follow-up implementation task must do
2056
+
2057
+ Written the way §10.1/§10.2/§13.7 were, so the next brief needs no
2058
+ re-derivation.
2059
+
2060
+ **`scanner/src/lineage/ids.js`**
2061
+
2062
+ | # | Site | Change |
2063
+ |---|---|---|
2064
+ | 1 | after `edgeId` | add `provenanceNodeId` with §14.5's exact object signature; prefix `pnode:<kind>:`, via the existing `_hash`/`_canon` |
2065
+ | 2 | after it | add `provenanceEdgeId` with §14.5's exact object signature; prefix `pedge:` |
2066
+ | 3 | `test/lineage/ids.test.js` | extend with the discriminator-separation and bulk-non-collision cases the PoC carries (`C4/5`, `C4/5b`) |
2067
+ | 4 | `validate.js` | **no change.** `pnode:`/`pedge:` are not `DataFlowGraph v1` entity kinds and must not be added to its id-prefix regexes. Confirm by running `npm run test:lineage` — the json-schema-parity test must stay green untouched |
2068
+
2069
+ **`scanner/src/lineage/path-store.js` (new)**
2070
+
2071
+ | # | Item | Detail |
2072
+ |---|---|---|
2073
+ | 5 | imports | `ids.js` ONLY. Never `engine.js`/`summaries.js`/`driver.js` (§14.1). Add a test that asserts this by reading the file's own import list, so the boundary is enforced rather than documented |
2074
+ | 6 | `HOP_FIELDS` | the explicit 14-field list from §3 + §13.0, in a fixed order — never `Object.keys(h)` (§14.6) |
2075
+ | 7 | `classifyIn` / `classifyOut` | §14.3's rules verbatim, including `lossReason === null` in the peer-sourced branch (§14.4) and the `unclassified` fallthrough |
2076
+ | 8 | `PathStore` | `addHop(hop) -> boolean`, `addHops(hops) -> number`, `markTruncated(scope, context, reason)`; two-phase build; the traversal-free read API listed in §14.6 |
2077
+ | 9 | edge construction | per-group cross product with the peer×peer exclusion (§14.3) and the per-pairing ambiguity measure (§14.7) |
2078
+ | 10 | `diagnostics()` | `{ malformed, unclassified, truncations, orphanedPeerSources }` — all four are "recorded, never thrown, never dropped". `orphanedPeerSources` is §14.4's disclosed stream-completeness gap: a peer-sourced `call-resolved` hop (`lossReason: null`, non-null `peerScope`) whose named `(peerScope, peerContext, ⟨return⟩, dataElementId)` node has zero real in-edges once the whole stream has been ingested — detectable via the store's own `inIndex`/`outIndex` at build-finalize time, no new input needed. Reachable today via a cache warmed by a no-recorder run and reused by a later recorder-attached run (`driver.js`'s own returned cache, exactly as `driver.test.js` reuses it) — record it, do not fabricate an origin for it and do not drop the edge. |
2079
+
2080
+ **Tests**
2081
+
2082
+ | # | Change |
2083
+ |---|---|
2084
+ | 11 | Re-point `path-store-poc.test.js` at the shipped `path-store.js`/`ids.js`, delete its two local prototype blocks, rename it to `path-store.test.js`, and update the `test:lineage` script in `scanner/package.json` in the SAME commit — C3's item 15 precedent |
2085
+ | 12 | Keep every assertion, and especially keep `C4/Q2b` (the literal-§2.2 store with a dead-end callee exit) — it is the only guard that stops the §14.4 correction being silently undone by a future refactor of `classifyIn` |
2086
+ | 13 | Add a driver-level test only once a hop-emitting driver run is possible (Sub-project D/E). Until then a driver test would assert on an empty stream and be vacuous — see the note at the top of `engine-provenance-interprocedural.test.js`'s own driver test for the same reasoning |
2087
+
2088
+ **Deliberately NOT in the follow-up's scope:** everything in §14.9, plus any
2089
+ change to `engine.js`/`summaries.js` (C4 consumes the C3 stream exactly as
2090
+ shipped — no hop shape change is needed to answer Q1 or Q2).
2091
+
2092
+ ### 14.11 Measured numbers
2093
+
2094
+ Every row produced by running the PoC's own fixtures through the prototype
2095
+ store on 2026-08-30. "raw" is records offered, "dedup" is records accepted
2096
+ after §14.6's ingest dedup.
2097
+
2098
+ | fixture | raw | dedup | groups | nodes | edges | cross-scope | ambiguous |
2099
+ |---|---|---|---|---|---|---|---|
2100
+ | `const b = a.email; return b;` | 4 | 4 | 2 | 3 | 2 | 0 | 0 |
2101
+ | §6's own worked example (2 fields, object literal) | 14 | 14 | 6 | 8 | 6 | 0 | 0 |
2102
+ | `while` loop with a re-assigned variable | 12 | 8 | 4 | 4 | 4 | 0 | 0 |
2103
+ | 2-function resolved call | 8 | 8 | 3 | 5 | 5 | 2 | 1 |
2104
+ | 3-function chain, 2 call sites | 20 | 20 | 7 | 11 | 11 | 6 | 2 |
2105
+ | mutual recursion (`ping`/`pong`/`top`) | 34 | 19 | 6 | 8 | 11 | 5 | 2 |
2106
+ | §9.1's cross-join (`{a: p.email, b: q.email}`) | 9 | 8 | 2 | 5 | 6 | 0 | 4 |
2107
+
2108
+ §6's fixture is the one to read closely: its 14 deduplicated records become
2109
+ **8 nodes and 6 edges** — the two field-distinct three-hop paths §6 predicts
2110
+ (`user.email → u.email → o.email → ⟨return⟩` and the same for `.ssn`), with
2111
+ **zero** materialized paths. That is FR-303's compactness requirement,
2112
+ measured rather than claimed.
2113
+
2114
+ ---
2115
+
2116
+ ## 15. Bounded path reconstruction (Sub-project C, increment 5)
2117
+
2118
+ Added 2026-08-30 by increment C5's design task. Everything in this section
2119
+ is **decided**, not proposed, and every behavioural claim and every number
2120
+ in it was produced by running code in
2121
+ `scanner/test/lineage/path-query-poc.test.js` — a throwaway-named PoC
2122
+ committed alongside this section, which prototypes `path-query.js` and the
2123
+ one new `ids.js` function LOCALLY (shipped source is unmodified by this
2124
+ design task, exactly as C1's, C3's and C4's own design tasks did). §15.10
2125
+ is the follow-up implementation task's file/line checklist.
2126
+
2127
+ §14.9 drew the boundary this section crosses: *"No backward walk, no
2128
+ reconstruction, no path budget, no prioritization. C5's, entirely."* Five
2129
+ questions were open when this increment was scoped, and none is answerable
2130
+ on paper. All five are now answered by execution:
2131
+
2132
+ - **Q3 — what IS a path in the output?** A node id sequence *and* the edge
2133
+ sequence that joins it, with the grading-bearing edge fields carried
2134
+ inline. A bare node sequence is provably insufficient. §15.2.
2135
+ - **Q4 — what stops a backward walk on a genuinely cyclic DAG?** An
2136
+ explicit **per-path** visited set (never a global one), plus three
2137
+ in-walk budgets (`maxExpansions`, `maxDepth`, `maxCandidatePaths`) — see
2138
+ §15.10's own corrected count (Task 2 review finding 5; an earlier draft
2139
+ here said "four," double-counting or mis-scoping against the two
2140
+ post-hoc caps §15.5 adds separately). Proven terminating on `C4/4`'s real
2141
+ mutual-recursion cycle. §15.3.
2142
+ - **Q5 — what does §18.4's "cap alternate paths per source/sink pair" bound
2143
+ when there is no source/sink registry?** The **(terminal node, start
2144
+ node) pair** — the terminal node is the closest thing to a "source" this
2145
+ increment has, and a purely global cap is measurably worse. §15.5.
2146
+ - **Q6 — what is FR-305's "materially different" with today's signals?**
2147
+ The **edge** id sequence, never the node id sequence. Proven against a
2148
+ real fixture where the two disagree. §15.6.
2149
+ - **Q7 — what does §18.4's "prioritize paths that differ in boundary,
2150
+ transformation, or protection state" mean when two of those three do not
2151
+ exist yet?** It asks for a **diverse retained set**, not a top-N by any
2152
+ scalar — so the cap is diversity-first over the signals that do exist,
2153
+ and the two absent dimensions are named, not faked. §15.7.
2154
+
2155
+ ### 15.1 `path-query.js`: what it is, and the boundary it inherits
2156
+
2157
+ `path-query.js` is a **pure consumer of a built `PathStore`**. It takes a
2158
+ store and one node id and returns a bounded, ordered, honestly-labelled
2159
+ list of reconstructed paths. It never sees a hop record — C4 already turned
2160
+ those into a DAG — and it inherits §14.1's isolation rule unchanged:
2161
+
2162
+ > **`path-query.js` must NEVER import `engine.js`, `summaries.js`, or
2163
+ > `driver.js`**, and must consume `PathStore` ONLY through its public read
2164
+ > API — never `_groups`/`_build()`/`_peerSourced`/any other `_`-prefixed
2165
+ > field. Its only import is `ids.js`, for `pathId`.
2166
+
2167
+ Same reason as C4's: a real project-wide driver run still emits **zero**
2168
+ hops today (no source registry — Sub-projects D/E), so a query module that
2169
+ could only be exercised through the driver would be untestable by
2170
+ construction. The PoC pins the private-field half of this by string-matching
2171
+ the prototype's own source for `store._`.
2172
+
2173
+ **One entry point, plus two small helpers:**
2174
+
2175
+ ```js
2176
+ reconstructPaths(store, startNodeId, opts = {}) // -> ReconstructionResult
2177
+ sinkCandidates(store) // -> node[] (§15.9)
2178
+ isIncompleteAnswer(result) // -> boolean (AC-10)
2179
+ ```
2180
+
2181
+ `startNodeId`, deliberately **not** named `sinkNodeId`. There is no sink
2182
+ registry, so calling the parameter a sink would import vocabulary the
2183
+ codebase does not have and would read as a promise this increment cannot
2184
+ keep. Once Sub-project D lands, the caller supplies a registered sink node
2185
+ here and nothing about the signature changes.
2186
+
2187
+ ### 15.2 Q3 — what a path IS: nodes AND edges, with the grading material inline
2188
+
2189
+ FR-306 requires that a path's output carry enough to grade each hop
2190
+ (implicit/widened vs. explicit). A bare node-id sequence cannot: every
2191
+ grading signal C4 records — `widenReasons`, `lossReasons`,
2192
+ `ambiguousCorrelation`, `annotations[]`, `crossScope`, `line`, and the four
2193
+ `inKind`/`inSubKind`/`outKind`/`outSubKind` fields — lives on the **edge**,
2194
+ not on either endpoint. And the node sequence is not even a unique key
2195
+ (§15.6). So:
2196
+
2197
+ ```js
2198
+ Path = {
2199
+ id, // `ppath:<12 hex>` — see §15.6
2200
+ nodeIds: [...], // SOURCE-FIRST; length = hopCount + 1
2201
+ edgeIds: [...], // SOURCE-FIRST; edgeIds[i] joins nodeIds[i] -> nodeIds[i+1]
2202
+ hops: [...], // one per edge, denormalized (below)
2203
+ hopCount,
2204
+ dataElementId, // singular — see the note below
2205
+ sourceNodeId, sinkNodeId,
2206
+ terminal: { nodeId, reason, kind }, // reason ∈ §15.4's terminal vocabulary
2207
+ complete, // === (terminal.reason === 'origin')
2208
+ crossScopeCount, widenedHopCount, lossHopCount, ambiguousHopCount,
2209
+ analysisTruncated, // §14.8's markTruncated, reaching a consumer at last
2210
+ shape, // §15.7's diversity signature
2211
+ }
2212
+ ```
2213
+
2214
+ **Source-first, even though the walk runs sink-first.** A human reads a
2215
+ flow source → sink; the walk is an implementation detail. The PoC asserts
2216
+ the exact node sequence §6 predicts, in that order, and asserts
2217
+ `hops[i].fromNodeId === nodeIds[i] && hops[i].toNodeId === nodeIds[i+1]`
2218
+ for every hop.
2219
+
2220
+ **A hop is a denormalized copy of the edge, not the edge id alone.** Ids
2221
+ alone would be smaller and would still round-trip through `store.getEdge`
2222
+ (the PoC checks that every `edgeIds[i]` does), but a path that cannot be
2223
+ graded without also carrying the store is a poor hand-off to C6, to
2224
+ Sub-project E's graph builder, and to Milestone 3's API. FR-303's
2225
+ compactness constraint governs the **store**, not an on-demand query
2226
+ result — §18.4's own wording is "store a provenance DAG, not a list of
2227
+ every expanded path", and the cap plus the budgets are what keep the
2228
+ materialized list bounded.
2229
+
2230
+ **A path never changes data element, and this needs no filtering.** Every
2231
+ edge `path-store.js` builds joins two nodes whose descriptors both take
2232
+ `dataElementId` from the same hop (§14.3), so a connected walk is
2233
+ field-precise by construction — FR-301's distinctness carried into
2234
+ FR-303's structure for free. Pinned directly (`C5/1b`: every edge in a real
2235
+ two-identity store has `from.dataElementId === e.dataElementId ===
2236
+ to.dataElementId`). A future change that broke this would silently let a
2237
+ reconstruction wander between data elements, so it is asserted rather than
2238
+ assumed.
2239
+
2240
+ ### 15.3 Q4 — the backward walk, and how it terminates on a real cycle
2241
+
2242
+ `edgesTo(nodeId)` is the traversal primitive. The walk is an **iterative
2243
+ DFS over an explicit stack — never recursion**, matching this package's own
2244
+ established discipline (`path-store.js` has no recursion at all; C5 has a
2245
+ graph walk and still has none).
2246
+
2247
+ **Termination discipline, in order of what actually does the work:**
2248
+
2249
+ 1. **A per-path visited set** — the set of nodes already on the current
2250
+ partial path. An in-edge whose source is already on the path is
2251
+ *clipped* and counted (`cyclesClipped`). This is what makes the walk
2252
+ terminate on a cyclic DAG regardless of budget: every enumerated path is
2253
+ a **simple** path, and a finite graph has finitely many. Measured on
2254
+ `C4/4`'s real 8-node/11-edge mutual-recursion cycle (§15.11's last row):
2255
+ the walk finishes having used **35 expansions** summed across all four
2256
+ sink candidates, with **7 clips**. `C5/3` re-runs it with every budget
2257
+ raised to 10^6 and asserts the expansion count is unchanged and far
2258
+ below the ceiling — the visited set, not a budget, is what stopped it.
2259
+ > **A GLOBAL visited set would be wrong, not merely conservative.** It
2260
+ > would make every node reachable on at most one path, which
2261
+ > deletes exactly the alternate paths FR-305 exists to show.
2262
+ 2. **`maxExpansions`** (default 10000) — total in-edges examined. This is
2263
+ the hard, shape-independent termination guarantee, and the only one that
2264
+ bounds *work* rather than *output*. It is the budget the brief's "a cap
2265
+ on total paths explored, not just total nodes visited" asks for.
2266
+ 3. **`maxDepth`** (default 64) — hops on a single path.
2267
+ > **Corrected by fix round 1 (finding 5): this is the SECOND most
2268
+ > load-bearing knob, not the most droppable one.** A DFS frame carries a
2269
+ > copy of the path so far, so extending a k-hop path costs O(k) and one
2270
+ > path of depth D costs **O(D²)** — a cost `maxExpansions` cannot see,
2271
+ > because it counts edges examined, not elements copied. Measured
2272
+ > (`C5/3d`) on a hand-built straight 3000-hop chain: the whole walk is
2273
+ > 3000 expansions — 30% of the default expansion budget (10000, corrected
2274
+ > from an earlier "0.03%" here that was wrong by 1000x), comfortably
2275
+ > under that budget so it would never fire — yet it takes ~150 ms unbounded against
2276
+ > ~0 ms at `maxDepth: 8`. It is also the **only** budget whose limit
2277
+ > produces an EMITTED, marked partial rather than an abandoned branch
2278
+ > (§15.4), proven by contrast on the same store.
2279
+ 4. **`maxCandidatePaths`** (default 256) — stop enumerating once this many
2280
+ complete-or-partial branches have been collected.
2281
+ > **Corrected by fix round 1 (finding 5). This knob is a TIGHTENING
2282
+ > constant, not an independent safety guarantee, and the earlier text
2283
+ > here ("bounds memory") was wrong as written.** The DFS emits at most
2284
+ > one candidate per *popped* frame, and a frame is only ever pushed by
2285
+ > an expansion, so `enumeratedPathCount ≤ expansionsUsed + 1` holds
2286
+ > **unconditionally** — `maxExpansions` already bounds the candidate
2287
+ > array on its own. `C5/3e` measures that bound across every start node
2288
+ > of three fixtures with this knob raised out of the way. What the knob
2289
+ > genuinely buys is a much tighter *default* (256, against the ~10001
2290
+ > `maxExpansions` alone would permit) and an early exit for a caller who
2291
+ > wants a few paths fast. It is kept for that, stated as that. Dropping
2292
+ > it, and its `'candidate-cap'` truncation reason with it, would also
2293
+ > have been defensible.
2294
+
2295
+ **The depth check runs AFTER the zero-in-edges check, deliberately.** A
2296
+ node with no predecessors is a genuine origin no matter how deep the walk
2297
+ is, so a path that reaches one is `complete: true` even at `maxDepth: 1`
2298
+ (`C5/3b` pins exactly this). Only a branch the *limit* stopped is marked
2299
+ partial.
2300
+
2301
+ **`edgesTo` is sorted by edge id before traversal.** `path-store.js` backs
2302
+ its indexes with `Set`s and the read API carries no inherent order, so an
2303
+ unsorted walk would be insertion-order-dependent. Sorting makes the
2304
+ enumeration order, and therefore every tie-broken output order, stable.
2305
+
2306
+ **Defaults are honestly uncalibrated.** They are two-plus orders of
2307
+ magnitude above what every fixture in the PoC needs (§15.11's largest row
2308
+ uses 35 expansions), but no fixture here is a real project, and no real
2309
+ project can be measured until a driver run emits hops (Sub-projects D/E).
2310
+ They are a starting point to re-measure then, not a tuned result.
2311
+
2312
+ ### 15.4 §18.4's load-bearing constraint: five distinguishable answers
2313
+
2314
+ > *"Never translate 'path budget exhausted' into 'no path.'"*
2315
+
2316
+ The result shape makes **five** answers pairwise distinguishable **in the
2317
+ data**, not by convention or by a caller's discipline:
2318
+
2319
+ Every result also carries `startNodeId` and `startNodeKind` (the started-from
2320
+ node's `kind`, or `null` when it is unknown), so a consumer can tell what it
2321
+ asked about without a second `getNode` call.
2322
+
2323
+ | answer | `truncated` | `unknownStartNode` | `noPathReason` | `truncationReasons` | means |
2324
+ |---|---|---|---|---|---|
2325
+ | complete, with paths | `false` | `false` | `null` | `[]` | this really is everything |
2326
+ | genuinely empty | `false` | `false` | `'no-incoming-edges'` | `[]` | we looked exhaustively; nothing flows in |
2327
+ | **recording gap** | `false` | `false` | `'incomplete-record'` | `[]` | nothing flows in *in the recorded stream*, and the store itself knows the stream is incomplete here — **not proof of absence** |
2328
+ | unknown node | `false` | `true` | `null` | `[]` | you asked about a node that is not in this store |
2329
+ | budget-truncated | `true` | `false` | **`null`** | non-empty | the list is short because a limit stopped us |
2330
+
2331
+ `C5/4b` asserts three of these — genuinely-empty, unknown-node and
2332
+ budget-truncated — are pairwise different as **literal JSON**, not merely
2333
+ different in spirit; `C5/4c` adds the recording-gap row against a real
2334
+ cache-warming fixture, and `C5/1` the first. The single most important cell is
2335
+ the bold `null`: **a truncated result can never acquire a `noPathReason`**,
2336
+ because `noPathReason` is only ever computed when `truncated === false`.
2337
+ That is the §18.4 constraint expressed as a code path, not as a comment.
2338
+
2339
+ **Truncation reasons** (result-level, sorted, non-empty iff `truncated`):
2340
+ `'expansion-budget'`, `'candidate-cap'`, `'depth-limit'`,
2341
+ `'per-terminal-cap'`, `'path-cap'`. `truncated` is *also* set whenever
2342
+ `droppedPathCount > 0`, so a cap that silently discarded a path is
2343
+ impossible.
2344
+
2345
+ **Terminal reasons** (per path): `'origin'` (the only one that sets
2346
+ `complete: true`), `'incomplete-record'`, `'cycle'`, `'depth-limit'`.
2347
+
2348
+ - A branch that ends because **every** continuation would revisit a node
2349
+ reports `'cycle'` — never `'origin'`. Presenting a cycle-clip as an
2350
+ origin would be §18.4's failure mode at path granularity. `C5/3c` proves
2351
+ a real cyclic fixture produces both labels, so they are demonstrably
2352
+ different rather than nominally so.
2353
+ - A branch that ends at a node in `diagnostics().orphanedPeerSources`
2354
+ reports `'incomplete-record'`. This is §14.4's disclosed
2355
+ stream-completeness gap reaching a consumer for the first time: C4
2356
+ records it, C5 is the first thing that must not lie about it. `C5/4c`
2357
+ builds the real cache-warmed-without-a-recorder scenario and proves both
2358
+ the zero-path form and the walked-into form.
2359
+ - **`'expansion-budget'` and `'candidate-cap'` are deliberately NOT
2360
+ terminal reasons.** When those trip, in-flight branches are **abandoned,
2361
+ not emitted**: a branch cut at an arbitrary global point has a
2362
+ meaningless prefix, and emitting it would manufacture a "path" the graph
2363
+ does not contain. `'depth-limit'` is different — that branch genuinely
2364
+ reached a stated ceiling — so it *is* emitted, marked partial. `C5/3b`
2365
+ pins that; `C5/3`'s two starved runs pin both of the others
2366
+ (`maxExpansions: 1` and `maxCandidatePaths: 1`), each producing
2367
+ `truncated: true` with `noPathReason: null` and no fabricated partial.
2368
+
2369
+ **Two further incompleteness signals, both distinct from `truncated`:**
2370
+
2371
+ - `analysisTruncated` — true when any node or edge on a returned path
2372
+ carries §14.8's `truncated: true` from `markTruncated(scope, context,
2373
+ reason)`. §14.8 reserved that channel and named no consumer; **C5 is its
2374
+ consumer.** `C5/4d` proves the path is still *returned* (never withheld)
2375
+ and still *labelled*, and that the flag stays separate from C5's own
2376
+ `truncated` so the two causes never merge.
2377
+ - `paths.some(p => !p.complete)` — a returned list every one of whose paths
2378
+ is partial is a very different answer from a list of complete ones.
2379
+
2380
+ `isIncompleteAnswer(result)` is the single derived predicate AC-10's
2381
+ persistent partial-coverage banner should drive off, so no caller
2382
+ re-derives it (and no caller forgets a term):
2383
+
2384
+ ```js
2385
+ result.truncated || result.unknownStartNode || result.analysisTruncated
2386
+ || result.noPathReason === 'incomplete-record'
2387
+ || result.paths.some((p) => !p.complete)
2388
+ ```
2389
+
2390
+ AC-10's *"a zero-flow filter result must say that the scope is
2391
+ incomplete"* then falls straight out: an empty `paths` with
2392
+ `isIncompleteAnswer() === true` is exactly that case, and an empty `paths`
2393
+ with `isIncompleteAnswer() === false` is honestly a real zero.
2394
+
2395
+ ### 15.5 Q5 — the alternate-path cap: per (terminal, start) pair, today
2396
+
2397
+ §18.4 says *"cap alternate paths per source/sink pair with an explicit
2398
+ truncation count."* `path-store.js` has no notion of a source or a sink.
2399
+ The naive reading — cap the paths returned by one call, i.e. per *start
2400
+ node* — is strictly coarser than "per pair", and the difference is not
2401
+ academic:
2402
+
2403
+ > **MEASURED (`C5/5f`), on the mutual-recursion fixture.** At the first
2404
+ > sink with two terminals the walk finds **6 paths across 2 distinct
2405
+ > terminals** — 4 complete, 2 cycle-terminated — and the two terminals are
2406
+ > separated by a real, deterministic ranking reason rather than by chance:
2407
+ > every *complete* path terminates at one of them, every
2408
+ > *cycle-terminated* path at the other, and `complete` is `comparePaths`'
2409
+ > **first** key. So a naive global top-N cap at **N = 4** fills entirely
2410
+ > from the complete terminal and covers **1 of 2**; the other terminal is
2411
+ > reported as having *zero* paths. That is §18.4's own failure
2412
+ > mode ("budget exhausted" presented as "no path") reached through the cap
2413
+ > rather than through the walk.
2414
+ >
2415
+ > **Re-anchored by fix round 1 (finding 3).** This measurement was
2416
+ > originally taken on the §9.1 cross-join fixture. The claim was true
2417
+ > there, but it proved nothing: all four of that fixture's paths share an
2418
+ > **identical** `comparePaths` content tuple (`C5/5d` now pins the flat
2419
+ > `[1,1,1,1]` ambiguity vector), so which two survived a naive cap was
2420
+ > decided purely by `pathId`'s hash. A measurement that holds by hash
2421
+ > coincidence is exactly the "prose stronger than the proof" failure this
2422
+ > document exists to prevent.
2423
+
2424
+ **Decided: the cap is applied per TERMINAL first, then globally.**
2425
+
2426
+ - `maxPathsPerTerminal` (default 8) — candidates are grouped by
2427
+ `terminal.nodeId`, ordered within the group (§15.7), and truncated there.
2428
+ Because every call already fixes one start node, "(terminal, start)" *is*
2429
+ a pair, and the terminal node is the closest thing to a source this
2430
+ increment has. This is a genuine per-pair cap today, not a stand-in.
2431
+ - `maxPaths` (default 32) — a global ceiling on the returned list,
2432
+ applied diversity-first (§15.7).
2433
+ - `result.terminals[]` reports, per terminal:
2434
+ `{nodeId, terminalReasons, enumeratedPathCount, keptPathCount,
2435
+ returnedPathCount, droppedPathCount, truncated}`. This is also what
2436
+ FR-305's *"the UI must show a path count"* needs — a count **per
2437
+ source/sink pair**, not one aggregate.
2438
+
2439
+ Two properties of that row are load-bearing, and fix round 1 found both
2440
+ of them wrong in the first draft:
2441
+
2442
+ > **`truncated` is `enumeratedPathCount > returnedPathCount` — measured
2443
+ > AFTER the global cap, never from the per-terminal cap alone (finding 1,
2444
+ > blocking).** The first draft computed it from `maxPathsPerTerminal`
2445
+ > only, *before* the diversity round-robin ran, so a terminal that the
2446
+ > **global** cap starved to zero returned paths still reported
2447
+ > `truncated: false`. Reproduced concretely on the §9.1 cross-join
2448
+ > fixture at `{maxPaths: 1, maxPathsPerTerminal: 8}`: the `p.email`
2449
+ > terminal showed `enumerated=2 kept=2 returned=0 truncated=false` — a
2450
+ > terminal with ZERO returned paths claiming it was not truncated. (That
2451
+ > exact row now reads `enumerated=2 kept=2 returned=0 droppedPathCount=2
2452
+ > truncated=true`, pinned verbatim by `C5/5e`.) That
2453
+ > is §18.4's exact failure mode reproduced at *pair* granularity, inside
2454
+ > the very field this section introduces to satisfy it at *call*
2455
+ > granularity, and it would have been read by precisely the consumers
2456
+ > (C6, Milestone 3's UI) that the field exists for. `droppedPathCount`
2457
+ > makes the count explicit per pair the way `result.droppedPathCount`
2458
+ > does per call, and `C5/5e` now pins BOTH cases — the per-terminal cap
2459
+ > (where the bug structurally cannot fire) and the global cap alone
2460
+ > (where it did) — plus the arithmetic that the per-terminal rows sum to
2461
+ > the per-call totals, so a consumer can never be told two different
2462
+ > stories.
2463
+ >
2464
+ > **`terminalReasons` is a sorted UNION over the terminal's own paths,
2465
+ > never a positional pick (finding 2).** A terminal can genuinely carry
2466
+ > MIXED reasons: on the mutual-recursion fixture at `maxDepth: 3`, one
2467
+ > sink has a terminal reached by both a `'cycle'` clip and a
2468
+ > `'depth-limit'` stop. The first draft's `group[0].terminal.reason`
2469
+ > therefore reported whichever the DFS happened to enumerate first —
2470
+ > the SAME representative-picking bug class C4's own final whole-branch
2471
+ > review found in `path-store.js`'s `origin` branch (`g.annotations[0]`)
2472
+ > and fixed the same way. The singular field is **removed**, not
2473
+ > supplemented, so no consumer can keep reading the order-dependent one;
2474
+ > `C5/5h` asserts its absence as well as the union's correctness.
2475
+
2476
+ **What changes when Sub-project D's registry lands.** Very little, and that
2477
+ is the point. D relabels *which* terminals are registered sources; the
2478
+ grouping key does not change, and neither does the cap's meaning. The one
2479
+ thing D adds is the ability to say "this terminal is not a registered
2480
+ source" — at which point a caller may want to *drop* rather than cap such
2481
+ paths, which is a filter, not a cap, and belongs in D's own increment. The
2482
+ per-call framing is therefore already correct: today's cap does not need to
2483
+ be revisited, only supplemented.
2484
+
2485
+ ### 15.6 Q6 — deduplication, and what FR-305 forbids hiding
2486
+
2487
+ > FR-305: *"Deduplication may collapse identical internal segments but
2488
+ > cannot hide materially different transformations or controls."*
2489
+
2490
+ **Decided: a path's identity is its EDGE id sequence, never its node id
2491
+ sequence.**
2492
+
2493
+ ```js
2494
+ pathId({ startNodeId, edgeIds }) // -> `ppath:<12 hex>`
2495
+ ```
2496
+
2497
+ §14.5 left `pathId` deliberately unclaimed for exactly this entity
2498
+ ("`pathId` is deliberately left unused. The thing C5 reconstructs *is* a
2499
+ path, and it will plausibly want that name"). C5 claims it, with a
2500
+ `ppath:` prefix joining the `pnode:`/`pedge:` family — a reconstructed path
2501
+ is not a `DataFlowGraph v1` entity either, so `validate.js` stays untouched
2502
+ (§15.10 item 4). `startNodeId` is in the discriminator even though it is
2503
+ strictly **redundant today** — a path always has at least one hop (a start
2504
+ node with no in-edges yields zero paths, never one empty path, §15.4), so
2505
+ the last edge id already determines it. It is kept because over-specifying
2506
+ a content hash costs nothing while under-specifying one is a silent merge —
2507
+ §14.5's own lesson, applied rather than re-learned — and because it keeps
2508
+ the id well-defined if a later increment ever admits a zero-hop or
2509
+ otherwise edge-less path entity.
2510
+
2511
+ **Why node-keyed dedup would violate FR-305, proven on a real fixture.**
2512
+ `C5/5` builds
2513
+
2514
+ ```js
2515
+ function f(user) { let a = user.email; let b = a; b = a; return b; }
2516
+ ```
2517
+
2518
+ and measures that the node pair `(a, data:email) -> (b, data:email)` is
2519
+ joined by **two distinct edges** — two assignments at two CFG nodes, which
2520
+ `provenanceEdgeId` keeps apart precisely because §14.5 put `siteNodeId` in
2521
+ the discriminator ("two structurally identical hops at two different
2522
+ program points are two materially different edges (FR-305)"). Reconstruction
2523
+ returns them as two paths with an **identical node sequence** and different
2524
+ `line`s. Collapsing on nodes would hide the differing program point — the
2525
+ one thing a reader would notice, and squarely inside "materially
2526
+ different". Collapsing on edge ids cannot hide anything, because an edge id
2527
+ is a content hash over every grading-bearing field (§14.5): two paths with
2528
+ the same edge sequence are identical hop-for-hop, in kind, sub-kind,
2529
+ reasons, and site.
2530
+
2531
+ **Honest scope, measured not assumed.** Within one `reconstructPaths` call
2532
+ a DFS with a per-path visited set **cannot** emit the same edge sequence
2533
+ twice, so dedup is *not* a volume control here — it is an identity
2534
+ definition (stable across runs and across calls: `C5/5c` proves two
2535
+ independent analysis runs of the same fixture produce the same `ppath:`
2536
+ id) and a safety net. `C5/5b` measures the no-duplicates property across
2537
+ every node of the cyclic fixture rather than asserting it.
2538
+
2539
+ **What is NOT collapsed, and why.** §9.1's cross-join phantoms
2540
+ (`p.email → x.b` where the value came from `q.email`) are genuinely
2541
+ different node sequences and are **kept**, marked
2542
+ `ambiguousCorrelation: true` on the offending hop, and de-prioritized by
2543
+ §15.7's order. That is §9.1's own "detect and mark, do not prevent"
2544
+ verdict carried to the output — and §14.7 reached the same verdict a second
2545
+ time on independent evidence. Silently collapsing them would be exactly the
2546
+ hiding FR-305 forbids; the `ambiguousHopCount` on the path is how a
2547
+ consumer tells them apart. `C5/5d` pins the *keeping* half on §9.1's own
2548
+ fixture (4 distinct routes, none collapsed).
2549
+
2550
+ > **Where the DE-PRIORITIZING half is actually proven, corrected by fix
2551
+ > round 1 (finding 4).** Not on §9.1's fixture: its ambiguity vector is a
2552
+ > flat `[1,1,1,1]`, so every ordering assertion made on it is **vacuous** —
2553
+ > a guarded "unambiguous before ambiguous" check never executes and a
2554
+ > monotonicity loop only ever compares `1 >= 1`. `C5/5d` now pins that
2555
+ > flatness explicitly, so the fixture can never again be mistaken for
2556
+ > ordering evidence, and proves the ordering on §14.7's leg fixture
2557
+ > instead, whose ambiguity genuinely varies (`1` vs `2`) — and where the
2558
+ > more-ambiguous paths are ALSO the boundary-crossing ones, so key 2
2559
+ > (ambiguity) is shown to *override* key 4 (cross-scope) rather than merely
2560
+ > agree with it. `C5/2` is the other genuine reorder (§15.7).
2561
+
2562
+ ### 15.7 Q7 — prioritization: diversity first, and what is honestly deferred
2563
+
2564
+ > §18.4: *"Prioritize paths that differ in boundary, transformation, or
2565
+ > protection state."*
2566
+
2567
+ Read carefully, that sentence asks for a **diverse retained set** — show
2568
+ the user paths that differ from each other — not a ranking by any single
2569
+ scalar. Both halves are implemented, and they are different mechanisms.
2570
+
2571
+ **(a) The cap is diversity-first.** Each path carries a `shape` signature
2572
+ built ONLY from signals that exist today:
2573
+
2574
+ ```
2575
+ <complete|partial> / <boundary|local> / <widened|explicit> / <lossy|intact> / <ambiguous|correlated>
2576
+ ```
2577
+
2578
+ from `terminal.reason`, `crossScopeCount`, `widenReasons`, `lossReasons`
2579
+ and `ambiguousCorrelation` respectively. When `maxPaths` binds, candidates
2580
+ are bucketed by `(terminal.nodeId, shape)` and taken **round-robin** across
2581
+ buckets, so no bucket is crowded out. `C5/5g` proves this on a real
2582
+ fixture (`{ r: helper(a), s: b.email }` — 8 paths from its `⟨return⟩` sink
2583
+ alone, spanning 2 shapes: `complete/boundary/…` and `complete/local/…`, a
2584
+ genuine boundary difference). Capped to exactly the bucket count, the
2585
+ retained set spans **every** shape — which a plain top-N by rank does not
2586
+ guarantee.
2587
+
2588
+ **(b) Within that, a deterministic total order** (`comparePaths`), keys in
2589
+ order:
2590
+
2591
+ 1. `complete` first — an incomplete path is not evidence of a full flow.
2592
+ 2. fewer `ambiguousHopCount` — §9.1/§14.7's marker, lower confidence.
2593
+ 3. fewer `lossHopCount`, then fewer `widenedHopCount` — FR-306's
2594
+ lower-confidence grades, in the order a reader would rank them.
2595
+ 4. **more** `crossScopeCount` — §18.4's "boundary" dimension, and this
2596
+ document's own repeated emphasis on interprocedural stitching being the
2597
+ hard, load-bearing case.
2598
+ 5. fewer hops — a shorter explanation, all else equal.
2599
+ 6. `id` lexicographic — stability, never a tie left to insertion order.
2600
+
2601
+ `C5/2` proves this order does real work rather than being decorative: at
2602
+ the plain 2-function resolved call, the **4-hop through-the-callee chain**
2603
+ (`crossScope 2`, `ambiguous 0`) is ranked **ahead of** the 2-hop bypass
2604
+ (`crossScope 0`, `ambiguous 1`) — the correct answer wins despite being
2605
+ twice as long, because length is the last content key rather than the
2606
+ first.
2607
+
2608
+ **Honestly deferred, and named:**
2609
+
2610
+ - **Transformation kind** — there is no transformation-kind recognition in
2611
+ this codebase at all. **Sub-project D.** Today's nearest signals
2612
+ (`widenReasons`/`lossReasons`) describe *analysis imprecision*, not a
2613
+ transformation the program performs, and using them as a stand-in would
2614
+ be inventing vocabulary that isn't backed by data.
2615
+ - **Protection state** — `protection.js` defines the verdict *model*; no
2616
+ analyzer produces a verdict. **Milestone 2.**
2617
+
2618
+ When either lands, it adds a component to `shape` and a key to
2619
+ `comparePaths`. Neither changes the mechanism, and neither is faked in the
2620
+ meantime.
2621
+
2622
+ ### 15.8 What C5 deliberately does not do
2623
+
2624
+ - **No source/sink registry.** `sinkCandidates()` (§15.9) is a structural
2625
+ stand-in and says so. **Sub-project D.**
2626
+ - **No FR-306 grade computation.** C5's path output *carries* every
2627
+ grading input (`widenReasons`, `lossReasons`, `ambiguousCorrelation`,
2628
+ `annotations[]`, `crossScope`, plus the four kind/sub-kind fields) and
2629
+ computes only counts. Turning counts into a grade is **C6**. Note
2630
+ §14.9's own correction: a consumer must read `annotations[]` too, not
2631
+ only the edge's top-level reason arrays — `hops[].annotations` is
2632
+ therefore carried verbatim.
2633
+ - **No `DataFlowGraph v1` output.** Sub-project E.
2634
+ - **No flow-sensitivity filter.** §9.2 offered C5 a lever (require
2635
+ non-decreasing `line`/`nodeId` along a path, or de-prioritize paths that
2636
+ violate it) and explicitly left the call to C5 "made against real
2637
+ measurements". **Declined for now, with the reason stated:** the
2638
+ measurement that would justify it does not exist — no fixture here has a
2639
+ real kill-then-reuse shape at scale, and §9.2's own note that the lever
2640
+ carries "false-negative risk on loops and back-edges" is not something to
2641
+ accept against zero evidence. The material is on the path (`hops[].line`,
2642
+ `hops[].siteNodeId`) for whichever increment does measure it.
2643
+ - **No change to `path-store.js`, and none to `field-identity.js`
2644
+ (never).** §15.9.
2645
+ - **No driver wiring.** A driver run emits zero hops today (§14.1); wiring
2646
+ is Sub-project D/E's, and it is a `driver.js` change, not a
2647
+ `path-query.js` one.
2648
+
2649
+ ### 15.9 `path-store.js`'s read API is sufficient — no change, and why
2650
+
2651
+ Checked by building the whole prototype against it. The query itself needs
2652
+ exactly **four** of the ten exported reads:
2653
+
2654
+ | read | used for |
2655
+ |---|---|
2656
+ | `getNode(id)` | start-node existence (the `unknownStartNode` answer), terminal `kind`, and each node's `truncated` flag |
2657
+ | `edgesTo(id)` | **the** traversal primitive — the only walk call, and the only read inside the loop |
2658
+ | `nodes()` | `sinkCandidates()`'s O(N) filter (§15.9's registry stand-in) |
2659
+ | `diagnostics()` | `orphanedPeerSources` → the `'incomplete-record'` signal, read once per call |
2660
+
2661
+ A hop is denormalized straight off the edge objects `edgesTo` already
2662
+ returns, so **`getEdge` is never called by the query** — it is used only by
2663
+ the tests, to prove every emitted `edgeIds[i]` round-trips. `nodeIdFor` is
2664
+ likewise fixtures-only. `edges`, `edgesFrom`, `hasEdge` and `stats` are
2665
+ unused entirely.
2666
+
2667
+ **The one question the brief raised explicitly — is there a cheap way to
2668
+ find every sink-shaped node without a registry? — is answered YES with no
2669
+ API addition.** `store.nodes()` already returns every node with its `kind`,
2670
+ so:
2671
+
2672
+ ```js
2673
+ function sinkCandidates(store) {
2674
+ return store.nodes().filter((n) =>
2675
+ n.kind === 'return' || n.kind === 'escape' || n.kind === 'loss');
2676
+ }
2677
+ ```
2678
+
2679
+ is an O(N) filter over the public API. It belongs in `path-query.js`, not
2680
+ in `path-store.js`: deciding what counts as a sink is a *query* concern,
2681
+ and the moment D ships a registry this helper is superseded rather than
2682
+ extended. It is named `sinkCandidates`, not `sinks`, for the same reason
2683
+ the entry point's parameter is `startNodeId` — it is a structural filter
2684
+ with no security opinion, and its doc comment says so.
2685
+
2686
+ `escape` and `loss` cannot appear as intermediates: `classifyIn` never
2687
+ produces them as a source, so nothing in the store ever points *out* of
2688
+ one. `C5/2b` asserts `edgesFrom(n).length === 0` for every such node in a
2689
+ real fixture, which is what makes them safe start nodes and impossible
2690
+ mid-path nodes.
2691
+
2692
+ **One observation, deliberately NOT a change request.** `edgesFrom`/
2693
+ `edgesTo` each call `this._build()` twice per invocation, and `nodes()`/
2694
+ `edges()` copy the whole map on every call. At `maxExpansions` scale
2695
+ (10^4 `edgesTo` calls, each an O(1) cached `_build()` plus an O(degree)
2696
+ map) this is immaterial, and §14.6 already names the real profiling target
2697
+ (the derived `context` key strings) for Sub-project E. Recorded so a later
2698
+ reader knows it was looked at and judged, not missed.
2699
+
2700
+ ### 15.10 What the follow-up implementation task must do
2701
+
2702
+ Written the way §10.1/§13.7/§14.10 were, so the next brief needs no
2703
+ re-derivation.
2704
+
2705
+ **`scanner/src/lineage/ids.js`**
2706
+
2707
+ | # | Site | Change |
2708
+ |---|---|---|
2709
+ | 1 | after `provenanceEdgeId` | add `pathId({ startNodeId, edgeIds }, discriminatorParts = [])` → `ppath:<12 hex>`, via the existing `_hash`/`_canon`. Object argument, matching the `provenanceNodeId`/`provenanceEdgeId` precedent (§14.5) |
2710
+ | 2 | `test/lineage/ids.test.js` | extend with `pathId` idempotence + non-collision (a changed edge id anywhere in the sequence, and a reordered sequence, must both move the id) |
2711
+ | 3 | — | **no change to `validate.js`.** `ppath:` is not a `DataFlowGraph v1` entity kind. Confirm by running `npm run test:lineage`; `json-schema-parity.test.js` must stay green untouched |
2712
+
2713
+ **`scanner/src/lineage/path-query.js` (new)**
2714
+
2715
+ | # | Item | Detail |
2716
+ |---|---|---|
2717
+ | 4 | imports | `ids.js` ONLY. Never `engine.js`/`summaries.js`/`driver.js`, and never a `_`-prefixed `PathStore` field. Add the same import-list self-check test `path-store.test.js`'s boundary test uses (§14.10 item 5), plus the `store\._` source scan the PoC's `C5/6` already carries |
2718
+ | 5 | `DEFAULTS` | `{ maxPaths: 32, maxPathsPerTerminal: 8, maxCandidatePaths: 256, maxExpansions: 10000, maxDepth: 64 }`, all `opts`-overridable. Document them as uncalibrated (§15.3) |
2719
+ | 6 | `reconstructPaths(store, startNodeId, opts)` | iterative DFS over an explicit stack — **no recursion**. Per-path visited set. `edgesTo` sorted by edge id. Zero-in-edges check BEFORE the depth check (§15.3) |
2720
+ | 7 | terminal classification | `'origin'` / `'incomplete-record'` (node in `diagnostics().orphanedPeerSources`) / `'cycle'` (every continuation clipped) / `'depth-limit'`. `'expansion-budget'`/`'candidate-cap'` are result-level only — those branches are abandoned, never emitted (§15.4) |
2721
+ | 8 | the cap | per `terminal.nodeId` first (`maxPathsPerTerminal`), then a diversity-first round-robin over `(terminal.nodeId, shape)` buckets for `maxPaths` (§15.5/§15.7). `result.terminals[]` per §15.5 |
2722
+ | 9 | the result shape | exactly §15.4's table, plus `startNodeId`/`startNodeKind`/`enumeratedPathCount`/`returnedPathCount`/`droppedPathCount`/`completePathCount`/`cyclesClipped`/`analysisTruncated`/`terminals[]`/`budget.expansionsUsed`. `noPathReason` must be computed ONLY when `truncated === false` — that ordering IS §18.4's constraint, and `terminals[].truncated`/`droppedPathCount` must be computed AFTER the global cap, never from the per-terminal cap alone (§15.5). **`completePathCount` is scoped to the full ENUMERATION (every complete path found before any cap is applied), same as `enumeratedPathCount`/`droppedPathCount` — NOT to `result.paths` after capping.** Final whole-branch review finding 5: it can therefore legitimately exceed `result.paths.length`; a caller wanting "how many complete paths are actually IN this response" must compute `result.paths.filter(p => p.complete).length` itself, never read `completePathCount` for that. |
2723
+ | 10 | `sinkCandidates(store)` | §15.9's filter, with the "not a registry" doc comment |
2724
+ | 11 | `isIncompleteAnswer(result)` | §15.4's five-term predicate, exported so AC-10's banner has one owner |
2725
+ | 11b | `comparePaths(a, b)` | §15.7's total order, **also exported**. It is not merely internal: the tests call it directly to build the naive-global-cap contrast (`C5/5f`), so items 12/13 below are unsatisfiable without it. Added by fix round 1 (finding 6) |
2726
+
2727
+ **Tests**
2728
+
2729
+ | # | Change |
2730
+ |---|---|
2731
+ | 12 | Re-point `path-query-poc.test.js` at the shipped `path-query.js`/`ids.js`, delete its local prototype block, rename it to `path-query.test.js`, and update the `test:lineage` script in `scanner/package.json` in the SAME commit — C3's item 15 / C4's item 11 precedent |
2732
+ | 13 | Keep every assertion, and especially keep `C5/4b` (the three empty-looking results as literal JSON) and `C5/4c` (`incomplete-record`) — together they are the only guard that stops §18.4's constraint being silently undone |
2733
+ | 14 | Keep `C5/M`'s measured-numbers table asserted against §15.11's published rows, so a refactor that changes a published number fails a test rather than leaving this document stale (C4's `C4/1b`/`C4/4` precedent). **The trade-off, stated rather than discovered later:** these numbers are a property of the current parser/IR/engine as much as of `path-query.js`, so an unrelated IR or engine change CAN fail this test without anything being wrong with reconstruction. That is the intended cost — the same one §14.11's own pinned counts carry — and the correct response is to re-measure and update §15.11's table in the same commit, never to relax the assertion. The `C5_PRINT_TABLE` env var prints the freshly measured rows to make that re-measurement one command; it must never be allowed to SKIP the assertion (fix round 1, finding 7) |
2734
+ | 15 | Add a driver-level test only once a hop-emitting driver run is possible (Sub-projects D/E) — until then it would assert on an empty store and be vacuous, same reasoning as §14.10 item 13 |
2735
+
2736
+ **Deliberately NOT in the follow-up's scope:** everything in §15.8, plus
2737
+ any change to `path-store.js`, `engine.js`, `summaries.js` or `driver.js`
2738
+ (C5 consumes the C4 DAG exactly as shipped — no store change is needed to
2739
+ answer Q3-Q7).
2740
+
2741
+ ### 15.11 Measured numbers
2742
+
2743
+ Every row produced by running the PoC's own fixtures through the prototype
2744
+ on 2026-08-30 (`C5/M`), with the three OUTPUT caps (`maxPaths`,
2745
+ `maxPathsPerTerminal`, `maxCandidatePaths`) raised to 10^6 and the work
2746
+ budgets left at their defaults, so the numbers describe the graph rather
2747
+ than a cap. Every row asserts `truncated: false`, which is what proves no
2748
+ budget bound it. "sinks" is `sinkCandidates()`'s count; the walk is run once
2749
+ per sink candidate and the remaining columns are summed across them.
2750
+ "expansions" is in-edges examined; "clipped" is per-path visited-set
2751
+ rejections (i.e. cycle encounters).
2752
+
2753
+ | fixture | sinks | paths | complete | partial | max hops | expansions | clipped |
2754
+ |---|---|---|---|---|---|---|---|
2755
+ | `const b = a.email; return b;` | 1 | 1 | 1 | 0 | 2 | 2 | 0 |
2756
+ | §6's worked example (2 fields, object literal) | 2 | 2 | 2 | 0 | 3 | 6 | 0 |
2757
+ | 2-function resolved call | 2 | 3 | 3 | 0 | 4 | 7 | 0 |
2758
+ | §9.1's cross-join (`{a: p.email, b: q.email}`) | 1 | 4 | 4 | 0 | 2 | 6 | 0 |
2759
+ | §14.7's leg counter-example (`{r: helper(a), s: b.email}`) | 3 | 11 | 11 | 0 | 4 | 18 | 0 |
2760
+ | mutual recursion (`ping`/`pong`/`top`) — cyclic | 4 | 13 | 9 | 4 | 6 | 35 | 7 |
2761
+
2762
+ Three rows to read closely:
2763
+
2764
+ - **§6's worked example** reconstructs into exactly the **two field-distinct
2765
+ three-hop paths** §14.11 predicted from its 8 nodes / 6 edges —
2766
+ `user.email → u.email → o.email → ⟨return⟩` and the same for `.ssn`, with
2767
+ zero truncation and zero cross-contamination. §14.11 proved the structure
2768
+ was there; this row is the structure actually being read back out. That
2769
+ is FR-303's *"ordered paths can be reconstructed"* half, measured.
2770
+ - **The 2-function resolved call**'s 3 paths split 1 + 2 across its two
2771
+ sink candidates: `helper`'s own exit node yields the single path
2772
+ `u.email → ⟨return helper⟩`, and `caller`'s exit node yields **two** —
2773
+ the real 4-hop through-the-callee chain
2774
+ (`a.email → u.email → ⟨return helper⟩ → out → ⟨return caller⟩`) and
2775
+ §14.7's disclosed 2-hop **bypass** (`a.email → out → ⟨return caller⟩`),
2776
+ which skips the callee. The bypass is kept, marked
2777
+ `ambiguousCorrelation`, and ranked **last** (`C5/2`) — the shipped design
2778
+ neither deletes it nor lets it outrank the real chain.
2779
+ - **Mutual recursion** is the only row with partial paths: 4 of its 13 are
2780
+ `'cycle'`-terminated. They are labelled, not dropped, and not disguised
2781
+ as origins. 35 expansions and 7 clips on an 8-node/11-edge cyclic graph
2782
+ is the whole termination story, and no budget was involved in it.
2783
+
2784
+ ---
2785
+
2786
+ ## 16. FR-306 edge grading (Sub-project C, increment 6)
2787
+
2788
+ **Implemented.** Design landed as `dcd06001` (fix round `120fb8c9`, small
2789
+ follow-up `79c01c13`); shipped by Task 2 (`c63c6338`), whole-branch reviewed
2790
+ clean. **This is the final increment of Sub-project C — C1-C6 are all
2791
+ complete as of this commit.** The section below is kept in its original,
2792
+ design-time voice (present/future tense, "committed alongside this
2793
+ section", "the follow-up implementation task must…") as the historical
2794
+ record of what was decided and why — read `flow-grade.js`/`path-query.js`
2795
+ themselves, or `scanner/src/lineage/CLAUDE.md`'s own module-table rows, for
2796
+ the current, as-shipped description. The design-time PoC file this section
2797
+ originally shipped alongside it (`flow-grade-poc.test.js`) no longer
2798
+ exists — Task 2 absorbed everything it proved into the permanent suite
2799
+ (`flow-grade.test.js`) and deleted it, per §16.8 item 11.
2800
+
2801
+ **§16.7 Finding 1, specifically, is now CLOSED, not merely disclosed** —
2802
+ this section's own design task found a real, measured undercount bug in
2803
+ `path-query.js`'s already-shipped `materialize()` (from the already-merged
2804
+ increment C5), initially mis-estimated the cost of fixing it, corrected
2805
+ that estimate after independent verification (twice, by the design's own
2806
+ fix round and a separate scoped re-review) found the fix breaks nothing,
2807
+ and Task 2 shipped the fix (four lines, confined to `materialize()`,
2808
+ verified a fourth time — by the final whole-branch review — to change
2809
+ exactly zero other assertions in the tree). §16.7's own heading below
2810
+ ("Findings this increment does NOT fix, named rather than patched") is
2811
+ accurate for Findings 2 and 3, which remain genuinely open and are made
2812
+ binding on Sub-project D — but Finding 1 no longer belongs under that
2813
+ heading in spirit, only in the historical record's own voice; read the
2814
+ **CORRECTED** box inside §16.7 Finding 1 for the full before/after account.
2815
+
2816
+ Added 2026-08-30 by increment C6's design task. Everything in this section
2817
+ is **decided**, not proposed, and every behavioural claim and every number
2818
+ in it was produced by running code in
2819
+ `scanner/test/lineage/flow-grade-poc.test.js` — a throwaway-named PoC
2820
+ committed alongside this section, which prototypes `flow-grade.js` LOCALLY
2821
+ (shipped source under `src/lineage/` is unmodified by this design task,
2822
+ exactly as C1's, C3's, C4's and C5's own design tasks were). §16.8 is the
2823
+ follow-up implementation task's file/line checklist.
2824
+
2825
+ **FR-306, verbatim:** *"Implicit/control-dependent and unknown-field
2826
+ widened flows must be visually distinct and lower-confidence. They may not
2827
+ be displayed as the same evidence grade as an explicit field assignment."*
2828
+
2829
+ > **Fix round 1 (2026-08-30), from this increment's own task review.** One
2830
+ > BLOCKING defect and three refinements were found in §16's first draft and
2831
+ > are corrected in place, each marked where it applies — recorded here
2832
+ > rather than silently rewritten, per this document's own policy (§13's
2833
+ > own fix-round box set the precedent). The BLOCKING one is worth stating
2834
+ > at the top because it is the same failure mode this document exists to
2835
+ > prevent: **§16.7 Finding 1 deferred a fix on a cost estimate that was
2836
+ > never measured, and the estimate was false.** The reviewer ran the
2837
+ > four-line fix and found it breaks nothing; the deferral survived, the
2838
+ > false reason did not, and §16.8 now *schedules* the remediation instead
2839
+ > of recording "`path-query.js`: none". Also corrected: `gradePath` now
2840
+ > returns full `HopGrade` objects rather than bare strings (§16.4);
2841
+ > §16.7 Finding 2 is restated as the §18.4 re-opening it actually is,
2842
+ > with a binding hand-off to Sub-project D's scoping; and §16.10 states
2843
+ > the explicitness-vs-completeness asymmetry as a trade-off.
2844
+
2845
+ §15.8 drew the boundary this section crosses: *"No FR-306 grade
2846
+ computation. C5's path output CARRIES every grading input … and computes
2847
+ only counts. Turning counts into a grade is C6."* Five questions were open
2848
+ when this increment was scoped, and none is answerable on paper. All five
2849
+ are now answered by execution:
2850
+
2851
+ - **Q8 — how many tiers, and separated by what?** Five, plus an
2852
+ empty-input answer: `explicit` > `widened` > `implicit` (reserved) >
2853
+ `severed` > `ambiguous`. §16.2/§16.3.
2854
+ - **Q9 — does crossing a function boundary lower a hop's grade?** **No.**
2855
+ A sound interprocedural stitch grades identically to the same flow
2856
+ inlined, and a cross-scope demotion would have *inverted* the ranking
2857
+ C5's own `comparePaths` already ships. §16.6.
2858
+ - **Q10 — does `annotations[]` factor into the grade?** **Yes, and this is
2859
+ the single most load-bearing decision in the section.** A genuine
2860
+ widening lives ONLY in `annotations[]` on three real, separately-parsed
2861
+ fixtures — not just §13.6's context-cap marker. A grader reading only
2862
+ the edge's top-level arrays grades those flows `explicit`, which is
2863
+ FR-306's own literal prohibition. §16.5.
2864
+ - **Q11 — is a per-path aggregate needed, or is per-hop grading enough?**
2865
+ **Both are needed**, and FR-306's two clauses are why. §16.4.
2866
+ - **Q12 — reuse `protection.js`'s `EVIDENCE_GRADES`?** **No**, and the
2867
+ reason is a correctness hazard rather than taste. §16.2.
2868
+
2869
+ ### 16.1 `flow-grade.js`: a new module, not new exports on `path-query.js`
2870
+
2871
+ **Decided:** a new file, `scanner/src/lineage/flow-grade.js`, with **zero
2872
+ imports** — one step stricter than `path-query.js`'s own `['./ids.js']`
2873
+ boundary, and it inherits §14.1/§15.1's rule unchanged (never
2874
+ `engine.js`/`summaries.js`/`driver.js`).
2875
+
2876
+ The obvious alternative — `gradeHop`/`gradePath` exported from
2877
+ `path-query.js` itself, since they consume nothing it does not already
2878
+ produce — was evaluated and rejected on three grounds, one of which is
2879
+ measured:
2880
+
2881
+ - **Grading needs neither a path nor the store.** `gradeHop` returns
2882
+ **byte-identical** results for a raw `PathStore` **edge** and for the
2883
+ `Hop` denormalized from it (`C6/11`, over every edge of the 2-function
2884
+ resolved-call fixture) — because a hop *is* a denormalized copy of its
2885
+ edge (§15.2). So Sub-project E's graph builder and Milestone 3's API can
2886
+ grade an edge without running a DFS, and Milestone 4 can grade a stored
2887
+ edge with no reconstruction at all. Putting the function inside the
2888
+ reconstruction module would make every such consumer import the walk.
2889
+ - **`path-query.js`'s shipped boundary test asserts its import list is
2890
+ EXACTLY `['./ids.js']`.** A separate grading module could not be
2891
+ imported by it without weakening that test, and this increment
2892
+ deliberately does not weaken it.
2893
+ - **C6's implementation then touches zero existing `src/lineage/*.js`
2894
+ files** — the same property C4's and C5's design tasks preserved.
2895
+
2896
+ **Exports** (the exact signatures the follow-up task must ship):
2897
+
2898
+ ```js
2899
+ export const FLOW_EVIDENCE_GRADES; // frozen, confidence order
2900
+ export const IMPLICIT_FLOW_REASONS; // frozen, ['control-dependence']
2901
+ export const DEGRADED_LOSS_REASONS; // frozen, ['context-cap-degraded']
2902
+ export function flowGradeRank(grade); // -> 0..5, throws on unrecognized
2903
+ export function aggregateFlowGrades(grades); // -> grade, mirrors aggregateVerdicts
2904
+ export function gradeHop(hop); // -> HopGrade (§16.3)
2905
+ export function gradePath(path); // -> PathGrade (§16.4)
2906
+ ```
2907
+
2908
+ `gradeHop` accepts a `path-query.js` `Hop` **or** a `path-store.js` edge;
2909
+ `gradePath` accepts a `path-query.js` `Path`. Neither mutates its input,
2910
+ and neither reads a `_`-prefixed field of anything.
2911
+
2912
+ ### 16.2 Q12 — a NEW vocabulary, and why `EVIDENCE_GRADES` is the wrong one
2913
+
2914
+ ```js
2915
+ export const FLOW_EVIDENCE_GRADES = Object.freeze([
2916
+ 'explicit', 'widened', 'implicit', 'severed', 'ambiguous', 'unassessed',
2917
+ ]); // CONFIDENCE order: index 0 is the most confident
2918
+ ```
2919
+
2920
+ **`protection.js`'s `EVIDENCE_GRADES` (`['runtime', 'code_and_config',
2921
+ 'code', 'config', 'declared', 'manual', 'none']`) is deliberately NOT
2922
+ reused or extended.** It was read in full before this was decided, and the
2923
+ rejection is on substance, not on convenience:
2924
+
2925
+ - **It grades a different axis.** Those values name *where a protection
2926
+ verdict's evidence came from* — observed at runtime, read out of code,
2927
+ read out of configuration, merely declared. Every flow grade in this
2928
+ section comes from **the same** evidence source (static field-identity
2929
+ analysis of code); what varies is how *explicit* the recorded data
2930
+ movement is. Mapping `widened` onto `declared`, or `explicit` onto
2931
+ `code`, would be a category error that silently changes what
2932
+ `protection.js` means.
2933
+ - **Extending it would be a live correctness hazard, not just churn.**
2934
+ `EVIDENCE_GRADES` is consumed by `protection.js`'s own
2935
+ `isValidProtectionDimension` and, through this package's stated "every
2936
+ enum here is a single source of truth" convention, by
2937
+ `dataflow-graph.schema.json` and `validate.js`. Adding `widened` to it
2938
+ would make a flow grade **validate cleanly as a protection evidence
2939
+ grade** on a `DataFlowGraph v1` entity. That is exactly the
2940
+ indistinguishable-namespace bug §14.5 rejected `node:`/`edge:` prefixes
2941
+ for.
2942
+ - **A separate vocabulary touches none of those files this increment**,
2943
+ which is a factor in its favour rather than an afterthought — see
2944
+ §16.10 for what Sub-project E must do when a grade *does* reach a
2945
+ `DataFlowGraph v1` entity.
2946
+
2947
+ `C6/0` asserts the two enums share **no** value, that the ranks are a
2948
+ total order, and — the check that actually stops a future edit going
2949
+ wrong — that the aggregation table (§16.4) is a permutation of the value
2950
+ list, so no grade can be silently missing from either side.
2951
+
2952
+ **What each value means:**
2953
+
2954
+ | grade | meaning | produced by |
2955
+ |---|---|---|
2956
+ | `explicit` | the engine resolved a real, field-precise data movement exactly | a hop with no widen/loss/implicit reason and `ambiguousCorrelation: false` |
2957
+ | `widened` | the movement is real but over-approximated — FR-306's "unknown-field widened" | any `widenReason` (`dynamic-property-key`, `unresolved-call`), top-level **or** annotation-carried |
2958
+ | `implicit` | control-dependent, not a data assignment — FR-306's "implicit/control-dependent" | **RESERVED. Nothing emits it today** (§16.3) |
2959
+ | `severed` | the trail is honestly recorded as stopping here | any `lossReason` (`unsupported-target`, `context-cap-degraded`), top-level **or** annotation-carried |
2960
+ | `ambiguous` | the engine cannot confirm this specific pairing happened at all | `ambiguousCorrelation: true` (§9.1/§14.7) |
2961
+ | `unassessed` | there was nothing to grade | `aggregateFlowGrades([])` only — never a real hop |
2962
+
2963
+ **Why `ambiguous` is the lowest tier**, below both `widened` and
2964
+ `severed`: a widened hop *certainly happened* and is merely imprecise; a
2965
+ severed hop *certainly happened* and its continuation is unrepresented; an
2966
+ ambiguous hop **may never have happened at all** — §14.7's own resolved-call
2967
+ bypass is exactly such an edge, real data flow that is not the route the
2968
+ program takes. This is also the order C5 already shipped: §15.7's
2969
+ `comparePaths` keys are `ambiguousHopCount`, then `lossHopCount`, then
2970
+ `widenedHopCount`, and §15.7's own prose calls the latter two "FR-306's
2971
+ lower-confidence grades, in the order a reader would rank them". C6 adopts
2972
+ that order rather than inventing a competing one.
2973
+
2974
+ **Why `implicit` sits between `widened` and `severed`:** it is FR-306's
2975
+ other named category, so it belongs adjacent to `widened`; and it is
2976
+ weaker evidence than a widened data assignment, because no data movement
2977
+ was observed at all — only an inference from control.
2978
+
2979
+ ### 16.3 The per-hop rule
2980
+
2981
+ `gradeHop(hop)` returns an **object**, never a bare string — see §16.5 for
2982
+ why that is structural rather than stylistic:
2983
+
2984
+ ```js
2985
+ {
2986
+ grade, // one of FLOW_EVIDENCE_GRADES, never 'unassessed'
2987
+ rank, // flowGradeRank(grade); 0 = most confident
2988
+ factors, // sorted, deduped: 'widen:<r>' | 'loss:<r>' |
2989
+ // 'implicit:<r>' | 'ambiguous-correlation' |
2990
+ // 'analysis-truncated' | 'cross-scope'
2991
+ widenReasons, // sorted UNION of top-level + annotations[]
2992
+ lossReasons, // sorted UNION of top-level + annotations[]
2993
+ implicitReasons, // the IMPLICIT_FLOW_REASONS subset, removed from the two above
2994
+ annotationOnly, // the reasons that would have been INVISIBLE to a
2995
+ // top-level-only reader — see §16.5
2996
+ ambiguousCorrelation, degraded, truncated, crossScope,
2997
+ incomplete, // grade === 'severed' || degraded || truncated
2998
+ }
2999
+ ```
3000
+
3001
+ **Precedence, worst wins within one hop** (a hop can carry several
3002
+ signals at once — `C6/2b` measures a real hop with two widen reasons):
3003
+
3004
+ 1. `ambiguousCorrelation === true` → `ambiguous`
3005
+ 2. any non-implicit `lossReason` → `severed`
3006
+ 3. any `IMPLICIT_FLOW_REASONS` reason → `implicit`
3007
+ 4. any non-implicit `widenReason` → `widened`
3008
+ 5. otherwise → `explicit`
3009
+
3010
+ **`implicit` is reserved and currently unreachable, deliberately.**
3011
+ §10.2's own verdict for `if` is explicit: *"the engine models no implicit
3012
+ flow today, so there is nothing to emit; do not invent one."* The tier
3013
+ exists because FR-306 names it first, and it is kept-but-hand-tested on
3014
+ exactly §14.2's `origin`-node-kind precedent (*"a real, honestly-disclosed
3015
+ gap, but not a dead branch: it is the exact shape a Sub-project D … will
3016
+ produce"*). Its trigger is a reason string in the exported
3017
+ `IMPLICIT_FLOW_REASONS` set (today `['control-dependence']`), which is
3018
+ also **removed** from `widenReasons` so it can never double-count.
3019
+ `C6/10` proves both halves: no hop of five real fixtures grades
3020
+ `implicit`, and a hand-built control-dependence hop does.
3021
+
3022
+ > **The subtraction applies to the WIDEN side ONLY** (fix round 1,
3023
+ > nitpick 8). Subtracting `IMPLICIT_FLOW_REASONS` from `lossReasons` too
3024
+ > would mean that if a future increment ever emitted
3025
+ > `lossReason: 'control-dependence'`, that hop would be silently
3026
+ > **upgraded** from `severed` to the *more confident* `implicit` tier —
3027
+ > a grade moving in the optimistic direction as a side effect of a reason
3028
+ > string being added somewhere else. Unreachable today (no loss reason is
3029
+ > in the set), and now unreachable by construction rather than by luck.
3030
+ > A control-dependent hop that is also a recorded dead end grades
3031
+ > `severed`, which is the safe direction.
3032
+
3033
+ **`crossScope`, `originated` and `truncated` are never grade inputs.**
3034
+ `crossScope` is §16.6; `truncated` (§14.8's `markTruncated`) is an
3035
+ analysis-run fact, not a statement about this hop's explicitness, so it is
3036
+ a flag and a factor only — `C6/9` marks a clean fixture truncated and
3037
+ asserts the grade stays `explicit` while `truncated`/`incomplete` flip to
3038
+ `true`.
3039
+
3040
+ ### 16.4 Q11 — the per-path aggregate is worst-wins, and per-hop alone is NOT enough
3041
+
3042
+ **Decided: both, and FR-306's two clauses are the reason.** The sentence
3043
+ has two halves and they are different requirements:
3044
+
3045
+ - *"must be visually distinct"* — a **per-hop** claim. A UI marks the
3046
+ widened hop, not the whole path; if the whole path were graded down
3047
+ uniformly, the reader could not see which step is the weak one.
3048
+ `gradePath` therefore returns `hops[]` and `worstHopIndex`.
3049
+ - *"may not be displayed as the same evidence grade as an explicit field
3050
+ assignment"* — a claim about **the evidence grade a flow is displayed
3051
+ at**, i.e. one scalar per path. With per-hop grading alone there is no
3052
+ such scalar, so nothing prevents a UI from displaying a path containing
3053
+ a widened hop as an explicit flow.
3054
+
3055
+ **The aggregate is the WORST grade among the hops**, mirroring
3056
+ `protection.js`'s `aggregateVerdicts()` risk-precedence reduction — the
3057
+ established precedent in this exact package — via a private precedence
3058
+ table that is the reverse of the confidence order, with `unassessed` last
3059
+ so it survives only when it is alone:
3060
+
3061
+ ```js
3062
+ const _PRECEDENCE = ['ambiguous', 'severed', 'implicit', 'widened', 'explicit', 'unassessed'];
3063
+ ```
3064
+
3065
+ `aggregateFlowGrades` copies `aggregateVerdicts`' contract verbatim,
3066
+ including its refusals: an empty array is `'unassessed'`, and an
3067
+ **unrecognized grade throws** rather than silently sorting last (`C6/7c`
3068
+ — a typo, or a `protection.js` value handed in by mistake, must not
3069
+ quietly rank as safest).
3070
+
3071
+ **Every other reduction was executed, not argued** (`C6/7`, on
3072
+ `const a = user.email; const b = mystery(a); return b;` — hop grades
3073
+ `['explicit', 'widened', 'explicit']`): first-wins, last-wins, best-wins
3074
+ and majority-wins all report `explicit` for a path that provably contains
3075
+ a widened hop. Only worst-wins satisfies FR-306. `C6/7b` additionally
3076
+ proves the reduction is **order-independent** across all 5×5 tier pairs —
3077
+ an order-dependent aggregate would be the representative-picking bug class
3078
+ C4 found in `path-store.js`'s `origin` branch and C5 found in
3079
+ `terminals[].terminalReasons`, for the third time.
3080
+
3081
+ **`gradePath` returns the FULL `HopGrade` objects, not bare grade
3082
+ strings** (fix round 1, finding 3). The first draft returned
3083
+ `hopGrades: string[]` plus a path-level `factors` union, which loses
3084
+ per-hop *cause*: a caller rendering FR-306's "visually distinct" half
3085
+ would have had to re-invoke `gradeHop` on every hop to learn *which*
3086
+ factor drove *that* hop's grade, re-deriving what `gradePath` had just
3087
+ computed and thrown away. Settled now rather than after a follow-up task
3088
+ has implemented against the narrower shape.
3089
+
3090
+ > **The trade-off, stated rather than left implicit.** A `PathGrade` now
3091
+ > carries N objects instead of N strings, which is strictly more memory
3092
+ > per graded path. This is the same trade §15.2 already made deliberately
3093
+ > one layer down — carrying a denormalized copy of each edge on the path
3094
+ > rather than an edge id — for the same reason: *"a path that cannot be
3095
+ > graded without also carrying the store is a poor hand-off."* A path
3096
+ > grade that cannot be **rendered** without re-grading every hop is the
3097
+ > same mistake one layer up. The rejected middle option, a parallel
3098
+ > `hopFactors[]` array alongside `hopGrades[]`, is exactly the
3099
+ > redundant-fields-drift shape §3 rejected `widened` for. A caller wanting
3100
+ > only the strings writes `g.hops.map((h) => h.grade)`.
3101
+
3102
+ > **`rank` is NOT a monotone image of `_PRECEDENCE`, and no consumer
3103
+ > should derive one from the other** (fix round 1, nitpick 7). For the
3104
+ > five real grades they are exact reverses, but `'unassessed'` is last in
3105
+ > **both** — so `flowGradeRank('unassessed')` is 5 (least confident) while
3106
+ > its aggregation precedence is also 5 (loses to everything). That is
3107
+ > deliberate, and it is the same asymmetry `protection.js` carries between
3108
+ > `EVIDENCE_GRADES` and its own `_PRECEDENCE`. It is unreachable through a
3109
+ > real `Path` today — `path-query.js` guarantees `hopCount >= 1` (§15.6),
3110
+ > so `gradePath` never sees an empty hop list — but a consumer that
3111
+ > computed one table from the other would get `'unassessed'` wrong the
3112
+ > first time it *was* reachable.
3113
+
3114
+ `gradePath` also **recomputes** its counts from `gradeHop` and
3115
+ deliberately does **not** read the Path's own
3116
+ `widenedHopCount`/`lossHopCount`/`ambiguousHopCount` — see §16.7 Finding 1.
3117
+
3118
+ ### 16.5 Q10 — `annotations[]` is folded into the grade AND flagged separately
3119
+
3120
+ §14.9's own corrected note warned that a C6 implementer reading only
3121
+ `widenReasons`/`lossReasons` off the edges would silently drop §13.6's
3122
+ context-cap-degraded marker. **Measured, the problem is larger than that
3123
+ warning states**, and it is what forced this decision:
3124
+
3125
+ > **`C6/5` — a genuine `widenReason: 'unresolved-call'` lives ONLY in
3126
+ > `annotations[]`, with the edge's own `widenReasons` EMPTY, on three
3127
+ > real, separately-parsed fixtures:**
3128
+ >
3129
+ > ```js
3130
+ > function f(user) { sink(mystery(user.email)); } // member -> call-arg
3131
+ > function f(user) { const o = { a: mystery(user.email) }; return o; } // member -> assign
3132
+ > function f(user, c) { const o = c ? mystery(user.email) : user.email; return o; }
3133
+ > ```
3134
+ >
3135
+ > In each, the widening is produced by an expression-internal construct
3136
+ > whose hop §2.2 classifies as an **annotation** (null `fromPath`, null
3137
+ > `peerScope`), so `path-store.js` never folds its reason into
3138
+ > `edge.widenReasons` — that array is built from the *edge-forming* halves
3139
+ > (`s.hop.widenReason`, `o.hop.widenReason`) in every case that occurs
3140
+ > here. **One narrow exception, for accuracy** (fix round 1, nitpick 6):
3141
+ > `path-store.js`'s `origin` branch *does* union the annotations' own
3142
+ > reasons into `originWidenReasons`/`originLossReasons`, so in that ONE
3143
+ > case an annotation's reason reaches `edge.widenReasons` after all. It is
3144
+ > immaterial today — §14.2 discloses `origin` as hand-fixture-only, and no
3145
+ > real-parser fixture reaches it — but the union in §16.5's rule is what
3146
+ > makes the grade correct under *both* branches rather than only the
3147
+ > common one. The PoC runs the naive
3148
+ > top-level-only grader against these hops and measures it returning
3149
+ > **`explicit`** — FR-306's literal prohibition, reached by reading the
3150
+ > field the requirement's own material appears to live in. This is not
3151
+ > §13.6's one exotic marker; it is an ordinary shape of ordinary code.
3152
+
3153
+ **Decided, and the two halves are not alternatives:**
3154
+
3155
+ 1. **Fold annotation-carried reasons into the grade.** `widenReasons` and
3156
+ `lossReasons` on a `HopGrade` are the sorted UNION of the hop's own
3157
+ top-level arrays and every `annotations[].widenReason` / `.lossReason`.
3158
+ An annotation's reason is the same *kind of fact* as a top-level one —
3159
+ it is on a different field only because §2.2 classified that half as an
3160
+ annotation, which is a statement about edge formation, not about
3161
+ evidence quality.
3162
+ 2. **AND surface it separately, by cause.** `degraded: true` is raised
3163
+ whenever a `DEGRADED_LOSS_REASONS` value is present, the specific
3164
+ reason is named in `factors`, and `annotationOnly[]` names exactly
3165
+ which inputs a top-level-only reader would have missed. §18.4 requires
3166
+ the *cause* be visible, not merely a tier: "context budget exhausted"
3167
+ must stay distinguishable from "the target was unrepresentable", and
3168
+ both must stay distinguishable from "no flow".
3169
+
3170
+ For §13.6's marker specifically (`C6/6`): the degraded `call-arg-bind`
3171
+ edge has `lossReasons: []` and `widenReasons: []` at top level, the naive
3172
+ grader calls it `explicit`, and `gradeHop` returns
3173
+ `grade: 'severed', degraded: true, annotationOnly: ['loss:context-cap-degraded']`.
3174
+ Grading it `severed` is the reading §13.6 itself asked for — the marker
3175
+ belongs *on* the real argument→parameter edge, saying the data was bound
3176
+ into a callee whose downstream is unrepresented.
3177
+
3178
+ **Why `gradeHop` returns an object rather than a string.** The brief for
3179
+ this increment asked that an annotation-only marker never be "silently
3180
+ invisible to a consumer reading only the grade". The structural answer is
3181
+ that **there is no way to read only the grade**: the return value is an
3182
+ object whose `grade` is one field among `factors`, `annotationOnly`,
3183
+ `degraded`, `truncated` and `incomplete`. A consumer that wants the
3184
+ scalar must reach through an object that already handed it the
3185
+ disclosure.
3186
+
3187
+ ### 16.6 Q9 — crossing a function boundary does NOT lower the grade
3188
+
3189
+ **Decided: `crossScope` is a factor, never a demotion.** Three
3190
+ independent reasons, two of them measured:
3191
+
3192
+ - **FR-306 names two lower-confidence categories, and this is neither.**
3193
+ Implicit/control-dependent, and unknown-field widened. A function
3194
+ boundary is not an imprecision.
3195
+ - **The stitch is proven sound, not merely plausible.** §14.3's Q1 proof:
3196
+ the node id computed from `(peerScope, peerContext, toPath, id)` is
3197
+ **byte-identical** to the node id the callee independently created from
3198
+ its own hop. There is no over-approximation to grade down.
3199
+ - **A cross-scope demotion would report code structure, not evidence
3200
+ quality.** `C6/4` grades the same flow twice — inlined
3201
+ (`const b = user.email; return b;`) and factored into two functions
3202
+ (`helper(u){return u.email}` / `caller(a){const out = helper(a); …}`) —
3203
+ and measures **the same grade** (`explicit`, rank 0, hop grades
3204
+ `['explicit','explicit']`). Under a demotion, well-factored code would
3205
+ systematically grade below the identical inlined flow.
3206
+ - **And it would invert C5's own ranking.** `C6/4b`: at the 2-function
3207
+ resolved call, the real through-the-callee chain (`crossScopeCount: 2`)
3208
+ grades `explicit` while §14.7's disclosed bypass (`crossScopeCount: 0`)
3209
+ grades `ambiguous` — the correct path outranks the artefact. §15.7's
3210
+ `comparePaths` already ranks **more** `crossScopeCount` as **better**
3211
+ (key 4); a cross-scope demotion would have put C6's grade in direct
3212
+ contradiction with C5's shipped order.
3213
+
3214
+ The boundary crossing is still disclosed — `crossScope: true` on the
3215
+ `HopGrade` and `'cross-scope'` in `factors` — so a UI can render it
3216
+ distinctly without it being a confidence claim.
3217
+
3218
+ ### 16.7 Findings this increment does NOT fix, named rather than patched
3219
+
3220
+ **Finding 1 (Minor, soundness-unaffected; SCHEDULED, not merely
3221
+ disclosed) — `path-query.js`'s `Path.widenedHopCount` / `lossHopCount` /
3222
+ `shape` under-report annotation-carried reasons.** `materialize()`
3223
+ computes them as `hops.filter((h) => h.widenReasons.length > 0).length`
3224
+ etc., i.e. from the two edge-forming halves only — the exact blind spot
3225
+ §16.5 measures. It is the same document-vs-code gap in miniature:
3226
+ §14.9's own correction and §15.8 both state the rule (*"a consumer must
3227
+ read `annotations[]` too, not only the edge's top-level reason arrays"*),
3228
+ and `materialize()` is precisely such a consumer, written in the same
3229
+ increment that wrote both sentences.
3230
+
3231
+ > **CORRECTED 2026-08-30 by this increment's own fix round 1 (finding 1,
3232
+ > BLOCKING). The first draft of this paragraph deferred the fix and gave a
3233
+ > FALSE reason for deferring** — it claimed closing it "changes
3234
+ > `comparePaths`' ordering, §15.7's `shape` bucketing for the diversity
3235
+ > cap, and §15.11's published measured table." **Measured, all three are
3236
+ > false**, and the measurement is the argument:
3237
+ >
3238
+ > - The whole fix is four lines inside `materialize()` (§16.8's
3239
+ > `path-query.js` row carries them verbatim). Applied to the shipped
3240
+ > file, `npm run test:lineage` ran **328/329**, and the ONE failure was
3241
+ > this document's own PoC test asserting the defect's *presence* — which
3242
+ > is a test that is supposed to fail once the defect is gone, and has
3243
+ > since been flipped to assert the fixed behaviour instead (`C6/5b`).
3244
+ > **Zero** other tests moved.
3245
+ > - **§15.11's table is not a function of these fields at all.** Its
3246
+ > columns are sinks / paths / complete / partial / max hops / expansions
3247
+ > / clipped; none is derived from `widenedHopCount`, `lossHopCount` or
3248
+ > `shape`. This is not an inspection claim — `C5/M` pins that table and
3249
+ > passed unchanged under the patched build.
3250
+ > - The `comparePaths` / diversity-bucket risk is **unexercised by any
3251
+ > fixture in the tree**: every annotation-carrying fixture's terminal
3252
+ > yields exactly one path, so there is nothing to reorder and nothing to
3253
+ > bucket. (§15.11's own runs also raise the output caps out of the way,
3254
+ > so `shape` cannot bind there even in principle.)
3255
+ >
3256
+ > The **deferral itself stands** — a design task changes no shipped
3257
+ > `src/lineage/*.js` file, and C6's grade functions never depended on the
3258
+ > buggy field — but it is now scheduled as its own item (§16.8's
3259
+ > `path-query.js` row) rather than dismissed with "`path-query.js`: none",
3260
+ > which is what the false cost estimate had produced.
3261
+
3262
+ **The fix belongs in `materialize()` and nowhere else.** It must NOT be
3263
+ pushed down into `path-store.js`'s `edge.widenReasons` / `edge.lossReasons`:
3264
+ those two arrays are part of `provenanceEdgeId`'s discriminator (§14.5),
3265
+ so widening them would move every `pedge:` id and, through them, every
3266
+ `ppath:` id (§15.6) — a re-hash of the entire DAG to correct a display
3267
+ count. `C6/5b` (widen) and `C6/5c` (loss) prototype the corrected
3268
+ computation locally and assert the target behaviour; both pass against the
3269
+ current tree **and** against the patched build, so neither blocks the fix
3270
+ nor goes vacuous after it.
3271
+
3272
+ **Finding 2 (§18.4's own load-bearing constraint, re-opened at the query
3273
+ boundary) — a §13.6-degraded binding edge is unreachable from every
3274
+ structural sink candidate.** `C6/6b` measures it: the degraded
3275
+ `call-arg-bind` edge's target is an ordinary `path` node (the callee's
3276
+ parameter) with **zero** outgoing edges, because the callee's body was
3277
+ never analyzed. It is therefore not a `sinkCandidates()` result
3278
+ (`return`/`escape`/`loss` only) and not on any path leading to one, so a
3279
+ sink-rooted reconstruction surfaces **no** path carrying the marker — the
3280
+ §16.9 table's last row shows the whole degraded fixture yielding only
3281
+ `explicit`/`ambiguous` path grades.
3282
+
3283
+ This is **not** a scoping footnote, and the first draft of this paragraph
3284
+ understated it. §13.6 exists because a B6 cap degradation was *"completely
3285
+ silent"* — *"'context budget exhausted' is indistinguishable from 'no flow
3286
+ crosses this call'"*, §18.4's single most load-bearing constraint. C3
3287
+ closed that at the hop layer, C4 proved the marker survives onto a real
3288
+ edge (`C4/Q2c`), C5 built the query — and at the query boundary the
3289
+ silence **returns**: a caller who asks the only question the API makes
3290
+ easy (walk back from the sink candidates) is told about zero degraded
3291
+ flows, with nothing anywhere in the answer saying a degradation happened.
3292
+ The marker is intact in the store and graded correctly the moment anything
3293
+ looks at it (`C6/6`); what is missing is that nothing *asks*.
3294
+
3295
+ **Not C6's to fix** — grading is handed a hop and grades it; the gap is in
3296
+ which start nodes a caller is pointed at. The fix is either a
3297
+ `sinkCandidates()` change (a *query* concern, §15.9, which already frames
3298
+ that helper as a structural stand-in to be superseded) or Sub-project D's
3299
+ registry deciding a degraded dead end is a reportable endpoint. **Binding
3300
+ on whoever scopes Sub-project D:** carry this forward as an explicit
3301
+ scoping item, not as a note inherited from here. The candidate shape, so D
3302
+ does not re-derive it: a `path` node with zero out-edges whose in-edges
3303
+ carry a `context-cap-degraded` annotation is exactly a
3304
+ *truncation-terminal*, detectable with the `outIndex` the store already
3305
+ builds, and it is a sink in every sense that matters to §18.4 even though
3306
+ it is a sink in none of the three senses §14.2's node-kind table records.
3307
+
3308
+ **Finding 3 (Minor, tier-unaffected) — `factors` inherits
3309
+ `DESIGN_INTRAPROCEDURAL.md`'s round-6 Finding 3 mislabel.** `step()`'s
3310
+ `assign`/`return` sites stamp a hardcoded `'unresolved-call'` whenever
3311
+ they forward a bare `widened` flag (§10.1's own 2026-08-30 note), so a
3312
+ widening actually caused by a dynamic property key can be *named*
3313
+ `widen:unresolved-call` in a grade's `factors`. The **grade** is
3314
+ unaffected — both are widenings and both select the `widened` tier — and
3315
+ `C6/2b` shows the read side does carry both reasons where it knows them.
3316
+ Closing it needs `resolveExprIdentities` to thread a real reason string
3317
+ through its return value, which has been out of scope since C2.
3318
+
3319
+ ### 16.8 What the follow-up implementation task must do
3320
+
3321
+ Written the way §10.1/§13.7/§14.10/§15.10 were, so the next brief needs no
3322
+ re-derivation.
3323
+
3324
+ **`scanner/src/lineage/flow-grade.js` (new)**
3325
+
3326
+ | # | Item | Detail |
3327
+ |---|---|---|
3328
+ | 1 | the whole module | Lift the local prototype block at the top of `test/lineage/flow-grade-poc.test.js` — it is written to be lifted (no test-only code inside it), but **"verbatim" is not literally executable: every declaration in it is a bare `const`/`function`, so the follow-up must add `export` to each of the seven public ones** (fix round 1, nitpick 5), and must NOT export `_PRECEDENCE` (item 3). `_sortedUnion` stays private too. **Zero imports** — add the same import-list self-check test `path-store.test.js`/`path-query.test.js` already carry, asserting the specifier list is EXACTLY `[]` |
3329
+ | 2 | `FLOW_EVIDENCE_GRADES` | §16.2's frozen array, in CONFIDENCE order. Export `IMPLICIT_FLOW_REASONS` and `DEGRADED_LOSS_REASONS` too — a consumer must be able to test membership without re-typing a literal |
3330
+ | 3 | `_PRECEDENCE` | §16.4's private table. Keep it private, exactly as `protection.js` keeps its own; the parity check in `C6/0` is what stops it drifting from the value list |
3331
+ | 4 | `flowGradeRank` / `aggregateFlowGrades` | throw on an unrecognized grade, `'unassessed'` for empty — `aggregateVerdicts`' contract verbatim |
3332
+ | 5 | `gradeHop` | §16.3's precedence and §16.5's UNION. It must accept a raw `PathStore` edge as well as a `Hop`; `C6/11` is the guard |
3333
+ | 6 | `gradePath` | §16.4's worst-wins, `hops[]` (the **full `HopGrade` objects**, not bare strings — fix round 1, finding 3), `worstHopIndex`, and counts **recomputed from `gradeHop`** — never read off the Path (§16.7 Finding 1) |
3334
+
3335
+ **`scanner/src/lineage/path-query.js` — one small, separately-committable
3336
+ remediation, distinct from the `flow-grade.js` work above.** Added by fix
3337
+ round 1 (finding 1), replacing this table's original "`path-query.js`:
3338
+ none", which rested on a cost estimate §16.7 Finding 1 has since measured
3339
+ to be false.
3340
+
3341
+ | # | Site | Change |
3342
+ |---|---|---|
3343
+ | 7 | `materialize()`, the `widenedHopCount`/`lossHopCount` lines (today `path-query.js:108-109`) | Make the two counts annotation-aware, closing §16.7 Finding 1: <br>`const _annWiden = (h) => h.widenReasons.length > 0 \|\| (h.annotations ?? []).some((a) => a.widenReason != null);`<br>`const _annLoss = (h) => h.lossReasons.length > 0 \|\| (h.annotations ?? []).some((a) => a.lossReason != null);`<br>then `hops.filter(_annWiden)` / `hops.filter(_annLoss)`. §15.7's `shape` is derived from the same two filters and is therefore fixed by the same change — do **not** leave `shape` reading the old predicate. **Do NOT push this into `path-store.js`**: `edge.widenReasons`/`edge.lossReasons` are in `provenanceEdgeId`'s discriminator (§14.5), so changing them moves every `pedge:`/`ppath:` id |
3344
+ | 7b | verification | Measured on the shipped tree during fix round 1: the patch above takes `test:lineage` to **330/330** with no other assertion moved, and §15.11's `C5/M` table passes untouched. `C6/5b`/`C6/5c` already assert the post-fix behaviour, so they are the regression guard and need no edit when it lands. Re-measure §16.9's own table in the same commit only if a number there moves — it did not under the round-1 patch |
3345
+ | 7c | **not** in scope | Do **not** attach a `grade` to `Path` — it would change the shape `C5/4b`'s literal-JSON guard is pinned against, and it would force `path-query.js` to import a second module in violation of its own boundary test. A consumer calls `gradePath(path)` |
3346
+
3347
+ **No change to any other file.**
3348
+
3349
+ | # | File | Change |
3350
+ |---|---|---|
3351
+ | 8 | `path-store.js`, `engine.js`, `summaries.js`, `driver.js`, `field-identity.js` | **none** (`field-identity.js`: never) |
3352
+ | 9 | `schema.js` / `dataflow-graph.schema.json` / `validate.js` | **none this increment.** A flow grade is not a `DataFlowGraph v1` entity field yet. Confirm by running `npm run test:lineage` — `json-schema-parity.test.js` must stay green untouched. **Binding on Sub-project E:** the moment a flow grade is written onto a `DataFlowGraph v1` edge, this package's "every enum here is a single source of truth" convention applies and all three files must gain it in the same commit |
3353
+ | 10 | `protection.js` | **none.** §16.2 |
3354
+
3355
+ **Tests**
3356
+
3357
+ | # | Change |
3358
+ |---|---|
3359
+ | 11 | Re-point `flow-grade-poc.test.js` at the shipped `flow-grade.js`, delete its local prototype block, rename it to `flow-grade.test.js`, and update the `test:lineage` script in `scanner/package.json` in the SAME commit — C3's item 15 / C4's item 11 / C5's item 12 precedent |
3360
+ | 12 | Keep every assertion, and especially keep **`C6/5`** (the three annotation-only widening fixtures with the naive grader executed alongside) and **`C6/6`** (§13.6's marker) — together they are the only guard that stops §16.5's union being silently narrowed back to the top-level arrays, which would restore the exact FR-306 violation this increment exists to close |
3361
+ | 12b | **`C6/5b`/`C6/5c` outlive their prototype.** They currently assert item 7's target behaviour against the file-local `withAnnotationAwareCounts` helper. When item 7 lands in `materialize()`, delete that helper and re-point both tests at the Path's OWN `widenedHopCount`/`lossHopCount`/`shape`, keeping the assertions identical (`1`, `1`, `'widened'`, `'lossy'`). Drop the two forward-compatible `<=` guards at the same time — they exist only to let these tests pass on both sides of the fix, and once it has landed a `<=` where an `===` belongs is a weaker test than the tree deserves |
3362
+ | 13 | Keep `C6/12`'s pinned hop count and §16.9's table asserted, per §15.10 item 14's stated trade-off: an unrelated IR/engine change CAN move these numbers, and the correct response is to re-measure and update §16.9 in the same commit, never to relax the assertion |
3363
+ | 14 | Add a driver-level test only once a hop-emitting driver run is possible (Sub-projects D/E) — until then it would grade an empty store and be vacuous, same reasoning as §14.10 item 13 / §15.10 item 15 |
3364
+
3365
+ ### 16.9 Measured numbers
3366
+
3367
+ Every row produced by running the PoC's own fixtures on 2026-08-30, with
3368
+ the reconstruction budgets left at their `DEFAULTS`. "paths" is summed
3369
+ across every `sinkCandidates()` start node; "hops" is the total hop count
3370
+ across those paths.
3371
+
3372
+ | fixture | paths | hops | hop grades | path grades | annotation-only reasons |
3373
+ |---|---|---|---|---|---|
3374
+ | `const b = user.email; return b;` | 1 | 2 | explicit:2 | explicit:1 | — |
3375
+ | unresolved call `mystery(user.email)` | 1 | 2 | widened:1 explicit:1 | widened:1 | — |
3376
+ | dynamic-key write `bag[k] = user.email` | 1 | 2 | widened:1 explicit:1 | widened:1 | — |
3377
+ | dynamic-key read `user[k]` | 1 | 2 | widened:1 explicit:1 | widened:1 | — |
3378
+ | §9.1 cross-join `{a: p.email, b: q.email}` | 4 | 8 | ambiguous:4 explicit:4 | ambiguous:4 | — |
3379
+ | 2-function resolved call | 3 | 8 | explicit:7 ambiguous:1 | explicit:2 ambiguous:1 | — |
3380
+ | bare-call arg `sink(mystery(user.email))` | 2 | 2 | widened:1 explicit:1 | widened:1 explicit:1 | `widen:unresolved-call` |
3381
+ | object prop `{a: mystery(user.email)}` | 2 | 3 | explicit:2 widened:1 | explicit:1 widened:1 | `widen:unresolved-call` |
3382
+ | ternary `c ? mystery(user.email) : user.email` | 2 | 3 | explicit:2 widened:1 | explicit:1 widened:1 | `widen:unresolved-call` |
3383
+ | unsupported target `({a: obj.z} = user)` | 1 | 1 | severed:1 | severed:1 | — |
3384
+ | mixed clean + widened | 1 | 3 | explicit:2 widened:1 | widened:1 | — |
3385
+ | §13.6 context-cap degraded (cap 1) | 6 | 25 | explicit:22 ambiguous:3 | explicit:3 ambiguous:3 | — |
3386
+
3387
+ Three rows to read closely:
3388
+
3389
+ - **The three annotation-only rows** are §16.5's whole argument. Each has
3390
+ a hop the naive top-level-only grader calls `explicit` and this design
3391
+ calls `widened`, and the `annotationOnly` column names the input that
3392
+ made the difference. `bare-call arg` is the sharpest: two `escape`
3393
+ sinks, two one-hop paths, and only one of them is widened — so the
3394
+ distinction is not an artefact of the fixture having one path.
3395
+ - **The 2-function resolved call** is §16.6's: 7 of its 8 hops grade
3396
+ `explicit` despite three of them crossing a function boundary, and the
3397
+ single `ambiguous` hop is §14.7's disclosed bypass, not the stitch.
3398
+ - **§13.6's degraded fixture** shows **no** `severed` and **no** degraded
3399
+ path grade — that is §16.7 Finding 2, measured. The marker is graded
3400
+ correctly (`C6/6`); it is simply not reachable from any sink candidate.
3401
+
3402
+ `C6/12` additionally sweeps **28 real hops across 10 fixtures** and asserts
3403
+ the closed-set property FR-306 literally demands, in both directions: a
3404
+ hop carrying any widen/loss/implicit/ambiguity signal — top-level **or**
3405
+ annotation-only — is never graded `explicit`, and a hop carrying none is
3406
+ never graded lower.
3407
+
3408
+ ### 16.10 What C6 deliberately does not do
3409
+
3410
+ - **No transformation-kind or protection component in the grade.**
3411
+ §15.7's honest deferral stands: transformation-kind recognition does not
3412
+ exist (Sub-project D) and no analyzer produces a protection verdict
3413
+ (Milestone 2). When either lands it adds a *separate* dimension
3414
+ alongside this one, not a value inside `FLOW_EVIDENCE_GRADES` — a flow's
3415
+ explicitness and its protection state are orthogonal, exactly as
3416
+ `protection.js` keeps verdict and evidence grade orthogonal.
3417
+ - **The single grade scalar conflates explicitness with one KIND of
3418
+ completeness, and deliberately not with another. Stated as a trade-off,
3419
+ not left implicit** (fix round 1, finding 4). `severed` — a per-hop
3420
+ `lossReason` — is folded INTO the grade, while `truncated` (§14.8's
3421
+ analysis-run `markTruncated`) and `complete: false` (a partial path) are
3422
+ kept OUT of it, as flags. The line is *what the fact is about*: a
3423
+ `lossReason` is a statement about **this hop** ("the data went somewhere
3424
+ this analysis cannot represent"), which is the same subject FR-306's
3425
+ other tiers speak about, so it belongs on the same axis; an
3426
+ `ITER_BUDGET` break or a depth-limited walk is a statement about **the
3427
+ run**, and folding it in would make one function's budget silently
3428
+ re-grade every hop in it, including hops that are perfectly explicit
3429
+ (`C6/9` measures exactly that not happening). The cost of the asymmetry,
3430
+ disclosed: a reader who takes `grade` alone as "how much do I trust this
3431
+ flow" gets the loss half and misses the truncation half — which is why
3432
+ `incomplete` folds all three and why §16.5 makes the return value an
3433
+ object rather than a scalar. A future increment that finds the line
3434
+ drawn in the wrong place should move `severed` OUT to a flag (making the
3435
+ grade purely an explicitness axis), never move `truncated` IN.
3436
+ - **No implicit/control-dependence ANALYSIS.** The `implicit` tier is
3437
+ reserved (§16.3). Producing the reason that fills it is an `engine.js`
3438
+ change at §10.2's `if` row and is not in Sub-project C's scope.
3439
+ - **No grade attached to `Path` or to a `DataFlowGraph v1` edge.**
3440
+ §16.8 items 7 and 9.
3441
+ - **No UI or visual grammar.** FR-306's "visually distinct" half is
3442
+ satisfied here by making the distinction *available and unmissable* per
3443
+ hop; rendering it is Milestone 3's.
3444
+ - **No change to `path-query.js`'s counts *in this design task*** — but the
3445
+ change is **scheduled**, not declined: §16.8 item 7 carries the exact
3446
+ four-line fix, and §16.7 Finding 1 carries the measurement that the
3447
+ first draft's cost estimate for it was wrong. A design task changes no
3448
+ shipped `src/lineage/*.js` file; that is the only reason it is not done
3449
+ here.
3450
+ - **No change to `field-identity.js`** (never), and no change to any
3451
+ existing `src/lineage/*.js` file in the design task itself.