@duckcodeailabs/dql-agent 1.14.2 → 1.14.4

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 (196) hide show
  1. package/dist/agent-run-engine.d.ts +239 -4
  2. package/dist/agent-run-engine.d.ts.map +1 -1
  3. package/dist/agent-run-engine.js +2924 -85
  4. package/dist/agent-run-engine.js.map +1 -1
  5. package/dist/agent-run-gates.d.ts.map +1 -1
  6. package/dist/agent-run-gates.js +51 -3
  7. package/dist/agent-run-gates.js.map +1 -1
  8. package/dist/agent-run-planner.d.ts +6 -0
  9. package/dist/agent-run-planner.d.ts.map +1 -1
  10. package/dist/agent-run-planner.js +2 -0
  11. package/dist/agent-run-planner.js.map +1 -1
  12. package/dist/agent-run-store.d.ts.map +1 -1
  13. package/dist/agent-run-store.js +591 -11
  14. package/dist/agent-run-store.js.map +1 -1
  15. package/dist/agentic/analyst-loop.d.ts +9 -1
  16. package/dist/agentic/analyst-loop.d.ts.map +1 -1
  17. package/dist/agentic/analyst-loop.js +204 -4
  18. package/dist/agentic/analyst-loop.js.map +1 -1
  19. package/dist/agentic/assumptions.d.ts +1 -1
  20. package/dist/agentic/assumptions.d.ts.map +1 -1
  21. package/dist/agentic/assumptions.js.map +1 -1
  22. package/dist/agentic/ledger-tools.d.ts.map +1 -1
  23. package/dist/agentic/ledger-tools.js +8 -1
  24. package/dist/agentic/ledger-tools.js.map +1 -1
  25. package/dist/agentic/orchestrator-policy.d.ts +3 -1
  26. package/dist/agentic/orchestrator-policy.d.ts.map +1 -1
  27. package/dist/agentic/orchestrator-policy.js +1 -1
  28. package/dist/agentic/orchestrator-policy.js.map +1 -1
  29. package/dist/agentic/research-agent.d.ts.map +1 -1
  30. package/dist/agentic/research-agent.js +10 -1
  31. package/dist/agentic/research-agent.js.map +1 -1
  32. package/dist/agentic/sql-authorization.d.ts +8 -0
  33. package/dist/agentic/sql-authorization.d.ts.map +1 -1
  34. package/dist/agentic/sql-authorization.js +3 -0
  35. package/dist/agentic/sql-authorization.js.map +1 -1
  36. package/dist/agentic/tool-loop.d.ts +13 -1
  37. package/dist/agentic/tool-loop.d.ts.map +1 -1
  38. package/dist/agentic/tool-loop.js +441 -32
  39. package/dist/agentic/tool-loop.js.map +1 -1
  40. package/dist/analytical-compatibility.d.ts.map +1 -1
  41. package/dist/analytical-compatibility.js +8 -1
  42. package/dist/analytical-compatibility.js.map +1 -1
  43. package/dist/analytical-failure-repair.d.ts.map +1 -1
  44. package/dist/analytical-failure-repair.js +6 -2
  45. package/dist/analytical-failure-repair.js.map +1 -1
  46. package/dist/analytical-frame.d.ts +42 -1
  47. package/dist/analytical-frame.d.ts.map +1 -1
  48. package/dist/analytical-frame.js +490 -26
  49. package/dist/analytical-frame.js.map +1 -1
  50. package/dist/analytical-orchestration.d.ts +1259 -9
  51. package/dist/analytical-orchestration.d.ts.map +1 -1
  52. package/dist/analytical-orchestration.js +970 -84
  53. package/dist/analytical-orchestration.js.map +1 -1
  54. package/dist/answer-loop.d.ts +230 -10
  55. package/dist/answer-loop.d.ts.map +1 -1
  56. package/dist/answer-loop.js +1182 -85
  57. package/dist/answer-loop.js.map +1 -1
  58. package/dist/answer-shape.d.ts +27 -0
  59. package/dist/answer-shape.d.ts.map +1 -1
  60. package/dist/answer-shape.js +55 -0
  61. package/dist/answer-shape.js.map +1 -1
  62. package/dist/ask-observability/index.d.ts +11 -0
  63. package/dist/ask-observability/index.d.ts.map +1 -0
  64. package/dist/ask-observability/index.js +7 -0
  65. package/dist/ask-observability/index.js.map +1 -0
  66. package/dist/ask-observability/instrumentation.d.ts +35 -0
  67. package/dist/ask-observability/instrumentation.d.ts.map +1 -0
  68. package/dist/ask-observability/instrumentation.js +523 -0
  69. package/dist/ask-observability/instrumentation.js.map +1 -0
  70. package/dist/ask-observability/observer.d.ts +62 -0
  71. package/dist/ask-observability/observer.d.ts.map +1 -0
  72. package/dist/ask-observability/observer.js +537 -0
  73. package/dist/ask-observability/observer.js.map +1 -0
  74. package/dist/ask-observability/portable.d.ts +98 -0
  75. package/dist/ask-observability/portable.d.ts.map +1 -0
  76. package/dist/ask-observability/portable.js +1089 -0
  77. package/dist/ask-observability/portable.js.map +1 -0
  78. package/dist/ask-observability/store.d.ts +130 -0
  79. package/dist/ask-observability/store.d.ts.map +1 -0
  80. package/dist/ask-observability/store.js +1114 -0
  81. package/dist/ask-observability/store.js.map +1 -0
  82. package/dist/ask-observability/types.d.ts +380 -0
  83. package/dist/ask-observability/types.d.ts.map +1 -0
  84. package/dist/ask-observability/types.js +2 -0
  85. package/dist/ask-observability/types.js.map +1 -0
  86. package/dist/ask-observability/utils.d.ts +21 -0
  87. package/dist/ask-observability/utils.d.ts.map +1 -0
  88. package/dist/ask-observability/utils.js +102 -0
  89. package/dist/ask-observability/utils.js.map +1 -0
  90. package/dist/ask-runtime/ask-agent-runtime-v2.d.ts +850 -0
  91. package/dist/ask-runtime/ask-agent-runtime-v2.d.ts.map +1 -0
  92. package/dist/ask-runtime/ask-agent-runtime-v2.js +1716 -0
  93. package/dist/ask-runtime/ask-agent-runtime-v2.js.map +1 -0
  94. package/dist/ask-runtime/ask-analyst-runtime.d.ts +95 -0
  95. package/dist/ask-runtime/ask-analyst-runtime.d.ts.map +1 -0
  96. package/dist/ask-runtime/ask-analyst-runtime.js +8280 -0
  97. package/dist/ask-runtime/ask-analyst-runtime.js.map +1 -0
  98. package/dist/conversation/session-store.d.ts +31 -0
  99. package/dist/conversation/session-store.d.ts.map +1 -1
  100. package/dist/conversation/session-store.js +38 -0
  101. package/dist/conversation/session-store.js.map +1 -1
  102. package/dist/conversation/snapshot.d.ts +4 -1
  103. package/dist/conversation/snapshot.d.ts.map +1 -1
  104. package/dist/conversation/snapshot.js +85 -3
  105. package/dist/conversation/snapshot.js.map +1 -1
  106. package/dist/conversation/turn-trust.d.ts.map +1 -1
  107. package/dist/conversation/turn-trust.js +2 -0
  108. package/dist/conversation/turn-trust.js.map +1 -1
  109. package/dist/embeddings/provider.d.ts +1 -0
  110. package/dist/embeddings/provider.d.ts.map +1 -1
  111. package/dist/embeddings/provider.js +18 -0
  112. package/dist/embeddings/provider.js.map +1 -1
  113. package/dist/generated-analytical-proposal.d.ts +37 -1
  114. package/dist/generated-analytical-proposal.d.ts.map +1 -1
  115. package/dist/generated-analytical-proposal.js +246 -3
  116. package/dist/generated-analytical-proposal.js.map +1 -1
  117. package/dist/governed-relational-compiler.d.ts +15 -0
  118. package/dist/governed-relational-compiler.d.ts.map +1 -1
  119. package/dist/governed-relational-compiler.js +99 -1
  120. package/dist/governed-relational-compiler.js.map +1 -1
  121. package/dist/index.d.ts +21 -12
  122. package/dist/index.d.ts.map +1 -1
  123. package/dist/index.js +14 -7
  124. package/dist/index.js.map +1 -1
  125. package/dist/intent-controller.d.ts +87 -1
  126. package/dist/intent-controller.d.ts.map +1 -1
  127. package/dist/intent-controller.js +2 -2
  128. package/dist/intent-controller.js.map +1 -1
  129. package/dist/kg/build.js +32 -2
  130. package/dist/kg/build.js.map +1 -1
  131. package/dist/meaning-resolution.d.ts +207 -6
  132. package/dist/meaning-resolution.d.ts.map +1 -1
  133. package/dist/meaning-resolution.js +439 -53
  134. package/dist/meaning-resolution.js.map +1 -1
  135. package/dist/metadata/analysis-planner.d.ts.map +1 -1
  136. package/dist/metadata/analysis-planner.js +78 -7
  137. package/dist/metadata/analysis-planner.js.map +1 -1
  138. package/dist/metadata/catalog.d.ts +14 -0
  139. package/dist/metadata/catalog.d.ts.map +1 -1
  140. package/dist/metadata/catalog.js +29 -2
  141. package/dist/metadata/catalog.js.map +1 -1
  142. package/dist/metadata/meaning-evidence.d.ts +28 -2
  143. package/dist/metadata/meaning-evidence.d.ts.map +1 -1
  144. package/dist/metadata/meaning-evidence.js +373 -16
  145. package/dist/metadata/meaning-evidence.js.map +1 -1
  146. package/dist/plan-execution-adapter.d.ts +9 -1
  147. package/dist/plan-execution-adapter.d.ts.map +1 -1
  148. package/dist/plan-execution-adapter.js +93 -13
  149. package/dist/plan-execution-adapter.js.map +1 -1
  150. package/dist/provider-egress.d.ts +28 -11
  151. package/dist/provider-egress.d.ts.map +1 -1
  152. package/dist/provider-egress.js +72 -22
  153. package/dist/provider-egress.js.map +1 -1
  154. package/dist/providers/claude.d.ts +2 -2
  155. package/dist/providers/claude.d.ts.map +1 -1
  156. package/dist/providers/claude.js +201 -37
  157. package/dist/providers/claude.js.map +1 -1
  158. package/dist/providers/dispatch.d.ts +27 -1
  159. package/dist/providers/dispatch.d.ts.map +1 -1
  160. package/dist/providers/dispatch.js +63 -1
  161. package/dist/providers/dispatch.js.map +1 -1
  162. package/dist/providers/gemini.js +10 -10
  163. package/dist/providers/gemini.js.map +1 -1
  164. package/dist/providers/index.d.ts +2 -2
  165. package/dist/providers/index.d.ts.map +1 -1
  166. package/dist/providers/index.js +1 -1
  167. package/dist/providers/index.js.map +1 -1
  168. package/dist/providers/ollama.js +15 -15
  169. package/dist/providers/ollama.js.map +1 -1
  170. package/dist/providers/openai.d.ts +2 -2
  171. package/dist/providers/openai.d.ts.map +1 -1
  172. package/dist/providers/openai.js +213 -43
  173. package/dist/providers/openai.js.map +1 -1
  174. package/dist/providers/types.d.ts +131 -1
  175. package/dist/providers/types.d.ts.map +1 -1
  176. package/dist/providers/types.js.map +1 -1
  177. package/dist/relationship-proof.d.ts +81 -0
  178. package/dist/relationship-proof.d.ts.map +1 -1
  179. package/dist/relationship-proof.js +228 -4
  180. package/dist/relationship-proof.js.map +1 -1
  181. package/dist/requirement-clauses.d.ts +23 -0
  182. package/dist/requirement-clauses.d.ts.map +1 -0
  183. package/dist/requirement-clauses.js +70 -0
  184. package/dist/requirement-clauses.js.map +1 -0
  185. package/dist/resolved-analytical-plan.d.ts +18 -4
  186. package/dist/resolved-analytical-plan.d.ts.map +1 -1
  187. package/dist/resolved-analytical-plan.js +953 -50
  188. package/dist/resolved-analytical-plan.js.map +1 -1
  189. package/dist/router.d.ts +118 -2
  190. package/dist/router.d.ts.map +1 -1
  191. package/dist/router.js +2481 -232
  192. package/dist/router.js.map +1 -1
  193. package/dist/semantic-bridge/compose.d.ts.map +1 -1
  194. package/dist/semantic-bridge/compose.js +23 -4
  195. package/dist/semantic-bridge/compose.js.map +1 -1
  196. package/package.json +4 -4
@@ -7,8 +7,11 @@ import { selectCascadeRunRoute } from "./cascade/route-policy.js";
7
7
  import { canUseEngineEscalation, canUseLaneRepair, cascadeBudgetTrace, createCascadeBudgetState, recordEngineEscalation, recordLaneRepair, } from "./cascade/budgets.js";
8
8
  import { buildAnalysisQuestionPlan } from "./metadata/analysis-planner.js";
9
9
  import { conversationHistoryFromContext, isLikelyClarificationReply, } from "./conversation/snapshot.js";
10
- import { buildCoverageGap, classifyProviderFailure, } from './analytical-orchestration.js';
10
+ import { buildCoverageGap, classifyProviderFailure, normalizeCanonicalQueryResult, } from './analytical-orchestration.js';
11
+ import { createAskToolKernelV2, createAskV2ExecutionCapabilityV1, isAskV2ExecutionReceiptAuthorizedV1, } from './ask-runtime/ask-agent-runtime-v2.js';
11
12
  import { evaluateAnalyticalRequestPolicy } from './analytical-request-policy.js';
13
+ import { frozenRequiredOutputBindingProofsForPlan } from './generated-analytical-proposal.js';
14
+ import { attachAskTraceObserverV1, askTraceObserverForV1, finalizeAgentRunTraceV1, noOpAskTraceObserverV1, recordAuthoritativeRouterDecisionV1, recordAuthoritativePlanFreezeV1, recordEngineTraceEventV1, recordExecutionAttemptSummaryV1, } from './ask-observability/index.js';
12
15
  /** Routes a stakeholder may never land on (analyst authoring lives in the Notebook). */
13
16
  const ANALYST_ONLY_ROUTES = new Set(["sql_cell", "dql_block_draft", "modeling_draft", "skill_draft"]);
