@clear-capabilities/agentic-security-scanner 0.145.0 → 0.147.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (195) hide show
  1. package/CHANGELOG.md +266 -0
  2. package/bin/agentic-security.js +3508 -69
  3. package/dist/1122.index.js +779 -0
  4. package/dist/{301.index.js → 1301.index.js} +2 -2
  5. package/dist/1379.index.js +591 -0
  6. package/dist/{444.index.js → 1444.index.js} +2 -2
  7. package/dist/{660.index.js → 1660.index.js} +2 -2
  8. package/dist/{700.index.js → 1700.index.js} +2 -2
  9. package/dist/{905.index.js → 1905.index.js} +3 -3
  10. package/dist/{920.index.js → 1920.index.js} +3 -3
  11. package/dist/{238.index.js → 2238.index.js} +3 -3
  12. package/dist/{271.index.js → 2271.index.js} +2 -2
  13. package/dist/{985.index.js → 2376.index.js} +1260 -340
  14. package/dist/2432.index.js +793 -0
  15. package/dist/2659.index.js +93 -0
  16. package/dist/{826.index.js → 2826.index.js} +2 -2
  17. package/dist/{830.index.js → 2830.index.js} +2 -2
  18. package/dist/2923.index.js +298 -0
  19. package/dist/{1.index.js → 3001.index.js} +5 -5
  20. package/dist/{117.index.js → 3117.index.js} +3 -3
  21. package/dist/3180.index.js +379 -0
  22. package/dist/3276.index.js +117 -0
  23. package/dist/{415.index.js → 3415.index.js} +2 -2
  24. package/dist/{499.index.js → 3499.index.js} +2 -2
  25. package/dist/3518.index.js +450 -0
  26. package/dist/{526.index.js → 3526.index.js} +5 -5
  27. package/dist/{736.index.js → 3736.index.js} +4 -4
  28. package/dist/{839.index.js → 3839.index.js} +4 -4
  29. package/dist/{113.index.js → 4113.index.js} +5 -5
  30. package/dist/{265.index.js → 4265.index.js} +2 -2
  31. package/dist/{384.index.js → 4384.index.js} +3 -3
  32. package/dist/4547.index.js +268 -0
  33. package/dist/4863.index.js +422 -0
  34. package/dist/{970.index.js → 4970.index.js} +2 -2
  35. package/dist/5051.index.js +841 -0
  36. package/dist/{144.index.js → 5144.index.js} +5 -5
  37. package/dist/{333.index.js → 5333.index.js} +3 -3
  38. package/dist/5343.index.js +185 -0
  39. package/dist/5350.index.js +866 -0
  40. package/dist/5561.index.js +436 -0
  41. package/dist/{637.index.js → 5637.index.js} +3 -3
  42. package/dist/{449.index.js → 5830.index.js} +2 -2
  43. package/dist/6626.index.js +532 -0
  44. package/dist/6662.index.js +297 -0
  45. package/dist/{675.index.js → 6675.index.js} +5 -5
  46. package/dist/{730.index.js → 6730.index.js} +6 -6
  47. package/dist/6829.index.js +225 -0
  48. package/dist/6944.index.js +130 -0
  49. package/dist/{178.index.js → 7178.index.js} +3 -3
  50. package/dist/{227.index.js → 7227.index.js} +2 -2
  51. package/dist/7310.index.js +520 -0
  52. package/dist/{552.index.js → 7552.index.js} +4 -4
  53. package/dist/7709.index.js +78 -0
  54. package/dist/8218.index.js +160 -0
  55. package/dist/{476.index.js → 8476.index.js} +4 -4
  56. package/dist/{513.index.js → 8513.index.js} +5 -5
  57. package/dist/{520.index.js → 8520.index.js} +2 -2
  58. package/dist/{718.index.js → 8718.index.js} +2 -2
  59. package/dist/{752.index.js → 8752.index.js} +2 -2
  60. package/dist/8846.index.js +100 -0
  61. package/dist/{435.index.js → 9091.index.js} +560 -189
  62. package/dist/{207.index.js → 9207.index.js} +2 -2
  63. package/dist/{220.index.js → 9220.index.js} +2 -2
  64. package/dist/9390.index.js +163 -0
  65. package/dist/{503.index.js → 9503.index.js} +2 -2
  66. package/dist/{801.index.js → 9801.index.js} +2 -2
  67. package/dist/{824.index.js → 9824.index.js} +2 -2
  68. package/dist/agentic-security.mjs +16 -16
  69. package/dist/agentic-security.mjs.sha256 +1 -1
  70. package/dist/compliance-frameworks/hipaa-security-rule.json +3 -2
  71. package/dist/frontend/index.html +21 -0
  72. package/dist/frontend/src/app.js +176 -0
  73. package/dist/frontend/src/components/evidence-inspector.js +141 -0
  74. package/dist/frontend/src/components/filter-rail.js +119 -0
  75. package/dist/frontend/src/components/query-bar.js +126 -0
  76. package/dist/frontend/src/data/flagship-graph.js +1460 -0
  77. package/dist/frontend/src/export-entry.js +36 -0
  78. package/dist/frontend/src/lib/api-client.js +92 -0
  79. package/dist/frontend/src/lib/contrast.js +34 -0
  80. package/dist/frontend/src/lib/dom.js +24 -0
  81. package/dist/frontend/src/lib/escape-html.js +16 -0
  82. package/dist/frontend/src/lib/flow-path.js +40 -0
  83. package/dist/frontend/src/lib/focus-controls.js +149 -0
  84. package/dist/frontend/src/lib/protection-visual.js +46 -0
  85. package/dist/frontend/src/lib/query-language.js +240 -0
  86. package/dist/frontend/src/lib/row-filters.js +43 -0
  87. package/dist/frontend/src/lib/state.js +84 -0
  88. package/dist/frontend/src/main.js +83 -0
  89. package/dist/frontend/src/shell.js +184 -0
  90. package/dist/frontend/src/views/architecture-view.js +798 -0
  91. package/dist/frontend/src/views/inventory-view.js +292 -0
  92. package/dist/frontend/src/views/privacy-view.js +172 -0
  93. package/dist/frontend/src/views/trace-view.js +206 -0
  94. package/dist/frontend/styles/architecture-view.css +93 -0
  95. package/dist/frontend/styles/filter-rail.css +34 -0
  96. package/dist/frontend/styles/inspector.css +69 -0
  97. package/dist/frontend/styles/inventory-view.css +74 -0
  98. package/dist/frontend/styles/privacy-view.css +86 -0
  99. package/dist/frontend/styles/query-bar.css +107 -0
  100. package/dist/frontend/styles/shell.css +155 -0
  101. package/dist/frontend/styles/tokens.css +128 -0
  102. package/dist/frontend/styles/trace-view.css +95 -0
  103. package/package.json +13 -6
  104. package/src/dataflow/CLAUDE.md +1 -1
  105. package/src/dataflow/catalog.js +42 -0
  106. package/src/dataflow/orm-write-catalog.js +175 -0
  107. package/src/engine.js +92 -1
  108. package/src/ir/CLAUDE.md +1 -0
  109. package/src/ir/chrome-probe.mjs +150 -0
  110. package/src/ir/parser-js.js +94 -7
  111. package/src/lineage/CLAUDE.md +1203 -0
  112. package/src/lineage/DESIGN_DESTINATION_RESOLVER.md +156 -0
  113. package/src/lineage/DESIGN_GRAPH_BUILDER.md +938 -0
  114. package/src/lineage/DESIGN_HANDLING_ANALYZER.md +355 -0
  115. package/src/lineage/DESIGN_INTRAPROCEDURAL.md +628 -0
  116. package/src/lineage/DESIGN_PATH_PROVENANCE.md +3451 -0
  117. package/src/lineage/DESIGN_QUEUE_DETAIL.md +120 -0
  118. package/src/lineage/DESIGN_REGISTRIES.md +880 -0
  119. package/src/lineage/DESIGN_STORE_DETAIL.md +143 -0
  120. package/src/lineage/DESIGN_TRANSIT_PROTECTION.md +245 -0
  121. package/src/lineage/classification.js +56 -0
  122. package/src/lineage/coverage.js +658 -0
  123. package/src/lineage/cross-repo-link.js +107 -0
  124. package/src/lineage/dataflow-graph.schema.json +184 -0
  125. package/src/lineage/decision-story.js +206 -0
  126. package/src/lineage/drift-policy.js +279 -0
  127. package/src/lineage/driver.js +135 -0
  128. package/src/lineage/engine.js +992 -0
  129. package/src/lineage/export-briefing.js +628 -0
  130. package/src/lineage/export-csv.js +62 -0
  131. package/src/lineage/export-json.js +238 -0
  132. package/src/lineage/export-privacy.js +258 -0
  133. package/src/lineage/federation-loader.js +111 -0
  134. package/src/lineage/field-identity.js +78 -0
  135. package/src/lineage/fixtures/build-flagship-fixture.mjs +272 -0
  136. package/src/lineage/fixtures/flagship-graph.json +1453 -0
  137. package/src/lineage/flow-grade.js +221 -0
  138. package/src/lineage/governance-edit.js +169 -0
  139. package/src/lineage/graph-builder.js +1114 -0
  140. package/src/lineage/graph-diff.js +431 -0
  141. package/src/lineage/graph-snapshot.js +180 -0
  142. package/src/lineage/handling-analyzer.js +168 -0
  143. package/src/lineage/ids.js +349 -0
  144. package/src/lineage/impact-assessment.js +76 -0
  145. package/src/lineage/impact-engine.js +268 -0
  146. package/src/lineage/index.js +281 -0
  147. package/src/lineage/language-coverage-tiers.js +58 -0
  148. package/src/lineage/obligation-mapping.js +126 -0
  149. package/src/lineage/obligation-predicates.js +235 -0
  150. package/src/lineage/observation-adapters.js +282 -0
  151. package/src/lineage/observation-correlation.js +622 -0
  152. package/src/lineage/observation-store.js +497 -0
  153. package/src/lineage/path-query.js +410 -0
  154. package/src/lineage/path-store.js +400 -0
  155. package/src/lineage/protection.js +53 -0
  156. package/src/lineage/recipient-profile.js +192 -0
  157. package/src/lineage/recipient-registry.js +394 -0
  158. package/src/lineage/redact-graph.js +224 -0
  159. package/src/lineage/remediation.js +417 -0
  160. package/src/lineage/resolve-destination.js +91 -0
  161. package/src/lineage/runtime-observation.js +464 -0
  162. package/src/lineage/scenario-diff.js +84 -0
  163. package/src/lineage/scenario-engine.js +251 -0
  164. package/src/lineage/scenario.js +101 -0
  165. package/src/lineage/schema.js +167 -0
  166. package/src/lineage/sink-registry.js +427 -0
  167. package/src/lineage/source-registry.js +357 -0
  168. package/src/lineage/source-seeding.js +212 -0
  169. package/src/lineage/summaries.js +590 -0
  170. package/src/lineage/transform-catalog.js +397 -0
  171. package/src/lineage/transit-protection.js +150 -0
  172. package/src/lineage/validate.js +285 -0
  173. package/src/mcp/CLAUDE.md +7 -1
  174. package/src/mcp/dataflow-tools.js +160 -0
  175. package/src/mcp/server.js +1 -1
  176. package/src/mcp/tools.js +2 -1
  177. package/src/pipeline/scan-health.js +19 -1
  178. package/src/posture/CLAUDE.md +19 -0
  179. package/src/posture/artifact-registry.js +52 -0
  180. package/src/posture/auditor-walkthrough.js +76 -0
  181. package/src/posture/compliance-frameworks/hipaa-security-rule.json +3 -2
  182. package/src/posture/obligation-evidence-pack.js +202 -0
  183. package/src/posture/remediation-ledger.js +337 -0
  184. package/src/server/CLAUDE.md +47 -0
  185. package/src/server/graph-loader.js +141 -0
  186. package/src/server/http-server.js +325 -0
  187. package/src/server/routes.js +129 -0
  188. package/src/server/security.js +111 -0
  189. package/src/server/static-assets.js +144 -0
  190. package/src/shared/frontend-root.js +52 -0
  191. package/dist/11.index.js +0 -353
  192. package/dist/259.index.js +0 -975
  193. package/dist/317.index.js +0 -300
  194. package/dist/609.index.js +0 -741
  195. package/dist/838.index.js +0 -152
