@odavl/guardian 2.0.0 → 2.0.1

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 (172) hide show
  1. package/CHANGELOG.md +210 -210
  2. package/LICENSE +21 -21
  3. package/README.md +297 -184
  4. package/bin/guardian.js +2242 -2221
  5. package/config/README.md +59 -59
  6. package/config/guardian.config.json +54 -54
  7. package/config/guardian.policy.json +12 -12
  8. package/config/profiles/docs.yaml +18 -18
  9. package/config/profiles/ecommerce.yaml +17 -17
  10. package/config/profiles/landing-demo.yaml +16 -16
  11. package/config/profiles/marketing.yaml +18 -18
  12. package/config/profiles/saas.yaml +21 -21
  13. package/flows/example-login-flow.json +36 -36
  14. package/flows/example-signup-flow.json +44 -44
  15. package/package.json +124 -116
  16. package/policies/enterprise.json +12 -12
  17. package/policies/landing-demo.json +22 -22
  18. package/policies/saas.json +12 -12
  19. package/policies/startup.json +12 -12
  20. package/src/enterprise/audit-logger.js +166 -166
  21. package/src/enterprise/pdf-exporter.js +267 -267
  22. package/src/enterprise/rbac-gate.js +142 -142
  23. package/src/enterprise/rbac.js +239 -239
  24. package/src/enterprise/site-manager.js +180 -180
  25. package/src/founder/feedback-system.js +156 -156
  26. package/src/founder/founder-tracker.js +213 -213
  27. package/src/founder/usage-signals.js +141 -141
  28. package/src/guardian/action-hints.js +439 -439
  29. package/src/guardian/alert-ledger.js +121 -121
  30. package/src/guardian/artifact-sanitizer.js +56 -56
  31. package/src/guardian/attempt-engine.js +1069 -1029
  32. package/src/guardian/attempt-registry.js +267 -267
  33. package/src/guardian/attempt-relevance.js +106 -106
  34. package/src/guardian/attempt-reporter.js +513 -507
  35. package/src/guardian/attempt.js +274 -273
  36. package/src/guardian/attempts-filter.js +63 -63
  37. package/src/guardian/auto-attempt-builder.js +283 -283
  38. package/src/guardian/baseline-registry.js +177 -177
  39. package/src/guardian/baseline-reporter.js +143 -143
  40. package/src/guardian/baseline-storage.js +285 -285
  41. package/src/guardian/baseline.js +535 -534
  42. package/src/guardian/behavioral-signals.js +261 -261
  43. package/src/guardian/breakage-intelligence.js +224 -224
  44. package/src/guardian/browser-pool.js +131 -131
  45. package/src/guardian/browser.js +119 -119
  46. package/src/guardian/canonical-truth.js +308 -308
  47. package/src/guardian/ci-cli.js +121 -121
  48. package/src/guardian/ci-gate.js +96 -96
  49. package/src/guardian/ci-mode.js +15 -15
  50. package/src/guardian/ci-output.js +55 -38
  51. package/src/guardian/cli-summary.js +102 -102
  52. package/src/guardian/confidence-signals.js +251 -251
  53. package/src/guardian/config-loader.js +161 -161
  54. package/src/guardian/config-validator.js +285 -283
  55. package/src/guardian/coverage-model.js +239 -239
  56. package/src/guardian/coverage-packs.js +58 -58
  57. package/src/guardian/crawler.js +142 -142
  58. package/src/guardian/data-guardian-detector.js +189 -189
  59. package/src/guardian/decision-authority.js +746 -725
  60. package/src/guardian/detection-layers.js +271 -271
  61. package/src/guardian/determinism.js +146 -146
  62. package/src/guardian/discovery-engine.js +661 -661
  63. package/src/guardian/drift-detector.js +100 -100
  64. package/src/guardian/enhanced-html-reporter.js +522 -522
  65. package/src/guardian/env-guard.js +128 -127
  66. package/src/guardian/error-clarity.js +399 -399
  67. package/src/guardian/export-contract.js +196 -196
  68. package/src/guardian/fail-safe.js +212 -212
  69. package/src/guardian/failure-intelligence.js +173 -173
  70. package/src/guardian/failure-taxonomy.js +169 -169
  71. package/src/guardian/final-outcome.js +206 -206
  72. package/src/guardian/first-run-profile.js +89 -89
  73. package/src/guardian/first-run.js +65 -67
  74. package/src/guardian/flag-validator.js +111 -111
  75. package/src/guardian/flow-executor.js +641 -639
  76. package/src/guardian/flow-registry.js +67 -67
  77. package/src/guardian/honesty.js +394 -394
  78. package/src/guardian/html-reporter.js +416 -416
  79. package/src/guardian/human-intent-resolver.js +296 -296
  80. package/src/guardian/human-interaction-model.js +351 -351
  81. package/src/guardian/human-journey-context.js +184 -184
  82. package/src/guardian/human-navigator.js +544 -544
  83. package/src/guardian/human-reporter.js +435 -431
  84. package/src/guardian/index.js +226 -221
  85. package/src/guardian/init-command.js +143 -143
  86. package/src/guardian/intent-detector.js +148 -146
  87. package/src/guardian/journey-definitions.js +132 -132
  88. package/src/guardian/journey-scan-cli.js +142 -145
  89. package/src/guardian/journey-scanner.js +583 -583
  90. package/src/guardian/junit-reporter.js +281 -281
  91. package/src/guardian/language-detection.js +99 -99
  92. package/src/guardian/live-alert.js +56 -56
  93. package/src/guardian/live-baseline-compare.js +146 -146
  94. package/src/guardian/live-cli.js +95 -95
  95. package/src/guardian/live-guardian.js +210 -210
  96. package/src/guardian/live-scheduler-runner.js +137 -137
  97. package/src/guardian/live-scheduler-state.js +167 -168
  98. package/src/guardian/live-scheduler.js +146 -146
  99. package/src/guardian/live-state.js +110 -110
  100. package/src/guardian/market-criticality.js +335 -335
  101. package/src/guardian/market-reporter.js +577 -577
  102. package/src/guardian/network-trace.js +178 -178
  103. package/src/guardian/obs-logger.js +110 -110
  104. package/src/guardian/observed-capabilities.js +427 -427
  105. package/src/guardian/output-contract.js +154 -0
  106. package/src/guardian/output-readability.js +264 -264
  107. package/src/guardian/parallel-executor.js +116 -116
  108. package/src/guardian/path-safety.js +56 -56
  109. package/src/guardian/pattern-analyzer.js +348 -348
  110. package/src/guardian/policy.js +432 -434
  111. package/src/guardian/prelaunch-gate.js +193 -193
  112. package/src/guardian/prerequisite-checker.js +101 -101
  113. package/src/guardian/preset-loader.js +152 -157
  114. package/src/guardian/profile-loader.js +96 -96
  115. package/src/guardian/reality.js +3025 -2826
  116. package/src/guardian/realworld-scenarios.js +94 -94
  117. package/src/guardian/reporter.js +167 -167
  118. package/src/guardian/retry-policy.js +123 -123
  119. package/src/guardian/root-cause-analysis.js +171 -171
  120. package/src/guardian/rules-engine.js +558 -558
  121. package/src/guardian/run-artifacts.js +212 -212
  122. package/src/guardian/run-cleanup.js +207 -207
  123. package/src/guardian/run-export.js +522 -522
  124. package/src/guardian/run-latest.js +90 -90
  125. package/src/guardian/run-list.js +211 -211
  126. package/src/guardian/run-summary.js +20 -20
  127. package/src/guardian/runtime-root.js +246 -246
  128. package/src/guardian/safety.js +248 -248
  129. package/src/guardian/scan-presets.js +133 -149
  130. package/src/guardian/screenshot.js +152 -152
  131. package/src/guardian/secret-hygiene.js +44 -44
  132. package/src/guardian/selector-fallbacks.js +394 -394
  133. package/src/guardian/semantic-contact-detection.js +255 -255
  134. package/src/guardian/semantic-contact-finder.js +201 -201
  135. package/src/guardian/semantic-targets.js +234 -234
  136. package/src/guardian/site-intelligence.js +588 -588
  137. package/src/guardian/site-introspection.js +257 -257
  138. package/src/guardian/sitemap.js +225 -225
  139. package/src/guardian/smoke.js +283 -258
  140. package/src/guardian/snapshot-schema.js +177 -290
  141. package/src/guardian/snapshot.js +430 -397
  142. package/src/guardian/stability-scorer.js +169 -169
  143. package/src/guardian/success-evaluator.js +214 -214
  144. package/src/guardian/template-command.js +184 -184
  145. package/src/guardian/text-formatters.js +426 -426
  146. package/src/guardian/timeout-profiles.js +57 -57
  147. package/src/guardian/truth/attempt.contract.js +158 -0
  148. package/src/guardian/truth/decision.contract.js +275 -0
  149. package/src/guardian/truth/snapshot.contract.js +363 -0
  150. package/src/guardian/validators.js +323 -323
  151. package/src/guardian/verdict-card.js +474 -474
  152. package/src/guardian/verdict-clarity.js +298 -298
  153. package/src/guardian/verdict-policy.js +363 -363
  154. package/src/guardian/verdict.js +333 -333
  155. package/src/guardian/verdicts.js +79 -74
  156. package/src/guardian/visual-diff.js +247 -247
  157. package/src/guardian/wait-for-outcome.js +119 -119
  158. package/src/guardian/watch-runner.js +181 -181
  159. package/src/guardian/watchdog-diff.js +167 -167
  160. package/src/guardian/webhook.js +206 -206
  161. package/src/payments/stripe-checkout.js +169 -169
  162. package/src/plans/plan-definitions.js +148 -148
  163. package/src/plans/plan-manager.js +211 -211
  164. package/src/plans/usage-tracker.js +210 -210
  165. package/src/recipes/recipe-engine.js +188 -188
  166. package/src/recipes/recipe-failure-analysis.js +159 -159
  167. package/src/recipes/recipe-registry.js +134 -134
  168. package/src/recipes/recipe-runtime.js +507 -507
  169. package/src/recipes/recipe-store.js +410 -410
  170. package/SECURITY.md +0 -77
  171. package/VERSIONING.md +0 -100
  172. package/guardian-contract-v1.md +0 -502