14
17
  /**
@@ -199,21 +202,30 @@ export function resolveClarificationContinuation(request) {
199
202
  if (!reply || (!structuredSelection && !isLikelyClarificationReply(reply)))
200
203
  return undefined;
201
204
  const fromServer = latestClarificationFromConversationContext(request.conversationContext);
205
+ const serverIssuedStructuredSelection = structuredSelection
206
+ ? serverIssuedStructuredClarification(request)
207
+ : undefined;
202
208
  const fromHistory = latestClarificationFromHistory(request.history);
203
209
  // A UI selection is bound to the exact run that rendered the options. Carry
204
210
  // that run's source question explicitly so the continuation still works when
205
211
  // the optional conversation store is unavailable, after a reload, or when
206
212
  // the user selects an option on an older visible answer. Server history still
207
213
  // supplies the original clarifying prose when it is available.
208
- const pending = explicitSourceQuestion
209
- ? {
210
- sourceQuestion: explicitSourceQuestion,
211
- clarifyingQuestion: fromServer?.clarifyingQuestion
212
- ?? fromHistory?.clarifyingQuestion
213
- ?? 'Which governed meaning should be used?',
214
- }
215
- : fromServer ?? fromHistory;
216
- if (!pending || pending.sourceQuestion.trim().toLowerCase() === reply.toLowerCase())
214
+ const pending = serverIssuedStructuredSelection
215
+ ?? (explicitSourceQuestion
216
+ ? {
217
+ sourceQuestion: explicitSourceQuestion,
218
+ clarifyingQuestion: fromServer?.clarifyingQuestion
219
+ ?? fromHistory?.clarifyingQuestion
220
+ ?? 'Which governed meaning should be used?',
221
+ }
222
+ : fromServer ?? fromHistory);
223
+ // A structured option intentionally submits the original question together
224
+ // with a stable, server-issued identifier. Treating that exact text as a
225
+ // fresh question drops the persisted typed frame and makes the router ask
226
+ // the same clarification again. The equality guard remains important for
227
+ // free-text replies, where an unchanged question carries no new meaning.
228
+ if (!pending || (!structuredSelection && pending.sourceQuestion.trim().toLowerCase() === reply.toLowerCase()))
217
229
  return undefined;
218
230
  return {
219
231
  ...pending,
@@ -226,6 +238,44 @@ export function resolveClarificationContinuation(request) {
226
238
  ].join('\n\n'),
227
239
  };
228
240
  }
241
+ /**
242
+ * Use the original server snapshot for an identifier-bound continuation when
243
+ * it carries the host-only authority record that local runtime reconstructs
244
+ * from its persisted thread. This prevents a browser-provided label (or a
245
+ * stale client source question) from replacing the typed analytical frame
246
+ * before router validation. The router still performs the final snapshot and
247
+ * option-ID validation before any plan can freeze.
248
+ */
249
+ function serverIssuedStructuredClarification(request) {
250
+ if (!request.threadId)
251
+ return undefined;
252
+ const context = clarificationRecord(request.conversationContext);
253
+ const authority = clarificationRecord(context?.serverIssuedClarificationSelection);
254
+ if (authority?.version !== 1
255
+ || clarificationString(authority.threadId) !== request.threadId)
256
+ return undefined;
257
+ const authoritySourceTurnId = clarificationString(authority.sourceTurnId);
258
+ const authoritySnapshotId = clarificationString(authority.snapshotId);
259
+ if (!authoritySourceTurnId || !authoritySnapshotId)
260
+ return undefined;
261
+ for (const source of [
262
+ clarificationRecord(context?.conversationEnvelope),
263
+ clarificationRecord(context?.serverSnapshot),
264
+ ]) {
265
+ if (clarificationString(source?.threadId) !== request.threadId)
266
+ continue;
267
+ const pending = clarificationRecord(source?.pendingClarification);
268
+ const selection = clarificationRecord(pending?.selection);
269
+ if (clarificationString(pending?.sourceTurnId) !== authoritySourceTurnId
270
+ || clarificationString(selection?.snapshotId) !== authoritySnapshotId)
271
+ continue;
272
+ const sourceQuestion = clarificationString(pending?.sourceQuestion);
273
+ const clarifyingQuestion = clarificationString(pending?.question);
274
+ if (sourceQuestion && clarifyingQuestion)
275
+ return { sourceQuestion, clarifyingQuestion };
276
+ }
277
+ return undefined;
278
+ }
229
279
  function latestClarificationFromHistory(history) {
230
280
  if (!history?.length)
231
281
  return undefined;
@@ -467,6 +517,7 @@ export class AgentRunEngine {
467
517
  budgetModel;
468
518
  maxSteps;
469
519
  routeTimeoutSignal;
520
+ traceObserverFactory;
470
521
  constructor(options = {}) {
471
522
  this.executors = options.executors ?? {};
472
523
  this.gates = options.gates ?? {};
@@ -485,6 +536,7 @@ export class AgentRunEngine {
485
536
  };
486
537
  this.maxSteps = Math.max(1, options.maxSteps ?? DEFAULT_MAX_STEPS);
487
538
  this.routeTimeoutSignal = options.routeTimeoutSignal ?? ((durationMs) => AbortSignal.timeout(durationMs));
539
+ this.traceObserverFactory = options.traceObserverFactory;
488
540
  }
489
541
  /**
490
542
  * Decide the high-level action. A forced `requestedMode` bypasses routing.
@@ -493,12 +545,22 @@ export class AgentRunEngine {
493
545
  */
494
546
  async decideRoute(request) {
495
547
  const requestedAction = requestedModeToAction(request.requestedMode);
548
+ // An explicitly selected authoritative-V2 Research turn still has to
549
+ // enter the V2 router once. The old forced-mode shortcut predates the V2
550
+ // tool kernel and returned the legacy `investigate` decision before the
551
+ // host could attach the immutable retrieval workspace, so the V2 Research
552
+ // planner/handler was never reached. This is deliberately narrow: legacy
553
+ // and shadow Research retain their existing forced-mode behavior, and no
554
+ // browser-provided value can set this host-owned runtime mode.
555
+ const authoritativeV2Research = request.askAgentRuntimeMode === 'authoritative_v2'
556
+ && request.requestedMode === 'research';
496
557
  // `ask` constrains the eventual analytical action to a direct answer, but it
497
558
  // still needs retrieval-first meaning resolution. Treating it like the SQL,
498
559
  // block, or app authoring modes used to bypass the evidence router entirely
499
560
  // on the primary Ask surface.
500
- if (requestedAction && request.requestedMode !== "ask")
561
+ if (requestedAction && request.requestedMode !== "ask" && !authoritativeV2Research) {
501
562
  return buildIntentDecision(request);
563
+ }
502
564
  if (this.router) {
503
565
  try {
504
566
  const routed = await this.router.decide(request);
@@ -542,6 +604,64 @@ export class AgentRunEngine {
542
604
  const startedAt = this.timestamp();
543
605
  const runStartedAtMs = Date.parse(startedAt);
544
606
  const requestedMode = request.requestedMode ?? "auto";
607
+ // OBS-001/OBS-002: valid engine requests receive a server-owned trace after
608
+ // their run ID is known. The observer is explicitly non-authoritative and
609
+ // a factory/store failure becomes a no-op, never an Ask failure.
610
+ let traceObserver = noOpAskTraceObserverV1;
611
+ try {
612
+ traceObserver = this.traceObserverFactory?.({ runId, request, startedAt, requestedMode })
613
+ ?? noOpAskTraceObserverV1;
614
+ }
615
+ catch {
616
+ traceObserver = noOpAskTraceObserverV1;
617
+ }
618
+ // The engine-owned ID is available before retrieval/routing. Bind it to
619
+ // this internal request object now so request-scoped host capabilities
620
+ // (for example one cold-literal probe) can never be minted against an
621
+ // anonymous or browser-supplied identity. Public ingress still strips any
622
+ // caller-provided `runId` before the engine chooses this value.
623
+ request = attachAskTraceObserverV1({ ...request, runId }, traceObserver);
624
+ // Continuity is relationship evidence, not new routing input. Keep only
625
+ // stable run IDs and one-way fingerprints so a trace can explain why this
626
+ // turn reused a clarification/result/derived plan without persisting chat
627
+ // text, values, SQL, or an invented parent trace.
628
+ if (clarificationContinuation) {
629
+ traceObserver.recordLink({
630
+ kind: 'clarification_continuation',
631
+ choiceFingerprint: traceLinkFingerprint(request.selectedEvidenceId ?? clarificationContinuation.sourceQuestion),
632
+ });
633
+ }
634
+ if (request.selectedResultBinding) {
635
+ traceObserver.recordLink({
636
+ kind: 'prior_result',
637
+ targetRunId: request.selectedResultBinding.sourceRunId,
638
+ choiceFingerprint: traceLinkFingerprint([
639
+ request.selectedResultBinding.sourceArtifactId,
640
+ request.selectedResultBinding.canonicalColumn,
641
+ request.selectedResultBinding.rowFingerprint,
642
+ request.selectedResultBinding.resultFingerprint,
643
+ ].join('\u0000')),
644
+ });
645
+ }
646
+ const derivedSourceRunId = traceDerivedSourceRunId(request.workspaceContext);
647
+ if (derivedSourceRunId) {
648
+ traceObserver.recordLink({
649
+ kind: 'derived_repair',
650
+ targetRunId: derivedSourceRunId,
651
+ choiceFingerprint: traceLinkFingerprint(derivedSourceRunId),
652
+ });
653
+ }
654
+ const conversationBinding = traceConversationBinding(request, clarificationContinuation);
655
+ const conversationTrace = traceObserver.startSpan({
656
+ name: 'conversation.hydrate',
657
+ stage: 'conversation',
658
+ payload: {
659
+ kind: 'conversation',
660
+ continuation: conversationBinding !== 'none',
661
+ binding: conversationBinding,
662
+ },
663
+ });
664
+ traceObserver.finishSpan(conversationTrace, { outcome: 'ok', reasonCode: 'completed' });
545
665
  const runBudget = request.runBudget ?? createAgentRunBudget({
546
666
  requestedMode,
547
667
  startedAtMs: runStartedAtMs,
@@ -549,7 +669,11 @@ export class AgentRunEngine {
549
669
  timeoutSignal: this.routeTimeoutSignal,
550
670
  nowMs: () => this.now().getTime(),
551
671
  });
552
- request = { ...request, runBudget, signal: runBudget.hardSignal };
672
+ // Preserve the non-enumerable observer across the immutable request update.
673
+ // A plain spread drops symbol properties, which previously made the router
674
+ // lose candidate/cascade/freeze evidence even though the engine still
675
+ // emitted its own outer spans.
676
+ request = attachAskTraceObserverV1({ ...request, runBudget, signal: runBudget.hardSignal }, traceObserver);
553
677
  const events = [];
554
678
  let plan;
555
679
  const executedSteps = [];
@@ -573,6 +697,7 @@ export class AgentRunEngine {
573
697
  startedAt,
574
698
  updatedAt: startedAt,
575
699
  },
700
+ ...(traceObserver.reference() ? { traceReference: traceObserver.reference() } : {}),
576
701
  };
577
702
  let checkpointQueue = Promise.resolve();
578
703
  const persistProgress = () => {
@@ -585,6 +710,23 @@ export class AgentRunEngine {
585
710
  artifacts: [...progress.artifacts],
586
711
  evaluations: [...progress.evaluations],
587
712
  events: [...progress.events],
713
+ ...(progress.analyticalTaskOutcomes
714
+ ? { analyticalTaskOutcomes: progress.analyticalTaskOutcomes.map((outcome) => ({
715
+ ...outcome,
716
+ ...(outcome.dependencyTaskIds ? { dependencyTaskIds: [...outcome.dependencyTaskIds] } : {}),
717
+ })) }
718
+ : {}),
719
+ ...(progress.analyticalTaskOutcomeSummary
720
+ ? {
721
+ analyticalTaskOutcomeSummary: {
722
+ ...progress.analyticalTaskOutcomeSummary,
723
+ successfulTaskIds: [...progress.analyticalTaskOutcomeSummary.successfulTaskIds],
724
+ failedTaskIds: [...progress.analyticalTaskOutcomeSummary.failedTaskIds],
725
+ dependencyBlockedTaskIds: [...progress.analyticalTaskOutcomeSummary.dependencyBlockedTaskIds],
726
+ },
727
+ }
728
+ : {}),
729
+ ...(progress.askAnalystState ? { askAnalystState: progress.askAnalystState } : {}),
588
730
  };
589
731
  checkpointQueue = checkpointQueue.then(async () => {
590
732
  try {
@@ -632,6 +774,12 @@ export class AgentRunEngine {
632
774
  }
633
775
  if (full.type === "step.completed")
634
776
  progress.steps = [...executedSteps];
777
+ // Event payloads are intentionally not copied: they may contain raw
778
+ // answer/tool data. The typed mapping records only stage identity.
779
+ recordEngineTraceEventV1(traceObserver, full);
780
+ const traceReference = traceObserver.reference();
781
+ if (traceReference)
782
+ progress.traceReference = traceReference;
635
783
  persistProgress();
636
784
  onEvent?.(full);
637
785
  };
@@ -645,6 +793,23 @@ export class AgentRunEngine {
645
793
  ...(clarificationContinuation ? { clarificationResolved: true } : {}),
646
794
  },
647
795
  });
796
+ // AskAnalystRuntimeV1 emits typed state checkpoints after framing and each
797
+ // bounded tool action. They are local/restart material only and are never
798
+ // exported through the content-free trace projection.
799
+ // Checkpointing adds a callback after the observer was attached above.
800
+ // Preserve the non-enumerable trace observer through this immutable update:
801
+ // otherwise the authoritative runtime can record its router state while
802
+ // the later provider/compiler/execution adapter sees a no-op observer.
803
+ // That made a successfully executed deterministic physical program appear
804
+ // to have no SQL generation, validation, authorization, or execution in
805
+ // the same trace.
806
+ request = attachAskTraceObserverV1({
807
+ ...request,
808
+ askAnalystCheckpoint: (state) => {
809
+ progress.askAnalystState = state;
810
+ persistProgress();
811
+ },
812
+ }, traceObserver);
648
813
  // This check is intentionally before route selection. A restricted direct
649
814
  // disclosure must not be embedded, retrieved, sent to a provider, value
650
815
  // probed, or compiled merely to explain why it cannot be answered.
@@ -728,6 +893,7 @@ export class AgentRunEngine {
728
893
  id: runId,
729
894
  question: submittedQuestion,
730
895
  requestedMode,
896
+ conversationBinding: request.conversationBinding ?? traceConversationBinding(request, clarificationContinuation),
731
897
  route: 'blocked',
732
898
  status: 'blocked',
733
899
  trustState: 'blocked',
@@ -754,7 +920,13 @@ export class AgentRunEngine {
754
920
  run.diagnosticReceipt = diagnosticReceiptForRun(run);
755
921
  run.diagnosticReceiptV2 = diagnosticReceiptV2ForRun(run);
756
922
  run.diagnosticReceiptV3 = diagnosticReceiptV3ForRun(run);
757
- run.artifacts = attachDiagnosticReceipt(run.artifacts, run.diagnosticReceipt, run.diagnosticReceiptV2, run.diagnosticReceiptV3);
923
+ run.diagnosticReceiptV4 = diagnosticReceiptV4ForRun(run);
924
+ attachAskAnalystRuntimeReceipt(run, request.askAgentRuntimeMode);
925
+ run.artifacts = attachDiagnosticReceipt(run.artifacts, run.diagnosticReceipt, run.diagnosticReceiptV2, run.diagnosticReceiptV3, run.diagnosticReceiptV4, run.diagnosticReceiptV5, run.diagnosticReceiptV6, run.diagnosticReceiptV7, run.diagnosticReceiptV8);
926
+ // Observability is deliberately finalized only after the authoritative
927
+ // receipt exists, and before the ordinary run store persists its compact
928
+ // reference. A local trace write failure never changes this outcome.
929
+ finalizeAgentRunTraceV1(traceObserver, run);
758
930
  await checkpointQueue;
759
931
  await this.store?.save(run);
760
932
  return run;
@@ -765,6 +937,11 @@ export class AgentRunEngine {
765
937
  // pre-try await escaped the engine and left active UI runs looking endless.
766
938
  let routeDecision = buildIntentDecision(request);
767
939
  try {
940
+ const classifySpan = traceObserver.startSpan({
941
+ name: 'request.classify',
942
+ stage: 'request',
943
+ payload: { kind: 'stage', requestedMode },
944
+ });
768
945
  routeDecision = clarificationContinuation && !request.selectedEvidenceId
769
946
  ? {
770
947
  action: "answer",
@@ -774,15 +951,52 @@ export class AgentRunEngine {
774
951
  source: "heuristic",
775
952
  }
776
953
  : await awaitWithAbort(this.decideRoute(request), request.signal);
954
+ // V2 owns interpretation and pre-freeze route progression in its bounded
955
+ // tool runtime. Carry only its host-created state to the executor; no
956
+ // public request path can manufacture this handoff. The engine remains
957
+ // the owner of policy, plan freeze, execution and persistence.
958
+ if (routeDecision.askAgentV2Decision) {
959
+ // `ASK_TRACE_OBSERVER_V1` is deliberately non-enumerable. This V2
960
+ // carrier update is the first immutable request replacement after
961
+ // routing, so a plain spread would detach the physical provider
962
+ // preflight from the root trace precisely on authoritative V2 turns.
963
+ // Keep the observer with the server-owned state; no client value can
964
+ // attach it.
965
+ const v2ExecutionCapability = routeDecision.askAgentV2Decision.mode === 'authoritative_v2'
966
+ ? createAskV2ExecutionCapabilityV1({
967
+ id: randomUUID(),
968
+ runId,
969
+ state: routeDecision.askAgentV2Decision.state,
970
+ })
971
+ : undefined;
972
+ request = attachAskTraceObserverV1({
973
+ ...request,
974
+ askAgentRuntimeMode: routeDecision.askAgentV2Decision.mode,
975
+ askAgentV2State: routeDecision.askAgentV2Decision.state,
976
+ ...(v2ExecutionCapability ? { askAgentV2ExecutionCapability: v2ExecutionCapability } : {}),
977
+ }, traceObserver);
978
+ }
777
979
  routeDecision = enforceOrdinaryAnalyticalPlanBoundary(request, routeDecision);
980
+ traceObserver.finishSpan(classifySpan, { outcome: 'ok', reasonCode: 'route_selected' });
981
+ // Router/cascade evidence is captured after its authoritative decision
982
+ // is sealed. The trace adapter only projects IDs, counters, and typed
983
+ // receipts; it never participates in route selection.
984
+ recordAuthoritativeRouterDecisionV1(traceObserver, routeDecision);
778
985
  const defaultRoute = answerAnywayRoute(constrainRouteForAudience(selectRoute(request, routeDecision), audience), request, audience, routeDecision);
779
986
  const authoritativeAsk = routeDecision.resolvedAnalyticalPlan?.mode === 'authoritative'
780
987
  && requestedMode !== 'research';
781
- const activePlanner = authoritativeAsk
988
+ // AskAnalystRuntimeV1 owns the single immutable task program. The
989
+ // engine may still use the legacy deterministic/LLM planners for every
990
+ // other surface, but it must never replace a runtime-frozen Ask task
991
+ // with a new generic one-step interpretation.
992
+ const runtimeFrozenPlan = authoritativeAsk
993
+ ? routeDecision.askAnalystDecision?.frozenPlan
994
+ : undefined;
995
+ const activePlanner = authoritativeAsk && !runtimeFrozenPlan
782
996
  ? createDeterministicAgentRunPlanner()
783
997
  : this.planner;
784
998
  const planningSignal = request.runBudget?.hardSignal ?? request.signal;
785
- plan = await awaitWithAbort(Promise.resolve(activePlanner.plan({
999
+ plan = runtimeFrozenPlan ?? await awaitWithAbort(Promise.resolve(activePlanner.plan({
786
1000
  request,
787
1001
  routeDecision,
788
1002
  defaultRoute,
@@ -802,6 +1016,38 @@ export class AgentRunEngine {
802
1016
  ...step,
803
1017
  route: answerAnywayRoute(constrainRouteForAudience(step.route, audience), request, audience, routeDecision),
804
1018
  }));
1019
+ // A multi-task authoritative Ask has one frozen task queue. V2 task
1020
+ // outcome receipts may retain an independent sibling when another task
1021
+ // fails; persisted pre-V2 decisions deliberately retain the historical
1022
+ // all-or-nothing aggregate below. Neither mode may silently omit a
1023
+ // task or substitute a freshly parsed child graph.
1024
+ const authoritativeTaskExecutions = authoritativeAsk
1025
+ ? routeDecision.askAnalystDecision?.taskExecutions ?? []
1026
+ : [];
1027
+ const initialTaskOutcomes = authoritativeAsk
1028
+ ? routeDecision.askAnalystDecision?.taskOutcomes ?? []
1029
+ : [];
1030
+ const initialTaskOutcomeSummary = authoritativeAsk
1031
+ ? routeDecision.askAnalystDecision?.taskOutcomeSummary
1032
+ : undefined;
1033
+ const authoritativePartialOutcomeMode = Boolean(initialTaskOutcomeSummary
1034
+ && initialTaskOutcomeSummary.taskCount > 1);
1035
+ const authoritativeCompoundAsk = authoritativeTaskExecutions.length > 1 || authoritativePartialOutcomeMode;
1036
+ if (initialTaskOutcomes.length > 0)
1037
+ progress.analyticalTaskOutcomes = initialTaskOutcomes;
1038
+ if (initialTaskOutcomeSummary)
1039
+ progress.analyticalTaskOutcomeSummary = initialTaskOutcomeSummary;
1040
+ const authoritativeTaskIds = new Set(authoritativeTaskExecutions.map((task) => task.taskId));
1041
+ const authoritativeQueueIds = queue
1042
+ .map((step) => step.askAnalystTaskId)
1043
+ .filter((taskId) => Boolean(taskId));
1044
+ const authoritativeQueueValid = !authoritativeCompoundAsk || (queue.length === authoritativeTaskExecutions.length
1045
+ && authoritativeQueueIds.length === authoritativeTaskExecutions.length
1046
+ && new Set(authoritativeQueueIds).size === authoritativeQueueIds.length
1047
+ && authoritativeQueueIds.every((taskId) => authoritativeTaskIds.has(taskId)));
1048
+ if (!authoritativeQueueValid) {
1049
+ throw Object.assign(new Error('The authoritative Ask runtime did not supply one frozen execution step for every executable task.'), { code: 'ASK_ANALYST_TASK_PLAN_MISMATCH' });
1050
+ }
805
1051
  const budgets = createCascadeBudgetState(this.budgetModel);
806
1052
  let stepCount = 0;
807
1053
  let finalStep;
@@ -812,11 +1058,73 @@ export class AgentRunEngine {
812
1058
  // non-answer step (e.g. a research/draft step that emits only an artifact)
813
1059
  // must not drop the data answer an earlier step already computed.
814
1060
  let bestAnswerResult;
815
- while (queue.length > 0 && stepCount < this.maxSteps) {
1061
+ /** Dependencies skipped after a parent execution failure. */
1062
+ const runtimeDependencyBlockedTaskIds = new Map();
1063
+ // Compiler receipts describe what can be attempted, never what has
1064
+ // executed. Persist an execution-only aggregate before task 1 starts,
1065
+ // and again after every task reaches a terminal step. That makes an
1066
+ // interrupted local run recoverable without advertising compiled tasks
1067
+ // as completed work.
1068
+ const checkpointAuthoritativeTaskOutcomes = () => {
1069
+ if (!authoritativePartialOutcomeMode)
1070
+ return;
1071
+ const aggregate = aggregateAuthoritativeTaskOutcomes({
1072
+ initialTaskOutcomes,
1073
+ taskExecutions: authoritativeTaskExecutions,
1074
+ steps: executedSteps,
1075
+ dependencyBlockedTaskIds: runtimeDependencyBlockedTaskIds,
1076
+ taskCount: initialTaskOutcomeSummary?.taskCount,
1077
+ finalized: false,
1078
+ });
1079
+ progress.analyticalTaskOutcomes = aggregate.outcomes;
1080
+ progress.analyticalTaskOutcomeSummary = aggregate.summary;
1081
+ progress.steps = [...executedSteps];
1082
+ persistProgress();
1083
+ };
1084
+ checkpointAuthoritativeTaskOutcomes();
1085
+ // The runtime bounds ordinary Ask to three accepted tasks. Once it
1086
+ // accepts a compound mission, every frozen child must receive one
1087
+ // execution attempt even when the generic engine's normal plan cap is
1088
+ // smaller. Do not turn the cap into a silent partial answer.
1089
+ const executionStepLimit = authoritativeCompoundAsk ? queue.length : this.maxSteps;
1090
+ while (queue.length > 0 && stepCount < executionStepLimit) {
816
1091
  const planned = queue.shift();
817
1092
  stepCount += 1;
818
1093
  const route = planned.route;
819
1094
  const stepId = `${runId}:step:${stepCount}`;
1095
+ // A compound authoritative Ask carries one immutable compiler handoff
1096
+ // per accepted task. Swap it in at the execution boundary rather than
1097
+ // letting task-2 inherit task-1's frame, candidates, or cascade.
1098
+ const taskExecution = authoritativeAsk && planned.askAnalystTaskId
1099
+ ? routeDecision.askAnalystDecision?.taskExecutions?.find((task) => task.taskId === planned.askAnalystTaskId)
1100
+ : undefined;
1101
+ const taskQuestion = taskExecution
1102
+ ? taskExecution.state.mission.tasks.find((task) => task.id === taskExecution.taskId)?.question
1103
+ ?? planned.goal
1104
+ : undefined;
1105
+ const taskRequest = taskExecution && taskQuestion
1106
+ ? attachAskTraceObserverV1({
1107
+ ...request,
1108
+ // Every compiler and executor sees only the frozen child
1109
+ // question. The submitted parent remains captured by the root
1110
+ // trace observer and is restored onto the persisted run below.
1111
+ question: taskQuestion,
1112
+ askAnalystTaskChild: {
1113
+ version: 1,
1114
+ taskId: taskExecution.taskId,
1115
+ question: taskQuestion,
1116
+ instructions: [...planned.successCriteria],
1117
+ },
1118
+ askAnalystState: taskExecution.state,
1119
+ askAnalystProgram: taskExecution.program,
1120
+ askAnalystMeaningResolution: taskExecution.meaningResolution,
1121
+ askAnalystTierReadiness: taskExecution.tierReadiness,
1122
+ hostRequirementSeed: taskExecution.requirementSeed,
1123
+ }, traceObserver)
1124
+ : request;
1125
+ let taskRouteDecision = taskExecution
1126
+ ? taskScopedRouteDecision(routeDecision, taskExecution)
1127
+ : routeDecision;
820
1128
  emit({
821
1129
  type: "step.started",
822
1130
  message: `Step ${stepCount}: ${planned.goal}`,
@@ -824,23 +1132,83 @@ export class AgentRunEngine {
824
1132
  payload: { stepId, index: stepCount, goal: planned.goal, successCriteria: planned.successCriteria },
825
1133
  });
826
1134
  const resolvedPlanShadow = stepCount === 1
827
- ? compareResolvedPlanShadow(routeDecision, route)
1135
+ ? compareResolvedPlanShadow(taskRouteDecision, route)
828
1136
  : undefined;
829
1137
  emit({
830
1138
  type: "route.decided",
831
1139
  message: stepCount === 1
832
- ? routeDecision.reason
1140
+ ? taskRouteDecision.reason
833
1141
  : `Routed step ${stepCount} to ${route.replaceAll("_", " ")}.`,
834
1142
  route,
835
1143
  payload: stepCount === 1
836
1144
  ? {
837
- ...routeDecision,
1145
+ ...taskRouteDecision,
838
1146
  ...(resolvedPlanShadow
839
1147
  ? { resolvedPlanShadow }
840
1148
  : {}),
841
1149
  }
842
1150
  : { route, goal: planned.goal },
843
1151
  });
1152
+ // V2 compound Ask treats a task dependency as an execution boundary,
1153
+ // not a reason to reinterpret or replan the child. A dependent child
1154
+ // may use its predecessor only after that predecessor produced an
1155
+ // accepted result. Independent siblings continue to their own frozen
1156
+ // programs after a failure; this branch is deliberately restricted to
1157
+ // the additive V2 receipt so pre-V2 persisted compound runs preserve
1158
+ // their historical all-or-nothing behavior.
1159
+ const dependencyTaskIds = taskExecution
1160
+ ? taskExecution.dependencyTaskIds
1161
+ ?? taskExecution.state.mission.tasks.find((task) => task.id === taskExecution.taskId)?.dependencies
1162
+ ?? []
1163
+ : [];
1164
+ const unmetDependencyIds = authoritativePartialOutcomeMode && taskExecution
1165
+ ? dependencyTaskIds.filter((dependencyTaskId) => !hasAcceptedAuthoritativeTaskResult(executedSteps.find((step) => step.askAnalystTaskId === dependencyTaskId)))
1166
+ : [];
1167
+ if (taskExecution && unmetDependencyIds.length > 0) {
1168
+ runtimeDependencyBlockedTaskIds.set(taskExecution.taskId, unmetDependencyIds);
1169
+ const summary = 'This task was not executed because a required task did not complete successfully.';
1170
+ const dependencyStep = {
1171
+ id: stepId,
1172
+ index: stepCount,
1173
+ route,
1174
+ ...(planned.askAnalystTaskId ? { askAnalystTaskId: planned.askAnalystTaskId } : {}),
1175
+ goal: planned.goal,
1176
+ successCriteria: planned.successCriteria,
1177
+ status: 'blocked',
1178
+ attempts: 0,
1179
+ summary,
1180
+ evaluations: [{
1181
+ id: `task-dependency:${taskExecution.taskId}`,
1182
+ label: 'Task dependency',
1183
+ passed: false,
1184
+ severity: 'blocking',
1185
+ message: summary,
1186
+ evidence: { dependencyTaskIds: unmetDependencyIds },
1187
+ }],
1188
+ artifacts: [],
1189
+ };
1190
+ executedSteps.push(dependencyStep);
1191
+ const dependencyOutcome = {
1192
+ status: 'blocked',
1193
+ trustState: 'blocked',
1194
+ artifacts: [],
1195
+ stopReason: 'blocked',
1196
+ summary,
1197
+ };
1198
+ finalStep = dependencyStep;
1199
+ finalResult = { status: 'blocked', trustState: 'blocked', summary };
1200
+ finalOutcome = dependencyOutcome;
1201
+ checkpointAuthoritativeTaskOutcomes();
1202
+ emit({
1203
+ type: 'step.completed',
1204
+ message: `Step ${stepCount} dependency blocked.`,
1205
+ route,
1206
+ status: 'blocked',
1207
+ trustState: 'blocked',
1208
+ payload: { stepId, status: 'blocked', dependencyTaskIds: unmetDependencyIds },
1209
+ });
1210
+ continue;
1211
+ }
844
1212
  let attempt = 0;
845
1213
  let repairHint;
846
1214
  let priorEvaluations;
@@ -859,11 +1227,15 @@ export class AgentRunEngine {
859
1227
  : `Re-running ${route.replaceAll("_", " ")} executor (repair attempt ${attempt}).`,
860
1228
  route,
861
1229
  });
1230
+ // Provider readiness belongs at the provider boundary. The engine
1231
+ // cannot infer it from an executor return value: a deterministic
1232
+ // route may be provider-free and a provider route can fail during
1233
+ // preflight before any executor result exists.
862
1234
  result = await this.executeRoute({
863
1235
  runId,
864
- request,
1236
+ request: taskRequest,
865
1237
  route,
866
- routeDecision,
1238
+ routeDecision: taskRouteDecision,
867
1239
  maxRepairAttempts: budgets.limits.lane.execution,
868
1240
  attempt,
869
1241
  stepGoal: planned.goal,
@@ -873,20 +1245,67 @@ export class AgentRunEngine {
873
1245
  emit,
874
1246
  emitAnswerDelta: onAnswerDelta,
875
1247
  });
1248
+ recordExecutionAttemptSummaryV1(traceObserver, result);
876
1249
  // The router owns a frozen analytical tier. An executor may report a
877
1250
  // same-tier execution failure, but it cannot turn a certified or
878
1251
  // semantic plan into generated work (or vice versa) after execution
879
1252
  // has started. Keep this guard in the engine as well as host adapters
880
1253
  // so an injected/legacy executor cannot redefine durable provenance.
881
- routeDecision = applyExploratoryExecutionFreeze(routeDecision, result.analyticalExecutionFreeze);
882
- result = preserveFrozenAnalyticalRoute(route, routeDecision, result);
883
- result = consumeRepeatedClarificationSelection(request, routeDecision, result);
1254
+ const planWasFrozen = taskRouteDecision.analyticalCascadeDecision?.planFrozen === true;
1255
+ taskRouteDecision = applyExploratoryExecutionFreeze(taskRouteDecision, result.analyticalExecutionFreeze);
1256
+ taskRouteDecision = applyExploratoryExecutionFreeze(taskRouteDecision, result.analyticalExecutionRepairFreeze);
1257
+ // Legacy/non-authoritative execution has no task-local durable
1258
+ // decision. Promote the validated host authorization receipts back
1259
+ // to the run-level decision so the persisted run and trace retain
1260
+ // the same immutable exploratory handoff that the executor used.
1261
+ // Authoritative compound Ask keeps its outer decision as a turn
1262
+ // summary and records each task-local handoff independently.
1263
+ // A single authoritative task is still the whole Ask answer. Its
1264
+ // host-issued exploratory execution freeze must be promoted to the
1265
+ // outer decision so V3/V6 persistence and the trace retain the same
1266
+ // capability receipt that authorized SQL. Compound Ask keeps each
1267
+ // frozen child isolated under its task execution receipts.
1268
+ if (!taskExecution || (routeDecision.askAnalystDecision?.taskExecutions?.length ?? 0) === 1) {
1269
+ routeDecision = taskRouteDecision;
1270
+ }
1271
+ // The router froze the exploratory plan before SQL generation. The
1272
+ // host receipt below only authorizes this exact SQL/target against
1273
+ // that immutable plan; it never creates a second freeze transition.
1274
+ if (!planWasFrozen && taskRouteDecision.analyticalCascadeDecision?.planFrozen) {
1275
+ recordAuthoritativePlanFreezeV1(traceObserver, taskRouteDecision.analyticalCascadeDecision);
1276
+ }
1277
+ result = preserveFrozenAnalyticalRoute(route, taskRouteDecision, result);
1278
+ result = consumeRepeatedClarificationSelection(taskRequest, taskRouteDecision, result);
884
1279
  if (result.analyticalTurnPlan)
885
1280
  progress.analyticalTurnPlan = result.analyticalTurnPlan;
886
1281
  if (result.analyticalTaskOutcomes)
887
1282
  progress.analyticalTaskOutcomes = result.analyticalTaskOutcomes;
888
1283
  persistProgress();
889
- evaluations = this.evaluate({ route, request, routeDecision, result, attempt });
1284
+ // A terminal V2 `finish_answer` is accepted only after the host has
1285
+ // frozen one snapshot-bound plan and recorded an actual execution
1286
+ // result. The generic evaluator predates that runtime and can
1287
+ // otherwise request a legacy replan of the very same certified
1288
+ // artifact. That second invocation is correctly refused by the
1289
+ // V2 kernel as `POST_FREEZE_REPAIR_REQUIRED`, but it also discards
1290
+ // the valid result which already ran. Preserve the successful V2
1291
+ // boundary here; terminal V2 errors still flow through the ordinary
1292
+ // evaluation and blocked-outcome path below.
1293
+ const acceptedV2TerminalState = acceptedAskAgentV2TerminalState(taskRequest, taskRouteDecision, result, runId);
1294
+ // Run the generic gates for a successful V2 terminal too — but with
1295
+ // their REPAIRS disarmed. Replacing the whole array with the single
1296
+ // terminal receipt meant the answer-shape gate never inspected a V2
1297
+ // result: a run whose executed columns did not match the question's
1298
+ // required outputs shipped as "1 check passed". The repair hooks
1299
+ // stay stripped for the reason documented above — the legacy replan
1300
+ // would discard the validated result — so a failed gate REPORTS
1301
+ // (status/summary see it) without ever re-planning the frozen plan.
1302
+ evaluations = acceptedV2TerminalState
1303
+ ? [
1304
+ ...this.evaluate({ route, request: taskRequest, routeDecision: taskRouteDecision, result, attempt })
1305
+ .map(disarmRepairForV2Terminal),
1306
+ acceptedAskAgentV2TerminalEvaluation(acceptedV2TerminalState),
1307
+ ]
1308
+ : this.evaluate({ route, request: taskRequest, routeDecision: taskRouteDecision, result, attempt });
890
1309
  for (const evaluation of evaluations) {
891
1310
  emit({
892
1311
  type: "evaluation.recorded",
@@ -908,7 +1327,7 @@ export class AgentRunEngine {
908
1327
  // A frozen analytical plan has one route and no downstream planner,
909
1328
  // rematch, route escalation, or whole-answer regeneration authority.
910
1329
  // Typed server-issued repair is a separate derived run.
911
- if (authoritativeAsk || routeDecision.analyticalCascadeDecision?.planFrozen === true) {
1330
+ if (authoritativeAsk || taskRouteDecision.analyticalCascadeDecision?.planFrozen === true) {
912
1331
  stepStatus = 'needs_review';
913
1332
  break;
914
1333
  }
@@ -925,7 +1344,7 @@ export class AgentRunEngine {
925
1344
  artifacts: result.artifacts ?? [],
926
1345
  };
927
1346
  const decision = await activePlanner.replan({
928
- request,
1347
+ request: taskRequest,
929
1348
  plan,
930
1349
  currentStep,
931
1350
  remainingSteps: queue,
@@ -1007,6 +1426,7 @@ export class AgentRunEngine {
1007
1426
  id: stepId,
1008
1427
  index: stepCount,
1009
1428
  route,
1429
+ ...(planned.askAnalystTaskId ? { askAnalystTaskId: planned.askAnalystTaskId } : {}),
1010
1430
  goal: planned.goal,
1011
1431
  successCriteria: planned.successCriteria,
1012
1432
  status: "escalated",
@@ -1015,6 +1435,7 @@ export class AgentRunEngine {
1015
1435
  evaluations,
1016
1436
  artifacts: [],
1017
1437
  });
1438
+ checkpointAuthoritativeTaskOutcomes();
1018
1439
  emit({
1019
1440
  type: "step.completed",
1020
1441
  message: `Step ${stepCount} escalated to ${escalation.route.replaceAll("_", " ")}.`,
@@ -1029,11 +1450,12 @@ export class AgentRunEngine {
1029
1450
  });
1030
1451
  continue;
1031
1452
  }
1032
- const outcome = computeStepOutcome(route, result, evaluations, request, isClarify, clarifyQuestion, routeDecision.terminalOutcome?.message);
1453
+ const outcome = computeStepOutcome(route, result, evaluations, taskRequest, isClarify, clarifyQuestion, taskRouteDecision.terminalOutcome?.message);
1033
1454
  const step = {
1034
1455
  id: stepId,
1035
1456
  index: stepCount,
1036
1457
  route,
1458
+ ...(planned.askAnalystTaskId ? { askAnalystTaskId: planned.askAnalystTaskId } : {}),
1037
1459
  resolvedRoute: result.resolvedRoute,
1038
1460
  goal: planned.goal,
1039
1461
  successCriteria: planned.successCriteria,
@@ -1053,6 +1475,11 @@ export class AgentRunEngine {
1053
1475
  payload: artifact,
1054
1476
  });
1055
1477
  }
1478
+ // Artifact events above are the immutable result proof. Checkpoint the
1479
+ // task only after that proof has joined persisted progress; a restart
1480
+ // between task siblings can then retain a completed independent result
1481
+ // and mark only its dependents as blocked.
1482
+ checkpointAuthoritativeTaskOutcomes();
1056
1483
  emit({
1057
1484
  type: "step.completed",
1058
1485
  message: `Step ${stepCount} ${step.status}.`,
@@ -1066,7 +1493,14 @@ export class AgentRunEngine {
1066
1493
  finalStep = step;
1067
1494
  finalResult = result;
1068
1495
  finalOutcome = outcome;
1069
- break;
1496
+ // Every accepted authoritative child was frozen before the first
1497
+ // execution. A blocked/clarify outcome for task-1 must not prevent
1498
+ // task-2 from receiving its independently scoped attempt and receipt.
1499
+ // Finalization below aggregates these outcomes as all-or-nothing, so
1500
+ // no partial answer can escape.
1501
+ if (!authoritativeCompoundAsk)
1502
+ break;
1503
+ continue;
1070
1504
  }
1071
1505
  finalStep = step;
1072
1506
  finalResult = result;
@@ -1074,14 +1508,36 @@ export class AgentRunEngine {
1074
1508
  if (outcome.status !== "blocked" && typeof result.answer === "string" && result.answer.trim().length > 0) {
1075
1509
  bestAnswerResult = result;
1076
1510
  }
1077
- if (outcome.status === "blocked")
1511
+ if (outcome.status === "blocked" && !authoritativeCompoundAsk)
1078
1512
  break;
1079
- if (outcome.status === "needs_clarification")
1513
+ if (outcome.status === "needs_clarification" && !authoritativeCompoundAsk)
1080
1514
  break;
1081
- if (isTerminalSuccess(route, outcome))
1515
+ // A successful task is terminal only for a single-task Ask. Multi-task
1516
+ // authoritative plans were all frozen before execution and therefore
1517
+ // continue to their own task-local result receipt.
1518
+ const hasMoreAuthoritativeTasks = authoritativeAsk
1519
+ && (routeDecision.askAnalystDecision?.taskExecutions?.length ?? 0) > 1;
1520
+ if (isTerminalSuccess(route, outcome) && !hasMoreAuthoritativeTasks)
1082
1521
  break;
1083
1522
  // Otherwise continue to the next planned step (if any remain).
1084
1523
  }
1524
+ const authoritativeTaskOutcomeAggregate = authoritativePartialOutcomeMode
1525
+ ? aggregateAuthoritativeTaskOutcomes({
1526
+ initialTaskOutcomes,
1527
+ taskExecutions: authoritativeTaskExecutions,
1528
+ steps: executedSteps,
1529
+ dependencyBlockedTaskIds: runtimeDependencyBlockedTaskIds,
1530
+ taskCount: initialTaskOutcomeSummary?.taskCount,
1531
+ finalized: true,
1532
+ })
1533
+ : undefined;
1534
+ const authoritativeCompoundFailure = authoritativeCompoundAsk && !authoritativePartialOutcomeMode
1535
+ ? compoundAskFailureForFrozenTasks({
1536
+ expectedTaskIds: [...authoritativeTaskIds],
1537
+ plan,
1538
+ steps: executedSteps,
1539
+ })
1540
+ : undefined;
1085
1541
  const run = this.finalizeRun({
1086
1542
  runId,
1087
1543
  request,
@@ -1095,6 +1551,8 @@ export class AgentRunEngine {
1095
1551
  finalOutcome,
1096
1552
  clarifyOutcome,
1097
1553
  bestAnswerResult,
1554
+ ...(authoritativeTaskOutcomeAggregate ? { authoritativeTaskOutcomeAggregate } : {}),
1555
+ ...(authoritativeCompoundFailure ? { authoritativeCompoundFailure } : {}),
1098
1556
  budgetUsage: cascadeBudgetTrace(budgets),
1099
1557
  events,
1100
1558
  });
@@ -1114,7 +1572,10 @@ export class AgentRunEngine {
1114
1572
  run.diagnosticReceipt = diagnosticReceiptForRun(run);
1115
1573
  run.diagnosticReceiptV2 = diagnosticReceiptV2ForRun(run);
1116
1574
  run.diagnosticReceiptV3 = diagnosticReceiptV3ForRun(run);
1117
- run.artifacts = attachDiagnosticReceipt(run.artifacts, run.diagnosticReceipt, run.diagnosticReceiptV2, run.diagnosticReceiptV3);
1575
+ run.diagnosticReceiptV4 = diagnosticReceiptV4ForRun(run);
1576
+ attachAskAnalystRuntimeReceipt(run, request.askAgentRuntimeMode);
1577
+ run.artifacts = attachDiagnosticReceipt(run.artifacts, run.diagnosticReceipt, run.diagnosticReceiptV2, run.diagnosticReceiptV3, run.diagnosticReceiptV4, run.diagnosticReceiptV5, run.diagnosticReceiptV6, run.diagnosticReceiptV7, run.diagnosticReceiptV8);
1578
+ finalizeAgentRunTraceV1(traceObserver, run);
1118
1579
  await checkpointQueue;
1119
1580
  await this.store?.save(run);
1120
1581
  return run;
@@ -1166,6 +1627,7 @@ export class AgentRunEngine {
1166
1627
  id: runId,
1167
1628
  question: submittedQuestion,
1168
1629
  requestedMode,
1630
+ conversationBinding: request.conversationBinding ?? traceConversationBinding(request, clarificationContinuation),
1169
1631
  route: cancelledRoute,
1170
1632
  status: "cancelled",
1171
1633
  trustState: "not_applicable",
@@ -1201,6 +1663,9 @@ export class AgentRunEngine {
1201
1663
  };
1202
1664
  run.diagnosticReceiptV2 = diagnosticReceiptV2ForRun(run);
1203
1665
  run.diagnosticReceiptV3 = diagnosticReceiptV3ForRun(run);
1666
+ run.diagnosticReceiptV4 = diagnosticReceiptV4ForRun(run);
1667
+ attachAskAnalystRuntimeReceipt(run, request.askAgentRuntimeMode);
1668
+ finalizeAgentRunTraceV1(traceObserver, run);
1204
1669
  await checkpointQueue;
1205
1670
  await this.store?.save(run);
1206
1671
  return run;
@@ -1208,7 +1673,9 @@ export class AgentRunEngine {
1208
1673
  const message = isOrchestrationBudgetExhausted(err)
1209
1674
  ? 'Ask could not complete within its bounded orchestration. Nothing was executed; narrow the metric or dimension and retry.'
1210
1675
  : err instanceof Error && err.name === "TimeoutError"
1211
- ? "This analytical run reached its time limit before it finished. A timeout alone does not prove a cross-model join or semantic-modeling problem. Open Trust & Steps to see the last recorded phase; retry the same bounded question or use Research for a longer budget. No result was accepted."
1676
+ ? requestedMode === 'research'
1677
+ ? 'This Research run reached its bounded deadline before finalization. Review the recorded branch receipts and trace, then narrow the investigation and retry. No result was accepted.'
1678
+ : "This analytical run reached its time limit before it finished. A timeout alone does not prove a cross-model join or semantic-modeling problem. Open Trust & Steps to see the last recorded phase; retry the same bounded question or use Research for a longer budget. No result was accepted."
1212
1679
  : err instanceof Error ? err.message : String(err);
1213
1680
  const failedRoute = progress.route;
1214
1681
  const failedPhase = progress.lifecycle.phase;
@@ -1220,7 +1687,7 @@ export class AgentRunEngine {
1220
1687
  trustState: "blocked",
1221
1688
  });
1222
1689
  const completedAt = this.timestamp();
1223
- const failure = diagnosticFailureFromError(err, failedPhase);
1690
+ const failure = diagnosticFailureFromError(err, failedPhase, requestedMode);
1224
1691
  const evaluations = [
1225
1692
  ...progress.evaluations,
1226
1693
  {
@@ -1248,6 +1715,7 @@ export class AgentRunEngine {
1248
1715
  id: runId,
1249
1716
  question: submittedQuestion,
1250
1717
  requestedMode,
1718
+ conversationBinding: request.conversationBinding ?? traceConversationBinding(request, clarificationContinuation),
1251
1719
  route: "blocked",
1252
1720
  status: "blocked",
1253
1721
  trustState: "blocked",
@@ -1285,7 +1753,10 @@ export class AgentRunEngine {
1285
1753
  };
1286
1754
  run.diagnosticReceiptV2 = diagnosticReceiptV2ForRun(run);
1287
1755
  run.diagnosticReceiptV3 = diagnosticReceiptV3ForRun(run);
1288
- run.artifacts = attachDiagnosticReceipt(retainedArtifacts, receipt, run.diagnosticReceiptV2, run.diagnosticReceiptV3);
1756
+ run.diagnosticReceiptV4 = diagnosticReceiptV4ForRun(run);
1757
+ attachAskAnalystRuntimeReceipt(run, request.askAgentRuntimeMode);
1758
+ run.artifacts = attachDiagnosticReceipt(retainedArtifacts, receipt, run.diagnosticReceiptV2, run.diagnosticReceiptV3, run.diagnosticReceiptV4, run.diagnosticReceiptV5, run.diagnosticReceiptV6, run.diagnosticReceiptV7, run.diagnosticReceiptV8);
1759
+ finalizeAgentRunTraceV1(traceObserver, run);
1289
1760
  await checkpointQueue;
1290
1761
  await this.store?.save(run);
1291
1762
  return run;
@@ -1296,12 +1767,120 @@ export class AgentRunEngine {
1296
1767
  const repairAttempts = input.budgetUsage.usage.laneExecutionAttemptsUsed;
1297
1768
  const escalationAttempts = input.budgetUsage.usage.engineEscalationsUsed;
1298
1769
  const completedAt = this.timestamp();
1770
+ if (input.authoritativeTaskOutcomeAggregate) {
1771
+ const aggregate = input.authoritativeTaskOutcomeAggregate;
1772
+ const successfulSteps = input.steps.filter((step) => {
1773
+ const taskId = step.askAnalystTaskId;
1774
+ return taskId
1775
+ ? aggregate.summary.successfulTaskIds.includes(taskId)
1776
+ : false;
1777
+ });
1778
+ const lastSuccessfulStep = [...successfulSteps].reverse()[0];
1779
+ const route = lastSuccessfulStep?.resolvedRoute ?? lastSuccessfulStep?.route ?? 'blocked';
1780
+ const hasSuccessfulTask = aggregate.summary.successfulTaskIds.length > 0;
1781
+ const status = !hasSuccessfulTask
1782
+ ? 'blocked'
1783
+ : aggregate.summary.trustState === 'review_required'
1784
+ ? 'needs_review'
1785
+ : 'completed';
1786
+ const artifacts = input.steps.flatMap((step) => step.artifacts);
1787
+ const evaluations = input.steps.flatMap((step) => step.evaluations);
1788
+ const partialSummary = taskOutcomeAggregateSummaryText(aggregate.summary);
1789
+ return {
1790
+ id: input.runId,
1791
+ question: input.request.question,
1792
+ requestedMode: input.requestedMode,
1793
+ conversationBinding: input.request.conversationBinding ?? traceConversationBinding(input.request, undefined),
1794
+ route,
1795
+ status,
1796
+ trustState: aggregate.summary.trustState,
1797
+ stopReason: status === 'blocked'
1798
+ ? 'blocked'
1799
+ : aggregate.summary.trustState === 'review_required'
1800
+ ? 'generated_review_required'
1801
+ : 'governed_compound_answer',
1802
+ startedAt: input.startedAt,
1803
+ completedAt,
1804
+ selectedObject: input.request.selectedObject,
1805
+ executionTarget: input.request.executionTarget,
1806
+ routeDecision: input.routeDecision,
1807
+ plan: input.plan,
1808
+ steps: input.steps,
1809
+ summary: partialSummary,
1810
+ answer: partialSummary,
1811
+ answerKind: 'governed',
1812
+ artifacts,
1813
+ evaluations,
1814
+ events: input.events,
1815
+ nextActions: applyAudienceToNextActions(defaultNextActions(route, status), resolveAudience(input.request), status),
1816
+ repairAttempts,
1817
+ escalationAttempts,
1818
+ budgetUsage: input.budgetUsage,
1819
+ analyticalTaskOutcomes: aggregate.outcomes,
1820
+ analyticalTaskOutcomeSummary: aggregate.summary,
1821
+ ...authoringDerivationFromRequest(input.request),
1822
+ };
1823
+ }
1824
+ if (input.authoritativeCompoundFailure) {
1825
+ const failure = input.authoritativeCompoundFailure;
1826
+ const failedSteps = input.steps.filter((step) => step.askAnalystTaskId && failure.failedTaskIds.includes(step.askAnalystTaskId));
1827
+ // The per-task steps remain on the run and trace as evidence, but a
1828
+ // compound Ask never adopts an earlier data artifact/answer after one
1829
+ // accepted frozen task failed. Returning only the failed artifacts
1830
+ // avoids presenting a partial result as the response to the whole ask.
1831
+ const artifacts = failedSteps.flatMap((step) => step.artifacts);
1832
+ const evaluations = [
1833
+ ...input.steps.flatMap((step) => step.evaluations),
1834
+ {
1835
+ id: 'authoritative-compound-all-or-nothing',
1836
+ label: 'Frozen Ask task completion',
1837
+ passed: false,
1838
+ severity: 'blocking',
1839
+ message: failure.message,
1840
+ evidence: {
1841
+ expectedTaskIds: failure.expectedTaskIds,
1842
+ completedTaskIds: failure.completedTaskIds,
1843
+ failedTaskIds: failure.failedTaskIds,
1844
+ missingTaskIds: failure.missingTaskIds,
1845
+ },
1846
+ },
1847
+ ];
1848
+ return {
1849
+ id: input.runId,
1850
+ question: input.request.question,
1851
+ requestedMode: input.requestedMode,
1852
+ conversationBinding: input.request.conversationBinding ?? traceConversationBinding(input.request, undefined),
1853
+ route: 'blocked',
1854
+ status: 'blocked',
1855
+ trustState: 'blocked',
1856
+ stopReason: 'blocked',
1857
+ startedAt: input.startedAt,
1858
+ completedAt,
1859
+ selectedObject: input.request.selectedObject,
1860
+ executionTarget: input.request.executionTarget,
1861
+ routeDecision: input.routeDecision,
1862
+ plan: input.plan,
1863
+ steps: input.steps,
1864
+ summary: failure.message,
1865
+ answer: failure.message,
1866
+ answerKind: 'governed',
1867
+ artifacts,
1868
+ evaluations,
1869
+ events: input.events,
1870
+ nextActions: applyAudienceToNextActions(defaultNextActions('blocked', 'blocked'), resolveAudience(input.request), 'blocked'),
1871
+ repairAttempts,
1872
+ escalationAttempts,
1873
+ budgetUsage: input.budgetUsage,
1874
+ ...authoringDerivationFromRequest(input.request),
1875
+ };
1876
+ }
1299
1877
  if (!finalStep || !finalResult || !finalOutcome) {
1300
1878
  // No step produced a usable result (e.g. an empty plan). Treat as blocked.
1301
1879
  return {
1302
1880
  id: input.runId,
1303
1881
  question: input.request.question,
1304
1882
  requestedMode: input.requestedMode,
1883
+ conversationBinding: input.request.conversationBinding ?? traceConversationBinding(input.request, undefined),
1305
1884
  route: "blocked",
1306
1885
  status: "blocked",
1307
1886
  trustState: "blocked",
@@ -1331,6 +1910,30 @@ export class AgentRunEngine {
1331
1910
  };
1332
1911
  }
1333
1912
  const route = finalResult.resolvedRoute ?? finalStep.resolvedRoute ?? finalStep.route;
1913
+ // Provider diagnostics are produced at the physical runner boundary.
1914
+ // Merge only that typed, redacted observation; executor prose never gets
1915
+ // to rewrite routing, trust, or cascade authority.
1916
+ // A V2 provider boundary can be observed by the tool runner after its
1917
+ // legacy-shaped no-answer envelope has already crossed the local executor
1918
+ // adapter. The immutable V2 state is shared with this finalizer and is
1919
+ // the durable source of truth at that point. Project its terminal
1920
+ // provider observation here, at the persisted route-decision boundary,
1921
+ // rather than trying to infer it from user-facing error prose upstream.
1922
+ const providerFailure = finalResult.providerFailure
1923
+ // A frozen child receives the same server-owned V2 state as the root
1924
+ // request. Its scoped compiler decision deliberately replaces the
1925
+ // business-plan fields, so use the request carrier as the first-class
1926
+ // persistence fallback rather than losing a physical provider
1927
+ // observation simply because the child route was rehydrated.
1928
+ ?? providerFailureFromAskAgentV2State(input.request.askAgentV2State ?? input.routeDecision.askAgentV2Decision?.state)
1929
+ // Planner/preflight failures can be terminal before a V2 executor emits
1930
+ // an answer envelope. The typed decision is already the authoritative
1931
+ // boundary in that case; retain it for older receipt readers without
1932
+ // classifying user-facing prose.
1933
+ ?? input.routeDecision.providerFailure;
1934
+ const finalRouteDecision = providerFailure
1935
+ ? { ...input.routeDecision, providerFailure }
1936
+ : input.routeDecision;
1334
1937
  // Aggregate artifacts across every accepted step so a multi-step plan
1335
1938
  // (e.g. research → block draft) surfaces all of its durable work, while the
1336
1939
  // status/trust/answer reflect the final step.
@@ -1348,6 +1951,7 @@ export class AgentRunEngine {
1348
1951
  id: input.runId,
1349
1952
  question: input.request.question,
1350
1953
  requestedMode: input.requestedMode,
1954
+ conversationBinding: input.request.conversationBinding ?? traceConversationBinding(input.request, undefined),
1351
1955
  route,
1352
1956
  status: finalOutcome.status,
1353
1957
  trustState: finalOutcome.trustState,
@@ -1356,7 +1960,7 @@ export class AgentRunEngine {
1356
1960
  completedAt,
1357
1961
  selectedObject: input.request.selectedObject,
1358
1962
  executionTarget: input.request.executionTarget,
1359
- routeDecision: input.routeDecision,
1963
+ routeDecision: finalRouteDecision,
1360
1964
  plan: input.plan,
1361
1965
  steps: input.steps,
1362
1966
  summary: finalOutcome.summary,
@@ -1380,6 +1984,8 @@ export class AgentRunEngine {
1380
1984
  ...(finalResult.narrationIntegrityReceipt ? {
1381
1985
  narrationIntegrityReceipt: finalResult.narrationIntegrityReceipt,
1382
1986
  } : {}),
1987
+ ...(finalResult.askAnalystState ? { askAnalystState: finalResult.askAnalystState } : {}),
1988
+ ...(finalResult.businessAnswer ? { businessAnswer: finalResult.businessAnswer } : {}),
1383
1989
  escalationAttempts,
1384
1990
  budgetUsage: input.budgetUsage,
1385
1991
  ...(finalResult.analyticalTurnPlan ? { analyticalTurnPlan: finalResult.analyticalTurnPlan } : {}),
@@ -1411,9 +2017,13 @@ export class AgentRunEngine {
1411
2017
  const signal = context.request.runBudget?.hardSignal ?? context.request.signal;
1412
2018
  if (signal?.aborted)
1413
2019
  throw signal.reason ?? routeTimeoutError();
2020
+ // Route executors own physical provider/tool/SQL boundaries. Preserve the
2021
+ // non-enumerable observer when adding the run signal; a normal object
2022
+ // spread would otherwise leave canonical routing evidence intact while
2023
+ // silently dropping every physical execution span.
1414
2024
  const execution = Promise.resolve(executor({
1415
2025
  ...context,
1416
- request: { ...context.request, ...(signal ? { signal } : {}) },
2026
+ request: attachAskTraceObserverV1({ ...context.request, ...(signal ? { signal } : {}) }, askTraceObserverForV1(context.request)),
1417
2027
  }));
1418
2028
  return awaitWithAbort(execution, signal);
1419
2029
  }
@@ -1431,6 +2041,285 @@ export class AgentRunEngine {
1431
2041
  return this.now().toISOString();
1432
2042
  }
1433
2043
  }
2044
+ function canonicalTaskResultArtifactForStep(step) {
2045
+ if (!step)
2046
+ return undefined;
2047
+ for (const artifact of step.artifacts) {
2048
+ if (artifact.kind !== 'answer' || artifact.trustState === 'blocked')
2049
+ continue;
2050
+ const payload = objectRecordForResultFacts(artifact.payload);
2051
+ const result = objectRecordForResultFacts(payload?.result);
2052
+ const fingerprint = stringForResultFacts(result?.resultFingerprint);
2053
+ if (!result || !fingerprint)
2054
+ continue;
2055
+ const canonical = canonicalResultForFactProjection(result);
2056
+ if (!canonical || canonical.columns.length === 0 || canonical.resultFingerprint !== fingerprint)
2057
+ continue;
2058
+ return { resultFingerprint: fingerprint };
2059
+ }
2060
+ return undefined;
2061
+ }
2062
+ function hasAcceptedAuthoritativeTaskResult(step) {
2063
+ if (step?.status !== 'passed' && step?.status !== 'repaired' && step?.status !== 'needs_review')
2064
+ return false;
2065
+ // A generated/review-required response becomes an accepted independent task
2066
+ // only once the immutable canonical result artifact is present. Narrative
2067
+ // text alone is not evidence that a query executed.
2068
+ return Boolean(canonicalTaskResultArtifactForStep(step));
2069
+ }
2070
+ function aggregateAuthoritativeTaskOutcomes(input) {
2071
+ const outcomeByTaskId = new Map();
2072
+ for (const outcome of input.initialTaskOutcomes) {
2073
+ // Each compiler task is authoritative exactly once. Preserve a planning
2074
+ // gap/dependency receipt while permitting a matching executable task to
2075
+ // replace only its own provisional status after it actually runs.
2076
+ outcomeByTaskId.set(outcome.taskId, {
2077
+ ...outcome,
2078
+ ...(outcome.dependencyTaskIds ? { dependencyTaskIds: [...outcome.dependencyTaskIds] } : {}),
2079
+ });
2080
+ }
2081
+ const orderedTaskIds = [
2082
+ ...input.taskExecutions.map((task) => task.taskId),
2083
+ ...input.initialTaskOutcomes.map((outcome) => outcome.taskId),
2084
+ ].filter((taskId, index, all) => all.indexOf(taskId) === index);
2085
+ for (const taskExecution of input.taskExecutions) {
2086
+ const taskId = taskExecution.taskId;
2087
+ const dependencyTaskIds = input.dependencyBlockedTaskIds.get(taskId);
2088
+ if (dependencyTaskIds?.length) {
2089
+ outcomeByTaskId.set(taskId, {
2090
+ version: 1,
2091
+ taskId,
2092
+ status: 'dependency_blocked',
2093
+ trustState: 'blocked',
2094
+ summary: 'This task was not executed because a required task did not complete successfully.',
2095
+ failure: {
2096
+ version: 1,
2097
+ code: 'DEPENDENCY_BLOCKED',
2098
+ message: 'A prerequisite task did not complete successfully.',
2099
+ phase: 'dependency',
2100
+ },
2101
+ dependencyTaskIds: [...dependencyTaskIds],
2102
+ });
2103
+ continue;
2104
+ }
2105
+ const step = input.steps.find((candidate) => candidate.askAnalystTaskId === taskId);
2106
+ if (!step) {
2107
+ if (!input.finalized)
2108
+ continue;
2109
+ outcomeByTaskId.set(taskId, {
2110
+ version: 1,
2111
+ taskId,
2112
+ status: 'blocked',
2113
+ trustState: 'blocked',
2114
+ summary: 'This task did not receive its required frozen execution attempt.',
2115
+ failure: {
2116
+ version: 1,
2117
+ code: 'TASK_EXECUTION_MISSING',
2118
+ message: 'This task did not receive its required frozen execution attempt.',
2119
+ phase: 'execution',
2120
+ },
2121
+ });
2122
+ continue;
2123
+ }
2124
+ if (hasAcceptedAuthoritativeTaskResult(step)) {
2125
+ const trustState = taskOutcomeTrustForExecutedStep(step, taskExecution);
2126
+ const resultFingerprint = taskResultFingerprintForStep(step);
2127
+ outcomeByTaskId.set(taskId, {
2128
+ version: 1,
2129
+ taskId,
2130
+ status: 'completed',
2131
+ trustState,
2132
+ summary: step.summary,
2133
+ ...(resultFingerprint ? { resultFingerprint } : {}),
2134
+ });
2135
+ continue;
2136
+ }
2137
+ const isClarification = step.status === 'clarify';
2138
+ const acceptedWithoutCanonicalResult = step.status === 'passed'
2139
+ || step.status === 'repaired'
2140
+ || step.status === 'needs_review';
2141
+ const message = acceptedWithoutCanonicalResult
2142
+ ? 'This task did not produce an immutable canonical result artifact.'
2143
+ : step.summary ?? (isClarification
2144
+ ? 'This task requires a business clarification before it can run.'
2145
+ : 'This task did not complete its frozen execution.');
2146
+ outcomeByTaskId.set(taskId, {
2147
+ version: 1,
2148
+ taskId,
2149
+ status: isClarification ? 'gap' : 'blocked',
2150
+ trustState: 'blocked',
2151
+ summary: message,
2152
+ failure: {
2153
+ version: 1,
2154
+ code: acceptedWithoutCanonicalResult
2155
+ ? 'TASK_EXECUTION_RESULT_MISSING'
2156
+ : isClarification ? 'TASK_REQUIRES_CLARIFICATION' : 'TASK_EXECUTION_FAILED',
2157
+ message,
2158
+ phase: 'execution',
2159
+ },
2160
+ });
2161
+ }
2162
+ const outcomes = orderedTaskIds
2163
+ .map((taskId) => outcomeByTaskId.get(taskId))
2164
+ .filter((outcome) => Boolean(outcome));
2165
+ const successfulTaskIds = outcomes
2166
+ .filter((outcome) => outcome.status === 'completed' || outcome.status === 'partial')
2167
+ .map((outcome) => outcome.taskId);
2168
+ const failedTaskIds = outcomes
2169
+ .filter((outcome) => outcome.status !== 'completed' && outcome.status !== 'partial' && outcome.status !== 'dependency_blocked')
2170
+ .map((outcome) => outcome.taskId);
2171
+ const dependencyBlockedTaskIds = outcomes
2172
+ .filter((outcome) => outcome.status === 'dependency_blocked')
2173
+ .map((outcome) => outcome.taskId);
2174
+ const successfulTrustStates = outcomes
2175
+ .filter((outcome) => outcome.status === 'completed' || outcome.status === 'partial')
2176
+ .map((outcome) => outcome.trustState ?? 'blocked');
2177
+ const taskCount = Math.max(input.taskCount ?? 0, outcomes.length);
2178
+ return {
2179
+ outcomes,
2180
+ summary: {
2181
+ version: 1,
2182
+ status: successfulTaskIds.length === 0
2183
+ ? 'blocked'
2184
+ : failedTaskIds.length || dependencyBlockedTaskIds.length || successfulTaskIds.length < taskCount
2185
+ ? 'partial'
2186
+ : 'completed',
2187
+ trustState: leastTrustedExecutedTaskOutcomeState(successfulTrustStates),
2188
+ taskCount,
2189
+ successfulTaskIds,
2190
+ failedTaskIds,
2191
+ dependencyBlockedTaskIds,
2192
+ },
2193
+ };
2194
+ }
2195
+ function taskOutcomeTrustForExecutedStep(step, taskExecution) {
2196
+ const artifactStates = step.artifacts
2197
+ .map((artifact) => normalizeTaskOutcomeTrustState(artifact.trustState))
2198
+ .filter((state) => Boolean(state));
2199
+ const compiledState = taskExecution.compiledTrustState
2200
+ ?? taskOutcomeTrustStateForCompiler(taskExecution.resolvedPlan.compiler);
2201
+ // A review-required compiler is never elevated merely because an adapter
2202
+ // artifact used the older `governed` label.
2203
+ if (step.status === 'needs_review' || compiledState === 'review_required')
2204
+ return 'review_required';
2205
+ return leastTrustedExecutedTaskOutcomeState([...artifactStates, compiledState]);
2206
+ }
2207
+ function normalizeTaskOutcomeTrustState(trustState) {
2208
+ if (trustState === 'grounded')
2209
+ return 'governed';
2210
+ return trustState === 'certified'
2211
+ || trustState === 'governed'
2212
+ || trustState === 'review_required'
2213
+ || trustState === 'blocked'
2214
+ || trustState === 'not_applicable'
2215
+ ? trustState
2216
+ : undefined;
2217
+ }
2218
+ function taskOutcomeTrustStateForCompiler(compiler) {
2219
+ if (compiler === 'certified')
2220
+ return 'certified';
2221
+ if (compiler === 'metricflow' || compiler === 'governed_relational')
2222
+ return 'governed';
2223
+ if (compiler === 'exploratory_sql')
2224
+ return 'review_required';
2225
+ return 'blocked';
2226
+ }
2227
+ function leastTrustedExecutedTaskOutcomeState(states) {
2228
+ if (states.length === 0)
2229
+ return 'blocked';
2230
+ const score = {
2231
+ certified: 4,
2232
+ governed: 3,
2233
+ review_required: 2,
2234
+ not_applicable: 1,
2235
+ blocked: 0,
2236
+ };
2237
+ return states.reduce((least, candidate) => score[candidate] < score[least] ? candidate : least);
2238
+ }
2239
+ function taskResultFingerprintForStep(step) {
2240
+ return canonicalTaskResultArtifactForStep(step)?.resultFingerprint;
2241
+ }
2242
+ function taskOutcomeAggregateSummaryText(summary) {
2243
+ if (summary.status === 'completed') {
2244
+ return `All ${summary.taskCount} independent analytical task${summary.taskCount === 1 ? '' : 's'} completed.`;
2245
+ }
2246
+ if (summary.status === 'partial') {
2247
+ return `${summary.successfulTaskIds.length} of ${summary.taskCount} independent analytical task${summary.taskCount === 1 ? '' : 's'} completed. The remaining task receipts explain what needs attention.`;
2248
+ }
2249
+ return 'No independently executable analytical task completed. Review the task receipts for the recorded gaps or dependency blocks.';
2250
+ }
2251
+ /**
2252
+ * A compound authoritative Ask is accepted only after every task has its own
2253
+ * immutable program. Preserve a receipt for every attempted child, then make
2254
+ * the parent terminal when any child blocked/clarified/escalated or was never
2255
+ * attempted. This is intentionally evaluated after the queue drains so a
2256
+ * first failure cannot hide a later frozen task from the trace.
2257
+ */
2258
+ function compoundAskFailureForFrozenTasks(input) {
2259
+ const expectedTaskIds = [...input.expectedTaskIds];
2260
+ const expected = new Set(expectedTaskIds);
2261
+ const taskSteps = input.steps.filter((step) => step.askAnalystTaskId && expected.has(step.askAnalystTaskId));
2262
+ const completedTaskIds = [...new Set(taskSteps
2263
+ .filter((step) => step.status === 'passed' || step.status === 'repaired' || step.status === 'needs_review')
2264
+ .map((step) => step.askAnalystTaskId))];
2265
+ const failedTaskIds = [...new Set(taskSteps
2266
+ .filter((step) => step.status === 'blocked' || step.status === 'clarify' || step.status === 'escalated')
2267
+ .map((step) => step.askAnalystTaskId))];
2268
+ const attempted = new Set(taskSteps.map((step) => step.askAnalystTaskId));
2269
+ const missingTaskIds = expectedTaskIds.filter((taskId) => !attempted.has(taskId));
2270
+ if (failedTaskIds.length === 0 && missingTaskIds.length === 0 && completedTaskIds.length === expectedTaskIds.length) {
2271
+ return undefined;
2272
+ }
2273
+ const failureParts = [
2274
+ failedTaskIds.length ? `${failedTaskIds.length} frozen task${failedTaskIds.length === 1 ? '' : 's'} failed` : '',
2275
+ missingTaskIds.length ? `${missingTaskIds.length} frozen task${missingTaskIds.length === 1 ? '' : 's'} did not receive an execution attempt` : '',
2276
+ ].filter(Boolean);
2277
+ return {
2278
+ expectedTaskIds,
2279
+ completedTaskIds,
2280
+ failedTaskIds,
2281
+ missingTaskIds,
2282
+ message: `The Ask plan ran every available frozen task, but ${failureParts.join(' and ') || 'the task receipts were incomplete'}. No partial result was accepted.`,
2283
+ };
2284
+ }
2285
+ function traceLinkFingerprint(value) {
2286
+ return `sha256:${createHash('sha256').update(value).digest('hex')}`;
2287
+ }
2288
+ /** Only a host-produced repair derivation is linkable; ordinary authoring context is not. */
2289
+ function traceDerivedSourceRunId(workspaceContext) {
2290
+ if (!workspaceContext || typeof workspaceContext !== 'object' || Array.isArray(workspaceContext))
2291
+ return undefined;
2292
+ const record = workspaceContext;
2293
+ const derivation = record.traceDerivation;
2294
+ if (derivation !== 'analytical_repair' && derivation !== 'derived_repair')
2295
+ return undefined;
2296
+ return typeof record.sourceRunId === 'string' && record.sourceRunId.trim()
2297
+ ? record.sourceRunId
2298
+ : undefined;
2299
+ }
2300
+ /**
2301
+ * Record only why the conversation boundary was available, never the member,
2302
+ * question, row, or free-text selection that it carried. The local runtime
2303
+ * resolves its typed follow-up before retrieval; this trace label lets an
2304
+ * office reproduction distinguish a missing binding from a later retrieval or
2305
+ * execution failure without turning traces into chat persistence.
2306
+ */
2307
+ function traceConversationBinding(request, clarification) {
2308
+ if (clarification || request.selectedEvidenceId)
2309
+ return 'structured_clarification';
2310
+ if (request.conversationBinding)
2311
+ return request.conversationBinding;
2312
+ if (request.selectedResultBinding)
2313
+ return 'prior_result';
2314
+ const context = request.conversationContext;
2315
+ if (!context || Object.keys(context).length === 0)
2316
+ return 'none';
2317
+ if ('analyticalTaskDependencyBinding' in context)
2318
+ return 'task_dependency';
2319
+ // A snapshot merely makes a prior binding *available*. It is not evidence
2320
+ // that this self-contained question selected it.
2321
+ return 'none';
2322
+ }
1434
2323
  /**
1435
2324
  * Ordinary analytical Ask cannot delegate meaning to the legacy answer
1436
2325
  * generator. Retrieval/meaning must first produce the immutable RAP consumed by
@@ -1595,14 +2484,19 @@ function applyExploratoryExecutionFreeze(decision, freeze) {
1595
2484
  return decision;
1596
2485
  const cascade = decision.analyticalCascadeDecision;
1597
2486
  const attempt = cascade?.attempts.find((candidate) => candidate.tier === 'exploratory_sql');
2487
+ const selectedPlan = decision.resolvedAnalyticalPlan;
2488
+ const existing = cascade?.exploratoryExecutionFreeze;
2489
+ const existingRepair = cascade?.exploratoryRepairExecutionFreeze;
2490
+ const authorizationAttempt = normalizedExploratoryAuthorizationAttempt(freeze);
1598
2491
  const sameCandidates = Boolean(attempt
1599
2492
  && attempt.candidateIds.length === freeze.candidateIds.length
1600
2493
  && attempt.candidateIds.every((candidate, index) => candidate === freeze.candidateIds[index]));
1601
2494
  const retrievalSnapshotId = decision.retrievalEvidence?.snapshotId;
1602
- const valid = Boolean(cascade
2495
+ const validBaseReceipt = Boolean(cascade
1603
2496
  && cascade.selectedTier === 'exploratory_sql'
1604
- && cascade.planFrozen === false
2497
+ && cascade.planFrozen === true
1605
2498
  && attempt?.outcome === 'executable'
2499
+ && attempt.planFrozen === true
1606
2500
  && sameCandidates
1607
2501
  && freeze.version === 1
1608
2502
  && freeze.selectedTier === 'exploratory_sql'
@@ -1612,28 +2506,132 @@ function applyExploratoryExecutionFreeze(decision, freeze) {
1612
2506
  && freeze.snapshotId.trim()
1613
2507
  && freeze.targetFingerprint.trim()
1614
2508
  && freeze.sqlFingerprint.trim()
2509
+ && selectedPlan?.capability === 'bounded_exploration'
2510
+ && selectedPlan.planId === freeze.planId
2511
+ && selectedPlan.fingerprint === freeze.planFingerprint
2512
+ && selectedPlan.snapshotId === freeze.snapshotId
2513
+ && freezeCarriesRequiredOutputBindings(selectedPlan, freeze)
1615
2514
  && (!retrievalSnapshotId || retrievalSnapshotId === freeze.snapshotId));
1616
- if (!valid) {
1617
- throw Object.assign(new Error('The exploratory execution receipt did not match the router-selected candidate set and was not accepted.'), {
1618
- code: 'EXPLORATORY_FREEZE_RECEIPT_MISMATCH',
1619
- });
2515
+ if (!validBaseReceipt) {
2516
+ throw exploratoryAuthorizationStateMismatch();
2517
+ }
2518
+ // A replay of one exact host handoff is harmless. A repair is a fresh,
2519
+ // separately-minted capability, but its receipt must name the initial SQL
2520
+ // authorization and keep every immutable plan binding identical.
2521
+ if (authorizationAttempt.index === 0) {
2522
+ if (existing) {
2523
+ if (sameExploratoryAuthorizationReceipt(existing, freeze))
2524
+ return decision;
2525
+ throw exploratoryAuthorizationStateMismatch();
2526
+ }
2527
+ if (existingRepair)
2528
+ throw exploratoryAuthorizationStateMismatch();
2529
+ return withExploratoryAuthorizationReceipt(decision, freeze, 'initial');
2530
+ }
2531
+ if (authorizationAttempt.index !== 1
2532
+ || !authorizationAttempt.parentSqlFingerprint
2533
+ || !existing
2534
+ || existingRepair
2535
+ || authorizationAttempt.parentSqlFingerprint !== existing.sqlFingerprint
2536
+ || !sameExploratoryPlanBindings(existing, freeze)) {
2537
+ throw exploratoryAuthorizationStateMismatch();
1620
2538
  }
2539
+ return withExploratoryAuthorizationReceipt(decision, freeze, 'repair');
2540
+ }
2541
+ function withExploratoryAuthorizationReceipt(decision, freeze, kind) {
2542
+ const cascade = decision.analyticalCascadeDecision;
1621
2543
  return {
1622
2544
  ...decision,
1623
2545
  analyticalCascadeDecision: {
1624
2546
  ...cascade,
1625
- planFrozen: true,
1626
- exploratoryExecutionFreeze: freeze,
2547
+ ...(kind === 'initial'
2548
+ ? { exploratoryExecutionFreeze: freeze }
2549
+ : { exploratoryRepairExecutionFreeze: freeze }),
1627
2550
  attempts: cascade.attempts.map((candidate) => candidate.tier === 'exploratory_sql'
1628
2551
  ? {
1629
2552
  ...candidate,
1630
- planFrozen: true,
1631
- reason: `${candidate.reason} Host-authorized immutable exploratory execution plan ${freeze.planId}.`,
2553
+ // The router froze the plan before SQL generation. The host only
2554
+ // binds exact SQL/target bytes to that immutable plan. A repair
2555
+ // cannot choose another tier or mutate the analytical frame.
2556
+ reason: kind === 'repair'
2557
+ ? `${candidate.reason} Host authorized one same-plan SQL repair against frozen plan ${freeze.planId}.`
2558
+ : `${candidate.reason} Host authorized SQL execution against frozen plan ${freeze.planId}.`,
1632
2559
  }
1633
2560
  : candidate),
1634
2561
  },
1635
2562
  };
1636
2563
  }
2564
+ function normalizedExploratoryAuthorizationAttempt(freeze) {
2565
+ const attempt = freeze.authorizationAttempt;
2566
+ // V1/V3 persisted receipts predate explicit authorization-attempt evidence.
2567
+ // They are compatible only as the original handoff, never as a repair.
2568
+ if (!attempt)
2569
+ return { index: 0 };
2570
+ if (attempt.version !== 1 || (attempt.index !== 0 && attempt.index !== 1)) {
2571
+ throw exploratoryAuthorizationStateMismatch();
2572
+ }
2573
+ if (attempt.index === 0) {
2574
+ if ('parentSqlFingerprint' in attempt && attempt.parentSqlFingerprint) {
2575
+ throw exploratoryAuthorizationStateMismatch();
2576
+ }
2577
+ return { index: 0 };
2578
+ }
2579
+ if (!attempt.parentSqlFingerprint?.trim())
2580
+ throw exploratoryAuthorizationStateMismatch();
2581
+ return { index: 1, parentSqlFingerprint: attempt.parentSqlFingerprint };
2582
+ }
2583
+ function sameExploratoryPlanBindings(left, right) {
2584
+ return left.version === right.version
2585
+ && left.selectedTier === right.selectedTier
2586
+ && left.planId === right.planId
2587
+ && left.planFingerprint === right.planFingerprint
2588
+ && left.snapshotId === right.snapshotId
2589
+ && left.targetFingerprint === right.targetFingerprint
2590
+ && left.authorization === right.authorization
2591
+ && sameFrozenRequiredOutputBindings(left.requiredOutputBindings, right.requiredOutputBindings)
2592
+ && left.candidateIds.length === right.candidateIds.length
2593
+ && left.candidateIds.every((candidate, index) => candidate === right.candidateIds[index]);
2594
+ }
2595
+ function freezeCarriesRequiredOutputBindings(plan, freeze) {
2596
+ if (!plan)
2597
+ return false;
2598
+ // Pre-V4 persisted plans did not carry an output contract. They remain
2599
+ // readable, but newly frozen plans with explicit outputs must carry the
2600
+ // exact physical binding proofs below.
2601
+ const required = plan.outputContract?.requiredOutputs ?? [];
2602
+ if (required.length === 0)
2603
+ return true;
2604
+ const expected = frozenRequiredOutputBindingProofsForPlan(plan);
2605
+ const actual = freeze.requiredOutputBindings;
2606
+ return expected.length === required.length
2607
+ && Array.isArray(actual)
2608
+ && sameFrozenRequiredOutputBindings(actual, expected);
2609
+ }
2610
+ function sameFrozenRequiredOutputBindings(left, right) {
2611
+ const normalize = (bindings) => (bindings ?? []).map((binding) => [
2612
+ binding.version,
2613
+ binding.outputName.toLowerCase().replace(/["`\[\]]/g, ''),
2614
+ binding.qualifiedId,
2615
+ binding.relation.toLowerCase().replace(/["`\[\]]/g, '').replace(/\s*\.\s*/g, '.'),
2616
+ binding.column.toLowerCase().replace(/["`\[\]]/g, ''),
2617
+ ].join('|')).sort();
2618
+ const leftBindings = normalize(left);
2619
+ const rightBindings = normalize(right);
2620
+ return leftBindings.length === rightBindings.length
2621
+ && leftBindings.every((binding, index) => binding === rightBindings[index]);
2622
+ }
2623
+ function sameExploratoryAuthorizationReceipt(left, right) {
2624
+ const leftAttempt = normalizedExploratoryAuthorizationAttempt(left);
2625
+ const rightAttempt = normalizedExploratoryAuthorizationAttempt(right);
2626
+ return sameExploratoryPlanBindings(left, right)
2627
+ && left.sqlFingerprint === right.sqlFingerprint
2628
+ && left.authorization === right.authorization
2629
+ && leftAttempt.index === rightAttempt.index
2630
+ && leftAttempt.parentSqlFingerprint === rightAttempt.parentSqlFingerprint;
2631
+ }
2632
+ function exploratoryAuthorizationStateMismatch() {
2633
+ return Object.assign(new Error('The exploratory SQL authorization receipt did not match the already-frozen analytical plan. Execution was not attempted.'), { code: 'INTERNAL_EXPLORATORY_AUTHORIZATION_STATE_MISMATCH' });
2634
+ }
1637
2635
  /**
1638
2636
  * Has this exact clarification already been asked in this thread?
1639
2637
  *
@@ -1668,6 +2666,26 @@ function clarificationAlreadyAsked(clarifyingQuestion, history) {
1668
2666
  return lastAsk > 0;
1669
2667
  }
1670
2668
  function enforceOrdinaryAnalyticalPlanBoundary(request, decision) {
2669
+ // V2 deliberately has no deterministic business-meaning terminal at this
2670
+ // seam. Its candidate workspace is a bounded agent input, and pre-freeze
2671
+ // ineligible/unavailable/ambiguous outcomes are returned to that same tool
2672
+ // loop. Do not let V1's rescue/reinterpretation policy create a second
2673
+ // authority before the tool runtime can try the next safe tier.
2674
+ if (decision.askAgentV2Decision?.mode === 'authoritative_v2'
2675
+ || request.askAgentRuntimeMode === 'authoritative_v2') {
2676
+ return decision;
2677
+ }
2678
+ // AskAnalystRuntimeV1 has already retrieved, planned, verified and (when
2679
+ // possible) frozen this ordinary Ask turn. The engine is a dispatcher at
2680
+ // this boundary, not a second cascade owner. In particular, do not let the
2681
+ // legacy modelling-gap rescue reinterpret a pre-freeze canonical decision:
2682
+ // that used to turn one immutable cascade into two competing routes.
2683
+ // Post-freeze executor/warehouse safety checks remain below the engine
2684
+ // boundary and are intentionally unchanged.
2685
+ if (decision.askAnalystDecision?.mode === 'authoritative'
2686
+ || request.askAnalystState?.mode === 'authoritative') {
2687
+ return decision;
2688
+ }
1671
2689
  const ordinaryAsk = request.requestedMode === undefined
1672
2690
  || request.requestedMode === 'auto'
1673
2691
  || request.requestedMode === 'ask';
@@ -1946,10 +2964,21 @@ function terminalLifecycle(prior, phase, completedAt, eventCursor) {
1946
2964
  completedAt,
1947
2965
  };
1948
2966
  }
1949
- function diagnosticFailureFromError(error, phase) {
2967
+ function diagnosticFailureFromError(error, phase, requestedMode) {
1950
2968
  const name = error instanceof Error ? error.name : "";
1951
2969
  const message = error instanceof Error ? error.message : String(error);
1952
2970
  const lower = `${name} ${message}`.toLowerCase();
2971
+ if (error
2972
+ && typeof error === 'object'
2973
+ && error.code === 'INTERNAL_EXPLORATORY_AUTHORIZATION_STATE_MISMATCH') {
2974
+ return {
2975
+ code: 'INTERNAL_EXPLORATORY_AUTHORIZATION_STATE_MISMATCH',
2976
+ phase: 'sql.authorize',
2977
+ message: 'The frozen exploratory plan did not match the SQL authorization receipt. Execution was not attempted.',
2978
+ recoverable: false,
2979
+ safeActions: ['export_redacted_trace'],
2980
+ };
2981
+ }
1953
2982
  if (isOrchestrationBudgetExhausted(error)) {
1954
2983
  return {
1955
2984
  code: 'orchestration_budget_exhausted',
@@ -1960,6 +2989,15 @@ function diagnosticFailureFromError(error, phase) {
1960
2989
  };
1961
2990
  }
1962
2991
  if (name === "TimeoutError" || lower.includes("time limit") || lower.includes("timeout")) {
2992
+ if (requestedMode === 'research') {
2993
+ return {
2994
+ code: 'RESEARCH_RUN_DEADLINE',
2995
+ phase: 'research.run',
2996
+ message: 'Research reached its bounded run deadline before finalization.',
2997
+ recoverable: true,
2998
+ safeActions: ['inspect_failure'],
2999
+ };
3000
+ }
1963
3001
  return {
1964
3002
  code: "TIMEOUT",
1965
3003
  phase,
@@ -2058,6 +3096,11 @@ function diagnosticReceiptV3ForRun(run) {
2058
3096
  // route names or identifier text here: that erased stale/error lane states
2059
3097
  // and falsely reported governed-relational success for pure exploration.
2060
3098
  const cascade = run.routeDecision?.analyticalCascadeDecision;
3099
+ // The router may retain a broader terminal witness for presentation, while
3100
+ // the cascade carries the only persistable, enumerated relationship-proof
3101
+ // receipt. Prefer that immutable cascade value and do not infer a gap from a
3102
+ // failure message or route label here.
3103
+ const terminalGap = cascade?.terminalGap;
2061
3104
  const sourceCoverage = cascade?.sourceCoverage ?? [];
2062
3105
  const planFrozen = cascade?.planFrozen ?? false;
2063
3106
  const artifactProviderDiagnostic = run.artifacts
@@ -2066,50 +3109,1681 @@ function diagnosticReceiptV3ForRun(run) {
2066
3109
  .map((payload) => payload.providerFailure)
2067
3110
  .find((failure) => Boolean(failure) && typeof failure === 'object' && !Array.isArray(failure));
2068
3111
  const persistedProviderDiagnostic = artifactProviderDiagnostic?.diagnostic;
2069
- const provider = persistedProviderDiagnostic && typeof persistedProviderDiagnostic === 'object'
2070
- ? persistedProviderDiagnostic
2071
- : (() => {
2072
- const failure = run.diagnosticReceipt?.failure;
2073
- return failure && (failure.code === 'AI_PROVIDER_FAILURE' || /provider/i.test(failure.code))
2074
- ? classifyProviderFailure({ message: failure.message, code: failure.code, phase: 'generation' })
2075
- : undefined;
2076
- })();
3112
+ const provider = run.routeDecision?.providerFailure
3113
+ ?? (persistedProviderDiagnostic && typeof persistedProviderDiagnostic === 'object'
3114
+ ? persistedProviderDiagnostic
3115
+ : (() => {
3116
+ const failure = run.diagnosticReceipt?.failure;
3117
+ return failure && (failure.code === 'AI_PROVIDER_FAILURE' || /provider/i.test(failure.code))
3118
+ ? classifyProviderFailure({ message: failure.message, code: failure.code, phase: 'generation' })
3119
+ : undefined;
3120
+ })());
2077
3121
  return {
2078
3122
  version: 3,
2079
3123
  runId: run.id,
2080
3124
  sourceCoverage,
2081
3125
  ...(cascade ? { cascade } : {}),
3126
+ ...(terminalGap ? { terminalGap } : {}),
2082
3127
  planFrozen,
2083
3128
  ...(provider ? { provider } : {}),
2084
3129
  finalStopReason: run.stopReason,
2085
3130
  };
2086
3131
  }
2087
- function emptyRunTelemetry(total, fallbackReason) {
2088
- return {
3132
+ /**
3133
+ * Build the one canonical, content-safe Ask story. This is produced from the
3134
+ * authoritative run receipt once, then joined by both the inspector and the
3135
+ * full local trace. Neither surface is allowed to reconstruct an incident from
3136
+ * spans or a generic error string.
3137
+ */
3138
+ function diagnosticReceiptV4ForRun(run) {
3139
+ const cascade = run.routeDecision?.analyticalCascadeDecision;
3140
+ const requirements = cascade?.requirements;
3141
+ const candidates = run.routeDecision?.retrievalEvidence?.candidateTraceMetadata ?? [];
3142
+ const roleCounts = new Map();
3143
+ for (const candidate of candidates) {
3144
+ roleCounts.set(candidate.role, (roleCounts.get(candidate.role) ?? 0) + 1);
3145
+ }
3146
+ const researchBranchObservability = researchBranchObservabilityForRun(run);
3147
+ for (const evidence of researchBranchObservability.evidenceByRole) {
3148
+ roleCounts.set(evidence.role, (roleCounts.get(evidence.role) ?? 0) + evidence.candidateCount);
3149
+ }
3150
+ const terminalIncident = terminalIncidentForRun(run, cascade?.stopReason);
3151
+ const runtimeReviewRequired = run.askAnalystState?.resolvedPlan?.reviewRequired === true
3152
+ || run.routeDecision?.askAnalystDecision?.state.resolvedPlan?.reviewRequired === true;
3153
+ const summaryInput = {
2089
3154
  version: 1,
2090
- stageDurationsMs: { total },
2091
- providerRoundTrips: 0,
2092
- toolCalls: 0,
2093
- sqlExecutions: 0,
2094
- repairs: 0,
2095
- egressReceipts: 0,
2096
- fallbackReason,
3155
+ understoodRequest: {
3156
+ measures: requirements?.measures.length ?? 0,
3157
+ dimensions: requirements?.dimensions.length ?? 0,
3158
+ entityRequested: Boolean((requirements?.entityTerms.length ?? 0) || (requirements?.entityDisplayTerms.length ?? 0)),
3159
+ outputCount: requirements?.outputTerms?.length ?? 0,
3160
+ ...(requirements?.ranking
3161
+ ? { ranking: { ...requirements.ranking } }
3162
+ : {}),
3163
+ // This comes from the server-owned request admission, not a generic
3164
+ // `followsUp` heuristic. A complete question with thread history is
3165
+ // still `none` unless it explicitly selected a valid binding.
3166
+ conversationBinding: run.conversationBinding ?? 'none',
3167
+ },
3168
+ evidenceByRole: [...roleCounts.entries()]
3169
+ .map(([role, candidateCount]) => ({ role, candidateCount }))
3170
+ .sort((left, right) => left.role.localeCompare(right.role)),
3171
+ tierDecisions: (cascade?.attempts ?? []).map((attempt) => ({
3172
+ tier: attempt.tier,
3173
+ outcome: attempt.outcome,
3174
+ planFrozen: attempt.planFrozen,
3175
+ })),
3176
+ ...(cascade?.selectedTier
3177
+ ? {
3178
+ selectedPlan: {
3179
+ tier: cascade.selectedTier,
3180
+ planFrozen: cascade.planFrozen,
3181
+ reviewRequired: cascade.selectedTier === 'exploratory_sql' || runtimeReviewRequired,
3182
+ },
3183
+ }
3184
+ : {}),
3185
+ ...(terminalIncident ? { terminalIncident } : {}),
3186
+ ...(researchBranchObservability.summary ? { researchBranchSummary: researchBranchObservability.summary } : {}),
3187
+ safeNextAction: terminalIncident?.safeAction
3188
+ ?? (researchBranchObservability.summary?.partialSuccess
3189
+ ? researchBranchObservability.summary.safeAction
3190
+ : 'none'),
3191
+ };
3192
+ const summary = {
3193
+ ...summaryInput,
3194
+ summaryFingerprint: receiptFingerprint(summaryInput),
2097
3195
  };
2098
- }
2099
- function withTotalDuration(telemetry, total) {
2100
3196
  return {
2101
- ...telemetry,
2102
- stageDurationsMs: { ...telemetry.stageDurationsMs, total },
3197
+ version: 4,
3198
+ runId: run.id,
3199
+ summary,
3200
+ ...(terminalIncident ? { terminalIncident } : {}),
3201
+ finalStopReason: run.stopReason,
2103
3202
  };
2104
3203
  }
2105
- function durationBetweenMs(startedAt, completedAt) {
2106
- const duration = Date.parse(completedAt) - Date.parse(startedAt);
2107
- return Number.isFinite(duration) && duration >= 0 ? Math.min(86_400_000, duration) : 0;
3204
+ /**
3205
+ * Attach the V1.15 runtime state after the executor settles. The state was
3206
+ * created before the compiler broker ran; this final projection adds only
3207
+ * outcome counters and never asks a legacy layer to reinterpret the question.
3208
+ */
3209
+ function attachAskAnalystRuntimeReceipt(run, runtimeMode) {
3210
+ if (runtimeMode)
3211
+ run.askAgentRuntimeMode = runtimeMode;
3212
+ const initial = run.askAnalystState ?? run.routeDecision?.askAnalystDecision?.state;
3213
+ if (!initial) {
3214
+ attachAskAgentV2RuntimeReceipt(run);
3215
+ return;
3216
+ }
3217
+ const phase = run.status === 'needs_clarification'
3218
+ ? 'clarify'
3219
+ : run.status === 'blocked' || run.status === 'cancelled'
3220
+ ? 'blocked'
3221
+ : 'executed';
3222
+ const executionAttempts = analyticalExecutionAttemptCount(run);
3223
+ const workspaceTools = runtimeWorkspaceToolsForRun(initial, run, executionAttempts);
3224
+ const state = finalizeAskAnalystState(initial, {
3225
+ phase,
3226
+ workspaceTools,
3227
+ ...(run.routeDecision?.askAnalystDecision?.resolvedPlan
3228
+ ? { resolvedPlan: run.routeDecision.askAnalystDecision.resolvedPlan }
3229
+ : {}),
3230
+ toolCalls: workspaceTools.length,
3231
+ executionAttempts,
3232
+ repairAttempts: run.repairAttempts,
3233
+ });
3234
+ run.askAnalystState = state;
3235
+ // Ordinary Ask intentionally does not send result rows to a narration
3236
+ // provider. Every successful compiler still owes the reader a useful,
3237
+ // fact-bound answer, though. Project the canonical local result into a
3238
+ // bounded set of deterministic facts before accepting the BusinessAnswer;
3239
+ // this is the common path for certified blocks, MetricFlow, and safe
3240
+ // physical execution that did not already produce graph-native facts.
3241
+ attachDeterministicResultFacts(run);
3242
+ // The persisted/runtime envelope is the only reader-facing answer authority
3243
+ // for an authoritative Ask. Never let an executor's arbitrary `answer`
3244
+ // bypass the fact/narrative validation below after the runtime has frozen a
3245
+ // program.
3246
+ run.businessAnswer = businessAnswerForRun(run);
3247
+ run.answer = run.businessAnswer.answer;
3248
+ run.diagnosticReceiptV5 = diagnosticReceiptV5ForRun(run, state, run.businessAnswer);
3249
+ run.diagnosticReceiptV6 = diagnosticReceiptV6ForRun(run, state, run.diagnosticReceiptV5);
3250
+ run.diagnosticReceiptV7 = diagnosticReceiptV7ForRun(run, state, run.diagnosticReceiptV6);
3251
+ }
3252
+ /** V2's compact receipt is additive and deliberately does not alter V1-V7. */
3253
+ function attachAskAgentV2RuntimeReceipt(run) {
3254
+ const state = run.routeDecision?.askAgentV2Decision?.state;
3255
+ if (!state)
3256
+ return;
3257
+ run.askAgentRuntimeMode ??= state.mode;
3258
+ // The V2 tool runtime may already have recorded the exact terminal boundary
3259
+ // (for example provider versus execution failure). Do not overwrite it
3260
+ // with the engine's broad status during persistence.
3261
+ if (!state.terminalOutcome) {
3262
+ state.terminal = run.status === 'needs_clarification'
3263
+ ? 'clarification'
3264
+ : run.status === 'blocked' || run.status === 'cancelled'
3265
+ ? 'error'
3266
+ : 'completed';
3267
+ }
3268
+ // V8 reports only V2 tool/execution evidence. A route step or an inspected
3269
+ // candidate is not a warehouse connection, and a failed validation is not a
3270
+ // result. Deriving these fields from the actual canonical tool receipts
3271
+ // keeps a terminal tool error blocked instead of making it look like a
3272
+ // review-required generated result.
3273
+ const executionTools = new Set([
3274
+ 'run_certified',
3275
+ 'compile_and_run_semantic',
3276
+ 'compile_and_run_dql',
3277
+ 'validate_and_run_sql',
3278
+ ]);
3279
+ const executionObservations = state.observations.filter((observation) => executionTools.has(observation.tool)
3280
+ && (observation.outcome === 'executed' || observation.outcome === 'error')
3281
+ && observation.origin === 'execution');
3282
+ const executionAttempts = executionObservations.length;
3283
+ const hasExecutedResult = executionObservations.some((observation) => observation.outcome === 'executed');
3284
+ // V2 deliberately has no V1 `resolvedAnalyticalPlan`. Once its immutable
3285
+ // tool receipt proves a frozen execution result, project the same bounded
3286
+ // deterministic facts used by the older authoritative runtime. This is
3287
+ // presentation only: it neither reroutes the question nor grants a new
3288
+ // execution capability.
3289
+ if (hasExecutedResult) {
3290
+ attachDeterministicResultFacts(run);
3291
+ run.businessAnswer = businessAnswerForRun(run);
3292
+ run.answer = run.businessAnswer.answer;
3293
+ }
3294
+ // The V2 receipt has no row/prompt payload. It may nevertheless state the
3295
+ // count of accepted fact identities only after an actual result boundary.
3296
+ const businessAnswer = run.businessAnswer ?? businessAnswerForRun(run);
3297
+ run.diagnosticReceiptV8 = createAskToolKernelV2(state).diagnosticReceipt(run.stopReason, {
3298
+ connectionAttempted: executionAttempts > 0,
3299
+ executionAttempts,
3300
+ factCount: hasExecutedResult ? businessAnswer.factIds.length : 0,
3301
+ narration: hasExecutedResult && businessAnswer.mode === 'facts_only'
3302
+ ? 'fact_bound'
3303
+ : run.status === 'needs_clarification'
3304
+ ? 'not_applicable'
3305
+ : 'deterministic_fallback',
3306
+ }, {
3307
+ // These are physical egress receipts owned by the server wrapper. A
3308
+ // provider planning observation alone never increments the user-visible
3309
+ // dispatch count.
3310
+ providerDispatches: run.providerEgressReceipts?.length ?? 0,
3311
+ toolCalls: state.observations.filter((observation) => !observation.executionAuthorized).length,
3312
+ executionAttempts,
3313
+ repairs: state.observations.filter((observation) => observation.executionAuthorized && observation.samePlanRepair).length,
3314
+ });
3315
+ }
3316
+ /** Preserve either persisted state version while adding executor-owned facts. */
3317
+ function finalizeAskAnalystState(initial, input) {
3318
+ return {
3319
+ ...initial,
3320
+ phase: input.phase,
3321
+ workspace: {
3322
+ ...initial.workspace,
3323
+ tools: input.workspaceTools,
3324
+ },
3325
+ ...(input.resolvedPlan ? { resolvedPlan: input.resolvedPlan } : {}),
3326
+ toolCalls: input.toolCalls,
3327
+ executionAttempts: input.executionAttempts,
3328
+ repairAttempts: input.repairAttempts,
3329
+ };
3330
+ }
3331
+ function analyticalExecutionAttemptCount(run) {
3332
+ const executableRoutes = new Set([
3333
+ 'certified_answer', 'semantic_answer', 'generated_answer', 'research',
3334
+ ]);
3335
+ return run.steps
3336
+ .filter((step) => executableRoutes.has(step.resolvedRoute ?? step.route))
3337
+ .reduce((total, step) => total + step.attempts, 0);
3338
+ }
3339
+ /**
3340
+ * Keep the default runtime story short and useful: snapshot, interpretation
3341
+ * when a provider actually ran, compiler, execution, and the one permitted
3342
+ * repair. Detailed nested spans remain in Advanced trace instead of flooding
3343
+ * the first diagnostic view with every retrieval lane.
3344
+ */
3345
+ function runtimeWorkspaceToolsForRun(initial, run, executionAttempts) {
3346
+ const tools = [...initial.workspace.tools];
3347
+ const has = (kind) => tools.some((tool) => tool.kind === kind);
3348
+ const candidateIds = initial.workspace.admittedCandidateIds.slice(0, 32);
3349
+ // Provider meaning receipts are written by AskAnalystRuntimeV1 at the actual
3350
+ // call boundary. Do not synthesize one from aggregate telemetry here.
3351
+ if (executionAttempts > 0 && !has('execute')) {
3352
+ tools.push({
3353
+ version: 1,
3354
+ id: 'tool:execute',
3355
+ kind: 'execute',
3356
+ status: run.status === 'blocked' || run.status === 'cancelled' ? 'failed' : 'completed',
3357
+ candidateIds,
3358
+ reasonCode: run.status === 'blocked' || run.status === 'cancelled' ? 'execution_failed' : 'execution_completed',
3359
+ });
3360
+ }
3361
+ if (run.repairAttempts > 0 && !has('repair')) {
3362
+ tools.push({
3363
+ version: 1,
3364
+ id: 'tool:repair',
3365
+ kind: 'repair',
3366
+ status: run.status === 'blocked' ? 'failed' : 'completed',
3367
+ candidateIds,
3368
+ reasonCode: run.status === 'blocked' ? 'repair_exhausted' : 'repair_completed',
3369
+ });
3370
+ }
3371
+ return tools.slice(0, 12);
3372
+ }
3373
+ const RESULT_FACT_MAX_ROWS = 10;
3374
+ const RESULT_FACT_MAX_COLUMNS = 12;
3375
+ const RESULT_FACT_MAX_VALUE_CHARS = 1_024;
3376
+ const RESULT_FACT_NARRATIVE_ROWS = 5;
3377
+ /**
3378
+ * Turn a successful canonical result into local, result-fingerprint-bound
3379
+ * facts when a compiler did not emit the stricter analytical graph fact set.
3380
+ * This never calls a provider and never trusts an executor's prose. It is
3381
+ * deliberately bounded by rows, columns, and scalar size so a result cannot
3382
+ * turn an Ask receipt into an unbounded secondary data store.
3383
+ */
3384
+ function attachDeterministicResultFacts(run) {
3385
+ if (run.status !== 'completed' && run.status !== 'needs_review')
3386
+ return;
3387
+ // Do not let a prior SQL cell, a draft, or an unrelated answer artifact
3388
+ // become reader-facing fact authority. A fact projection belongs only to the
3389
+ // executed answer artifact from the final authoritative frozen plan.
3390
+ const authoritative = authoritativeExecutedAnswerArtifactsForRun(run);
3391
+ const authoritativeIds = new Set(authoritative.map(({ artifact }) => artifact.id));
3392
+ if (authoritativeIds.size === 0)
3393
+ return;
3394
+ run.artifacts = run.artifacts.map((artifact) => {
3395
+ if (!authoritativeIds.has(artifact.id))
3396
+ return artifact;
3397
+ const payload = objectRecordForResultFacts(artifact.payload);
3398
+ if (!payload || payload.kind === 'no_answer' || hasFactLinkedNarrative(payload))
3399
+ return artifact;
3400
+ const projection = deterministicResultFactProjection({
3401
+ artifactId: artifact.id,
3402
+ trustState: artifact.trustState,
3403
+ question: run.question,
3404
+ result: payload.result,
3405
+ answerTier: typeof payload.answerTier === 'string' ? payload.answerTier : undefined,
3406
+ });
3407
+ if (!projection)
3408
+ return artifact;
3409
+ return {
3410
+ ...artifact,
3411
+ payload: {
3412
+ ...payload,
3413
+ analyticalFacts: projection.factSet,
3414
+ analyticalNarrative: projection.narrative,
3415
+ },
3416
+ };
3417
+ });
3418
+ }
3419
+ /**
3420
+ * Facts and narrative may only come from the final execution artifact selected
3421
+ * by an authoritative frozen Ask plan. `run.artifacts` intentionally retains
3422
+ * earlier durable work for inspection, so scanning it wholesale would let a
3423
+ * stale SQL cell or previous answer supersede the result the engine actually
3424
+ * accepted. Multiple final answer artifacts are acceptable only when they
3425
+ * prove the same canonical result fingerprint; ambiguity fails closed.
3426
+ */
3427
+ function authoritativeExecutedAnswerArtifactsForRun(run) {
3428
+ // Notebook Ask submits ordinary analytical turns as `auto`; the runtime has
3429
+ // already classified and frozen the authoritative Ask plan by this point.
3430
+ // Treating only the legacy explicit `ask` mode as fact eligible discarded
3431
+ // verified result facts after a successful query and produced the generic
3432
+ // "no fact-linked narrative" message. Other modes remain closed here.
3433
+ const runtimeFrozenAuthoritative = run.askAnalystState?.mode === 'authoritative'
3434
+ && run.askAnalystState.resolvedPlan?.planFrozen === true;
3435
+ const decisionFrozenAuthoritative = run.routeDecision?.resolvedAnalyticalPlan?.mode === 'authoritative'
3436
+ && run.routeDecision.analyticalCascadeDecision?.planFrozen === true;
3437
+ // V2 freezes its typed plan in the tool kernel rather than in V1's
3438
+ // `resolvedAnalyticalPlan`. It may project local facts only when the
3439
+ // terminal state says `finish_answer` *and* a real execution observation
3440
+ // exists; a provider/general answer cannot acquire governed facts merely by
3441
+ // finishing a turn.
3442
+ const v2State = run.routeDecision?.askAgentV2Decision?.state;
3443
+ const runtimeFrozenAuthoritativeV2 = run.askAgentRuntimeMode === 'authoritative_v2'
3444
+ && v2State?.resolvedPlan?.frozen === true
3445
+ && v2State.terminalOutcome?.kind === 'finish_answer'
3446
+ && v2State.observations.some((observation) => (observation.outcome === 'executed'
3447
+ && observation.origin === 'execution'
3448
+ && (observation.tool === 'run_certified'
3449
+ || observation.tool === 'compile_and_run_semantic'
3450
+ || observation.tool === 'compile_and_run_dql'
3451
+ || observation.tool === 'validate_and_run_sql')));
3452
+ if ((run.requestedMode !== 'ask' && run.requestedMode !== 'auto')
3453
+ || run.status === 'blocked'
3454
+ || run.status === 'cancelled'
3455
+ || (!runtimeFrozenAuthoritative && !decisionFrozenAuthoritative && !runtimeFrozenAuthoritativeV2)) {
3456
+ return [];
3457
+ }
3458
+ const finalStep = [...run.steps].reverse().find((step) => (step.resolvedRoute ?? step.route) === run.route
3459
+ && step.status !== 'blocked'
3460
+ && step.status !== 'clarify');
3461
+ const finalAnswerIds = new Set(finalStep?.artifacts
3462
+ .filter((artifact) => artifact.kind === 'answer' && artifact.trustState !== 'blocked')
3463
+ .map((artifact) => artifact.id));
3464
+ // A V2 host result can reach the engine through its terminal executor
3465
+ // envelope after the step was created. Its aggregate artifacts retain the
3466
+ // frozen result even when the step-local artifact list is empty. This
3467
+ // fallback is deliberately limited to a frozen V2 terminal execution; the
3468
+ // canonical fingerprint check below still rejects ambiguity.
3469
+ const acceptedArtifactIds = runtimeFrozenAuthoritativeV2
3470
+ ? new Set(run.artifacts
3471
+ .filter((artifact) => artifact.kind === 'answer' && artifact.trustState !== 'blocked')
3472
+ .map((artifact) => artifact.id))
3473
+ : finalAnswerIds.size > 0
3474
+ ? finalAnswerIds
3475
+ : undefined;
3476
+ if (!acceptedArtifactIds?.size)
3477
+ return [];
3478
+ const candidates = run.artifacts.flatMap((artifact) => {
3479
+ if (!acceptedArtifactIds.has(artifact.id) || artifact.kind !== 'answer' || artifact.trustState === 'blocked')
3480
+ return [];
3481
+ const payload = objectRecordForResultFacts(artifact.payload);
3482
+ const rawResult = payload && objectRecordForResultFacts(payload.result);
3483
+ const canonical = rawResult ? canonicalResultForFactProjection(rawResult) : undefined;
3484
+ if (!payload || !canonical)
3485
+ return [];
3486
+ return [{ artifact, payload, resultFingerprint: canonical.resultFingerprint }];
3487
+ });
3488
+ const resultFingerprints = new Set(candidates.map((candidate) => candidate.resultFingerprint));
3489
+ return resultFingerprints.size === 1 ? candidates : [];
3490
+ }
3491
+ function deterministicResultFactProjection(input) {
3492
+ const rawResult = objectRecordForResultFacts(input.result);
3493
+ const canonical = rawResult ? canonicalResultForFactProjection(rawResult) : undefined;
3494
+ if (!rawResult || !canonical)
3495
+ return undefined;
3496
+ const rawReceipt = objectRecordForResultFacts(rawResult.executionReceipt);
3497
+ const receiptFingerprint = stringForResultFacts(rawReceipt?.resultFingerprint);
3498
+ if (canonical.columns.length === 0)
3499
+ return undefined;
3500
+ const columns = canonical.columns.slice(0, RESULT_FACT_MAX_COLUMNS);
3501
+ const rows = canonical.rows.slice(0, RESULT_FACT_MAX_ROWS).map((row) => Object.fromEntries(columns.flatMap((column) => {
3502
+ const value = boundedResultFactValue(row[column]);
3503
+ return value === undefined ? [] : [[column, value]];
3504
+ })));
3505
+ const provenance = {
3506
+ artifactId: input.artifactId,
3507
+ trustState: input.trustState,
3508
+ ...(input.answerTier ?? canonical.answerTier ? { answerTier: input.answerTier ?? canonical.answerTier } : {}),
3509
+ ...(receiptFingerprint ? { executionReceiptFingerprint: receiptFingerprint } : {}),
3510
+ };
3511
+ const scopeDetails = {
3512
+ rowCount: canonical.rowCount,
3513
+ returnedRowCount: canonical.rows.length,
3514
+ columns,
3515
+ ...(canonical.truncated ? { truncated: true } : {}),
3516
+ };
3517
+ // The applied window is host-computed at the execution boundary and rides
3518
+ // on the result payload itself — never provider prose. Without it, a
3519
+ // truthful zero-row window answer is indistinguishable from a failure.
3520
+ const rawWindow = objectRecordForResultFacts(rawResult.appliedTimeWindow);
3521
+ const appliedTimeWindow = rawWindow
3522
+ && stringForResultFacts(rawWindow.expression)
3523
+ && stringForResultFacts(rawWindow.startInclusive)
3524
+ && stringForResultFacts(rawWindow.endExclusive)
3525
+ ? {
3526
+ expression: stringForResultFacts(rawWindow.expression),
3527
+ startInclusive: stringForResultFacts(rawWindow.startInclusive),
3528
+ endExclusive: stringForResultFacts(rawWindow.endExclusive),
3529
+ }
3530
+ : undefined;
3531
+ const facts = [
3532
+ {
3533
+ factId: deterministicResultFactId(canonical.resultFingerprint, 'scope', scopeDetails),
3534
+ kind: 'result_scope',
3535
+ resultFingerprint: canonical.resultFingerprint,
3536
+ details: scopeDetails,
3537
+ provenance,
3538
+ },
3539
+ ...(appliedTimeWindow ? [{
3540
+ factId: deterministicResultFactId(canonical.resultFingerprint, 'window', appliedTimeWindow),
3541
+ kind: 'result_window',
3542
+ resultFingerprint: canonical.resultFingerprint,
3543
+ details: appliedTimeWindow,
3544
+ provenance,
3545
+ }] : []),
3546
+ ...rows.map((values, rowIndex) => ({
3547
+ factId: deterministicResultFactId(canonical.resultFingerprint, `row:${rowIndex}`, values),
3548
+ kind: 'result_row',
3549
+ resultFingerprint: canonical.resultFingerprint,
3550
+ rowIndex,
3551
+ values,
3552
+ provenance,
3553
+ })),
3554
+ ];
3555
+ const factSetPayload = {
3556
+ version: 1,
3557
+ resultFingerprint: canonical.resultFingerprint,
3558
+ facts,
3559
+ };
3560
+ const factSet = {
3561
+ ...factSetPayload,
3562
+ factSetId: `result-facts:${deterministicResultHash(factSetPayload).slice(0, 24)}`,
3563
+ };
3564
+ const narrative = deterministicResultNarrative({
3565
+ question: input.question,
3566
+ factSet,
3567
+ rowCount: canonical.rowCount,
3568
+ returnedRowCount: canonical.rows.length,
3569
+ truncated: canonical.truncated === true,
3570
+ columns,
3571
+ });
3572
+ return { factSet, narrative };
3573
+ }
3574
+ function deterministicResultNarrative(input) {
3575
+ const scope = input.factSet.facts[0];
3576
+ const windowFact = input.factSet.facts.find((fact) => fact.kind === 'result_window');
3577
+ const windowText = windowFact
3578
+ ? `${String(windowFact.details?.expression)} (${String(windowFact.details?.startInclusive)} through ${String(windowFact.details?.endExclusive)}, end exclusive)`
3579
+ : undefined;
3580
+ const claims = [{
3581
+ claimId: 'claim:result_scope',
3582
+ factIds: [scope.factId],
3583
+ text: `The query returned ${input.rowCount.toLocaleString()} row${input.rowCount === 1 ? '' : 's'} across ${input.columns.length.toLocaleString()} column${input.columns.length === 1 ? '' : 's'}${input.truncated ? '; the returned rows are truncated.' : '.'}`,
3584
+ }];
3585
+ if (windowFact && windowText && input.returnedRowCount > 0) {
3586
+ claims.push({
3587
+ claimId: 'claim:result_window',
3588
+ factIds: [windowFact.factId],
3589
+ text: `Rows are filtered to ${windowText}.`,
3590
+ });
3591
+ }
3592
+ const rowFacts = input.factSet.facts
3593
+ .filter((fact) => fact.kind === 'result_row' && fact.rowIndex !== undefined && Boolean(fact.values))
3594
+ .slice(0, RESULT_FACT_NARRATIVE_ROWS);
3595
+ const rankedQuestion = /\b(?:top|highest|most|least|lowest)\b/i.test(input.question);
3596
+ for (const fact of rowFacts) {
3597
+ const values = fact.values;
3598
+ const labelColumn = input.columns.find((column) => /(?:customer|account|client|user|name)(?:_|$)/i.test(column) && values[column] != null)
3599
+ ?? input.columns.find((column) => values[column] != null);
3600
+ const label = labelColumn ? deterministicResultDisplayValue(values[labelColumn]) : undefined;
3601
+ const details = input.columns
3602
+ .filter((column) => column !== labelColumn && values[column] !== undefined)
3603
+ .map((column) => `${humanizeResultColumn(column)}: ${deterministicResultDisplayValue(values[column])}`);
3604
+ const text = label
3605
+ ? `${rankedQuestion ? 'Returned result' : 'Result'} ${fact.rowIndex + 1}: ${label}${details.length > 0 ? ` — ${details.join('; ')}` : ''}.`
3606
+ : `Returned result ${fact.rowIndex + 1}${details.length > 0 ? `: ${details.join('; ')}` : '.'}`;
3607
+ claims.push({
3608
+ claimId: `claim:result_row:${fact.rowIndex}`,
3609
+ factIds: [fact.factId],
3610
+ text,
3611
+ });
3612
+ }
3613
+ if (rowFacts.length === 0 && input.returnedRowCount === 0) {
3614
+ // A LIMIT truncates surplus rows; returning zero therefore proves the
3615
+ // window itself matched nothing. Naming the exact dates turns "failure"
3616
+ // into "true and actionable": the reader can see at once whether their
3617
+ // data simply ends before the requested period.
3618
+ claims.push({
3619
+ claimId: 'claim:no_returned_rows',
3620
+ factIds: windowFact ? [scope.factId, windowFact.factId] : [scope.factId],
3621
+ text: windowFact && windowText
3622
+ ? `The query returned no rows for the requested window ${windowText} — the governed source holds no matching rows in that period.`
3623
+ : 'The query completed with zero returned rows.',
3624
+ });
3625
+ }
3626
+ return {
3627
+ version: 1,
3628
+ factSetId: input.factSet.factSetId,
3629
+ text: claims.map((claim) => claim.text).join(' '),
3630
+ claims,
3631
+ };
3632
+ }
3633
+ function hasFactLinkedNarrative(payload) {
3634
+ const factSet = objectRecordForResultFacts(payload.analyticalFacts);
3635
+ const narrative = objectRecordForResultFacts(payload.analyticalNarrative);
3636
+ if (!factSet || !narrative || typeof factSet.factSetId !== 'string' || narrative.factSetId !== factSet.factSetId)
3637
+ return false;
3638
+ // Facts may only narrate the exact canonical result that the artifact
3639
+ // persists. A graph-native fact set produced before a normalization or
3640
+ // execution-receipt change is useful diagnostics, but it is not authority
3641
+ // for the reader-facing answer.
3642
+ const rawResult = objectRecordForResultFacts(payload.result);
3643
+ const canonical = rawResult ? canonicalResultForFactProjection(rawResult) : undefined;
3644
+ if (rawResult && (!canonical || factSet.resultFingerprint !== canonical.resultFingerprint))
3645
+ return false;
3646
+ const factIds = new Set(Array.isArray(factSet.facts)
3647
+ ? factSet.facts.flatMap((fact) => {
3648
+ const record = objectRecordForResultFacts(fact);
3649
+ return typeof record?.factId === 'string' ? [record.factId] : [];
3650
+ })
3651
+ : []);
3652
+ if (factIds.size === 0 || !Array.isArray(narrative.claims))
3653
+ return false;
3654
+ const claims = narrative.claims.flatMap((claim) => {
3655
+ const record = objectRecordForResultFacts(claim);
3656
+ const ids = Array.isArray(record?.factIds)
3657
+ ? record.factIds.filter((id) => typeof id === 'string')
3658
+ : [];
3659
+ return ids.length > 0 ? [ids] : [];
3660
+ });
3661
+ return claims.length > 0 && claims.every((ids) => ids.every((id) => factIds.has(id)));
3662
+ }
3663
+ function canonicalResultForFactProjection(rawResult) {
3664
+ const rawReceipt = objectRecordForResultFacts(rawResult.executionReceipt);
3665
+ const suppliedFingerprint = stringForResultFacts(rawResult.resultFingerprint);
3666
+ const receiptFingerprint = stringForResultFacts(rawReceipt?.resultFingerprint);
3667
+ // The persisted result fingerprint identifies the exact rendered row set.
3668
+ // A nested receipt can identify an earlier graph/adapter boundary instead,
3669
+ // so retain it separately as provenance but never let it replace the reader
3670
+ // result identity. The host has already admitted this internal result at the
3671
+ // execution boundary; this projector never accepts public row input.
3672
+ return normalizeCanonicalQueryResult({
3673
+ columns: rawResult.columns,
3674
+ rows: rawResult.rows,
3675
+ rowCount: rawResult.rowCount,
3676
+ executionTime: rawResult.executionTime,
3677
+ resultFingerprint: suppliedFingerprint ?? receiptFingerprint,
3678
+ executionReceipt: rawResult.executionReceipt,
3679
+ trustState: rawResult.trustState,
3680
+ answerTier: rawResult.answerTier,
3681
+ });
3682
+ }
3683
+ function objectRecordForResultFacts(value) {
3684
+ return value && typeof value === 'object' && !Array.isArray(value)
3685
+ ? value
3686
+ : undefined;
3687
+ }
3688
+ function stringForResultFacts(value) {
3689
+ return typeof value === 'string' && value.trim() ? value.trim() : undefined;
3690
+ }
3691
+ function boundedResultFactValue(value) {
3692
+ if (value === null || typeof value === 'string' || typeof value === 'boolean') {
3693
+ return typeof value === 'string' && value.length > RESULT_FACT_MAX_VALUE_CHARS ? undefined : value;
3694
+ }
3695
+ if (typeof value === 'number')
3696
+ return Number.isFinite(value) ? value : undefined;
3697
+ if (typeof value === 'bigint')
3698
+ return value.toString();
3699
+ try {
3700
+ const serialized = JSON.stringify(value);
3701
+ return serialized && serialized.length <= RESULT_FACT_MAX_VALUE_CHARS ? serialized : undefined;
3702
+ }
3703
+ catch {
3704
+ return undefined;
3705
+ }
3706
+ }
3707
+ function deterministicResultFactId(resultFingerprint, kind, payload) {
3708
+ return `result-fact:${deterministicResultHash({ resultFingerprint, kind, payload }).slice(0, 24)}`;
3709
+ }
3710
+ function deterministicResultHash(value) {
3711
+ return createHash('sha256').update(JSON.stringify(value)).digest('hex');
3712
+ }
3713
+ function deterministicResultDisplayValue(value) {
3714
+ if (value === null)
3715
+ return 'null';
3716
+ if (typeof value === 'string')
3717
+ return value;
3718
+ if (typeof value === 'number')
3719
+ return Number.isFinite(value) ? String(value) : 'not-a-number';
3720
+ if (typeof value === 'boolean')
3721
+ return value ? 'true' : 'false';
3722
+ return typeof value === 'undefined' ? 'undefined' : String(value);
3723
+ }
3724
+ function humanizeResultColumn(column) {
3725
+ return column.replace(/[_-]+/g, ' ').replace(/\b\w/g, (character) => character.toUpperCase());
3726
+ }
3727
+ function businessAnswerForRun(run) {
3728
+ // An accepted compound Ask is all-or-nothing. The engine deliberately
3729
+ // continues through later frozen children after an earlier one fails so the
3730
+ // trace has a receipt for each task; it must not then replace the aggregate
3731
+ // terminal message with a generic connection incident or a later child
3732
+ // result. The blocking evaluation is server-generated at finalization, not
3733
+ // executor prose, so it is a safe deterministic answer authority here.
3734
+ const compoundFailure = run.evaluations.find((evaluation) => evaluation.id === 'authoritative-compound-all-or-nothing'
3735
+ && evaluation.severity === 'blocking'
3736
+ && evaluation.passed === false
3737
+ && typeof evaluation.message === 'string'
3738
+ && evaluation.message.trim().length > 0);
3739
+ if (compoundFailure) {
3740
+ return {
3741
+ version: 1,
3742
+ mode: 'deterministic_fallback',
3743
+ trustState: 'blocked',
3744
+ factIds: [],
3745
+ answer: compoundFailure.message,
3746
+ limitations: ['No partial result was accepted because one or more frozen Ask tasks did not complete.'],
3747
+ };
3748
+ }
3749
+ const authoritative = authoritativeExecutedAnswerArtifactsForRun(run);
3750
+ const factIds = new Set();
3751
+ const resultFingerprint = authoritative[0]?.resultFingerprint;
3752
+ const factSetIds = new Set();
3753
+ const narratives = [];
3754
+ for (const { payload: record } of authoritative) {
3755
+ const factSet = record.analyticalFacts;
3756
+ if (factSet && typeof factSet === 'object' && !Array.isArray(factSet)) {
3757
+ const factRecord = factSet;
3758
+ if (factRecord.resultFingerprint !== resultFingerprint)
3759
+ continue;
3760
+ if (typeof factRecord.factSetId === 'string')
3761
+ factSetIds.add(factRecord.factSetId);
3762
+ if (Array.isArray(factRecord.facts)) {
3763
+ for (const fact of factRecord.facts) {
3764
+ if (fact && typeof fact === 'object' && typeof fact.factId === 'string') {
3765
+ factIds.add(fact.factId);
3766
+ }
3767
+ }
3768
+ }
3769
+ }
3770
+ const narrative = record.analyticalNarrative;
3771
+ if (narrative && typeof narrative === 'object' && !Array.isArray(narrative)) {
3772
+ const narrativeRecord = narrative;
3773
+ const text = typeof narrativeRecord.text === 'string' ? narrativeRecord.text.trim() : '';
3774
+ const factSetId = typeof narrativeRecord.factSetId === 'string' ? narrativeRecord.factSetId : '';
3775
+ const claims = Array.isArray(narrativeRecord.claims)
3776
+ ? narrativeRecord.claims.flatMap((claim) => {
3777
+ if (!claim || typeof claim !== 'object' || Array.isArray(claim))
3778
+ return [];
3779
+ const claimRecord = claim;
3780
+ const ids = Array.isArray(claimRecord.factIds)
3781
+ ? claimRecord.factIds.filter((id) => typeof id === 'string')
3782
+ : [];
3783
+ return [{ factIds: ids }];
3784
+ })
3785
+ : [];
3786
+ if (text && factSetId)
3787
+ narratives.push({ text, factSetId, claims });
3788
+ }
3789
+ }
3790
+ const acceptedNarrative = narratives.find((narrative) => factSetIds.has(narrative.factSetId)
3791
+ && narrative.claims.length > 0
3792
+ && narrative.claims.every((claim) => claim.factIds.length > 0 && claim.factIds.every((id) => factIds.has(id))));
3793
+ const factsOnly = Boolean(acceptedNarrative);
3794
+ const deterministicAnswer = run.status === 'blocked' || run.status === 'cancelled'
3795
+ ? deterministicTerminalAnswerForRun(run)
3796
+ : run.status === 'needs_clarification'
3797
+ ? 'One business choice is required before DQL can run this question.'
3798
+ : run.analyticalTaskOutcomeSummary?.status === 'partial'
3799
+ ? taskOutcomeAggregateSummaryText(run.analyticalTaskOutcomeSummary)
3800
+ : 'The query completed, but no fact-linked narrative was retained. Open the result to review the validated data.';
3801
+ return {
3802
+ version: 1,
3803
+ mode: factsOnly ? 'facts_only' : 'deterministic_fallback',
3804
+ trustState: run.trustState === 'grounded' ? 'governed' : run.trustState,
3805
+ factIds: [...factIds].sort(),
3806
+ ...(resultFingerprint ? { resultFingerprint } : {}),
3807
+ ...(run.analyticalTaskOutcomeSummary ? { taskOutcomeSummary: run.analyticalTaskOutcomeSummary } : {}),
3808
+ answer: acceptedNarrative?.text ?? deterministicAnswer,
3809
+ limitations: run.status === 'blocked'
3810
+ ? ['No executable result was accepted.']
3811
+ : run.status === 'needs_clarification'
3812
+ ? ['A materially different executable business meaning requires a choice.']
3813
+ : run.analyticalTaskOutcomeSummary?.status === 'partial'
3814
+ ? ['One or more independent analytical tasks did not complete; inspect the retained task receipts.']
3815
+ : factsOnly
3816
+ ? []
3817
+ : ['Narrative is deterministic because no validated analytical fact set was retained.'],
3818
+ };
3819
+ }
3820
+ /**
3821
+ * A blocked Ask still needs a useful, content-safe explanation. Derive this
3822
+ * only from the typed terminal incident already persisted for the run: never
3823
+ * surface a raw connector, provider, SQL, or model error through the answer
3824
+ * field. The trace retains the redacted diagnostic receipt for operators.
3825
+ */
3826
+ /** Words that carry no business meaning when matching a question to a field. */
3827
+ const UNMODELED_STOP_WORDS = new Set([
3828
+ 'what', 'which', 'who', 'whom', 'whose', 'where', 'when', 'why', 'how', 'the', 'a', 'an',
3829
+ 'is', 'are', 'was', 'were', 'be', 'been', 'do', 'does', 'did', 'has', 'have', 'had',
3830
+ 'for', 'from', 'with', 'and', 'or', 'of', 'in', 'on', 'at', 'to', 'by', 'per', 'each',
3831
+ 'me', 'my', 'our', 'his', 'her', 'their', 'them', 'they', 'he', 'she', 'it', 'that', 'this',
3832
+ 'show', 'list', 'give', 'find', 'tell', 'belongs', 'belong', 'get', 'top', 'most', 'many',
3833
+ ]);
3834
+ /** The identifier leaves the snapshot actually admitted, as plain labels. */
3835
+ function modeledFieldLabels(run) {
3836
+ const state = run.askAnalystState ?? run.routeDecision?.askAnalystDecision?.state;
3837
+ const ids = [
3838
+ ...(state?.workspace?.workspaceCandidateIds ?? []),
3839
+ ...(state?.workspace?.admittedCandidateIds ?? []),
3840
+ ];
3841
+ const labels = ids.map((id) => {
3842
+ const leaf = id.split(':').pop() ?? id;
3843
+ return (leaf.split('.').pop() ?? leaf).replace(/_/g, ' ').trim().toLowerCase();
3844
+ }).filter((label) => label.length > 2);
3845
+ return [...new Set(labels)];
3846
+ }
3847
+ /**
3848
+ * Say what is missing, and what exists instead.
3849
+ *
3850
+ * "DQL could not prove one safe analytical path" is true and useless: it does
3851
+ * not say which part of the question could not be served, so the reader cannot
3852
+ * tell a modeling gap from a bug and has nothing to try next. When a term in
3853
+ * the question matches nothing the snapshot admitted — asking for "region"
3854
+ * where only locations are modeled — naming that term and the nearest governed
3855
+ * fields turns a dead end into a next step.
3856
+ */
3857
+ function unmodeledRequestAnswer(run) {
3858
+ const question = typeof run.question === 'string' ? run.question : '';
3859
+ if (!question.trim())
3860
+ return undefined;
3861
+ const labels = modeledFieldLabels(run);
3862
+ if (labels.length === 0)
3863
+ return undefined;
3864
+ const haystack = labels.join(' ');
3865
+ const unmodeled = question
3866
+ .toLowerCase()
3867
+ .replace(/[^a-z0-9\s]+/g, ' ')
3868
+ .split(/\s+/)
3869
+ .filter((word) => word.length > 3 && !UNMODELED_STOP_WORDS.has(word))
3870
+ // A term the admitted snapshot never mentions, in any field, anywhere.
3871
+ .find((word) => !haystack.includes(word) && !haystack.includes(word.replace(/s$/, '')));
3872
+ if (!unmodeled)
3873
+ return undefined;
3874
+ const alternatives = labels.filter((label) => !/^\d/.test(label)).slice(0, 5);
3875
+ return `"${unmodeled}" is not modeled in this project, so no governed query can answer it.`
3876
+ + (alternatives.length
3877
+ ? ` The fields that are modeled here include ${alternatives.join(', ')}.`
3878
+ + ' Ask again using one of those, or tell me which should stand in for'
3879
+ + ` "${unmodeled}".`
3880
+ : '');
3881
+ }
3882
+ function deterministicTerminalAnswerForRun(run) {
3883
+ const incident = terminalIncidentForRun(run, run.routeDecision?.analyticalCascadeDecision?.stopReason);
3884
+ switch (incident?.code) {
3885
+ case 'CONNECTION_NOT_CONFIGURED':
3886
+ return 'No database connection is configured yet. Add an approved connection, then retry this question.';
3887
+ case 'PROVIDER_FAILURE':
3888
+ return 'The AI provider could not complete this Ask step. Check provider readiness, then retry.';
3889
+ case 'COMPILATION_FAILED':
3890
+ return 'DQL selected a governed plan but could not compile it for the current target. Review the semantic target, then retry.';
3891
+ case 'RESULT_CONTRACT_MISMATCH':
3892
+ return 'The query ran, but its result did not match the frozen plan. Review the result contract and trace, then retry.';
3893
+ case 'ANALYTICAL_COVERAGE_GAP':
3894
+ return unmodeledRequestAnswer(run)
3895
+ ?? 'DQL could not prove one safe analytical path from the current metadata snapshot. Review the available modeled fields, then retry.';
3896
+ case 'ANALYTICAL_EXECUTION_FAILED':
3897
+ return 'The selected governed query did not complete on the current connection. Review the connection and trace, then retry.';
3898
+ case 'CANCELLED':
3899
+ return 'This Ask run was cancelled before it completed.';
3900
+ default:
3901
+ return 'No executable data answer was accepted for this Ask run.';
3902
+ }
3903
+ }
3904
+ function diagnosticReceiptV5ForRun(run, state, businessAnswer) {
3905
+ const legacy = run.diagnosticReceiptV4?.summary;
3906
+ const selectedCompiler = state.resolvedPlan?.compiler;
3907
+ const ordinaryRoleInferenceAmbiguity = run.status === 'needs_clarification'
3908
+ && (state.version === 2 || state.version === 3)
3909
+ && state.planningReceipt?.verification?.reasonCode === 'ordinary_role_inference_ambiguous';
3910
+ const whatHappened = run.status === 'blocked'
3911
+ ? 'The Ask runtime did not complete an executable analytical answer.'
3912
+ : run.status === 'needs_clarification'
3913
+ ? ordinaryRoleInferenceAmbiguity
3914
+ ? 'The Ask runtime paused because inferred candidate fields need one business choice.'
3915
+ : 'The Ask runtime paused because validated executable meanings materially differ.'
3916
+ : selectedCompiler
3917
+ ? `The Ask runtime compiled one route-neutral program with the ${selectedCompiler} compiler.`
3918
+ : 'The Ask runtime completed without selecting an analytical compiler.';
3919
+ const why = run.status === 'blocked'
3920
+ ? legacy?.terminalIncident
3921
+ ? `The recorded terminal incident was ${legacy.terminalIncident.code}.`
3922
+ : 'No safe executable compiler plan was accepted from the current evidence snapshot.'
3923
+ : run.status === 'needs_clarification'
3924
+ ? ordinaryRoleInferenceAmbiguity
3925
+ ? 'The snapshot retained multiple safe inferred fields for one requested role, so DQL did not choose or execute a query.'
3926
+ : 'The selected meanings would change the result, so DQL did not guess.'
3927
+ : state.resolvedPlan?.reviewRequired
3928
+ ? 'The selected plan required review before generated SQL execution.'
3929
+ : 'The selected plan passed the current compiler and trust boundary.';
3930
+ const impact = run.status === 'blocked'
3931
+ ? 'No executable data answer was completed for this run.'
3932
+ : run.status === 'needs_clarification'
3933
+ ? 'The query is waiting for one business choice; no query was executed.'
3934
+ : businessAnswer.mode === 'facts_only'
3935
+ ? 'The displayed answer is bound to validated result facts.'
3936
+ : 'The displayed answer uses the deterministic narration fallback.';
3937
+ const summaryInput = {
3938
+ version: 2,
3939
+ runtimeMode: state.mode,
3940
+ whatHappened,
3941
+ why,
3942
+ impact,
3943
+ nextAction: legacy?.safeNextAction ?? 'none',
3944
+ ...(selectedCompiler ? { selectedCompiler } : {}),
3945
+ programTaskCount: state.program.taskIds.length,
3946
+ admittedCandidateCount: state.workspace.admittedCandidateIds.length,
3947
+ toolCallCount: state.toolCalls,
3948
+ executionAttempts: state.executionAttempts,
3949
+ };
3950
+ const provider = providerFailureForRun(run);
3951
+ return {
3952
+ version: 5,
3953
+ runId: run.id,
3954
+ state: diagnosticAskAnalystState(state),
3955
+ summary: {
3956
+ ...summaryInput,
3957
+ summaryFingerprint: receiptFingerprint(summaryInput),
3958
+ },
3959
+ businessAnswer: diagnosticBusinessAnswer(businessAnswer),
3960
+ ...(provider ? { provider } : {}),
3961
+ finalStopReason: run.stopReason,
3962
+ };
3963
+ }
3964
+ /**
3965
+ * V6 is the concise, receipt-only Ask story shown by default. It is built
3966
+ * from already durable state at finalization, so it cannot create a second
3967
+ * routing authority or infer a connection/SQL attempt before one occurred.
3968
+ */
3969
+ function diagnosticReceiptV6ForRun(run, state, receipt) {
3970
+ const tools = state.workspace.tools;
3971
+ const tool = (kind) => tools.find((item) => item.kind === kind);
3972
+ const planner = tool('provider_meaning');
3973
+ const extension = tool('candidate_extension');
3974
+ const cascade = run.routeDecision?.analyticalCascadeDecision;
3975
+ const planFrozen = state.resolvedPlan?.planFrozen === true || cascade?.planFrozen === true;
3976
+ const executionAttempts = state.executionAttempts;
3977
+ const connectionAttempted = planFrozen && (executionAttempts > 0 || terminalConnectionSetupFailureForRun(run));
3978
+ const persistedPlanning = state.version === 2 || state.version === 3 ? state.planningReceipt : undefined;
3979
+ // A failed dispatch is still a planner call. Older persisted V2 state could
3980
+ // be checkpointed before its receipt incremented, while the tool boundary
3981
+ // was already durable. Prefer that boundary to avoid a V6/UI story which
3982
+ // says "planner skipped" even though diagnostics show a provider attempt.
3983
+ const plannerAttempted = planner?.status === 'completed' || planner?.status === 'failed';
3984
+ const plannerCalls = Math.max(persistedPlanning?.plannerCalls ?? 0, plannerAttempted ? Math.max(1, state.planningContinuations) : 0);
3985
+ const revisionCalls = persistedPlanning?.revisionCalls
3986
+ ?? (extension?.status === 'completed' && plannerCalls > 1 ? 1 : 0);
3987
+ const plannerMode = persistedPlanning?.mode ?? (plannerCalls === 0
3988
+ ? (state.workspace.admittedCandidateIds.length ? 'deterministic_binding' : 'exact_fast_path')
3989
+ : revisionCalls > 0 ? 'targeted_revision' : 'initial_planner');
3990
+ // V2 workspace admission captures count-only business role coverage after
3991
+ // exact pins, atomic relationship paths, and the 16-card cap. Prefer it to
3992
+ // raw retrieval metadata: the latter answers "what was found", while this
3993
+ // receipt must explain "what the planner could actually use". V1 remains
3994
+ // readable through the legacy trace-metadata fallback.
3995
+ const recordedRoleCoverage = state.workspace.version === 2
3996
+ ? state.workspace.roleCoverage
3997
+ : undefined;
3998
+ const roleCounts = new Map();
3999
+ for (const candidate of run.routeDecision?.retrievalEvidence?.candidateTraceMetadata ?? []) {
4000
+ roleCounts.set(candidate.role, (roleCounts.get(candidate.role) ?? 0) + 1);
4001
+ }
4002
+ const roleCoverage = recordedRoleCoverage?.length
4003
+ ? recordedRoleCoverage
4004
+ .filter((entry) => Number.isFinite(entry.candidateCount) && entry.candidateCount >= 0)
4005
+ .map((entry) => ({
4006
+ role: entry.role,
4007
+ candidateCount: entry.candidateCount,
4008
+ ...(entry.state === 'alternatives' || entry.state === 'proven' ? { state: entry.state } : {}),
4009
+ }))
4010
+ .sort((left, right) => left.role.localeCompare(right.role))
4011
+ : [...roleCounts.entries()]
4012
+ .map(([role, candidateCount]) => ({ role, candidateCount }))
4013
+ .sort((left, right) => left.role.localeCompare(right.role));
4014
+ const terminalIncident = run.diagnosticReceiptV4?.terminalIncident;
4015
+ const verification = persistedPlanning?.verification ?? (state.phase === 'blocked'
4016
+ ? { version: 1, status: 'invalid', missingRoles: [], candidateIds: [], reasonCode: 'pre_freeze_verification_blocked' }
4017
+ : state.phase === 'clarify'
4018
+ ? { version: 1, status: 'ambiguous', missingRoles: [], candidateIds: [], reasonCode: 'validated_meaning_ambiguity' }
4019
+ : { version: 1, status: 'valid', missingRoles: [], candidateIds: state.program.candidateIds.slice(0, 16), reasonCode: 'immutable_program_verified' });
4020
+ const story = [
4021
+ { stage: 'retrieval', status: tool('retrieve_snapshot')?.status === 'failed' ? 'unavailable' : 'completed', reasonCode: tool('retrieve_snapshot')?.reasonCode ?? 'snapshot_not_recorded' },
4022
+ { stage: 'role_coverage', status: state.workspace.workspaceCandidateIds?.length || state.workspace.admittedCandidateIds.length ? 'completed' : 'unavailable', reasonCode: state.workspace.workspaceCandidateIds?.length ? 'bounded_workspace_qualified' : 'no_qualified_workspace' },
4023
+ {
4024
+ stage: 'planner',
4025
+ status: planner?.status === 'failed' ? 'blocked' : plannerCalls > 0 ? 'completed' : 'skipped',
4026
+ reasonCode: planner?.reasonCode ?? plannerMode,
4027
+ },
4028
+ { stage: 'verification', status: verification.status === 'valid' ? 'completed' : verification.status === 'ambiguous' ? 'blocked' : 'blocked', reasonCode: verification.reasonCode },
4029
+ { stage: 'targeted_recovery', status: extension?.status === 'completed' ? 'completed' : 'skipped', reasonCode: extension?.reasonCode ?? 'not_required' },
4030
+ { stage: 'cascade', status: cascade?.planFrozen ? 'completed' : state.phase === 'blocked' ? 'blocked' : 'unavailable', reasonCode: cascade?.stopReason ?? (state.phase === 'blocked' ? 'pre_freeze_blocked' : 'cascade_not_selected') },
4031
+ { stage: 'freeze', status: planFrozen ? 'completed' : 'skipped', reasonCode: planFrozen ? 'immutable_plan_frozen' : 'no_plan_frozen' },
4032
+ { stage: 'connection', status: connectionAttempted ? 'completed' : 'skipped', reasonCode: connectionAttempted ? 'connection_boundary_attempted_after_freeze' : 'connection_not_attempted' },
4033
+ { stage: 'execution', status: executionAttempts > 0 ? 'completed' : 'skipped', reasonCode: executionAttempts > 0 ? 'executor_attempt_recorded' : 'execution_not_attempted' },
4034
+ { stage: 'facts', status: receipt.businessAnswer?.factIds.length ? 'completed' : 'skipped', reasonCode: receipt.businessAnswer?.factIds.length ? 'fact_bound_business_answer' : 'no_accepted_result_facts' },
4035
+ ];
4036
+ return {
4037
+ ...receipt,
4038
+ version: 6,
4039
+ planning: {
4040
+ version: 1,
4041
+ mode: plannerMode,
4042
+ plannerCalls,
4043
+ revisionCalls,
4044
+ verification,
4045
+ },
4046
+ roleCoverage,
4047
+ cascade: {
4048
+ attempts: (cascade?.attempts ?? []).map((attempt) => ({
4049
+ tier: attempt.tier,
4050
+ outcome: attempt.outcome,
4051
+ planFrozen: attempt.planFrozen,
4052
+ })),
4053
+ ...(cascade?.selectedTier ? { selectedTier: cascade.selectedTier } : {}),
4054
+ ...(cascade?.stopReason ? { stopReason: cascade.stopReason } : {}),
4055
+ planFrozen,
4056
+ },
4057
+ ...(terminalIncident ? {
4058
+ origin: {
4059
+ boundary: terminalIncident.boundary,
4060
+ origin: terminalIncident.origin,
4061
+ impact: terminalIncident.impact,
4062
+ },
4063
+ } : {}),
4064
+ connection: { attempted: connectionAttempted },
4065
+ execution: { attempts: executionAttempts },
4066
+ facts: {
4067
+ factCount: receipt.businessAnswer?.factIds.length ?? 0,
4068
+ ...(receipt.businessAnswer?.resultFingerprint ? { resultFingerprint: receipt.businessAnswer.resultFingerprint } : {}),
4069
+ },
4070
+ safeNextAction: receipt.summary.nextAction,
4071
+ story,
4072
+ };
4073
+ }
4074
+ /**
4075
+ * V7 is the compact first-read inspector. It is a pure projection of the
4076
+ * finalized V6 receipt and frozen runtime state, so browser presentation
4077
+ * cannot become a second planner, router, or trust authority.
4078
+ */
4079
+ function diagnosticReceiptV7ForRun(run, state, receipt) {
4080
+ const requirements = state.frame.requirements;
4081
+ const planning = receipt.planning;
4082
+ const resolvedPlan = state.resolvedPlan ?? run.routeDecision?.askAnalystDecision?.resolvedPlan;
4083
+ const factCount = receipt.facts.factCount;
4084
+ const narration = factCount > 0
4085
+ ? 'fact_bound'
4086
+ : run.status === 'completed' || run.status === 'needs_review'
4087
+ ? 'result_without_facts'
4088
+ : 'not_applicable';
4089
+ return {
4090
+ ...receipt,
4091
+ version: 7,
4092
+ inspector: {
4093
+ understood: {
4094
+ questionKind: state.frame.kind,
4095
+ conversationBinding: state.frame.conversation.binding,
4096
+ measureCount: requirements.measures.length,
4097
+ dimensionCount: requirements.dimensions.length + requirements.entityDisplayTerms.length + (requirements.outputTerms?.length ?? 0),
4098
+ entityRequested: requirements.entityTerms.length > 0,
4099
+ hasBoundFilter: state.program.filters.length > 0,
4100
+ },
4101
+ evidence: {
4102
+ admittedCandidateCount: state.workspace.admittedCandidateIds.length,
4103
+ roleCount: receipt.roleCoverage.length,
4104
+ recoveryAttempted: receipt.story.some((step) => step.stage === 'targeted_recovery' && step.status === 'completed'),
4105
+ },
4106
+ planning: {
4107
+ mode: planning?.mode ?? 'deterministic_binding',
4108
+ plannerCalls: planning?.plannerCalls ?? 0,
4109
+ verification: planning?.verification.status ?? (state.phase === 'clarify' ? 'ambiguous' : state.phase === 'blocked' ? 'invalid' : 'valid'),
4110
+ },
4111
+ route: {
4112
+ ...(receipt.cascade.selectedTier ? { selectedTier: receipt.cascade.selectedTier } : {}),
4113
+ tierAttemptCount: receipt.cascade.attempts.length,
4114
+ planFrozen: receipt.cascade.planFrozen,
4115
+ reviewRequired: resolvedPlan?.reviewRequired === true,
4116
+ },
4117
+ outcome: {
4118
+ connectionAttempted: receipt.connection.attempted,
4119
+ executionAttempts: receipt.execution.attempts,
4120
+ factCount,
4121
+ narration,
4122
+ },
4123
+ },
4124
+ };
4125
+ }
4126
+ function providerFailureForRun(run) {
4127
+ if (run.routeDecision?.providerFailure)
4128
+ return run.routeDecision.providerFailure;
4129
+ if (run.diagnosticReceiptV3?.provider)
4130
+ return run.diagnosticReceiptV3.provider;
4131
+ for (const artifact of run.artifacts) {
4132
+ const payload = artifact.payload;
4133
+ if (!payload || typeof payload !== 'object' || Array.isArray(payload))
4134
+ continue;
4135
+ const failure = payload.providerFailure;
4136
+ if (!failure || typeof failure !== 'object' || Array.isArray(failure))
4137
+ continue;
4138
+ const diagnostic = failure.diagnostic;
4139
+ if (diagnostic && typeof diagnostic === 'object' && !Array.isArray(diagnostic)) {
4140
+ return diagnostic;
4141
+ }
4142
+ }
4143
+ return undefined;
4144
+ }
4145
+ /**
4146
+ * Project the V2 tool-kernel's terminal provider observation for V1/V3
4147
+ * receipt readers. This runs only at the final persistence boundary, after
4148
+ * the provider/tool runner has settled its shared state. It intentionally
4149
+ * does not classify free-form error text or synthesize a failure for a
4150
+ * non-terminal provider observation.
4151
+ */
4152
+ function providerFailureFromAskAgentV2State(state) {
4153
+ if (state?.terminalOutcome?.kind !== 'provider_failure')
4154
+ return undefined;
4155
+ const provider = [...state.observations]
4156
+ .reverse()
4157
+ .find((observation) => observation.provider)?.provider;
4158
+ if (!provider)
4159
+ return undefined;
4160
+ const phase = provider.phase === 'agent_control'
4161
+ || provider.phase === 'tool_followup'
4162
+ ? 'generation'
4163
+ : provider.phase;
4164
+ const safeAction = provider.safeAction === 'retry_same_provider'
4165
+ || provider.safeAction === 'fix_provider_configuration'
4166
+ || provider.safeAction === 'wait_and_retry'
4167
+ || provider.safeAction === 'inspect_run'
4168
+ || provider.safeAction === 'none'
4169
+ ? provider.safeAction
4170
+ : 'inspect_run';
4171
+ return {
4172
+ version: 1,
4173
+ cause: provider.cause,
4174
+ phase,
4175
+ retryable: provider.retryable,
4176
+ safeAction,
4177
+ };
4178
+ }
4179
+ /** Content-free export boundary for V5 inspector/full-trace receipts. */
4180
+ function diagnosticAskAnalystState(state) {
4181
+ return {
4182
+ version: 1,
4183
+ mode: state.mode,
4184
+ phase: state.phase,
4185
+ questionFingerprint: state.frame.questionFingerprint,
4186
+ kind: state.frame.kind,
4187
+ requirementCounts: {
4188
+ measures: state.frame.requirements.measures.length,
4189
+ dimensions: state.frame.requirements.dimensions.length,
4190
+ entityTerms: state.frame.requirements.entityTerms.length + state.frame.requirements.entityDisplayTerms.length,
4191
+ members: state.frame.requirements.memberTerms.length,
4192
+ filters: state.program.filters?.length ?? 0,
4193
+ },
4194
+ mission: {
4195
+ mode: state.mission.mode,
4196
+ taskCount: state.mission.tasks.length,
4197
+ deferredTaskCount: state.mission.deferredTasks?.length ?? 0,
4198
+ hypothesisCount: state.mission.hypotheses.length,
4199
+ },
4200
+ workspace: {
4201
+ ...(state.workspace.snapshotId ? { snapshotId: state.workspace.snapshotId } : {}),
4202
+ ...(state.workspace.sourceFingerprint ? { sourceFingerprint: state.workspace.sourceFingerprint } : {}),
4203
+ admittedCandidateCount: state.workspace.admittedCandidateIds.length,
4204
+ excludedCandidateCount: state.workspace.excludedCandidates.length,
4205
+ sourceCoverage: state.workspace.sourceCoverage.map((coverage) => ({
4206
+ source: coverage.source,
4207
+ status: coverage.status,
4208
+ candidateCount: coverage.candidateIds.length,
4209
+ })),
4210
+ tools: state.workspace.tools.map((tool) => ({
4211
+ id: tool.id,
4212
+ kind: tool.kind,
4213
+ status: tool.status,
4214
+ reasonCode: tool.reasonCode,
4215
+ })),
4216
+ },
4217
+ program: {
4218
+ id: state.program.id,
4219
+ taskCount: state.program.taskIds.length,
4220
+ candidateCount: state.program.candidateIds.length,
4221
+ requiredRoles: [...state.program.requiredRoles],
4222
+ outputAssertionCount: state.program.outputs.assertions?.length ?? 0,
4223
+ },
4224
+ ...(state.resolvedPlan ? { resolvedPlan: state.resolvedPlan } : {}),
4225
+ counters: {
4226
+ planningContinuations: state.planningContinuations,
4227
+ toolCalls: state.toolCalls,
4228
+ executionAttempts: state.executionAttempts,
4229
+ repairAttempts: state.repairAttempts,
4230
+ },
4231
+ };
4232
+ }
4233
+ function diagnosticBusinessAnswer(answer) {
4234
+ return {
4235
+ version: 1,
4236
+ mode: answer.mode,
4237
+ trustState: answer.trustState,
4238
+ factIds: [...answer.factIds],
4239
+ ...(answer.resultFingerprint ? { resultFingerprint: answer.resultFingerprint } : {}),
4240
+ limitationCount: answer.limitations.length,
4241
+ };
4242
+ }
4243
+ const ASK_SUMMARY_EVIDENCE_ROLES = [
4244
+ 'metric',
4245
+ 'entity_key',
4246
+ 'entity_label',
4247
+ 'categorical_dimension',
4248
+ 'time_dimension',
4249
+ 'member',
4250
+ 'relationship',
4251
+ 'context',
4252
+ ];
4253
+ const ASK_RESEARCH_BRANCH_FAILURE_CODES = [
4254
+ 'execution_failed',
4255
+ 'research_branch_timeout',
4256
+ 'budget_exhausted',
4257
+ 'run_deadline',
4258
+ 'cancelled',
4259
+ ];
4260
+ const ASK_RESEARCH_CHILD_TIERS = [
4261
+ 'certified',
4262
+ 'semantic',
4263
+ 'governed_relational',
4264
+ 'exploratory_sql',
4265
+ ];
4266
+ /**
4267
+ * Project only persisted, typed Research child evidence into V4. The root
4268
+ * result remains authoritative: this helper never promotes a failed branch
4269
+ * into a root incident or infers a missing plan from spans.
4270
+ */
4271
+ function researchBranchObservabilityForRun(run) {
4272
+ // A persisted V4 receipt can be reprojected after request normalization by
4273
+ // a host. The root route is therefore the durable authority as well as the
4274
+ // original requested mode: an explicit Research run must not lose its
4275
+ // child-story merely because an older host omitted `requestedMode` while
4276
+ // preserving the authoritative `research` route and research artifact.
4277
+ if (run.requestedMode !== 'research' && run.route !== 'research') {
4278
+ return { evidenceByRole: [] };
4279
+ }
4280
+ const payload = persistedResearchArtifactPayloadForRun(run);
4281
+ if (!payload)
4282
+ return { evidenceByRole: [] };
4283
+ const rawReceipts = Array.isArray(payload.researchBranchReceipts)
4284
+ ? payload.researchBranchReceipts
4285
+ : [];
4286
+ const receipts = new Map();
4287
+ for (const value of rawReceipts) {
4288
+ const receipt = clarificationRecord(value);
4289
+ const childRunId = clarificationString(receipt?.childRunId);
4290
+ const branchId = clarificationString(receipt?.branchId);
4291
+ const state = clarificationString(receipt?.state);
4292
+ const stopReason = clarificationString(receipt?.stopReason);
4293
+ // This field is producer-owned. A malformed imported receipt must not
4294
+ // become an apparently successful Research story.
4295
+ if (!receipt || !childRunId || !branchId || !state || !stopReason)
4296
+ continue;
4297
+ const key = `${childRunId}:${branchId}`;
4298
+ if (!receipts.has(key))
4299
+ receipts.set(key, receipt);
4300
+ }
4301
+ if (receipts.size === 0)
4302
+ return { evidenceByRole: [] };
4303
+ const childRuns = persistedResearchChildRuns(payload);
4304
+ const evidenceByRole = persistedResearchChildEvidenceByRole(childRuns);
4305
+ const receiptBackedChildIds = persistedReceiptBackedResearchChildIds(payload);
4306
+ let completedBranches = 0;
4307
+ let failedBranches = 0;
4308
+ let timedOutBranches = 0;
4309
+ let skippedBranches = 0;
4310
+ const failureReasons = new Map();
4311
+ const linkedChildRunIds = new Set();
4312
+ for (const receipt of receipts.values()) {
4313
+ const childRunId = clarificationString(receipt.childRunId);
4314
+ linkedChildRunIds.add(childRunId);
4315
+ const state = clarificationString(receipt.state);
4316
+ const stopReason = clarificationString(receipt.stopReason);
4317
+ if (state === 'completed' && stopReason === 'completed') {
4318
+ completedBranches += 1;
4319
+ continue;
4320
+ }
4321
+ if (state === 'timed_out')
4322
+ timedOutBranches += 1;
4323
+ else if (state === 'skipped')
4324
+ skippedBranches += 1;
4325
+ else
4326
+ failedBranches += 1;
4327
+ if (isAskResearchBranchFailureCode(stopReason)) {
4328
+ failureReasons.set(stopReason, (failureReasons.get(stopReason) ?? 0) + 1);
4329
+ }
4330
+ }
4331
+ const receiptBackedBranches = [...receipts.values()]
4332
+ .filter((receipt) => clarificationString(receipt.state) === 'completed'
4333
+ && clarificationString(receipt.stopReason) === 'completed'
4334
+ && receiptBackedChildIds.has(clarificationString(receipt.childRunId)))
4335
+ .length;
4336
+ const incompleteBranches = failedBranches + timedOutBranches + skippedBranches;
4337
+ const summary = {
4338
+ version: 1,
4339
+ totalBranches: receipts.size,
4340
+ completedBranches,
4341
+ receiptBackedBranches,
4342
+ failedBranches,
4343
+ timedOutBranches,
4344
+ skippedBranches,
4345
+ partialSuccess: receiptBackedBranches > 0 && incompleteBranches > 0,
4346
+ failureReasons: [...failureReasons.entries()]
4347
+ .map(([code, branchCount]) => ({ code, branchCount }))
4348
+ .sort((left, right) => left.code.localeCompare(right.code)),
4349
+ availableChildPlans: persistedResearchChildPlans(childRuns),
4350
+ linkedChildRunCount: linkedChildRunIds.size,
4351
+ safeAction: 'inspect_research_failures',
4352
+ };
4353
+ return { summary, evidenceByRole };
4354
+ }
4355
+ /** Use only the durable root research artifact with branch receipts. */
4356
+ function persistedResearchArtifactPayloadForRun(run) {
4357
+ let selected;
4358
+ let selectedCount = -1;
4359
+ for (const artifact of run.artifacts) {
4360
+ if (artifact.kind !== 'research_run')
4361
+ continue;
4362
+ const payload = clarificationRecord(artifact.payload);
4363
+ const count = Array.isArray(payload?.researchBranchReceipts) ? payload.researchBranchReceipts.length : 0;
4364
+ if (payload && count > selectedCount) {
4365
+ selected = payload;
4366
+ selectedCount = count;
4367
+ }
4368
+ }
4369
+ return selected;
4370
+ }
4371
+ function persistedResearchChildRuns(payload) {
4372
+ const candidates = [
4373
+ ...(Array.isArray(payload.researchRuns) ? payload.researchRuns : []),
4374
+ payload.researchRun,
4375
+ ];
4376
+ const byId = new Map();
4377
+ for (const value of candidates) {
4378
+ const child = clarificationRecord(value);
4379
+ const id = clarificationString(child?.id);
4380
+ if (child && id && !byId.has(id))
4381
+ byId.set(id, child);
4382
+ }
4383
+ return [...byId.values()];
4384
+ }
4385
+ function persistedResearchChildEvidenceByRole(childRuns) {
4386
+ const counts = new Map();
4387
+ for (const child of childRuns) {
4388
+ const routeDecision = clarificationRecord(child.routeDecision);
4389
+ const retrieval = clarificationRecord(routeDecision?.retrievalEvidence);
4390
+ const candidates = Array.isArray(retrieval?.candidateTraceMetadata)
4391
+ ? retrieval.candidateTraceMetadata
4392
+ : [];
4393
+ for (const value of candidates) {
4394
+ const candidate = clarificationRecord(value);
4395
+ const role = clarificationString(candidate?.role);
4396
+ if (!isAskSummaryEvidenceRole(role))
4397
+ continue;
4398
+ counts.set(role, (counts.get(role) ?? 0) + 1);
4399
+ }
4400
+ }
4401
+ return [...counts.entries()]
4402
+ .map(([role, candidateCount]) => ({ role, candidateCount }))
4403
+ .sort((left, right) => left.role.localeCompare(right.role));
4404
+ }
4405
+ function persistedReceiptBackedResearchChildIds(payload) {
4406
+ const ledger = clarificationRecord(payload.researchLedgerV2);
4407
+ const entries = Array.isArray(ledger?.entries) ? ledger.entries : [];
4408
+ const ids = new Set();
4409
+ for (const value of entries) {
4410
+ const entry = clarificationRecord(value);
4411
+ const id = clarificationString(entry?.id);
4412
+ const receipts = Array.isArray(entry?.receipts) ? entry.receipts : [];
4413
+ if (entry?.status === 'observed' && id && receipts.some((receipt) => clarificationString(receipt))) {
4414
+ ids.add(id);
4415
+ }
4416
+ }
4417
+ return ids;
4418
+ }
4419
+ function persistedResearchChildPlans(childRuns) {
4420
+ const plansByTier = new Map();
4421
+ for (const child of childRuns) {
4422
+ const childRunId = clarificationString(child.id);
4423
+ const context = clarificationRecord(child.context);
4424
+ const authority = clarificationRecord(context?.branchAuthority);
4425
+ const tier = clarificationString(authority?.selectedTier);
4426
+ const planId = clarificationString(authority?.planId);
4427
+ const planFingerprint = clarificationString(authority?.planFingerprint);
4428
+ if (!childRunId || authority?.planFrozen !== true || !isAskResearchChildTier(tier) || !planId || !planFingerprint)
4429
+ continue;
4430
+ const entry = plansByTier.get(tier) ?? { planKeys: new Set(), childRunIds: new Set() };
4431
+ entry.planKeys.add(`${planId}:${planFingerprint}`);
4432
+ entry.childRunIds.add(childRunId);
4433
+ plansByTier.set(tier, entry);
4434
+ }
4435
+ return [...plansByTier.entries()]
4436
+ .map(([tier, value]) => ({
4437
+ tier,
4438
+ frozenPlanCount: value.planKeys.size,
4439
+ branchCount: value.childRunIds.size,
4440
+ reviewRequired: tier === 'exploratory_sql',
4441
+ }))
4442
+ .sort((left, right) => left.tier.localeCompare(right.tier));
4443
+ }
4444
+ function isAskSummaryEvidenceRole(value) {
4445
+ return Boolean(value) && ASK_SUMMARY_EVIDENCE_ROLES.includes(value);
4446
+ }
4447
+ function isAskResearchBranchFailureCode(value) {
4448
+ return Boolean(value) && ASK_RESEARCH_BRANCH_FAILURE_CODES.includes(value);
4449
+ }
4450
+ function isAskResearchChildTier(value) {
4451
+ return Boolean(value) && ASK_RESEARCH_CHILD_TIERS.includes(value);
4452
+ }
4453
+ function terminalIncidentForRun(run, cascadeStopReason) {
4454
+ const runtimeState = run.askAnalystState ?? run.routeDecision?.askAnalystDecision?.state;
4455
+ const planFrozen = runtimeState?.resolvedPlan?.planFrozen === true
4456
+ || run.routeDecision?.analyticalCascadeDecision?.planFrozen === true;
4457
+ const executionRecorded = analyticalExecutionAttemptCount(run) > 0
4458
+ || (run.telemetry?.sqlExecutions ?? 0) > 0;
4459
+ const executionSetupFailure = terminalConnectionSetupFailureForRun(run);
4460
+ // Connection wording is legal only after an immutable plan crossed its
4461
+ // actual connector boundary. A planner/verification failure is never a
4462
+ // "current connection" incident merely because an older adapter used a
4463
+ // broad blocked status.
4464
+ if (executionSetupFailure && planFrozen) {
4465
+ return {
4466
+ version: 1,
4467
+ code: 'CONNECTION_NOT_CONFIGURED',
4468
+ boundary: 'sql.execute',
4469
+ origin: 'governance_gate',
4470
+ impact: 'execution_not_attempted',
4471
+ safeAction: 'configure_connection',
4472
+ };
4473
+ }
4474
+ const failureCode = run.diagnosticReceipt?.failure?.code;
4475
+ if (failureCode === 'INTERNAL_EXPLORATORY_AUTHORIZATION_STATE_MISMATCH') {
4476
+ return {
4477
+ version: 1,
4478
+ code: 'INTERNAL_EXPLORATORY_AUTHORIZATION_STATE_MISMATCH',
4479
+ boundary: 'sql.authorize',
4480
+ origin: 'internal_invariant',
4481
+ impact: 'execution_not_attempted',
4482
+ safeAction: 'export_redacted_trace',
4483
+ };
4484
+ }
4485
+ if (failureCode === 'RESEARCH_RUN_DEADLINE') {
4486
+ return {
4487
+ version: 1,
4488
+ code: 'RESEARCH_RUN_DEADLINE',
4489
+ boundary: 'run',
4490
+ origin: 'governance_gate',
4491
+ impact: 'answer_not_produced',
4492
+ safeAction: 'inspect_failure',
4493
+ };
4494
+ }
4495
+ // A completed root can still be materially limited when every admitted
4496
+ // Research child exhausted its bounded window. This is producer-owned
4497
+ // receipt evidence, not an incident reconstructed from trace timing. It
4498
+ // must be visible in the same V4 summary used by the inspector and full
4499
+ // trace so the user is never told there was no incident after a zero-finding
4500
+ // investigation.
4501
+ if (terminalResearchBranchTimeoutForRun(run)) {
4502
+ return {
4503
+ version: 1,
4504
+ code: 'RESEARCH_BRANCH_TIMEOUT',
4505
+ boundary: 'run',
4506
+ origin: 'governance_gate',
4507
+ impact: 'answer_not_produced',
4508
+ safeAction: 'inspect_research_failures',
4509
+ };
4510
+ }
4511
+ if (failureCode === 'RUN_CANCELLED' || run.status === 'cancelled') {
4512
+ return { version: 1, code: 'CANCELLED', boundary: 'run', origin: 'unknown', impact: 'run_cancelled', safeAction: 'none' };
4513
+ }
4514
+ if (failureCode === 'CONNECTION_NOT_CONFIGURED' && planFrozen) {
4515
+ return { version: 1, code: 'CONNECTION_NOT_CONFIGURED', boundary: 'sql.execute', origin: 'governance_gate', impact: 'execution_not_attempted', safeAction: 'configure_connection' };
4516
+ }
4517
+ // A frozen semantic/analytical plan may fail while the compiler is resolving
4518
+ // its already-proven identifiers. That is categorically different from a
4519
+ // warehouse failure: no statement was authorized or executed. Preserve the
4520
+ // producer's typed `COMPILATION_FAILED` cause before consulting connector
4521
+ // evidence so both Ask surfaces tell the same pre-SQL story.
4522
+ const compilationFailure = terminalCompilationFailureForRun(run);
4523
+ if (compilationFailure) {
4524
+ const semantic = isSemanticCompilationForRun(run);
4525
+ return {
4526
+ version: 1,
4527
+ code: 'COMPILATION_FAILED',
4528
+ boundary: semantic ? 'semantic.compile' : 'plan.compile',
4529
+ origin: semantic ? 'semantic_compiler' : 'plan_compiler',
4530
+ impact: 'execution_not_attempted',
4531
+ safeAction: compilationFailure.safeAction,
4532
+ };
4533
+ }
4534
+ // Result validation is a distinct post-execution boundary. The statement
4535
+ // may have run successfully, but its rows were deliberately rejected
4536
+ // against the immutable plan; do not rewrite that evidence as a connection
4537
+ // or SQL execution failure merely because both happen after plan freeze.
4538
+ const resultValidationFailure = terminalResultValidationFailureForRun(run);
4539
+ if (resultValidationFailure) {
4540
+ return {
4541
+ version: 1,
4542
+ code: 'RESULT_CONTRACT_MISMATCH',
4543
+ boundary: 'result.validate',
4544
+ origin: 'result_validator',
4545
+ impact: 'answer_not_produced',
4546
+ safeAction: resultValidationFailure.safeAction,
4547
+ };
4548
+ }
4549
+ const warehouseFailure = terminalWarehouseFailureForRun(run);
4550
+ if (warehouseFailure) {
4551
+ return {
4552
+ version: 1,
4553
+ code: 'ANALYTICAL_EXECUTION_FAILED',
4554
+ boundary: 'sql.execute',
4555
+ origin: 'warehouse',
4556
+ impact: 'execution_failed',
4557
+ // A typed missing relation after a frozen plan reached the connector is
4558
+ // not a generic retry. The target may be an empty local database or a
4559
+ // different approved warehouse, so direct the operator to that target.
4560
+ safeAction: warehouseFailure.category === 'unknown_relation'
4561
+ ? 'change_authorized_connection'
4562
+ : 'inspect_failure',
4563
+ };
4564
+ }
4565
+ if (run.diagnosticReceiptV3?.provider) {
4566
+ return { version: 1, code: 'PROVIDER_FAILURE', boundary: 'provider', origin: 'provider', impact: 'answer_not_produced', safeAction: 'inspect_failure' };
4567
+ }
4568
+ if (cascadeStopReason === 'coverage_gap' || cascadeStopReason === 'ambiguous' || cascadeStopReason === 'denied') {
4569
+ return { version: 1, code: 'ANALYTICAL_COVERAGE_GAP', boundary: 'cascade', origin: 'governance_gate', impact: 'answer_not_produced', safeAction: 'inspect_failure' };
4570
+ }
4571
+ // Planning/meaning/reference validation has no connection or SQL boundary.
4572
+ // Preserve that truth even when an older adapter gives the terminal run a
4573
+ // broad `blocked` status without a cascade stop reason.
4574
+ if (run.status === 'blocked'
4575
+ && runtimeState?.phase === 'blocked'
4576
+ && !planFrozen
4577
+ && !executionRecorded) {
4578
+ return {
4579
+ version: 1,
4580
+ code: 'ANALYTICAL_COVERAGE_GAP',
4581
+ boundary: 'cascade',
4582
+ origin: 'governance_gate',
4583
+ impact: 'answer_not_produced',
4584
+ safeAction: 'inspect_failure',
4585
+ };
4586
+ }
4587
+ if (run.status === 'blocked' && planFrozen && executionRecorded) {
4588
+ return { version: 1, code: 'ANALYTICAL_EXECUTION_FAILED', boundary: 'sql.execute', origin: 'unknown', impact: 'execution_failed', safeAction: 'inspect_failure' };
4589
+ }
4590
+ if (run.status === 'blocked') {
4591
+ return { version: 1, code: 'ANALYTICAL_COVERAGE_GAP', boundary: 'cascade', origin: 'governance_gate', impact: 'answer_not_produced', safeAction: 'inspect_failure' };
4592
+ }
4593
+ return undefined;
4594
+ }
4595
+ /**
4596
+ * Read only the narrow host setup receipt emitted before a connector receives
4597
+ * SQL. This must win over the broad analytical failure payload because a
4598
+ * semantic graph can catch the host error after its compiler work completed.
4599
+ */
4600
+ function terminalConnectionSetupFailureForRun(run) {
4601
+ return run.artifacts.some((artifact) => {
4602
+ const payload = artifact.payload;
4603
+ if (!payload || typeof payload !== 'object' || Array.isArray(payload))
4604
+ return false;
4605
+ const setup = payload.observabilityExecutionFailure;
4606
+ if (!setup || typeof setup !== 'object' || Array.isArray(setup))
4607
+ return false;
4608
+ const record = setup;
4609
+ return record.version === 1
4610
+ && record.phase === 'execution'
4611
+ && record.cause === 'connection_not_configured'
4612
+ && record.safeAction === 'configure_connection';
4613
+ });
4614
+ }
4615
+ /**
4616
+ * Read only a producer-owned analytical failure. A compiler failure may have
4617
+ * prepared SQL text, but it is still pre-execution until the durable telemetry
4618
+ * records a SQL call. This guard keeps a real warehouse failure from being
4619
+ * relabeled as semantic/planning just because a legacy adapter reused a broad
4620
+ * failure code in a later stage.
4621
+ */
4622
+ function terminalCompilationFailureForRun(run) {
4623
+ if ((run.telemetry?.sqlExecutions ?? 0) > 0)
4624
+ return undefined;
4625
+ for (const artifact of run.artifacts) {
4626
+ const payload = artifact.payload;
4627
+ if (!payload || typeof payload !== 'object' || Array.isArray(payload))
4628
+ continue;
4629
+ const record = payload;
4630
+ const failure = record.analyticalFailure;
4631
+ if (failure && typeof failure === 'object' && !Array.isArray(failure)) {
4632
+ const failureRecord = failure;
4633
+ if (failureRecord.code === 'COMPILATION_FAILED' && failureRecord.phase === 'compilation') {
4634
+ return {
4635
+ safeAction: terminalIncidentSafeAction(failureRecord.safeActions) ?? 'inspect_failure',
4636
+ };
4637
+ }
4638
+ }
4639
+ // Semantic adapters retain their own compiler receipt. Some historical
4640
+ // answer-loop paths lose the outer analytical-failure wrapper while
4641
+ // serializing a failed provider tool result; the typed semantic receipt is
4642
+ // still a pre-SQL compiler fact and must never fall through to the generic
4643
+ // "current connection" incident. It is only considered before a durable
4644
+ // SQL execution counter exists, so a real warehouse failure keeps its
4645
+ // execution classification.
4646
+ const semanticTrace = record.semanticExecutionTrace;
4647
+ if (!semanticTrace || typeof semanticTrace !== 'object' || Array.isArray(semanticTrace))
4648
+ continue;
4649
+ const traceFailure = semanticTrace.failure;
4650
+ if (!traceFailure || typeof traceFailure !== 'object' || Array.isArray(traceFailure))
4651
+ continue;
4652
+ const traceFailureRecord = traceFailure;
4653
+ if (traceFailureRecord.phase === 'compilation'
4654
+ && (traceFailureRecord.code === 'SEMANTIC_COMPILATION_FAILED'
4655
+ || traceFailureRecord.code === 'COMPILATION_FAILED')) {
4656
+ return {
4657
+ safeAction: terminalIncidentSafeAction(traceFailureRecord.safeActions) ?? 'inspect_failure',
4658
+ };
4659
+ }
4660
+ }
4661
+ return undefined;
4662
+ }
4663
+ /**
4664
+ * A validated result-contract rejection is neither a compiler failure nor a
4665
+ * warehouse failure. Read only the producer-owned typed failure so malformed
4666
+ * or legacy error text cannot manufacture this incident.
4667
+ */
4668
+ function terminalResultValidationFailureForRun(run) {
4669
+ for (const artifact of run.artifacts) {
4670
+ const payload = artifact.payload;
4671
+ if (!payload || typeof payload !== 'object' || Array.isArray(payload))
4672
+ continue;
4673
+ const failure = payload.analyticalFailure;
4674
+ if (!failure || typeof failure !== 'object' || Array.isArray(failure))
4675
+ continue;
4676
+ const failureRecord = failure;
4677
+ if (failureRecord.code !== 'RESULT_CONTRACT_MISMATCH' || failureRecord.phase !== 'result_validation')
4678
+ continue;
4679
+ return {
4680
+ safeAction: terminalIncidentSafeAction(failureRecord.safeActions) ?? 'inspect_failure',
4681
+ };
4682
+ }
4683
+ return undefined;
4684
+ }
4685
+ function isSemanticCompilationForRun(run) {
4686
+ // Only the router-owned cascade may identify a semantic execution tier.
4687
+ // Direct/legacy semantic callers can still carry an immutable plan, but
4688
+ // without that authority their failure is accurately a generic plan compile
4689
+ // incident rather than a reconstructed semantic route.
4690
+ return run.routeDecision?.analyticalCascadeDecision?.selectedTier === 'semantic';
4691
+ }
4692
+ /** Keep V4's recovery action in the same compact vocabulary as trace spans. */
4693
+ function terminalIncidentSafeAction(value) {
4694
+ if (!Array.isArray(value))
4695
+ return undefined;
4696
+ const actions = [
4697
+ 'export_redacted_trace',
4698
+ 'configure_connection',
4699
+ 'change_authorized_connection',
4700
+ 'inspect_failure',
4701
+ 'retry_same_plan',
4702
+ 'refresh_snapshot',
4703
+ 'edit_dql',
4704
+ 'open_sql_notebook',
4705
+ 'request_access',
4706
+ 'reapply_semantic_runtime',
4707
+ 'review_analytical_failure',
4708
+ 'inspect_research_failures',
4709
+ 'none',
4710
+ ];
4711
+ return value.find((action) => typeof action === 'string' && actions.includes(action));
4712
+ }
4713
+ /**
4714
+ * A Research root is deliberately allowed to complete its receipt-bound
4715
+ * synthesis after child deadlines. Surface a terminal incident only when no
4716
+ * child completed an observation and all admitted children were bounded out;
4717
+ * a partially successful investigation remains a review-required answer with
4718
+ * a limited-scope note rather than a false failure.
4719
+ */
4720
+ function terminalResearchBranchTimeoutForRun(run) {
4721
+ for (const artifact of run.artifacts) {
4722
+ const payload = artifact.payload;
4723
+ if (!payload || typeof payload !== 'object' || Array.isArray(payload))
4724
+ continue;
4725
+ const receipts = payload.researchBranchReceipts;
4726
+ if (!Array.isArray(receipts) || receipts.length === 0)
4727
+ continue;
4728
+ const stopReasons = receipts
4729
+ .map((receipt) => receipt && typeof receipt === 'object'
4730
+ ? receipt.stopReason
4731
+ : undefined)
4732
+ .filter((reason) => typeof reason === 'string');
4733
+ if (stopReasons.length !== receipts.length)
4734
+ continue;
4735
+ const allBounded = stopReasons.every((reason) => reason === 'research_branch_timeout' || reason === 'budget_exhausted');
4736
+ if (allBounded && stopReasons.some((reason) => reason === 'research_branch_timeout'))
4737
+ return true;
4738
+ }
4739
+ return false;
4740
+ }
4741
+ /**
4742
+ * Read only enum evidence emitted at the real connector boundary. SQL text and
4743
+ * redacted driver diagnostics remain in the artifact inspector; they cannot
4744
+ * become routing or trace-summary authority.
4745
+ */
4746
+ function terminalWarehouseFailureForRun(run) {
4747
+ for (const artifact of run.artifacts) {
4748
+ const payload = artifact.payload;
4749
+ if (!payload || typeof payload !== 'object' || Array.isArray(payload))
4750
+ continue;
4751
+ const failure = payload.warehouseFailure;
4752
+ if (!failure || typeof failure !== 'object' || Array.isArray(failure))
4753
+ continue;
4754
+ const record = failure;
4755
+ if (record.version === 1 && record.origin === 'warehouse' && typeof record.category === 'string') {
4756
+ return { category: record.category };
4757
+ }
4758
+ }
4759
+ return undefined;
4760
+ }
4761
+ function emptyRunTelemetry(total, fallbackReason) {
4762
+ return {
4763
+ version: 1,
4764
+ stageDurationsMs: { total },
4765
+ providerRoundTrips: 0,
4766
+ toolCalls: 0,
4767
+ sqlExecutions: 0,
4768
+ repairs: 0,
4769
+ egressReceipts: 0,
4770
+ fallbackReason,
4771
+ };
4772
+ }
4773
+ function withTotalDuration(telemetry, total) {
4774
+ return {
4775
+ ...telemetry,
4776
+ stageDurationsMs: { ...telemetry.stageDurationsMs, total },
4777
+ };
4778
+ }
4779
+ function durationBetweenMs(startedAt, completedAt) {
4780
+ const duration = Date.parse(completedAt) - Date.parse(startedAt);
4781
+ return Number.isFinite(duration) && duration >= 0 ? Math.min(86_400_000, duration) : 0;
2108
4782
  }
2109
4783
  function receiptFingerprint(value) {
2110
4784
  return createHash('sha256').update(JSON.stringify(value)).digest('hex');
2111
4785
  }
2112
- function attachDiagnosticReceipt(artifacts, receipt, receiptV2, receiptV3) {
4786
+ function attachDiagnosticReceipt(artifacts, receipt, receiptV2, receiptV3, receiptV4, receiptV5, receiptV6, receiptV7, receiptV8) {
2113
4787
  if (artifacts.length === 0) {
2114
4788
  if (!receipt.failure)
2115
4789
  return artifacts;
@@ -2118,7 +4792,7 @@ function attachDiagnosticReceipt(artifacts, receipt, receiptV2, receiptV3) {
2118
4792
  kind: "answer",
2119
4793
  title: "Agent run diagnostics",
2120
4794
  trustState: "blocked",
2121
- payload: { diagnosticReceipt: receipt, ...(receiptV2 ? { diagnosticReceiptV2: receiptV2 } : {}), ...(receiptV3 ? { diagnosticReceiptV3: receiptV3 } : {}) },
4795
+ payload: { diagnosticReceipt: receipt, ...(receiptV2 ? { diagnosticReceiptV2: receiptV2 } : {}), ...(receiptV3 ? { diagnosticReceiptV3: receiptV3 } : {}), ...(receiptV4 ? { diagnosticReceiptV4: receiptV4 } : {}), ...(receiptV5 ? { diagnosticReceiptV5: receiptV5 } : {}), ...(receiptV6 ? { diagnosticReceiptV6: receiptV6 } : {}), ...(receiptV7 ? { diagnosticReceiptV7: receiptV7 } : {}), ...(receiptV8 ? { diagnosticReceiptV8: receiptV8 } : {}) },
2122
4796
  }];
2123
4797
  }
2124
4798
  const preferredIndex = Math.max(0, artifacts.findIndex((artifact) => artifact.kind === "answer"));
@@ -2135,10 +4809,73 @@ function attachDiagnosticReceipt(artifacts, receipt, receiptV2, receiptV3) {
2135
4809
  diagnosticReceipt: receipt,
2136
4810
  ...(receiptV2 ? { diagnosticReceiptV2: receiptV2 } : {}),
2137
4811
  ...(receiptV3 ? { diagnosticReceiptV3: receiptV3 } : {}),
4812
+ ...(receiptV4 ? { diagnosticReceiptV4: receiptV4 } : {}),
4813
+ ...(receiptV5 ? { diagnosticReceiptV5: receiptV5 } : {}),
4814
+ ...(receiptV6 ? { diagnosticReceiptV6: receiptV6 } : {}),
4815
+ ...(receiptV7 ? { diagnosticReceiptV7: receiptV7 } : {}),
4816
+ ...(receiptV8 ? { diagnosticReceiptV8: receiptV8 } : {}),
2138
4817
  },
2139
4818
  };
2140
4819
  });
2141
4820
  }
4821
+ function acceptedAskAgentV2TerminalState(request, decision, result, runId) {
4822
+ // A scoped execution can retain an earlier immutable request snapshot while
4823
+ // the provider advances its cloned V2 state. That is why the execution
4824
+ // carrier is an explicit runner return value. It is *not* enough for an
4825
+ // executor to return receipt-shaped JSON: the engine verifies the
4826
+ // process-local server attestation, current run, immutable snapshot closure,
4827
+ // frozen plan identity, and canonical result fingerprint below.
4828
+ const states = [
4829
+ request.askAgentV2State,
4830
+ decision.askAgentV2Decision?.state,
4831
+ ].filter((state) => Boolean(state));
4832
+ if (result.status === 'blocked')
4833
+ return undefined;
4834
+ const receipt = result.askAgentV2ExecutionReceipt;
4835
+ const terminal = result.askAgentV2Outcome;
4836
+ const state = states.find((candidate) => isAskV2ExecutionReceiptAuthorizedV1({
4837
+ receipt,
4838
+ capability: request.askAgentV2ExecutionCapability,
4839
+ state: candidate,
4840
+ result: result.result,
4841
+ runId,
4842
+ }));
4843
+ if (state
4844
+ && receipt
4845
+ && terminal?.kind === 'finish_answer'
4846
+ && terminal.origin === 'execution') {
4847
+ return { tier: receipt.tier, planId: receipt.planId };
4848
+ }
4849
+ return undefined;
4850
+ }
4851
+ /**
4852
+ * Keep a gate's VERDICT while removing its authority to re-plan.
4853
+ *
4854
+ * After an accepted V2 terminal, the frozen plan already executed; the legacy
4855
+ * repair machinery would replan the same artifact, be refused as
4856
+ * POST_FREEZE_REPAIR_REQUIRED, and discard the validated result. The failure
4857
+ * itself must still be visible — that is the entire point of running the
4858
+ * gates — so only the repair hooks are stripped.
4859
+ */
4860
+ function disarmRepairForV2Terminal(evaluation) {
4861
+ if (evaluation.passed)
4862
+ return evaluation;
4863
+ const { suggestedRepair: _repair, repairAction: _action, ...reported } = evaluation;
4864
+ return reported;
4865
+ }
4866
+ function acceptedAskAgentV2TerminalEvaluation(boundary) {
4867
+ return {
4868
+ id: 'ask-v2-terminal-result',
4869
+ label: 'Authoritative V2 result validation',
4870
+ passed: true,
4871
+ severity: 'info',
4872
+ message: 'The snapshot-bound V2 plan executed and its result was validated before the terminal answer was accepted.',
4873
+ evidence: {
4874
+ tier: boundary.tier,
4875
+ planId: boundary.planId,
4876
+ },
4877
+ };
4878
+ }
2142
4879
  function computeStepOutcome(route, result, evaluations, request, isClarify, clarifyQuestion, terminalOutcomeMessage) {
2143
4880
  const fallback = defaultOutcome(route);
2144
4881
  if (isClarify) {
@@ -2150,16 +4887,32 @@ function computeStepOutcome(route, result, evaluations, request, isClarify, clar
2150
4887
  summary: result.summary ?? clarifyQuestion ?? fallback.summary,
2151
4888
  };
2152
4889
  }
2153
- const status = result.status ?? statusFromEvaluations(route, evaluations, fallback.status);
2154
- const trustState = result.trustState ?? trustStateFromEvaluations(route, evaluations, fallback.trustState);
4890
+ const rawStatus = result.status ?? statusFromEvaluations(route, evaluations, fallback.status);
4891
+ const rawTrustState = result.trustState ?? trustStateFromEvaluations(route, evaluations, fallback.trustState);
4892
+ // A unique semantic grouping may be inferred only under the runtime's
4893
+ // explicit review contract. Preserve that contract through the generic
4894
+ // engine adapter instead of allowing a successful MetricFlow executor to
4895
+ // silently re-label it as governed.
4896
+ const runtimeReviewRequired = request.askAnalystState?.resolvedPlan?.reviewRequired === true;
4897
+ const status = runtimeReviewRequired && rawStatus === 'completed'
4898
+ ? 'needs_review'
4899
+ : rawStatus;
4900
+ const trustState = runtimeReviewRequired && status !== 'blocked' && status !== 'needs_clarification'
4901
+ ? 'review_required'
4902
+ : rawTrustState;
2155
4903
  // API-007 / AGT-019: a blocked analytical run may carry an intentionally
2156
4904
  // redacted failure envelope needed for inspection and immutable repair. Keep
2157
4905
  // only artifacts that the executor explicitly marked blocked; never retain a
2158
4906
  // governed/reviewable artifact merely because it happened to accompany a
2159
4907
  // terminal failure.
2160
- const artifacts = status === "blocked"
4908
+ const rawArtifacts = status === "blocked"
2161
4909
  ? (result.artifacts ?? []).filter((artifact) => artifact.trustState === "blocked")
2162
4910
  : result.artifacts ?? defaultArtifacts(route, result, request);
4911
+ const artifacts = runtimeReviewRequired && status !== 'blocked'
4912
+ ? rawArtifacts.map((artifact) => artifact.trustState === 'blocked'
4913
+ ? artifact
4914
+ : { ...artifact, trustState: 'review_required' })
4915
+ : rawArtifacts;
2163
4916
  const stopReason = result.stopReason ?? stopReasonFor(route, status, trustState, artifacts);
2164
4917
  return {
2165
4918
  status,
@@ -2167,19 +4920,45 @@ function computeStepOutcome(route, result, evaluations, request, isClarify, clar
2167
4920
  artifacts,
2168
4921
  stopReason,
2169
4922
  summary: status === "blocked"
2170
- ? terminalOutcomeMessage ?? blockingOutcomeSummary(evaluations, fallback.summary)
4923
+ ? terminalOutcomeMessage ?? blockingOutcomeSummary(evaluations, result, fallback.summary)
2171
4924
  : result.summary ?? fallback.summary,
2172
4925
  ...(result.answerTier ? { terminalTier: result.answerTier } : {}),
2173
4926
  };
2174
4927
  }
2175
- function blockingOutcomeSummary(evaluations, fallback) {
4928
+ /**
4929
+ * The sentence a typed refusal code deserves. These are the honest,
4930
+ * user-actionable readings; the coarse code is still what machines branch on.
4931
+ */
4932
+ function refusalCodeSummary(code) {
4933
+ switch (code) {
4934
+ case 'grounding_gap': return 'DQL could not ground every part of this question in the current metadata snapshot, so no query was accepted.';
4935
+ case 'modeling_gap': return 'Part of this question is not modeled in this project yet, so no governed query can answer it as asked.';
4936
+ case 'ambiguous': return 'One business choice is required before DQL can run this question.';
4937
+ case 'provider_error': return 'The AI provider could not complete this Ask step.';
4938
+ case 'orchestration_budget_exhausted': return 'DQL stopped this run at its own orchestration budget before the question was settled.';
4939
+ case 'policy_blocked': return 'A governance policy blocked this request before execution.';
4940
+ case 'execution_error': return 'The selected governed query did not complete on the current connection.';
4941
+ default: return undefined;
4942
+ }
4943
+ }
4944
+ function blockingOutcomeSummary(evaluations, result, fallback) {
2176
4945
  const messages = evaluations
2177
4946
  .filter((evaluation) => !evaluation.passed && evaluation.severity === 'blocking')
2178
4947
  .map((evaluation) => evaluation.message.trim())
2179
4948
  .filter(Boolean);
2180
- return messages[0] ?? (fallback.includes('Answered')
2181
- ? 'The analytical result did not pass its required validation and was not accepted.'
2182
- : fallback);
4949
+ if (messages[0])
4950
+ return messages[0];
4951
+ // A typed refusal is the truth this run actually recorded. The old fallback
4952
+ // fabricated "did not pass its required validation" whenever no evaluation
4953
+ // was blocking and the route's default summary contained "Answered" — it
4954
+ // asserted a validation that never ran, and masked deadline/budget/gap
4955
+ // terminals behind an invented one.
4956
+ const typed = refusalCodeSummary(result.answerRefusalCode);
4957
+ if (typed)
4958
+ return typed;
4959
+ return fallback.includes('Answered')
4960
+ ? 'The run stopped before an answer was accepted. Open the trace for the exact boundary.'
4961
+ : fallback;
2183
4962
  }
2184
4963
  function consumeRepeatedClarificationSelection(request, routeDecision, result) {
2185
4964
  const selectedEvidenceId = request.selectedEvidenceId;
@@ -2200,6 +4979,48 @@ function consumeRepeatedClarificationSelection(request, routeDecision, result) {
2200
4979
  answer: message,
2201
4980
  };
2202
4981
  }
4982
+ /**
4983
+ * Rehydrate the server-owned compiler decision for one frozen Ask task. The
4984
+ * outer decision remains the durable turn summary, while this scoped view is
4985
+ * the sole authority passed to the executor/evaluator for the current step.
4986
+ */
4987
+ function taskScopedRouteDecision(outer, task) {
4988
+ return {
4989
+ ...task.compilerDecision,
4990
+ // The task compiler consumes its canonical local semantic execution ID
4991
+ // (for example `semantic:account_revenue:revenue`), while the persisted
4992
+ // meaning receipt must retain the exact qualified candidate selected by
4993
+ // the immutable planner (`semantic:metric:account_revenue.revenue`).
4994
+ // Preserve that reader-facing identity without changing the frozen
4995
+ // compiler plan or allowing a task to reinterpret meaning.
4996
+ meaningResolution: {
4997
+ ...task.meaningResolution,
4998
+ ...(outer.meaningResolution?.recommendedExecutionId
4999
+ ? { recommendedExecutionId: outer.meaningResolution.recommendedExecutionId }
5000
+ : {}),
5001
+ },
5002
+ // Preserve the root V2 runtime carrier across the frozen-task scope. It
5003
+ // contains only server-owned snapshot/tool observations and is the source
5004
+ // of a physical provider preflight outcome; dropping it here caused a
5005
+ // child request to persist a generic blocked result instead of its typed
5006
+ // provider diagnostic.
5007
+ ...(outer.askAgentV2Decision ? { askAgentV2Decision: outer.askAgentV2Decision } : {}),
5008
+ ...(outer.providerFailure ? { providerFailure: outer.providerFailure } : {}),
5009
+ // `compileVerifiedAskTasks` accepts a task only after the compiler broker
5010
+ // supplied its complete resolved-plan authority. Do not manufacture a
5011
+ // legacy resolved plan here from a V2 receipt.
5012
+ ...(task.compilerDecision.resolvedAnalyticalPlan
5013
+ ? { resolvedAnalyticalPlan: task.compilerDecision.resolvedAnalyticalPlan }
5014
+ : {}),
5015
+ askAnalystDecision: {
5016
+ version: 1,
5017
+ mode: outer.askAnalystDecision?.mode ?? 'authoritative',
5018
+ state: task.state,
5019
+ resolvedPlan: task.resolvedPlan,
5020
+ ...(outer.askAnalystDecision?.frozenPlan ? { frozenPlan: outer.askAnalystDecision.frozenPlan } : {}),
5021
+ },
5022
+ };
5023
+ }
2203
5024
  function isTerminalSuccess(route, outcome) {
2204
5025
  // A completed certified answer is the terminal success — no further steps add trust.
2205
5026
  // A conversational reply is likewise terminal (there is no data work to chain).
@@ -2360,6 +5181,21 @@ export function selectRoute(request, decision) {
2360
5181
  return 'blocked';
2361
5182
  if (decision.action === 'block')
2362
5183
  return 'blocked';
5184
+ // A unique Tier 1 artifact may be proven complete by the authoritative V2
5185
+ // retrieval workspace before any provider turn. Treat that host-owned
5186
+ // result as a route selection, not as a V1 business interpretation or an
5187
+ // instruction for the model to rediscover the same block. The artifact is
5188
+ // still rechecked and frozen only at the V2 execution-capability boundary.
5189
+ const v2State = decision.askAgentV2Decision?.mode === 'authoritative_v2'
5190
+ ? decision.askAgentV2Decision.state
5191
+ : undefined;
5192
+ const v2ExactCertified = decision.action === 'answer'
5193
+ && decision.requiresClarification !== true
5194
+ && Boolean(v2State?.exactCertifiedCandidateId
5195
+ && v2State.tierStates?.certified?.status === 'complete'
5196
+ && v2State.tierStates.certified.candidateIds.includes(v2State.exactCertifiedCandidateId));
5197
+ if (v2ExactCertified)
5198
+ return 'certified_answer';
2363
5199
  const authoritativePlan = decision.resolvedAnalyticalPlan?.mode === 'authoritative'
2364
5200
  ? decision.resolvedAnalyticalPlan
2365
5201
  : undefined;
@@ -2609,6 +5445,11 @@ function stopReasonFor(route, status, trustState, artifacts) {
2609
5445
  return "cancelled";
2610
5446
  if (status === "blocked" || trustState === "blocked")
2611
5447
  return "blocked";
5448
+ // A semantic result may be executable yet require review because the
5449
+ // runtime used one declared inferred grouping. Other reviewable authoring
5450
+ // routes keep their existing artifact-created stop semantics.
5451
+ if (route === "semantic_answer" && (status === "needs_review" || trustState === "review_required"))
5452
+ return "human_review_required";
2612
5453
  if (route === "conversation")
2613
5454
  return "conversational_reply";
2614
5455
  if (status === "needs_clarification")
@@ -2619,8 +5460,6 @@ function stopReasonFor(route, status, trustState, artifacts) {
2619
5460
  return "governed_semantic_answer";
2620
5461
  if (artifacts.length > 0 && route !== "generated_answer")
2621
5462
  return "artifact_created";
2622
- if (status === "needs_review")
2623
- return "human_review_required";
2624
5463
  return "generated_review_required";
2625
5464
  }
2626
5465
  function defaultNextActions(route, status) {