@@ -0,0 +1,628 @@
1
+ // export-briefing.js — M4 deliverable #7 (FR-501 §14, DFG-035): Executive
2
+ // Risk Story Mode. Turns Task 1's decision-story.js#rankFlows output into a
3
+ // 5-chapter Markdown narrative (`emitDecisionStory`), covering 6 audience
4
+ // modes that change wording/verbosity only — never the underlying ranking,
5
+ // facts, or chapter order (decision-story.js's own binding constraint:
6
+ // "never represents an uncalibrated score as expected financial loss or
7
+ // breach probability" extends here to "never represents a re-worded fact
8
+ // as a different fact").
9
+ //
10
+ // Chapter design (grounded in decision-story.js's real factor vocabulary
11
+ // and the DataFlowGraph v1 schema's real top-level fields — see this
12
+ // file's own review notes below for exactly which fields each chapter
13
+ // reads):
14
+ // 1. Scope & Confidence — graph.scope/scanHealth/coverage/limitations
15
+ // 2. Sensitive-Data Footprint — rankFlows grouped by sensitivity tier
16
+ // 3. External Exposure — externality.tier === 'external' flows
17
+ // 4. Control & Governance Gaps — controlVerdict.tier !== 'protected' flows
18
+ // 5. Change & Decisions Needed — NO new/worsened-flow claims (changeRecency
19
+ // is always unavailable per decision-story.js); an explicit "no
20
+ // historical baseline" disclosure instead, plus every flow whose
21
+ // policyState is manual_review_required/prohibited as a real,
22
+ // currently-decision-relevant fact.
23
+ //
24
+ // Markdown-escaping discipline (BLOCKING-1 precedent, export-privacy.js):
25
+ // chapter 4 interpolates the exact same flow.governanceRefs operator prose
26
+ // DPIA/RoPA does, so the exact same injection risk applies. _mdInline/
27
+ // _mdCell/_mdCode are reimplemented LOCALLY here (not imported from
28
+ // export-privacy.js, which does not export them either) per this
29
+ // codebase's established per-module-owns-its-own-escaping-helpers
30
+ // precedent, and are applied to every governance value and every
31
+ // graph-derived label/name this file interpolates.
32
+
33
+ import { rankFlows, RANKING_FACTORS, validateDecisionStory, AI_SINK_SUBTYPES } from './decision-story.js';
34
+ import { _filterGraph, computeGraphDigest } from './export-json.js';
35
+ import { storyId } from './ids.js';
36
+ import { EVIDENCE_GRADES } from './protection.js';
37
+ import { GOVERNANCE_FIELDS } from '../dataflow/privacy-governance.js';
38
+
39
+ const DECISION_STORY_VERSION = '1.0.0';
40
+
41
+ export const AUDIENCE_MODES = Object.freeze(['board', 'ciso', 'privacy', 'compliance', 'regulator', 'technical']);
42
+
43
+ // --- Local Markdown-escaping helpers (mirror export-privacy.js's own
44
+ // _mdInline/_mdCell/_mdCode verbatim — see this file's header for why they
45
+ // are not imported across modules). ---
46
+
47
+ /** Collapse embedded newlines to spaces — an unescaped newline in an
48
+ * interpolated value (operator-supplied governance prose, or a
49
+ * source-derived label) would otherwise break out of its Markdown line and
50
+ * inject arbitrary content (e.g. a fake heading) mid-document. */
51
+ function _mdInline(value) {
52
+ return String(value).replace(/\r\n|\r|\n/g, ' ');
53
+ }
54
+
55
+ /** _mdInline, plus pipe-escaping for a Markdown table cell — an unescaped
56
+ * `|` in a cell value shifts every later column in that row. Backslashes
57
+ * are escaped FIRST: a value already containing a literal `\|` would
58
+ * otherwise become `\\|` — an escaped backslash followed by a still-live
59
+ * `|` column delimiter. */
60
+ function _mdCell(value) {
61
+ return _mdInline(value).replace(/\\/g, '\\\\').replace(/\|/g, '\\|');
62
+ }
63
+
64
+ /** Wrap a value in a Markdown inline-code span, safe even when the value
65
+ * itself contains backticks — CommonMark's rule: the fence must be one
66
+ * backtick longer than the longest run of consecutive backticks anywhere
67
+ * in the content, padded with a space on each side. */
68
+ function _mdCode(value) {
69
+ const s = _mdInline(value);
70
+ const runs = s.match(/`+/g);
71
+ const maxRun = runs ? Math.max(...runs.map((r) => r.length)) : 0;
72
+ if (maxRun === 0) return `\`${s}\``;
73
+ const fence = '`'.repeat(maxRun + 1);
74
+ return `${fence} ${s} ${fence}`;
75
+ }
76
+
77
+ // --- Audience-mode wording table. Controls prose register and verbosity
78
+ // ONLY — see this file's own header. `chapter2Cap` is FR-501's own "maximum
79
+ // of seven primary observations" requirement, scoped to Chapter 2's
80
+ // per-flow observation list (never to the chapter's own aggregate
81
+ // summaries, which always report the full group). ---
82
+ const _AUDIENCE_WORDING = Object.freeze({
83
+ board: {
84
+ label: 'Board Briefing',
85
+ registerNote: 'Written for board-level review: plain language, decision-focused, capped at the primary observations that matter most.',
86
+ chapter2Cap: 7,
87
+ verbose: false,
88
+ },
89
+ ciso: {
90
+ label: 'CISO Briefing',
91
+ registerNote: 'Written for security leadership: risk-prioritized, control- and evidence-focused.',
92
+ chapter2Cap: null,
93
+ verbose: true,
94
+ },
95
+ privacy: {
96
+ label: 'Privacy Officer Briefing',
97
+ registerNote: 'Written for privacy/DPO review: data-class, AI-processing-context, and governance-gap focused.',
98
+ chapter2Cap: null,
99
+ verbose: true,
100
+ },
101
+ compliance: {
102
+ label: 'Compliance Briefing',
103
+ registerNote: 'Written for compliance review: policy-state and control-gap focused.',
104
+ chapter2Cap: null,
105
+ verbose: true,
106
+ },
107
+ regulator: {
108
+ label: 'Regulator-Facing Briefing',
109
+ registerNote: 'Written for external regulatory review: formal register, full evidentiary caveats preserved throughout.',
110
+ chapter2Cap: null,
111
+ verbose: true,
112
+ },
113
+ technical: {
114
+ label: 'Technical Briefing',
115
+ // Review finding (RECOMMENDED, fixed): the previous wording ("every
116
+ // factor tier shown") was false — only sensitivity/externality/
117
+ // controlVerdict are ever rendered per-flow; aiUse/breadth/
118
+ // evidenceConfidence affect ranking ORDER only and are never shown
119
+ // per item in any mode. Reworded to describe what is actually true.
120
+ registerNote: 'Full technical detail: no observation cap, verbose tables shown. Sensitivity, externality, and control-verdict tiers are shown per flow; all nine ranking factors (see the list at the end of this report) are considered when ordering flows, whether or not each is individually displayed.',
121
+ chapter2Cap: null,
122
+ verbose: true,
123
+ },
124
+ });
125
+
126
+ const SENSITIVITY_TIER_ORDER = ['critical', 'high', 'medium', 'low', 'none'];
127
+ const SENSITIVITY_LABELS = { critical: 'Critical', high: 'High', medium: 'Medium', low: 'Low', none: 'None / unclassified' };
128
+
129
+ const POLICY_LABELS = {
130
+ prohibited: 'prohibited by policy',
131
+ permitted: 'permitted',
132
+ conditionally_permitted: 'conditionally permitted',
133
+ manual_review_required: 'needs manual review',
134
+ not_evaluated: 'not yet evaluated against policy',
135
+ };
136
+
137
+ function _primaryDataElement(flow, graph) {
138
+ return (graph.dataElements ?? []).find((d) => (flow.dataElementIds ?? []).includes(d.id)) ?? null;
139
+ }
140
+
141
+ function _policyLabel(verdict, wording) {
142
+ return wording.verbose ? `\`${verdict}\`` : (POLICY_LABELS[verdict] ?? verdict);
143
+ }
144
+
145
+ function _flowHasUnencryptedTransit(flow, edgesById) {
146
+ return (flow.edgeIds ?? []).some((id) => edgesById.get(id)?.protection?.transit?.verdict === 'unprotected');
147
+ }
148
+
149
+ function _flowHasAtRestUnknown(flow, edgesById) {
150
+ return (flow.edgeIds ?? []).some((id) => {
151
+ const v = edgesById.get(id)?.protection?.atRest?.verdict;
152
+ return v === 'not_assessed' || v === 'unknown';
153
+ });
154
+ }
155
+
156
+ // --- Chapter 1: Scope & Confidence — direct reads off the graph's own
157
+ // required top-level envelope fields (scope/scanHealth/coverage/
158
+ // limitations; dataflow-graph.schema.json's own top-level `required` list).
159
+ function _chapter1ScopeConfidence(graph, wording, hasFilter) {
160
+ const lines = [];
161
+ lines.push('## Chapter 1: Scope & Confidence');
162
+ lines.push('');
163
+ const scope = graph.scope ?? {};
164
+ const scanHealth = graph.scanHealth ?? {};
165
+ const coverage = graph.coverage ?? {};
166
+ const limitations = graph.limitations ?? [];
167
+
168
+ lines.push(`This report is derived from a **${_mdInline(scope.source ?? 'unknown')}**-sourced data flow graph${scope.source === 'fixture' ? ' — illustrative demo data, not a real scan.' : '.'}`);
169
+ lines.push('');
170
+ lines.push(`Scan health: **${_mdInline(scanHealth.status ?? 'unknown')}**${scanHealth.reason ? ` (${_mdInline(scanHealth.reason)})` : ''}.`);
171
+ lines.push('');
172
+ if (graph.graphId) { lines.push(`Graph identity: \`${_mdInline(graph.graphId)}\`.`); lines.push(''); }
173
+
174
+ // Review finding (RECOMMENDED, fixed): --filter narrows every chapter's
175
+ // own flow content (via _filterGraph), but the coverage NUMBERS below
176
+ // still come from the SOURCE graph's own coverage ledger, which
177
+ // _filterGraph never touches (narrowing coverage counts to "how much of
178
+ // a filtered subgraph was covered" is not a well-defined question — the
179
+ // ledger is a whole-scan artifact). Left unlabeled, this read as an
180
+ // unscoped whole-scan ledger sitting beside a scoped, filtered chapter
181
+ // set with no indication either way — AC-25's own "coverage limitations
182
+ // remain prominent" requirement.
183
+ if (hasFilter) {
184
+ lines.push('**This report is scoped to a filtered subset of the graph** (`--filter`). The coverage figures below describe the WHOLE underlying scan, not just this filtered scope — Chapters 2 through 5 report only the filtered flows.');
185
+ lines.push('');
186
+ }
187
+
188
+ const sources = coverage.sources ?? {};
189
+ const sinks = coverage.sinks ?? {};
190
+ lines.push('**Coverage at a glance:**');
191
+ lines.push('');
192
+ lines.push(`- Sources matched: ${sources.matched ?? 0} (${sources.unseedable ?? 0} unseedable)`);
193
+ lines.push(`- Sink call sites: ${sinks.callStatementSites ?? 0} (${sinks.connected ?? 0} connected, ${sinks.disconnected ?? 0} disconnected, ${sinks.unsupportedSites ?? 0} unsupported)`);
194
+ if (coverage.degradedTerminals) lines.push(`- Degraded analysis terminals: ${coverage.degradedTerminals}`);
195
+ if (coverage.unresolvedDestinations) lines.push(`- Unresolved destinations: ${coverage.unresolvedDestinations}`);
196
+ if (coverage.pathBudgetTruncation) lines.push('- One or more path reconstructions hit a budget limit — treat affected flows as a lower bound, not a complete picture.');
197
+ lines.push('');
198
+
199
+ if (wording.verbose && Array.isArray(coverage.languages) && coverage.languages.length) {
200
+ lines.push('**Languages analyzed:**');
201
+ lines.push('');
202
+ for (const l of coverage.languages) lines.push(`- ${_mdInline(l.language)}: ${l.filesAnalyzed ?? 0}/${l.filesExpected ?? 0} file(s) analyzed`);
203
+ lines.push('');
204
+ }
205
+
206
+ if (limitations.length) {
207
+ lines.push('**Known limitations of this analysis:**');
208
+ lines.push('');
209
+ for (const l of limitations) lines.push(`- ${_mdInline(l)}`);
210
+ lines.push('');
211
+ }
212
+
213
+ lines.push('**Ranking factors honestly unavailable in this milestone:** `recipientJurisdiction` (needs a recipient-profile registry, not yet built) and `changeRecency` (needs the Data-Flow Time Machine, not yet built) are disclosed as unavailable on every flow scored below — never fabricated, never silently dropped from the factor list.');
214
+ lines.push('');
215
+
216
+ return { id: 'scope-confidence', number: 1, title: 'Scope & Confidence', itemCount: limitations.length, markdown: lines.join('\n') };
217
+ }
218
+
219
+ // --- Chapter 2: Sensitive-Data Footprint — rankFlows grouped by the
220
+ // sensitivity factor's own tier. Every ranked flow is pushed into ITS OWN
221
+ // tier's bucket unconditionally, including 'none' — mirrors
222
+ // export-privacy.js#_groupRowsByClass's own never-silently-drop-a-flow
223
+ // discipline (a bare `if (tier !== 'none') continue` would make an
224
+ // unclassified flow genuinely invisible from the whole chapter, not merely
225
+ // ungrouped).
226
+ function _chapter2SensitiveFootprint(ranked, graph, nodesById, wording) {
227
+ const lines = [];
228
+ lines.push('## Chapter 2: Sensitive-Data Footprint');
229
+ lines.push('');
230
+ if (ranked.length === 0) {
231
+ lines.push('No flows were identified in this graph scope — nothing to report.');
232
+ return { id: 'sensitive-footprint', number: 2, title: 'Sensitive-Data Footprint', itemCount: 0, markdown: lines.join('\n') };
233
+ }
234
+
235
+ const grouped = new Map();
236
+ for (const rf of ranked) {
237
+ const tier = rf.factors.sensitivity.tier;
238
+ if (!grouped.has(tier)) grouped.set(tier, []);
239
+ grouped.get(tier).push(rf);
240
+ }
241
+
242
+ const cap = wording.chapter2Cap;
243
+ let shown = 0;
244
+ let truncated = false;
245
+
246
+ for (const tier of SENSITIVITY_TIER_ORDER) {
247
+ const group = grouped.get(tier);
248
+ if (!group || group.length === 0) continue;
249
+ if (cap != null && shown >= cap) { truncated = true; break; }
250
+
251
+ lines.push(`### ${SENSITIVITY_LABELS[tier] ?? tier} (${group.length} flow(s))`);
252
+ lines.push('');
253
+
254
+ // Group-level aggregates always reflect the FULL group, regardless of
255
+ // the per-item cap below — the cap narrows the observation LIST, never
256
+ // the honest summary counts.
257
+ const dataClasses = new Set();
258
+ const destinations = new Set();
259
+ const aiContexts = new Set();
260
+ for (const rf of group) {
261
+ const de = _primaryDataElement(rf.flow, graph);
262
+ for (const c of de?.dataClasses ?? []) dataClasses.add(c);
263
+ for (const c of de?.aiContexts ?? []) aiContexts.add(c);
264
+ const snk = nodesById.get(rf.flow.sink);
265
+ if (snk) destinations.add(snk.label || snk.id);
266
+ }
267
+ if (dataClasses.size) lines.push(`- Data classes: ${[...dataClasses].sort().map(_mdCode).join(', ')}`);
268
+ if (destinations.size) lines.push(`- Destinations reached: ${[...destinations].sort().map(_mdCode).join(', ')}`);
269
+ if (wording.verbose && aiContexts.size) lines.push(`- AI processing contexts: ${[...aiContexts].sort().map(_mdCode).join(', ')}`);
270
+ lines.push('');
271
+
272
+ for (const rf of group) {
273
+ if (cap != null && shown >= cap) { truncated = true; break; }
274
+ const de = _primaryDataElement(rf.flow, graph);
275
+ const snk = nodesById.get(rf.flow.sink);
276
+ const controlNote = wording.verbose ? ` (control: \`${rf.factors.controlVerdict.tier}\`)` : '';
277
+ lines.push(`- ${_mdCode(de?.name ?? '(unnamed field)')} -> ${_mdCode(snk?.label ?? snk?.id ?? 'unknown destination')}${controlNote}`);
278
+ shown++;
279
+ }
280
+ lines.push('');
281
+ if (truncated) break;
282
+ }
283
+
284
+ if (truncated) {
285
+ lines.push(`_${ranked.length - shown} additional flow(s) not shown — capped at ${cap} primary observations for this audience mode._`);
286
+ lines.push('');
287
+ }
288
+
289
+ return { id: 'sensitive-footprint', number: 2, title: 'Sensitive-Data Footprint', itemCount: shown, markdown: lines.join('\n') };
290
+ }
291
+
292
+ // --- Chapter 3: External Exposure — externality.tier === 'external' flows,
293
+ // PLUS any flow whose sink node is genuinely FR-203-unresolved
294
+ // (node.kind === 'unresolved'). Final whole-branch review finding
295
+ // (BLOCKING, fixed): this chapter originally filtered on 'external' alone,
296
+ // but FR-203's unresolved-destination path (sink-registry.js) sets
297
+ // externality:'unknown' on the SAME return object as kind:'unresolved' —
298
+ // never 'external' — so EVERY unresolved-destination flow (including
299
+ // every real AI-provider flow in this JS catalog, since every AI SDK entry
300
+ // is a member-chain receiver that always triggers FR-203) was silently
301
+ // dropped from the whole chapter, and the chapter's own "unresolved
302
+ // destinations"/"AI providers" bullets were unreachable dead code.
303
+ //
304
+ // Deliberately narrower than "any externality:'unknown' flow": a plain
305
+ // resolved store-kind sink (a local database write) ALSO carries
306
+ // externality:'unknown' by category design (sink-registry.js's
307
+ // CATEGORY_EXTERNALITY — "could be local or third-party-managed, the
308
+ // registry can't tell"), which is a genuinely different, non-security
309
+ // concept from FR-203's "the destination itself could not be statically
310
+ // determined." Gating on node.kind === 'unresolved' targets the real
311
+ // FR-203 gap the review found without also pulling in every ordinary
312
+ // database/file/object-storage write in the graph.
313
+ function _chapter3ExternalExposure(ranked, graph, nodesById, wording) {
314
+ const lines = [];
315
+ lines.push('## Chapter 3: External Exposure');
316
+ lines.push('');
317
+ const exposureFlows = ranked.filter((rf) => {
318
+ if (rf.factors.externality.tier === 'external') return true;
319
+ return nodesById.get(rf.flow.sink)?.kind === 'unresolved';
320
+ });
321
+ if (exposureFlows.length === 0) {
322
+ lines.push('No flows in this graph scope reach an external or unresolved destination.');
323
+ return { id: 'external-exposure', number: 3, title: 'External Exposure', itemCount: 0, markdown: lines.join('\n') };
324
+ }
325
+
326
+ const resolvedExternal = exposureFlows.filter((rf) => rf.factors.externality.tier === 'external');
327
+ const unresolvedTier = exposureFlows.filter((rf) => nodesById.get(rf.flow.sink)?.kind === 'unresolved');
328
+
329
+ const destinations = new Set();
330
+ const aiProviders = new Set();
331
+ const unresolved = new Set();
332
+ for (const rf of exposureFlows) {
333
+ const snk = nodesById.get(rf.flow.sink);
334
+ if (!snk) continue;
335
+ destinations.add(snk.label || snk.id);
336
+ if (AI_SINK_SUBTYPES.includes(snk.subtype)) aiProviders.add(snk.label || snk.id);
337
+ if (snk.kind === 'unresolved') unresolved.add(snk.label || snk.id);
338
+ }
339
+
340
+ lines.push(`${exposureFlows.length} flow(s) reach an external or unresolved destination — ${resolvedExternal.length} resolved external, ${unresolvedTier.length} destination not statically resolved.`);
341
+ lines.push('');
342
+ if (destinations.size) { lines.push(`**Destinations:** ${[...destinations].sort().map(_mdCode).join(', ')}`); lines.push(''); }
343
+ if (aiProviders.size) { lines.push(`**AI providers/agents/tools among them:** ${[...aiProviders].sort().map(_mdCode).join(', ')}`); lines.push(''); }
344
+ if (unresolved.size) { lines.push(`**Destination not statically resolved (could not be determined by analysis):** ${[...unresolved].sort().map(_mdCode).join(', ')}`); lines.push(''); }
345
+
346
+ if (wording.verbose) {
347
+ lines.push('| Data element | Destination | Externality | Sensitivity | Control |');
348
+ lines.push('|---|---|---|---|---|');
349
+ for (const rf of exposureFlows) {
350
+ const de = _primaryDataElement(rf.flow, graph);
351
+ const snk = nodesById.get(rf.flow.sink);
352
+ const cells = [de?.name ?? '(unnamed field)', snk?.label ?? snk?.id ?? 'unknown', rf.factors.externality.tier, rf.factors.sensitivity.tier, rf.factors.controlVerdict.tier];
353
+ lines.push(`| ${cells.map(_mdCell).join(' | ')} |`);
354
+ }
355
+ lines.push('');
356
+ }
357
+
358
+ return { id: 'external-exposure', number: 3, title: 'External Exposure', itemCount: exposureFlows.length, markdown: lines.join('\n') };
359
+ }
360
+
361
+ // --- Chapter 4: Control & Governance Gaps — controlVerdict.tier !==
362
+ // 'protected' flows, sub-categorized into raw-logging / unencrypted-
363
+ // transit / at-rest-unknown / governance-field gaps / policy conflicts.
364
+ function _chapter4ControlGovernanceGaps(ranked, graph, nodesById, edgesById, wording) {
365
+ const lines = [];
366
+ lines.push('## Chapter 4: Control & Governance Gaps');
367
+ lines.push('');
368
+ const gaps = ranked.filter((rf) => rf.factors.controlVerdict.tier !== 'protected');
369
+ if (gaps.length === 0) {
370
+ lines.push('No control or governance gaps were identified in this graph scope.');
371
+ return { id: 'control-governance-gaps', number: 4, title: 'Control & Governance Gaps', itemCount: 0, markdown: lines.join('\n') };
372
+ }
373
+
374
+ const rawLogging = [];
375
+ const unencryptedTransit = [];
376
+ const atRestUnknown = [];
377
+ const governanceGapFlows = [];
378
+ // Final whole-branch review finding (BLOCKING, fixed): this used to be
379
+ // ONE bucket, `flow.policyVerdict !== 'permitted'`, rendered under
380
+ // "policy conflict (not permitted)". With no
381
+ // .agentic-security/privacy-policy.json on disk — the default for
382
+ // essentially every user — every flow reads `not_evaluated`, so every
383
+ // flow was reported as being in policy conflict: an unsupported
384
+ // compliance claim (AC-25's own "no unsupported ... compliance claim
385
+ // appears") about a flow no policy was ever applied to, AND a direct
386
+ // contradiction of Chapter 5, which correctly treats not_evaluated as
387
+ // "no decision needed" (decision-story.js's own _TIER_RANK already
388
+ // distinguishes these 4 states; this chapter was the one place that
389
+ // collapsed them). Split into three honestly-labeled buckets.
390
+ // manualReviewNeeded and conditionally_permitted have no real producer
391
+ // in the current pipeline (confirmed directly against
392
+ // graph-builder.js's own policyVerdict assignment site, Task 2's own
393
+ // disclosed finding) — only not_evaluated/permitted/prohibited are
394
+ // ever emitted from real code today. This branch's own correctness
395
+ // rests on the hand-traced symmetry with policyConflicts/notEvaluated
396
+ // below, not a real-graph regression test, for the same reason Task 2
397
+ // never fabricated a hand-built graph carrying an
398
+ // unreachable-in-practice verdict just to exercise this one branch
399
+ // (scoped re-review of the final-review fix round, noted but
400
+ // deliberately not "fixed" with a fake fixture).
401
+ const policyConflicts = [];
402
+ const manualReviewNeeded = [];
403
+ const notEvaluated = [];
404
+
405
+ for (const rf of gaps) {
406
+ const { flow } = rf;
407
+ const snk = nodesById.get(flow.sink);
408
+ if (flow.handling === 'raw' && snk?.kind === 'log') rawLogging.push(rf);
409
+ if (_flowHasUnencryptedTransit(flow, edgesById)) unencryptedTransit.push(rf);
410
+ if (_flowHasAtRestUnknown(flow, edgesById)) atRestUnknown.push(rf);
411
+ const gapFields = GOVERNANCE_FIELDS.filter((f) => (flow.governanceRefs?.[f]?.source ?? 'manual_required') === 'manual_required');
412
+ if (gapFields.length) governanceGapFlows.push({ rf, gapFields });
413
+ if (flow.policyVerdict === 'prohibited' || flow.policyVerdict === 'conditionally_permitted') policyConflicts.push(rf);
414
+ else if (flow.policyVerdict === 'manual_review_required') manualReviewNeeded.push(rf);
415
+ else if (flow.policyVerdict === 'not_evaluated') notEvaluated.push(rf);
416
+ }
417
+
418
+ lines.push(`${gaps.length} flow(s) do not carry a fully protected control verdict.`);
419
+ lines.push('');
420
+
421
+ const section = (title, items, render) => {
422
+ if (!items.length) return;
423
+ lines.push(`**${title} (${items.length}):**`);
424
+ lines.push('');
425
+ for (const item of items) lines.push(`- ${render(item)}`);
426
+ lines.push('');
427
+ };
428
+
429
+ // Every bullet includes its destination (N-3, review finding): the same
430
+ // field reaching two different sinks previously rendered two
431
+ // byte-identical bullets in a list, indistinguishable from a
432
+ // duplication bug.
433
+ const withDestination = (rf) => {
434
+ const de = _primaryDataElement(rf.flow, graph);
435
+ const snk = nodesById.get(rf.flow.sink);
436
+ return `${_mdCode(de?.name ?? '(unnamed field)')} -> ${_mdCode(snk?.label ?? snk?.id ?? 'unknown destination')}`;
437
+ };
438
+
439
+ section('Raw data reaching a log sink', rawLogging, withDestination);
440
+ section('Flows with unencrypted transit', unencryptedTransit, withDestination);
441
+ section('Flows with at-rest protection unknown', atRestUnknown, withDestination);
442
+
443
+ // A real Markdown TABLE (not a bullet list), deliberately — this is the
444
+ // one place in this chapter that interpolates the operator-supplied
445
+ // governance PROSE VALUES DPIA/RoPA read from flow.governanceRefs
446
+ // (export-privacy.js's own BLOCKING-1 precedent: an unescaped `|` or
447
+ // embedded newline in one of these values corrupts a table's column
448
+ // alignment or injects a fake heading). Every cell goes through _mdCell.
449
+ if (governanceGapFlows.length) {
450
+ lines.push(`**Flows with governance fields requiring manual input (${governanceGapFlows.length}):**`);
451
+ lines.push('');
452
+ const header = ['Data element', 'Destination', 'Missing fields', 'Provided values'];
453
+ lines.push(`| ${header.join(' | ')} |`);
454
+ lines.push(`|${header.map(() => '---').join('|')}|`);
455
+ for (const { rf, gapFields } of governanceGapFlows) {
456
+ const de = _primaryDataElement(rf.flow, graph);
457
+ const snk = nodesById.get(rf.flow.sink);
458
+ const provided = GOVERNANCE_FIELDS
459
+ .filter((f) => rf.flow.governanceRefs?.[f]?.source === 'operator_provided')
460
+ .map((f) => `${f}: ${rf.flow.governanceRefs[f].value}`)
461
+ .join('; ');
462
+ const cells = [de?.name ?? '(unnamed field)', snk?.label ?? snk?.id ?? 'unknown destination', gapFields.join(', '), provided || '(none)'];
463
+ lines.push(`| ${cells.map(_mdCell).join(' | ')} |`);
464
+ }
465
+ lines.push('');
466
+ }
467
+
468
+ const withPolicyLabel = (rf) => `${withDestination(rf)} — ${_mdInline(_policyLabel(rf.flow.policyVerdict, wording))}`;
469
+ section('Flows prohibited or conditionally permitted by policy', policyConflicts, withPolicyLabel);
470
+ section('Flows requiring manual policy review', manualReviewNeeded, withPolicyLabel);
471
+ section('Flows not yet evaluated against policy (no policy configured for this scan)', notEvaluated, withPolicyLabel);
472
+
473
+ return { id: 'control-governance-gaps', number: 4, title: 'Control & Governance Gaps', itemCount: gaps.length, markdown: lines.join('\n') };
474
+ }
475
+
476
+ // --- Chapter 5: Change & Decisions Needed — NO new/worsened-flow claims
477
+ // (changeRecency is always unavailable, decision-story.js's own header).
478
+ // A prominent disclosure instead (AC-25's "coverage limitations remain
479
+ // prominent"), plus every flow whose policyState is manual_review_required
480
+ // or prohibited, presented as a real, currently-decision-relevant fact.
481
+ function _chapter5ChangeAndDecisions(ranked, graph, wording) {
482
+ const lines = [];
483
+ lines.push('## Chapter 5: Change & Decisions Needed');
484
+ lines.push('');
485
+ lines.push('**No historical baseline is available in this milestone.** Change-over-time claims (a flow being new or having gotten worse since a prior scan) require the Data-Flow Time Machine, which has not shipped yet — nothing below claims a flow is new or has regressed. Every item is a real, currently-decision-relevant fact about the graph as it stands today.');
486
+ lines.push('');
487
+
488
+ const decisions = ranked.filter((rf) => rf.factors.policyState.tier === 'manual_review_required' || rf.factors.policyState.tier === 'prohibited');
489
+ if (decisions.length === 0) {
490
+ lines.push('No flows currently require a manual policy decision.');
491
+ lines.push('');
492
+ } else {
493
+ lines.push(`**Decisions needed now (${decisions.length}):**`);
494
+ lines.push('');
495
+ for (const rf of decisions) {
496
+ const de = _primaryDataElement(rf.flow, graph);
497
+ lines.push(`- ${_mdCode(de?.name ?? '(unnamed field)')} — ${_mdInline(_policyLabel(rf.flow.policyVerdict, wording))}`);
498
+ }
499
+ lines.push('');
500
+ }
501
+
502
+ return {
503
+ id: 'change-and-decisions', number: 5, title: 'Change & Decisions Needed', itemCount: decisions.length,
504
+ markdown: lines.join('\n'), decisions,
505
+ };
506
+ }
507
+
508
+ function _renderMarkdown(record, wording, chapters) {
509
+ const lines = [];
510
+ lines.push(`# Executive Risk Story — ${wording.label}`);
511
+ lines.push('');
512
+ // Full digest, never truncated (review finding, RECOMMENDED, fixed) —
513
+ // AC-25's own "preserves graph digest and reproducibility metadata"
514
+ // requirement; a truncated digest is a weaker reproducibility claim
515
+ // than the graph itself makes.
516
+ lines.push(`Generated ${_mdInline(record.generatedAt)} · graph digest \`${_mdInline(record.graphDigest)}\` · audience mode \`${record.audienceMode}\`.`);
517
+ lines.push('');
518
+ lines.push(wording.registerNote);
519
+ lines.push('');
520
+ lines.push('This report is generated from real, code-derived data flow analysis. It is a decision-support artifact, not a compliance certification — see Chapter 1 for scope and coverage limitations.');
521
+ lines.push('');
522
+ lines.push('---');
523
+ lines.push('');
524
+ for (const ch of chapters) {
525
+ lines.push(ch.markdown);
526
+ lines.push('');
527
+ }
528
+ lines.push('---');
529
+ lines.push('');
530
+ lines.push(`Ranking factors considered, in priority order: ${record.rankingFactors.map((f) => `\`${f}\``).join(', ')}.`);
531
+ return `${lines.join('\n').replace(/\n{3,}/g, '\n\n').trimEnd()}\n`;
532
+ }
533
+
534
+ /**
535
+ * Emit the 5-chapter Executive Risk Story (FR-501 §14). Returns
536
+ * {record, markdown} — `record` is a DecisionStory (§10.10 extension
537
+ * contract, validated before return, never a DataFlowGraph v1 entity),
538
+ * `markdown` is the human-readable narrative.
539
+ *
540
+ * @param {object} graph a real DataFlowGraph v1 document.
541
+ * @param {object} [opts]
542
+ * @param {{nodeIds:string[], edgeIds:string[]}} [opts.filter] the same
543
+ * {nodeIds, edgeIds} shape export-json.js/export-csv.js/export-privacy.js
544
+ * all use — narrows the GRAPH (via export-json.js's own _filterGraph)
545
+ * before flows are ranked. The graph DIGEST always identifies the
546
+ * SOURCE graph, never the filtered view — export-json.js's own
547
+ * established precedent (two different filters of one scan must report
548
+ * the same digest).
549
+ * @param {string} [opts.generatedAt] falls back to `graph.generatedAt` —
550
+ * NEVER wall-clock, so the record's own generatedAt/id stay deterministic
551
+ * under AGENTIC_SECURITY_DETERMINISTIC=1 test fixtures the same way
552
+ * every other M4 exporter's regression tests already rely on.
553
+ * @param {string} [opts.audienceMode] one of AUDIENCE_MODES, default
554
+ * 'technical'. Controls prose register/verbosity only — see this file's
555
+ * own header for the binding "never change facts" constraint.
556
+ * @param {string[]} [opts.factorOrder] overrides decision-story.js's own
557
+ * default RANKING_FACTORS priority sequence for THIS story — threaded
558
+ * straight through to rankFlows, satisfying the PRD's own "transparent
559
+ * CONFIGURABLE factors" requirement at the API level (review finding,
560
+ * RECOMMENDED, fixed — this was previously never threaded, so nothing
561
+ * external could reach rankFlows's own opts.factorOrder at all). Not yet
562
+ * exposed as its own `dataflow export` CLI flag — see commands/
563
+ * dataflow.md's own disclosure of that narrower, still-open gap.
564
+ */
565
+ export function emitDecisionStory(graph, opts = {}) {
566
+ const audienceMode = opts.audienceMode ?? 'technical';
567
+ if (!AUDIENCE_MODES.includes(audienceMode)) {
568
+ throw new Error(`emitDecisionStory: unrecognized audienceMode "${audienceMode}" — must be one of ${AUDIENCE_MODES.join('|')}`);
569
+ }
570
+ const wording = _AUDIENCE_WORDING[audienceMode];
571
+
572
+ const scopedGraph = opts.filter ? _filterGraph(graph, opts.filter) : graph;
573
+ const generatedAt = opts.generatedAt ?? graph.generatedAt;
574
+ const nodesById = new Map((scopedGraph.nodes ?? []).map((n) => [n.id, n]));
575
+ const edgesById = new Map((scopedGraph.edges ?? []).map((e) => [e.id, e]));
576
+ const ranked = rankFlows(scopedGraph, opts.factorOrder ? { factorOrder: opts.factorOrder } : undefined);
577
+
578
+ const ch1 = _chapter1ScopeConfidence(scopedGraph, wording, Boolean(opts.filter));
579
+ const ch2 = _chapter2SensitiveFootprint(ranked, scopedGraph, nodesById, wording);
580
+ const ch3 = _chapter3ExternalExposure(ranked, scopedGraph, nodesById, wording);
581
+ const ch4 = _chapter4ControlGovernanceGaps(ranked, scopedGraph, nodesById, edgesById, wording);
582
+ const ch5 = _chapter5ChangeAndDecisions(ranked, scopedGraph, wording);
583
+ const chapters = [ch1, ch2, ch3, ch4, ch5];
584
+
585
+ // Deliberate: the digest always identifies the SOURCE graph this story
586
+ // was taken from, never the filtered `scopedGraph` — mirrors
587
+ // export-json.js#exportGraphJSON's own established rule (AC-25's
588
+ // "preserves graph digest" requirement, satisfied against the graph the
589
+ // caller actually handed in, not a view of it).
590
+ const graphDigest = computeGraphDigest(graph);
591
+
592
+ const evidenceGrade = ranked.length === 0 ? 'none' : 'code';
593
+ if (!EVIDENCE_GRADES.includes(evidenceGrade)) {
594
+ throw new Error(`emitDecisionStory: internal error — evidenceGrade "${evidenceGrade}" is not a member of EVIDENCE_GRADES`);
595
+ }
596
+
597
+ const scopeQuery = { filter: opts.filter ?? null };
598
+
599
+ const record = {
600
+ id: storyId({ graphDigest, audienceMode, scopeQuery }),
601
+ version: DECISION_STORY_VERSION,
602
+ audienceMode,
603
+ scopeQuery,
604
+ // Strip ch5's internal-only `decisions` array (used below to build
605
+ // record.decisions) before it lands twice on the public record.
606
+ chapters: chapters.map(({ decisions: _decisions, ...c }) => c),
607
+ contributingGraphIds: graph.graphId ? [graph.graphId] : [],
608
+ rankingFactors: RANKING_FACTORS,
609
+ evidenceGrade,
610
+ coverage: scopedGraph.coverage ?? {},
611
+ decisions: ch5.decisions.map((rf) => ({
612
+ flowId: rf.flow.id,
613
+ policyVerdict: rf.flow.policyVerdict,
614
+ dataElementName: _primaryDataElement(rf.flow, scopedGraph)?.name ?? null,
615
+ sinkLabel: nodesById.get(rf.flow.sink)?.label ?? null,
616
+ })),
617
+ generatedAt,
618
+ graphDigest,
619
+ };
620
+
621
+ const { valid, errors } = validateDecisionStory(record);
622
+ if (!valid) {
623
+ throw new Error(`emitDecisionStory: internal error — produced an invalid DecisionStory record: ${JSON.stringify(errors)}`);
624
+ }
625
+
626
+ const markdown = _renderMarkdown(record, wording, chapters);
627
+ return { record, markdown };
628
+ }