@@ -1,725 +1,746 @@
1
- /**
2
- * UNIFIED DECISION AUTHORITY
3
- *
4
- * The SINGLE source of truth for final verdict determination.
5
- * All verdict signals (rules, flows, attempts, journey, policy, baseline)
6
- * flow through this function only.
7
- *
8
- * This module is PURE: no IO, no side effects, no hidden state.
9
- * All dependencies are passed in explicitly.
10
- */
11
-
12
- const {
13
- toCanonicalVerdict,
14
- mapExitCodeFromCanonical,
15
- normalizeCanonicalVerdict
16
- } = require('./verdicts');
17
-
18
- const {
19
- computeCoverageSummary,
20
- computeSelectorConfidence,
21
- SELECTOR_CONFIDENCE,
22
- COVERAGE_THRESHOLD
23
- } = require('./coverage-model');
24
-
25
- /**
26
- * RUNTIME GUARD: One-call-per-run enforcement
27
- * Prevents accidental double calls within the same process execution.
28
- * Per-run state is maintained via runId passed in options.
29
- */
30
- const callTracker = new Map(); // runId -> { called: boolean, timestamp }
31
-
32
- function validateSingleCall(runId) {
33
- const isProduction = process.env.NODE_ENV === 'production';
34
-
35
- // Track by runId. If no runId provided, use a default key for tests.
36
- const trackKey = runId || '__default_run__';
37
-
38
- if (callTracker.has(trackKey)) {
39
- const entry = callTracker.get(trackKey);
40
- const message = `computeDecisionAuthority called twice in same run (${trackKey}). First call: ${entry.timestamp}`;
41
-
42
- if (!isProduction) {
43
- throw new Error(message);
44
- }
45
- // In production, log warning but allow (graceful degradation)
46
- console.warn(`⚠️ ${message}`);
47
- }
48
-
49
- callTracker.set(trackKey, { called: true, timestamp: new Date().toISOString() });
50
- }
51
-
52
- function resetCallTracker(runId) {
53
- const trackKey = runId || '__default_run__';
54
- callTracker.delete(trackKey);
55
- }
56
-
57
- /**
58
- * VERDICT SOURCE CONSTANTS
59
- */
60
- const VERDICT_SOURCE = {
61
- RULES_ENGINE: 'rules_engine',
62
- RULES_ENGINE_FALLBACK: 'rules_engine_fallback',
63
- FLOWS_FAILURE: 'flows_failure',
64
- FLOWS_FRICTION: 'flows_friction',
65
- ATTEMPTS_FAILURE: 'attempts_failure',
66
- ATTEMPTS_FRICTION: 'attempts_friction',
67
- JOURNEY_DOWNGRADE: 'journey_downgrade',
68
- INSUFFICIENT_DATA: 'insufficient_data',
69
- OBSERVED: 'observed_success',
70
- POLICY_HARD_FAIL: 'policy_hard_failure',
71
- BASELINE_REGRESSION: 'baseline_regression',
72
- ERROR: 'error_handler'
73
- };
74
-
75
- /**
76
- * PRIMARY DECISION AUTHORITY FUNCTION
77
- *
78
- * Accepts all signals and produces a single, deterministic final verdict.
79
- *
80
- * @param {Object} signals - All verdict input signals
81
- * @param {Array} signals.flows - Flow execution results
82
- * @param {Array} signals.attempts - Attempt execution results
83
- * @param {Object} signals.rulesEngineOutput - Rules engine result (if successful)
84
- * @param {string} signals.journeyVerdict - Journey verdict (if human journey executed)
85
- * @param {Object} signals.policyEval - Policy evaluation result
86
- * @param {Object} signals.baseline - Baseline comparison result
87
- * @param {Object} signals.marketImpact - Market impact assessment
88
- * @param {Object} signals.coverage - Coverage statistics
89
- * @param {Object} signals.siteIntelligence - Site intelligence data
90
- * @param {Object} options - Configuration options
91
- * @param {boolean} options.ciMode - CI mode (affects logging)
92
- *
93
- * @returns {Object} - Final decision object
94
- * - finalVerdict: string (READY|FRICTION|DO_NOT_LAUNCH|INSUFFICIENT_DATA|ERROR)
95
- * - verdictSource: string (which component determined verdict)
96
- * - verdictHistory: Array of {phase, source, suggestedVerdict, reasonCode, timestamp}
97
- * - exitCode: number (0|1|2, derived deterministically from finalVerdict)
98
- * - reasons: Array of {code, message}
99
- * - confidence: number (0-1, how confident is the verdict)
100
- */
101
- function computeDecisionAuthority(signals, options = {}) {
102
- const runId = options.runId; // Per-run identifier
103
- const timestamp = Date.now();
104
-
105
- // RUNTIME GUARD: Enforce single call per run
106
- validateSingleCall(runId);
107
-
108
- // Initialize tracking
109
- const verdictHistory = [];
110
- const reasons = [];
111
- let currentVerdict = null;
112
- let verdictSource = null;
113
- let finalConfidence = 1.0;
114
-
115
- // Extract signals with safe defaults
116
- const flows = signals.flows || [];
117
- const attempts = signals.attempts || [];
118
- const rulesEngineOutput = signals.rulesEngineOutput || null;
119
- const journeyVerdict = signals.journeyVerdict || null;
120
- const policyEval = signals.policyEval || null;
121
- const baseline = signals.baseline || {};
122
- const audit = signals.audit || {};
123
- const humanPath = signals.humanPath || null; // Stage 3
124
- const networkSafety = signals.networkSafety || {};
125
- const secretFindings = signals.secretFindings || [];
126
-
127
- // ========================================================================
128
- // COVERAGE & SELECTOR CONFIDENCE PRE-CHECK
129
- // ========================================================================
130
-
131
- const coverageSummary = computeCoverageSummary(attempts, flows, audit);
132
- const selectorConfidenceSummary = computeSelectorConfidence(attempts);
133
-
134
- // Store for artifact inclusion
135
- const coverageInfo = {
136
- ...coverageSummary,
137
- selectorConfidence: selectorConfidenceSummary
138
- };
139
-
140
- // ========================================================================
141
- // PHASE 1: RULES ENGINE AUTHORITY (Highest Priority)
142
- // ========================================================================
143
-
144
- if (rulesEngineOutput) {
145
- // Rules engine succeeded and produced a verdict
146
- const rulesVerdict = toCanonicalVerdict(rulesEngineOutput.finalVerdict);
147
-
148
- verdictHistory.push({
149
- phase: 1,
150
- source: VERDICT_SOURCE.RULES_ENGINE,
151
- suggestedVerdict: rulesVerdict,
152
- reasonCode: 'RULES_ENGINE_TRIGGERED',
153
- triggeredRuleIds: rulesEngineOutput.triggeredRuleIds || [],
154
- timestamp
155
- });
156
-
157
- // Add final normalization entry to ensure >= 2 history entries
158
- const timestamp2 = new Date().toISOString();
159
- verdictHistory.push({
160
- phase: 'final',
161
- source: 'normalization',
162
- suggestedVerdict: rulesVerdict,
163
- reasonCode: 'VERDICT_NORMALIZED',
164
- timestamp: timestamp2
165
- });
166
-
167
- currentVerdict = rulesVerdict;
168
- verdictSource = VERDICT_SOURCE.RULES_ENGINE;
169
-
170
- // Add rules reasons to main reasons array
171
- if (rulesEngineOutput.reasons) {
172
- reasons.push(...rulesEngineOutput.reasons);
173
- }
174
-
175
- // ====================================================================
176
- // COVERAGE ENFORCEMENT: READY requires sufficient coverage
177
- // ====================================================================
178
- if (rulesVerdict === 'READY' && coverageSummary.coverageStatus === 'INSUFFICIENT') {
179
- // READY cannot proceed without sufficient coverage
180
- reasons.push({
181
- code: 'COVERAGE_INSUFFICIENT',
182
- message: `Coverage ${(coverageSummary.coverageRatio * 100).toFixed(1)}% below 70% threshold`,
183
- severity: 'blocker'
184
- });
185
-
186
- // Downgrade to FRICTION
187
- verdictHistory.push({
188
- phase: 'enforcement',
189
- source: 'coverage_check',
190
- suggestedVerdict: 'FRICTION',
191
- reasonCode: 'COVERAGE_INSUFFICIENT',
192
- timestamp: new Date().toISOString()
193
- });
194
-
195
- currentVerdict = 'FRICTION';
196
- verdictSource = 'coverage_downgrade';
197
- }
198
-
199
- // SELECTOR CONFIDENCE ENFORCEMENT: LOW confidence on critical path downgrades verdict
200
- if (rulesVerdict === 'READY' &&
201
- selectorConfidenceSummary.selectorConfidenceMin === SELECTOR_CONFIDENCE.LOW) {
202
- reasons.push({
203
- code: 'LOW_SELECTOR_CONFIDENCE',
204
- message: 'Critical interaction steps used LOW-confidence selectors (classes, nth-child)',
205
- severity: 'warning'
206
- });
207
-
208
- verdictHistory.push({
209
- phase: 'enforcement',
210
- source: 'selector_confidence_check',
211
- suggestedVerdict: 'FRICTION',
212
- reasonCode: 'LOW_SELECTOR_CONFIDENCE',
213
- timestamp: new Date().toISOString()
214
- });
215
-
216
- currentVerdict = currentVerdict === 'FRICTION' ? 'FRICTION' : 'FRICTION';
217
- verdictSource = 'selector_downgrade';
218
- }
219
-
220
- // Rules engine verdict takes absolute precedence - return immediately
221
- return buildFinalDecision({
222
- finalVerdict: currentVerdict || rulesVerdict,
223
- verdictSource: verdictSource || VERDICT_SOURCE.RULES_ENGINE,
224
- verdictHistory,
225
- reasons,
226
- confidence: rulesEngineOutput.confidence || 0.95,
227
- exitCode: mapExitCodeFromCanonical(currentVerdict || rulesVerdict),
228
- coverageInfo,
229
- humanPath,
230
- networkSafety,
231
- secretFindings
232
- });
233
- }
234
-
235
- // ========================================================================
236
- // PHASE 2: LEGACY VERDICT COMPUTATION (Flows → Attempts → Journey)
237
- // ========================================================================
238
-
239
- // Classify flows
240
- const failedFlows = flows.filter(f =>
241
- (f.outcome === 'FAILURE' || f.success === false) &&
242
- f.outcome !== 'NOT_APPLICABLE'
243
- );
244
- const frictionFlows = flows.filter(f =>
245
- f.outcome === 'FRICTION' && f.outcome !== 'NOT_APPLICABLE'
246
- );
247
- const notApplicableFlows = flows.filter(f => f.outcome === 'NOT_APPLICABLE');
248
-
249
- // Classify attempts
250
- const executedAttempts = attempts.filter(a => a.executed);
251
- const failedAttempts = executedAttempts.filter(a =>
252
- a.outcome === 'FAILURE' && a.outcome !== 'NOT_APPLICABLE'
253
- );
254
- const frictionAttempts = executedAttempts.filter(a =>
255
- a.outcome === 'FRICTION' && a.outcome !== 'NOT_APPLICABLE'
256
- );
257
- const notApplicableAttempts = attempts.filter(a => a.outcome === 'NOT_APPLICABLE');
258
- const successfulAttempts = executedAttempts.filter(a => a.outcome === 'SUCCESS');
259
-
260
- // Count applicable signals
261
- const applicableAttempts = attempts.filter(a => a.outcome !== 'NOT_APPLICABLE');
262
- const applicableFlows = flows.filter(f => f.outcome !== 'NOT_APPLICABLE');
263
-
264
- // STEP 2a: Check for CRITICAL FAILURES (Flows)
265
- if (failedFlows.length > 0) {
266
- verdictHistory.push({
267
- phase: 2,
268
- step: 'a',
269
- source: VERDICT_SOURCE.FLOWS_FAILURE,
270
- suggestedVerdict: 'DO_NOT_LAUNCH',
271
- reasonCode: 'FLOWS_HAVE_FAILURES',
272
- count: failedFlows.length,
273
- timestamp
274
- });
275
-
276
- currentVerdict = 'DO_NOT_LAUNCH';
277
- verdictSource = VERDICT_SOURCE.FLOWS_FAILURE;
278
- reasons.push({
279
- code: 'FLOW_FAILURES',
280
- message: `Critical flow failures detected (${failedFlows.length}): ${failedFlows.map(f => f.flowId || f.flowName).join(', ')}`
281
- });
282
-
283
- // Don't check attempts if flows already failed
284
- return buildFinalDecision({
285
- finalVerdict: currentVerdict,
286
- verdictSource,
287
- verdictHistory,
288
- reasons: enrichReasons(reasons, {failedFlows, failedAttempts, notApplicableFlows, notApplicableAttempts}),
289
- confidence: 0.99,
290
- exitCode: mapExitCodeFromCanonical(currentVerdict),
291
- coverageInfo,
292
- humanPath,
293
- networkSafety,
294
- secretFindings
295
- });
296
- }
297
-
298
- // STEP 2b: Check for FLOW FRICTION
299
- if (frictionFlows.length > 0 && failedFlows.length === 0) {
300
- verdictHistory.push({
301
- phase: 2,
302
- step: 'b',
303
- source: VERDICT_SOURCE.FLOWS_FRICTION,
304
- suggestedVerdict: 'FRICTION',
305
- reasonCode: 'FLOWS_HAVE_FRICTION',
306
- count: frictionFlows.length,
307
- timestamp
308
- });
309
-
310
- currentVerdict = 'FRICTION';
311
- verdictSource = VERDICT_SOURCE.FLOWS_FRICTION;
312
- reasons.push({
313
- code: 'FLOW_FRICTION',
314
- message: `Flow friction detected (${frictionFlows.length}): ${frictionFlows.map(f => f.flowId || f.flowName).join(', ')}`
315
- });
316
- }
317
-
318
- // STEP 2c: Check for ATTEMPT FAILURES (only if current verdict is not already FRICTION)
319
- if (currentVerdict !== 'FRICTION' && failedAttempts.length > 0) {
320
- verdictHistory.push({
321
- phase: 2,
322
- step: 'c',
323
- source: VERDICT_SOURCE.ATTEMPTS_FAILURE,
324
- suggestedVerdict: 'DO_NOT_LAUNCH',
325
- reasonCode: 'ATTEMPTS_HAVE_FAILURES',
326
- count: failedAttempts.length,
327
- timestamp
328
- });
329
-
330
- currentVerdict = 'DO_NOT_LAUNCH';
331
- verdictSource = VERDICT_SOURCE.ATTEMPTS_FAILURE;
332
- reasons.push({
333
- code: 'ATTEMPT_FAILURES',
334
- message: `Critical attempt failures detected (${failedAttempts.length}): ${failedAttempts.map(a => a.attemptId).join(', ')}`
335
- });
336
-
337
- return buildFinalDecision({
338
- finalVerdict: currentVerdict,
339
- verdictSource,
340
- verdictHistory,
341
- reasons: enrichReasons(reasons, {failedAttempts, notApplicableAttempts}),
342
- confidence: 0.99,
343
- exitCode: mapExitCodeFromCanonical(currentVerdict),
344
- coverageInfo,
345
- humanPath,
346
- networkSafety,
347
- secretFindings
348
- });
349
- }
350
-
351
- // STEP 2d: Check for ATTEMPT FRICTION (only if not already FRICTION from flows)
352
- if (currentVerdict !== 'FRICTION' && frictionAttempts.length > 0 && failedAttempts.length === 0) {
353
- verdictHistory.push({
354
- phase: 2,
355
- step: 'd',
356
- source: VERDICT_SOURCE.ATTEMPTS_FRICTION,
357
- suggestedVerdict: 'FRICTION',
358
- reasonCode: 'ATTEMPTS_HAVE_FRICTION',
359
- count: frictionAttempts.length,
360
- timestamp
361
- });
362
-
363
- currentVerdict = 'FRICTION';
364
- verdictSource = VERDICT_SOURCE.ATTEMPTS_FRICTION;
365
- reasons.push({
366
- code: 'ATTEMPT_FRICTION',
367
- message: `Attempt friction detected (${frictionAttempts.length}): ${frictionAttempts.map(a => a.attemptId).join(', ')}`
368
- });
369
- }
370
-
371
- // STEP 2e: Check for POLICY HARD FAILURE
372
- if (policyEval && !policyEval.passed && policyEval.exitCode === 1) {
373
- verdictHistory.push({
374
- phase: 2,
375
- step: 'e',
376
- source: VERDICT_SOURCE.POLICY_HARD_FAIL,
377
- suggestedVerdict: 'DO_NOT_LAUNCH',
378
- reasonCode: 'POLICY_HARD_FAILURE',
379
- timestamp
380
- });
381
-
382
- currentVerdict = 'DO_NOT_LAUNCH';
383
- verdictSource = VERDICT_SOURCE.POLICY_HARD_FAIL;
384
- reasons.push({
385
- code: 'POLICY_HARD_FAILURE',
386
- message: policyEval.summary || 'Policy hard failure detected'
387
- });
388
-
389
- return buildFinalDecision({
390
- finalVerdict: currentVerdict,
391
- verdictSource,
392
- verdictHistory,
393
- reasons: enrichReasons(reasons, {policyEval}),
394
- confidence: 0.99,
395
- exitCode: mapExitCodeFromCanonical(currentVerdict),
396
- coverageInfo,
397
- humanPath,
398
- networkSafety,
399
- secretFindings
400
- });
401
- }
402
-
403
- // ========================================================================
404
- // PHASE 3: DETERMINE DEFAULT VERDICT (No failures/friction found)
405
- // ========================================================================
406
-
407
- // Check if we have any applicable signals
408
- if (applicableAttempts.length === 0 && applicableFlows.length === 0) {
409
- verdictHistory.push({
410
- phase: 3,
411
- source: VERDICT_SOURCE.INSUFFICIENT_DATA,
412
- suggestedVerdict: 'INSUFFICIENT_DATA',
413
- reasonCode: 'NO_APPLICABLE_SIGNALS',
414
- timestamp
415
- });
416
-
417
- currentVerdict = 'INSUFFICIENT_DATA';
418
- verdictSource = VERDICT_SOURCE.INSUFFICIENT_DATA;
419
- reasons.push({
420
- code: 'NO_APPLICABLE_SIGNALS',
421
- message: 'No applicable flows or attempts found to execute'
422
- });
423
- finalConfidence = 0.3;
424
- } else {
425
- // We have signals and no critical failures/friction
426
- verdictHistory.push({
427
- phase: 3,
428
- source: VERDICT_SOURCE.OBSERVED,
429
- suggestedVerdict: 'OBSERVED',
430
- reasonCode: 'NO_CRITICAL_FAILURES',
431
- timestamp
432
- });
433
-
434
- currentVerdict = 'READY';
435
- verdictSource = VERDICT_SOURCE.OBSERVED;
436
- reasons.push({
437
- code: 'OBSERVED_SUCCESS',
438
- message: `Executed ${executedAttempts.length} attempt(s): ${successfulAttempts.length} successful, ${failedAttempts.length} failed, ${frictionAttempts.length} friction`
439
- });
440
- finalConfidence = 0.95;
441
-
442
- // *** COVERAGE ENFORCEMENT IN PHASE 3 ***
443
- // Even if no failures/friction, coverage must still be sufficient for READY
444
- if (coverageInfo.coverageStatus === 'INSUFFICIENT') {
445
- verdictHistory.push({
446
- phase: 3,
447
- source: 'coverage_enforcement',
448
- suggestedVerdict: 'FRICTION',
449
- reasonCode: 'COVERAGE_INSUFFICIENT',
450
- details: {
451
- coverage: coverageInfo.coverageRatio,
452
- threshold: COVERAGE_THRESHOLD,
453
- message: `Coverage is ${(coverageInfo.coverageRatio * 100).toFixed(1)}%, below ${(COVERAGE_THRESHOLD * 100).toFixed(0)}% threshold`
454
- },
455
- timestamp
456
- });
457
-
458
- currentVerdict = 'FRICTION';
459
- verdictSource = 'coverage_downgrade';
460
- finalConfidence = 0.75;
461
- }
462
-
463
- // *** SELECTOR CONFIDENCE ENFORCEMENT IN PHASE 3 ***
464
- // Even if coverage OK, LOW selector confidence blocks READY
465
- if (currentVerdict !== 'FRICTION' &&
466
- coverageInfo.selectorConfidence &&
467
- coverageInfo.selectorConfidence.selectorConfidenceMin === SELECTOR_CONFIDENCE.LOW) {
468
- verdictHistory.push({
469
- phase: 3,
470
- source: 'selector_confidence_enforcement',
471
- suggestedVerdict: 'FRICTION',
472
- reasonCode: 'LOW_SELECTOR_CONFIDENCE',
473
- details: {
474
- minConfidence: coverageInfo.selectorConfidence.selectorConfidenceMin,
475
- message: 'Critical interaction steps used LOW-confidence selectors (classes, nth-child, text)'
476
- },
477
- timestamp
478
- });
479
-
480
- currentVerdict = 'FRICTION';
481
- verdictSource = 'selector_downgrade';
482
- finalConfidence = 0.75;
483
- }
484
- }
485
-
486
- // ========================================================================
487
- // PHASE 4: JOURNEY VERDICT MERGE (Can downgrade but not upgrade)
488
- // ========================================================================
489
-
490
- if (journeyVerdict) {
491
- const canonicalJourney = toCanonicalVerdict(journeyVerdict);
492
- const canonicalCurrent = toCanonicalVerdict(currentVerdict);
493
-
494
- const rank = { READY: 0, FRICTION: 1, DO_NOT_LAUNCH: 2 };
495
-
496
- // Journey can only downgrade (move to higher rank number)
497
- if (rank[canonicalJourney] > rank[canonicalCurrent]) {
498
- verdictHistory.push({
499
- phase: 4,
500
- source: VERDICT_SOURCE.JOURNEY_DOWNGRADE,
501
- previousVerdict: currentVerdict,
502
- suggestedVerdict: canonicalJourney,
503
- reasonCode: 'JOURNEY_DOWNGRADE',
504
- timestamp
505
- });
506
-
507
- currentVerdict = canonicalJourney;
508
- verdictSource = VERDICT_SOURCE.JOURNEY_DOWNGRADE;
509
- reasons.push({
510
- code: 'JOURNEY_DOWNGRADE',
511
- message: `Journey verdict downgraded from ${canonicalCurrent} to ${canonicalJourney}`
512
- });
513
- finalConfidence = Math.min(finalConfidence, 0.85);
514
- }
515
- }
516
-
517
- // ========================================================================
518
- // PHASE 4a: SECURITY & NETWORK SAFETY ENFORCEMENT
519
- // ========================================================================
520
-
521
- const httpWarnings = Array.isArray(networkSafety?.httpWarnings) ? networkSafety.httpWarnings : [];
522
- const excessiveThirdParty = Boolean(networkSafety?.excessiveThirdParty);
523
- const thirdPartyCount = networkSafety?.thirdPartyCount || 0;
524
- const thirdPartyDomains = Array.isArray(networkSafety?.thirdPartyDomains) ? networkSafety.thirdPartyDomains : [];
525
-
526
- if (httpWarnings.length > 0) {
527
- reasons.push({
528
- code: 'INSECURE_TRANSPORT',
529
- message: `HTTP detected on ${httpWarnings.length} request(s): ${httpWarnings.slice(0, 3).join(', ')}`
530
- });
531
- verdictHistory.push({
532
- phase: 4,
533
- source: 'network_safety',
534
- suggestedVerdict: currentVerdict === 'DO_NOT_LAUNCH' ? currentVerdict : 'FRICTION',
535
- reasonCode: 'HTTP_WARNING',
536
- timestamp
537
- });
538
- if (currentVerdict === 'READY') {
539
- currentVerdict = 'FRICTION';
540
- verdictSource = 'network_safety';
541
- finalConfidence = Math.min(finalConfidence, 0.7);
542
- }
543
- }
544
-
545
- if (excessiveThirdParty) {
546
- reasons.push({
547
- code: 'EXCESSIVE_THIRD_PARTY',
548
- message: `Excessive third-party requests detected (${thirdPartyCount}). Domains: ${thirdPartyDomains.slice(0, 5).join(', ')}`
549
- });
550
- verdictHistory.push({
551
- phase: 4,
552
- source: 'network_safety',
553
- suggestedVerdict: currentVerdict === 'DO_NOT_LAUNCH' ? currentVerdict : 'FRICTION',
554
- reasonCode: 'EXCESSIVE_THIRD_PARTY',
555
- timestamp
556
- });
557
- if (currentVerdict === 'READY') {
558
- currentVerdict = 'FRICTION';
559
- verdictSource = 'network_safety';
560
- finalConfidence = Math.min(finalConfidence, 0.7);
561
- }
562
- } else if (thirdPartyCount > 0) {
563
- reasons.push({
564
- code: 'THIRD_PARTY_REQUESTS',
565
- message: `Observed ${thirdPartyCount} third-party request(s)`
566
- });
567
- }
568
-
569
- if (Array.isArray(secretFindings) && secretFindings.length > 0) {
570
- reasons.push({
571
- code: 'MISSING_SECRETS',
572
- message: `Required secrets missing: ${secretFindings.map(s => s.key).join(', ')}`
573
- });
574
- verdictHistory.push({
575
- phase: 4,
576
- source: 'secret_hygiene',
577
- suggestedVerdict: currentVerdict === 'DO_NOT_LAUNCH' ? currentVerdict : 'FRICTION',
578
- reasonCode: 'MISSING_SECRETS',
579
- timestamp
580
- });
581
- if (currentVerdict === 'READY') {
582
- currentVerdict = 'FRICTION';
583
- verdictSource = 'secret_hygiene';
584
- finalConfidence = Math.min(finalConfidence, 0.7);
585
- }
586
- }
587
-
588
- // ========================================================================
589
- // PHASE 5: BASELINE REGRESSION CHECK (Informational only, not verdict-changing)
590
- // ========================================================================
591
-
592
- const diff = baseline.diffResult || baseline.diff;
593
- if (diff && diff.regressions && Object.keys(diff.regressions).length > 0) {
594
- verdictHistory.push({
595
- phase: 5,
596
- source: 'baseline',
597
- regressionCount: Object.keys(diff.regressions).length,
598
- reasonCode: 'BASELINE_REGRESSIONS_DETECTED',
599
- timestamp
600
- });
601
-
602
- reasons.push({
603
- code: 'BASELINE_REGRESSIONS',
604
- message: `Baseline regressions detected: ${Object.keys(diff.regressions).join(', ')}`
605
- });
606
- }
607
-
608
- // ========================================================================
609
- // FINALIZE
610
- // ========================================================================
611
-
612
- // Add NOT_APPLICABLE as informational (not verdict-affecting)
613
- if (notApplicableFlows.length > 0) {
614
- reasons.push({
615
- code: 'NOT_APPLICABLE_FLOWS',
616
- message: `${notApplicableFlows.length} flow(s) not applicable to this site`
617
- });
618
- }
619
-
620
- if (notApplicableAttempts.length > 0) {
621
- reasons.push({
622
- code: 'NOT_APPLICABLE_ATTEMPTS',
623
- message: `${notApplicableAttempts.length} attempt(s) not applicable to this site`
624
- });
625
- }
626
-
627
- // Policy warnings (exitCode 2) are informational, not verdict-changing
628
- if (policyEval && !policyEval.passed && policyEval.exitCode === 2) {
629
- reasons.push({
630
- code: 'POLICY_WARNING',
631
- message: policyEval.summary || 'Policy warnings detected'
632
- });
633
- }
634
-
635
- return buildFinalDecision({
636
- finalVerdict: currentVerdict,
637
- verdictSource,
638
- verdictHistory,
639
- reasons,
640
- confidence: finalConfidence,
641
- exitCode: mapExitCodeFromCanonical(currentVerdict),
642
- coverageInfo,
643
- humanPath,
644
- networkSafety,
645
- secretFindings
646
- });
647
- }
648
-
649
- /**
650
- * Build the final decision object with all required fields
651
- */
652
- function buildFinalDecision({
653
- finalVerdict,
654
- verdictSource,
655
- verdictHistory,
656
- reasons,
657
- confidence,
658
- exitCode,
659
- coverageInfo,
660
- humanPath,
661
- networkSafety,
662
- secretFindings
663
- }) {
664
- // Ensure deterministic reason ordering
665
- const sortedReasons = (reasons || [])
666
- .filter(r => r && r.code && r.message)
667
- .sort((a, b) => a.code.localeCompare(b.code) || a.message.localeCompare(b.message));
668
-
669
- // Normalize verdict first
670
- const normalizedVerdict = normalizeCanonicalVerdict(finalVerdict) || 'UNKNOWN';
671
-
672
- // Calculate exit code from normalized verdict if not provided
673
- const finalExitCode = exitCode !== undefined ? exitCode : mapExitCodeFromCanonical(normalizedVerdict);
674
-
675
- return {
676
- finalVerdict: normalizedVerdict,
677
- verdictSource,
678
- verdictHistory,
679
- reasons: sortedReasons,
680
- confidence: Math.max(0, Math.min(1, confidence || 0.5)),
681
- exitCode: finalExitCode,
682
-
683
- // For backwards compatibility
684
- finalExitCode: finalExitCode,
685
-
686
- // Coverage information for decision artifact
687
- coverageInfo: coverageInfo || {},
688
-
689
- // Human navigation path (Stage 3)
690
- humanPath: humanPath || null,
691
-
692
- // Security and hygiene signals
693
- networkSafety: networkSafety || {},
694
- secretFindings: secretFindings || []
695
- };
696
- }
697
-
698
- /**
699
- * Enrich reasons array with additional context
700
- */
701
- function enrichReasons(reasons, context) {
702
- const { notApplicableFlows, notApplicableAttempts } = context;
703
-
704
- if (notApplicableFlows && notApplicableFlows.length > 0) {
705
- reasons.push({
706
- code: 'NOT_APPLICABLE_FLOWS',
707
- message: `${notApplicableFlows.length} flow(s) not applicable to this site`
708
- });
709
- }
710
-
711
- if (notApplicableAttempts && notApplicableAttempts.length > 0) {
712
- reasons.push({
713
- code: 'NOT_APPLICABLE_ATTEMPTS',
714
- message: `${notApplicableAttempts.length} attempt(s) not applicable to this site`
715
- });
716
- }
717
-
718
- return reasons;
719
- }
720
-
721
- module.exports = {
722
- computeDecisionAuthority,
723
- VERDICT_SOURCE,
724
- resetCallTracker // For testing: reset the call tracker
725
- };
1
+ /**
2
+ * UNIFIED DECISION AUTHORITY
3
+ *
4
+ * The SINGLE source of truth for final verdict determination.
5
+ * All verdict signals (rules, flows, attempts, journey, policy, baseline)
6
+ * flow through this function only.
7
+ *
8
+ * This module is PURE: no IO, no side effects, no hidden state.
9
+ * All dependencies are passed in explicitly.
10
+ *
11
+ * @typedef {import('./truth/decision.contract.js').FinalDecision} FinalDecision
12
+ * @typedef {import('./truth/decision.contract.js').FinalVerdict} FinalVerdict
13
+ * @typedef {import('./truth/decision.contract.js').VerdictSource} VerdictSource
14
+ * @typedef {import('./truth/decision.contract.js').DecisionReason} DecisionReason
15
+ * @typedef {import('./truth/decision.contract.js').VerdictHistoryEntry} VerdictHistoryEntry
16
+ */
17
+
18
+ const {
19
+ toCanonicalVerdict,
20
+ mapExitCodeFromCanonical,
21
+ normalizeCanonicalVerdict
22
+ } = require('./verdicts');
23
+
24
+ const {
25
+ computeCoverageSummary,
26
+ computeSelectorConfidence,
27
+ SELECTOR_CONFIDENCE,
28
+ COVERAGE_THRESHOLD
29
+ } = require('./coverage-model');
30
+
31
+ /**
32
+ * RUNTIME GUARD: One-call-per-run enforcement
33
+ * Prevents accidental double calls within the same process execution.
34
+ * Per-run state is maintained via runId passed in options.
35
+ */
36
+ const callTracker = new Map(); // runId -> { called: boolean, timestamp }
37
+
38
+ function validateSingleCall(runId) {
39
+ const isProduction = process.env.NODE_ENV === 'production';
40
+
41
+ // Track by runId. If no runId provided, use a default key for tests.
42
+ const trackKey = runId || '__default_run__';
43
+
44
+ if (callTracker.has(trackKey)) {
45
+ const entry = callTracker.get(trackKey);
46
+ const message = `computeDecisionAuthority called twice in same run (${trackKey}). First call: ${entry.timestamp}`;
47
+
48
+ if (!isProduction) {
49
+ throw new Error(message);
50
+ }
51
+ // In production, log warning but allow (graceful degradation)
52
+ console.warn(`⚠️ ${message}`);
53
+ }
54
+
55
+ callTracker.set(trackKey, { called: true, timestamp: new Date().toISOString() });
56
+ }
57
+
58
+ function resetCallTracker(runId) {
59
+ const trackKey = runId || '__default_run__';
60
+ callTracker.delete(trackKey);
61
+ }
62
+
63
+ /**
64
+ * VERDICT SOURCE CONSTANTS
65
+ */
66
+ const VERDICT_SOURCE = {
67
+ RULES_ENGINE: 'rules_engine',
68
+ RULES_ENGINE_FALLBACK: 'rules_engine_fallback',
69
+ FLOWS_FAILURE: 'flows_failure',
70
+ FLOWS_FRICTION: 'flows_friction',
71
+ ATTEMPTS_FAILURE: 'attempts_failure',
72
+ ATTEMPTS_FRICTION: 'attempts_friction',
73
+ JOURNEY_DOWNGRADE: 'journey_downgrade',
74
+ INSUFFICIENT_DATA: 'insufficient_data',
75
+ OBSERVED: 'observed_success',
76
+ POLICY_HARD_FAIL: 'policy_hard_failure',
77
+ BASELINE_REGRESSION: 'baseline_regression',
78
+ ERROR: 'error_handler'
79
+ };
80
+
81
+ /**
82
+ * PRIMARY DECISION AUTHORITY FUNCTION
83
+ *
84
+ * Accepts all signals and produces a single, deterministic final verdict.
85
+ *
86
+ * @param {Object} signals - All verdict input signals
87
+ * @param {Array} signals.flows - Flow execution results
88
+ * @param {Array} signals.attempts - Attempt execution results
89
+ * @param {Object} signals.rulesEngineOutput - Rules engine result (if successful)
90
+ * @param {string} signals.journeyVerdict - Journey verdict (if human journey executed)
91
+ * @param {Object} signals.policyEval - Policy evaluation result
92
+ * @param {Object} signals.baseline - Baseline comparison result
93
+ * @param {Object} signals.marketImpact - Market impact assessment
94
+ * @param {Object} signals.coverage - Coverage statistics
95
+ * @param {Object} signals.siteIntelligence - Site intelligence data
96
+ * @param {Object} options - Configuration options
97
+ * @param {boolean} options.ciMode - CI mode (affects logging)
98
+ *
99
+ * @returns {Object} - Final decision object
100
+ * - finalVerdict: string (READY|FRICTION|DO_NOT_LAUNCH|INSUFFICIENT_DATA|ERROR)
101
+ * - verdictSource: string (which component determined verdict)
102
+ * - verdictHistory: Array of {phase, source, suggestedVerdict, reasonCode, timestamp}
103
+ * - exitCode: number (0|1|2, derived deterministically from finalVerdict)
104
+ * - reasons: Array of {code, message}
105
+ * - confidence: number (0-1, how confident is the verdict)
106
+ */
107
+ function computeDecisionAuthority(signals, options = {}) {
108
+ const runId = options.runId; // Per-run identifier
109
+ const timestamp = Date.now();
110
+
111
+ // RUNTIME GUARD: Enforce single call per run
112
+ validateSingleCall(runId);
113
+
114
+ // Initialize tracking
115
+ /** @type {VerdictHistoryEntry[]} */
116
+ const verdictHistory = [];
117
+ /** @type {DecisionReason[]} */
118
+ const reasons = [];
119
+ /** @type {FinalVerdict|null} */
120
+ let currentVerdict = null;
121
+ /** @type {VerdictSource|null} */
122
+ let verdictSource = null;
123
+ let finalConfidence = 1.0;
124
+
125
+ // Extract signals with safe defaults
126
+ const flows = signals.flows || [];
127
+ const attempts = signals.attempts || [];
128
+ const rulesEngineOutput = signals.rulesEngineOutput || null;
129
+ const journeyVerdict = signals.journeyVerdict || null;
130
+ const policyEval = signals.policyEval || null;
131
+ const baseline = signals.baseline || {};
132
+ const audit = signals.audit || {};
133
+ const humanPath = signals.humanPath || null; // Stage 3
134
+ const networkSafety = signals.networkSafety || {};
135
+ const secretFindings = signals.secretFindings || [];
136
+
137
+ // ========================================================================
138
+ // COVERAGE & SELECTOR CONFIDENCE PRE-CHECK
139
+ // ========================================================================
140
+
141
+ const coverageSummary = computeCoverageSummary(attempts, flows, audit);
142
+ const selectorConfidenceSummary = computeSelectorConfidence(attempts);
143
+
144
+ // Store for artifact inclusion
145
+ const coverageInfo = {
146
+ ...coverageSummary,
147
+ selectorConfidence: selectorConfidenceSummary
148
+ };
149
+
150
+ // ========================================================================
151
+ // PHASE 1: RULES ENGINE AUTHORITY (Highest Priority)
152
+ // ========================================================================
153
+
154
+ if (rulesEngineOutput) {
155
+ // Rules engine succeeded and produced a verdict
156
+ const rulesVerdict = toCanonicalVerdict(rulesEngineOutput.finalVerdict);
157
+
158
+ verdictHistory.push(/** @type {VerdictHistoryEntry} */ ({
159
+ phase: 1,
160
+ source: VERDICT_SOURCE.RULES_ENGINE,
161
+ suggestedVerdict: rulesVerdict,
162
+ reasonCode: 'RULES_ENGINE_TRIGGERED',
163
+ triggeredRuleIds: rulesEngineOutput.triggeredRuleIds || [],
164
+ timestamp
165
+ }));
166
+
167
+ // Add final normalization entry to ensure >= 2 history entries
168
+ const timestamp2 = new Date().toISOString();
169
+ verdictHistory.push(/** @type {VerdictHistoryEntry} */ ({
170
+ phase: 'final',
171
+ source: 'normalization',
172
+ suggestedVerdict: rulesVerdict,
173
+ reasonCode: 'VERDICT_NORMALIZED',
174
+ timestamp: timestamp2
175
+ }));
176
+
177
+ currentVerdict = /** @type {FinalVerdict} */ (rulesVerdict);
178
+ verdictSource = /** @type {VerdictSource} */ (VERDICT_SOURCE.RULES_ENGINE);
179
+
180
+ // Add rules reasons to main reasons array
181
+ if (rulesEngineOutput.reasons) {
182
+ reasons.push(...rulesEngineOutput.reasons);
183
+ }
184
+
185
+ // ====================================================================
186
+ // COVERAGE ENFORCEMENT: READY requires sufficient coverage
187
+ // ====================================================================
188
+ if (rulesVerdict === 'READY' && coverageSummary.coverageStatus === 'INSUFFICIENT') {
189
+ // READY cannot proceed without sufficient coverage
190
+ reasons.push({
191
+ code: 'COVERAGE_INSUFFICIENT',
192
+ message: `Coverage ${(coverageSummary.coverageRatio * 100).toFixed(1)}% below 70% threshold`,
193
+ severity: 'blocker'
194
+ });
195
+
196
+ // Downgrade to FRICTION
197
+ verdictHistory.push(/** @type {VerdictHistoryEntry} */ ({
198
+ phase: 'enforcement',
199
+ source: 'coverage_check',
200
+ suggestedVerdict: 'FRICTION',
201
+ reasonCode: 'COVERAGE_INSUFFICIENT',
202
+ timestamp: new Date().toISOString()
203
+ }));
204
+
205
+ currentVerdict = /** @type {FinalVerdict} */ ('FRICTION');
206
+ verdictSource = 'coverage_downgrade';
207
+ }
208
+
209
+ // SELECTOR CONFIDENCE ENFORCEMENT: LOW confidence on critical path downgrades verdict
210
+ if (rulesVerdict === 'READY' &&
211
+ selectorConfidenceSummary.selectorConfidenceMin === SELECTOR_CONFIDENCE.LOW) {
212
+ reasons.push({
213
+ code: 'LOW_SELECTOR_CONFIDENCE',
214
+ message: 'Critical interaction steps used LOW-confidence selectors (classes, nth-child)',
215
+ severity: 'warning'
216
+ });
217
+
218
+ verdictHistory.push(/** @type {VerdictHistoryEntry} */ ({
219
+ phase: 'enforcement',
220
+ source: 'selector_confidence_check',
221
+ suggestedVerdict: 'FRICTION',
222
+ reasonCode: 'LOW_SELECTOR_CONFIDENCE',
223
+ timestamp: new Date().toISOString()
224
+ }));
225
+
226
+ currentVerdict = /** @type {FinalVerdict} */ ('FRICTION');
227
+ verdictSource = /** @type {VerdictSource} */ ('selector_downgrade');
228
+ }
229
+
230
+ // Rules engine verdict takes absolute precedence - return immediately
231
+ return buildFinalDecision({
232
+ finalVerdict: /** @type {FinalVerdict} */ (currentVerdict || rulesVerdict),
233
+ verdictSource: /** @type {VerdictSource} */ (verdictSource || VERDICT_SOURCE.RULES_ENGINE),
234
+ verdictHistory: /** @type {VerdictHistoryEntry[]} */ (verdictHistory),
235
+ reasons,
236
+ confidence: rulesEngineOutput.confidence || 0.95,
237
+ exitCode: mapExitCodeFromCanonical(currentVerdict || rulesVerdict),
238
+ coverageInfo,
239
+ humanPath,
240
+ networkSafety,
241
+ secretFindings
242
+ });
243
+ }
244
+
245
+ // ========================================================================
246
+ // PHASE 2: LEGACY VERDICT COMPUTATION (Flows → Attempts → Journey)
247
+ // ========================================================================
248
+
249
+ // Classify flows
250
+ const failedFlows = flows.filter(f =>
251
+ f.outcome === 'FAILURE' || f.success === false
252
+ );
253
+ const frictionFlows = flows.filter(f =>
254
+ f.outcome === 'FRICTION'
255
+ );
256
+ const notApplicableFlows = flows.filter(f => f.outcome === 'NOT_APPLICABLE');
257
+
258
+ // Classify attempts
259
+ const executedAttempts = attempts.filter(a => a.executed);
260
+ const failedAttempts = executedAttempts.filter(a =>
261
+ a.outcome === 'FAILURE'
262
+ );
263
+ const frictionAttempts = executedAttempts.filter(a =>
264
+ a.outcome === 'FRICTION'
265
+ );
266
+ const notApplicableAttempts = attempts.filter(a => a.outcome === 'NOT_APPLICABLE');
267
+ const successfulAttempts = executedAttempts.filter(a => a.outcome === 'SUCCESS');
268
+
269
+ // Count applicable signals
270
+ const applicableAttempts = attempts.filter(a => a.outcome !== 'NOT_APPLICABLE');
271
+ const applicableFlows = flows.filter(f => f.outcome !== 'NOT_APPLICABLE');
272
+
273
+ // STEP 2a: Check for CRITICAL FAILURES (Flows)
274
+ if (failedFlows.length > 0) {
275
+ verdictHistory.push(/** @type {VerdictHistoryEntry} */ ({
276
+ phase: 2,
277
+ step: 'a',
278
+ source: VERDICT_SOURCE.FLOWS_FAILURE,
279
+ suggestedVerdict: 'DO_NOT_LAUNCH',
280
+ reasonCode: 'FLOWS_HAVE_FAILURES',
281
+ count: failedFlows.length,
282
+ timestamp
283
+ }));
284
+
285
+ currentVerdict = /** @type {FinalVerdict} */ ('DO_NOT_LAUNCH');
286
+ verdictSource = /** @type {VerdictSource} */ (VERDICT_SOURCE.FLOWS_FAILURE);
287
+ reasons.push({
288
+ code: 'FLOW_FAILURES',
289
+ message: `Critical flow failures detected (${failedFlows.length}): ${failedFlows.map(f => f.flowId || f.flowName).join(', ')}`
290
+ });
291
+
292
+ // Don't check attempts if flows already failed
293
+ return buildFinalDecision({
294
+ finalVerdict: /** @type {FinalVerdict} */ (currentVerdict),
295
+ verdictSource: /** @type {VerdictSource} */ (verdictSource),
296
+ verdictHistory: /** @type {VerdictHistoryEntry[]} */ (verdictHistory),
297
+ reasons: enrichReasons(reasons, {failedFlows, failedAttempts, notApplicableFlows, notApplicableAttempts}),
298
+ confidence: 0.99,
299
+ exitCode: mapExitCodeFromCanonical(currentVerdict),
300
+ coverageInfo,
301
+ humanPath,
302
+ networkSafety,
303
+ secretFindings
304
+ });
305
+ }
306
+
307
+ // STEP 2b: Check for FLOW FRICTION
308
+ if (frictionFlows.length > 0 && failedFlows.length === 0) {
309
+ verdictHistory.push(/** @type {VerdictHistoryEntry} */ ({
310
+ phase: 2,
311
+ step: 'b',
312
+ source: VERDICT_SOURCE.FLOWS_FRICTION,
313
+ suggestedVerdict: 'FRICTION',
314
+ reasonCode: 'FLOWS_HAVE_FRICTION',
315
+ count: frictionFlows.length,
316
+ timestamp
317
+ }));
318
+
319
+ currentVerdict = 'FRICTION';
320
+ verdictSource = VERDICT_SOURCE.FLOWS_FRICTION;
321
+ reasons.push({
322
+ code: 'FLOW_FRICTION',
323
+ message: `Flow friction detected (${frictionFlows.length}): ${frictionFlows.map(f => f.flowId || f.flowName).join(', ')}`
324
+ });
325
+ }
326
+
327
+ // STEP 2c: Check for ATTEMPT FAILURES (only if current verdict is not already FRICTION)
328
+ if (currentVerdict !== 'FRICTION' && failedAttempts.length > 0) {
329
+ verdictHistory.push(/** @type {VerdictHistoryEntry} */ ({
330
+ phase: 2,
331
+ step: 'c',
332
+ source: VERDICT_SOURCE.ATTEMPTS_FAILURE,
333
+ suggestedVerdict: 'DO_NOT_LAUNCH',
334
+ reasonCode: 'ATTEMPTS_HAVE_FAILURES',
335
+ count: failedAttempts.length,
336
+ timestamp
337
+ }));
338
+
339
+ currentVerdict = 'DO_NOT_LAUNCH';
340
+ verdictSource = VERDICT_SOURCE.ATTEMPTS_FAILURE;
341
+ reasons.push({
342
+ code: 'ATTEMPT_FAILURES',
343
+ message: `Critical attempt failures detected (${failedAttempts.length}): ${failedAttempts.map(a => a.attemptId).join(', ')}`
344
+ });
345
+
346
+ return buildFinalDecision({
347
+ finalVerdict: /** @type {FinalVerdict} */ (currentVerdict),
348
+ verdictSource: /** @type {VerdictSource} */ (verdictSource),
349
+ verdictHistory: /** @type {VerdictHistoryEntry[]} */ (verdictHistory),
350
+ reasons: enrichReasons(reasons, {failedAttempts, notApplicableAttempts}),
351
+ confidence: 0.99,
352
+ exitCode: mapExitCodeFromCanonical(currentVerdict),
353
+ coverageInfo,
354
+ humanPath,
355
+ networkSafety,
356
+ secretFindings
357
+ });
358
+ }
359
+
360
+ // STEP 2d: Check for ATTEMPT FRICTION (only if not already FRICTION from flows)
361
+ if (currentVerdict !== 'FRICTION' && frictionAttempts.length > 0 && failedAttempts.length === 0) {
362
+ verdictHistory.push(/** @type {VerdictHistoryEntry} */ ({
363
+ phase: 2,
364
+ step: 'd',
365
+ source: VERDICT_SOURCE.ATTEMPTS_FRICTION,
366
+ suggestedVerdict: 'FRICTION',
367
+ reasonCode: 'ATTEMPTS_HAVE_FRICTION',
368
+ count: frictionAttempts.length,
369
+ timestamp
370
+ }));
371
+
372
+ currentVerdict = 'FRICTION';
373
+ verdictSource = VERDICT_SOURCE.ATTEMPTS_FRICTION;
374
+ reasons.push({
375
+ code: 'ATTEMPT_FRICTION',
376
+ message: `Attempt friction detected (${frictionAttempts.length}): ${frictionAttempts.map(a => a.attemptId).join(', ')}`
377
+ });
378
+ }
379
+
380
+ // STEP 2e: Check for POLICY HARD FAILURE
381
+ if (policyEval && !policyEval.passed && policyEval.exitCode === 1) {
382
+ verdictHistory.push(/** @type {VerdictHistoryEntry} */ ({
383
+ phase: 2,
384
+ step: 'e',
385
+ source: VERDICT_SOURCE.POLICY_HARD_FAIL,
386
+ suggestedVerdict: 'DO_NOT_LAUNCH',
387
+ reasonCode: 'POLICY_HARD_FAILURE',
388
+ timestamp
389
+ }));
390
+
391
+ currentVerdict = /** @type {FinalVerdict} */ ('DO_NOT_LAUNCH');
392
+ verdictSource = /** @type {VerdictSource} */ (VERDICT_SOURCE.POLICY_HARD_FAIL);
393
+ reasons.push({
394
+ code: 'POLICY_HARD_FAILURE',
395
+ message: policyEval.summary || 'Policy hard failure detected'
396
+ });
397
+
398
+ return buildFinalDecision({
399
+ finalVerdict: /** @type {FinalVerdict} */ (currentVerdict),
400
+ verdictSource: /** @type {VerdictSource} */ (verdictSource),
401
+ verdictHistory: /** @type {VerdictHistoryEntry[]} */ (verdictHistory),
402
+ reasons: enrichReasons(reasons, {policyEval}),
403
+ confidence: 0.99,
404
+ exitCode: mapExitCodeFromCanonical(currentVerdict),
405
+ coverageInfo,
406
+ humanPath,
407
+ networkSafety,
408
+ secretFindings
409
+ });
410
+ }
411
+
412
+ // ========================================================================
413
+ // PHASE 3: DETERMINE DEFAULT VERDICT (No failures/friction found)
414
+ // ========================================================================
415
+
416
+ // Check if we have any applicable signals
417
+ if (applicableAttempts.length === 0 && applicableFlows.length === 0) {
418
+ verdictHistory.push(/** @type {VerdictHistoryEntry} */ ({
419
+ phase: 3,
420
+ source: VERDICT_SOURCE.INSUFFICIENT_DATA,
421
+ suggestedVerdict: 'INSUFFICIENT_DATA',
422
+ reasonCode: 'NO_APPLICABLE_SIGNALS',
423
+ timestamp
424
+ }));
425
+
426
+ currentVerdict = 'INSUFFICIENT_DATA';
427
+ verdictSource = VERDICT_SOURCE.INSUFFICIENT_DATA;
428
+ reasons.push({
429
+ code: 'NO_APPLICABLE_SIGNALS',
430
+ message: 'No applicable flows or attempts found to execute'
431
+ });
432
+ finalConfidence = 0.3;
433
+ } else {
434
+ // We have signals and no critical failures/friction
435
+ verdictHistory.push(/** @type {VerdictHistoryEntry} */ ({
436
+ phase: 3,
437
+ source: VERDICT_SOURCE.OBSERVED,
438
+ suggestedVerdict: 'OBSERVED',
439
+ reasonCode: 'NO_CRITICAL_FAILURES',
440
+ timestamp
441
+ }));
442
+
443
+ currentVerdict = /** @type {FinalVerdict} */ ('READY');
444
+ verdictSource = VERDICT_SOURCE.OBSERVED;
445
+ reasons.push({
446
+ code: 'OBSERVED_SUCCESS',
447
+ message: `Executed ${executedAttempts.length} attempt(s): ${successfulAttempts.length} successful, ${failedAttempts.length} failed, ${frictionAttempts.length} friction`
448
+ });
449
+ finalConfidence = 0.95;
450
+
451
+ // *** COVERAGE ENFORCEMENT IN PHASE 3 ***
452
+ // Even if no failures/friction, coverage must still be sufficient for READY
453
+ if (coverageInfo.coverageStatus === 'INSUFFICIENT') {
454
+ verdictHistory.push(/** @type {VerdictHistoryEntry} */ ({
455
+ phase: 3,
456
+ source: 'coverage_enforcement',
457
+ suggestedVerdict: 'FRICTION',
458
+ reasonCode: 'COVERAGE_INSUFFICIENT',
459
+ details: {
460
+ coverage: coverageInfo.coverageRatio,
461
+ threshold: COVERAGE_THRESHOLD,
462
+ message: `Coverage is ${(coverageInfo.coverageRatio * 100).toFixed(1)}%, below ${(COVERAGE_THRESHOLD * 100).toFixed(0)}% threshold`
463
+ },
464
+ timestamp
465
+ }));
466
+
467
+ currentVerdict = /** @type {FinalVerdict} */ ('FRICTION');
468
+ verdictSource = 'coverage_downgrade';
469
+ finalConfidence = 0.75;
470
+ }
471
+
472
+ // *** SELECTOR CONFIDENCE ENFORCEMENT IN PHASE 3 ***
473
+ // Even if coverage OK, LOW selector confidence blocks READY
474
+ if (currentVerdict !== 'FRICTION' &&
475
+ coverageInfo.selectorConfidence &&
476
+ coverageInfo.selectorConfidence.selectorConfidenceMin === SELECTOR_CONFIDENCE.LOW) {
477
+ verdictHistory.push(/** @type {VerdictHistoryEntry} */ ({
478
+ phase: 3,
479
+ source: 'selector_confidence_enforcement',
480
+ suggestedVerdict: 'FRICTION',
481
+ reasonCode: 'LOW_SELECTOR_CONFIDENCE',
482
+ details: {
483
+ minConfidence: coverageInfo.selectorConfidence.selectorConfidenceMin,
484
+ message: 'Critical interaction steps used LOW-confidence selectors (classes, nth-child, text)'
485
+ },
486
+ timestamp
487
+ }));
488
+
489
+ currentVerdict = /** @type {FinalVerdict} */ ('FRICTION');
490
+ verdictSource = 'selector_downgrade';
491
+ finalConfidence = 0.75;
492
+ }
493
+ }
494
+
495
+ // ========================================================================
496
+ // PHASE 4: JOURNEY VERDICT MERGE (Can downgrade but not upgrade)
497
+ // ========================================================================
498
+
499
+ if (journeyVerdict) {
500
+ const canonicalJourney = toCanonicalVerdict(journeyVerdict);
501
+ const canonicalCurrent = toCanonicalVerdict(currentVerdict);
502
+
503
+ const rank = { READY: 0, FRICTION: 1, DO_NOT_LAUNCH: 2 };
504
+
505
+ // Journey can only downgrade (move to higher rank number)
506
+ if (rank[canonicalJourney] > rank[canonicalCurrent]) {
507
+ verdictHistory.push(/** @type {VerdictHistoryEntry} */ ({
508
+ phase: 4,
509
+ source: VERDICT_SOURCE.JOURNEY_DOWNGRADE,
510
+ previousVerdict: currentVerdict,
511
+ suggestedVerdict: canonicalJourney,
512
+ reasonCode: 'JOURNEY_DOWNGRADE',
513
+ timestamp
514
+ }));
515
+
516
+ currentVerdict = /** @type {FinalVerdict} */ (canonicalJourney);
517
+ verdictSource = VERDICT_SOURCE.JOURNEY_DOWNGRADE;
518
+ reasons.push({
519
+ code: 'JOURNEY_DOWNGRADE',
520
+ message: `Journey verdict downgraded from ${canonicalCurrent} to ${canonicalJourney}`
521
+ });
522
+ finalConfidence = Math.min(finalConfidence, 0.85);
523
+ }
524
+ }
525
+
526
+ // ========================================================================
527
+ // PHASE 4a: SECURITY & NETWORK SAFETY ENFORCEMENT
528
+ // ========================================================================
529
+
530
+ const httpWarnings = Array.isArray(networkSafety?.httpWarnings) ? networkSafety.httpWarnings : [];
531
+ const excessiveThirdParty = Boolean(networkSafety?.excessiveThirdParty);
532
+ const thirdPartyCount = networkSafety?.thirdPartyCount || 0;
533
+ const thirdPartyDomains = Array.isArray(networkSafety?.thirdPartyDomains) ? networkSafety.thirdPartyDomains : [];
534
+
535
+ if (httpWarnings.length > 0) {
536
+ reasons.push({
537
+ code: 'INSECURE_TRANSPORT',
538
+ message: `HTTP detected on ${httpWarnings.length} request(s): ${httpWarnings.slice(0, 3).join(', ')}`
539
+ });
540
+ verdictHistory.push(/** @type {VerdictHistoryEntry} */ ({
541
+ phase: 4,
542
+ source: 'network_safety',
543
+ suggestedVerdict: currentVerdict === 'DO_NOT_LAUNCH' ? currentVerdict : 'FRICTION',
544
+ reasonCode: 'HTTP_WARNING',
545
+ timestamp
546
+ }));
547
+ if (currentVerdict === 'READY') {
548
+ currentVerdict = 'FRICTION';
549
+ verdictSource = 'network_safety';
550
+ finalConfidence = Math.min(finalConfidence, 0.7);
551
+ }
552
+ }
553
+
554
+ if (excessiveThirdParty) {
555
+ reasons.push({
556
+ code: 'EXCESSIVE_THIRD_PARTY',
557
+ message: `Excessive third-party requests detected (${thirdPartyCount}). Domains: ${thirdPartyDomains.slice(0, 5).join(', ')}`
558
+ });
559
+ verdictHistory.push(/** @type {VerdictHistoryEntry} */ ({
560
+ phase: 4,
561
+ source: 'network_safety',
562
+ suggestedVerdict: currentVerdict === 'DO_NOT_LAUNCH' ? currentVerdict : 'FRICTION',
563
+ reasonCode: 'EXCESSIVE_THIRD_PARTY',
564
+ timestamp
565
+ }));
566
+ if (currentVerdict === 'READY') {
567
+ currentVerdict = 'FRICTION';
568
+ verdictSource = 'network_safety';
569
+ finalConfidence = Math.min(finalConfidence, 0.7);
570
+ }
571
+ } else if (thirdPartyCount > 0) {
572
+ reasons.push({
573
+ code: 'THIRD_PARTY_REQUESTS',
574
+ message: `Observed ${thirdPartyCount} third-party request(s)`
575
+ });
576
+ }
577
+
578
+ if (Array.isArray(secretFindings) && secretFindings.length > 0) {
579
+ reasons.push({
580
+ code: 'MISSING_SECRETS',
581
+ message: `Required secrets missing: ${secretFindings.map(s => s.key).join(', ')}`
582
+ });
583
+ verdictHistory.push(/** @type {VerdictHistoryEntry} */ ({
584
+ phase: 4,
585
+ source: 'secret_hygiene',
586
+ suggestedVerdict: currentVerdict === 'DO_NOT_LAUNCH' ? currentVerdict : 'FRICTION',
587
+ reasonCode: 'MISSING_SECRETS',
588
+ timestamp
589
+ }));
590
+ if (currentVerdict === 'READY') {
591
+ currentVerdict = 'FRICTION';
592
+ verdictSource = 'secret_hygiene';
593
+ finalConfidence = Math.min(finalConfidence, 0.7);
594
+ }
595
+ }
596
+
597
+ // ========================================================================
598
+ // PHASE 5: BASELINE REGRESSION CHECK (Informational only, not verdict-changing)
599
+ // ========================================================================
600
+
601
+ const diff = baseline.diffResult || baseline.diff;
602
+ if (diff && diff.regressions && Object.keys(diff.regressions).length > 0) {
603
+ verdictHistory.push(/** @type {VerdictHistoryEntry} */ ({
604
+ phase: 5,
605
+ source: 'baseline',
606
+ regressionCount: Object.keys(diff.regressions).length,
607
+ reasonCode: 'BASELINE_REGRESSIONS_DETECTED',
608
+ timestamp
609
+ }));
610
+
611
+ reasons.push({
612
+ code: 'BASELINE_REGRESSIONS',
613
+ message: `Baseline regressions detected: ${Object.keys(diff.regressions).join(', ')}`
614
+ });
615
+ }
616
+
617
+ // ========================================================================
618
+ // FINALIZE
619
+ // ========================================================================
620
+
621
+ // Add NOT_APPLICABLE as informational (not verdict-affecting)
622
+ if (notApplicableFlows.length > 0) {
623
+ reasons.push({
624
+ code: 'NOT_APPLICABLE_FLOWS',
625
+ message: `${notApplicableFlows.length} flow(s) not applicable to this site`
626
+ });
627
+ }
628
+
629
+ if (notApplicableAttempts.length > 0) {
630
+ reasons.push({
631
+ code: 'NOT_APPLICABLE_ATTEMPTS',
632
+ message: `${notApplicableAttempts.length} attempt(s) not applicable to this site`
633
+ });
634
+ }
635
+
636
+ // Policy warnings (exitCode 2) are informational, not verdict-changing
637
+ if (policyEval && !policyEval.passed && policyEval.exitCode === 2) {
638
+ reasons.push({
639
+ code: 'POLICY_WARNING',
640
+ message: policyEval.summary || 'Policy warnings detected'
641
+ });
642
+ }
643
+
644
+ return buildFinalDecision({
645
+ finalVerdict: currentVerdict,
646
+ verdictSource,
647
+ verdictHistory,
648
+ reasons,
649
+ confidence: finalConfidence,
650
+ exitCode: mapExitCodeFromCanonical(currentVerdict),
651
+ coverageInfo,
652
+ humanPath,
653
+ networkSafety,
654
+ secretFindings
655
+ });
656
+ }
657
+
658
+ /**
659
+ * Build the final decision object with all required fields
660
+ * @param {Object} params - Decision parameters
661
+ * @param {FinalVerdict} params.finalVerdict - Final canonical verdict
662
+ * @param {VerdictSource} params.verdictSource - Verdict source
663
+ * @param {VerdictHistoryEntry[]} params.verdictHistory - Verdict history
664
+ * @param {DecisionReason[]} params.reasons - Decision reasons
665
+ * @param {number} params.confidence - Confidence score (0-1)
666
+ * @param {number} [params.exitCode] - Exit code (if not provided, derived from verdict)
667
+ * @param {Object} [params.coverageInfo] - Coverage information
668
+ * @param {Object|null} [params.humanPath] - Human navigation path
669
+ * @param {Object} [params.networkSafety] - Network safety signals
670
+ * @param {Object[]} [params.secretFindings] - Secret findings
671
+ * @returns {FinalDecision}
672
+ */
673
+ function buildFinalDecision({
674
+ finalVerdict,
675
+ verdictSource,
676
+ verdictHistory,
677
+ reasons,
678
+ confidence,
679
+ exitCode,
680
+ coverageInfo,
681
+ humanPath,
682
+ networkSafety,
683
+ secretFindings
684
+ }) {
685
+ // Ensure deterministic reason ordering
686
+ const sortedReasons = (reasons || [])
687
+ .filter(r => r && r.code && r.message)
688
+ .sort((a, b) => a.code.localeCompare(b.code) || a.message.localeCompare(b.message));
689
+
690
+ // Normalize verdict first
691
+ const normalizedVerdict = normalizeCanonicalVerdict(finalVerdict) || 'UNKNOWN';
692
+
693
+ // Calculate exit code from normalized verdict if not provided
694
+ const finalExitCode = exitCode !== undefined ? exitCode : mapExitCodeFromCanonical(normalizedVerdict);
695
+
696
+ return {
697
+ finalVerdict: normalizedVerdict,
698
+ verdictSource,
699
+ verdictHistory,
700
+ reasons: sortedReasons,
701
+ confidence: Math.max(0, Math.min(1, confidence || 0.5)),
702
+ exitCode: finalExitCode,
703
+
704
+ // For backwards compatibility
705
+ finalExitCode: finalExitCode,
706
+
707
+ // Coverage information for decision artifact
708
+ coverageInfo: coverageInfo || {},
709
+
710
+ // Human navigation path (Stage 3)
711
+ humanPath: humanPath || null,
712
+
713
+ // Security and hygiene signals
714
+ networkSafety: networkSafety || {},
715
+ secretFindings: secretFindings || []
716
+ };
717
+ }
718
+
719
+ /**
720
+ * Enrich reasons array with additional context
721
+ */
722
+ function enrichReasons(reasons, context) {
723
+ const { notApplicableFlows, notApplicableAttempts } = context;
724
+
725
+ if (notApplicableFlows && notApplicableFlows.length > 0) {
726
+ reasons.push({
727
+ code: 'NOT_APPLICABLE_FLOWS',
728
+ message: `${notApplicableFlows.length} flow(s) not applicable to this site`
729
+ });
730
+ }
731
+
732
+ if (notApplicableAttempts && notApplicableAttempts.length > 0) {
733
+ reasons.push({
734
+ code: 'NOT_APPLICABLE_ATTEMPTS',
735
+ message: `${notApplicableAttempts.length} attempt(s) not applicable to this site`
736
+ });
737
+ }
738
+
739
+ return reasons;
740
+ }
741
+
742
+ module.exports = {
743
+ computeDecisionAuthority,
744
+ VERDICT_SOURCE,
745
+ resetCallTracker // For testing: reset the call tracker
746
+ };