@duckcodeailabs/dql-agent 1.14.1 → 1.14.3-rc.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (174) hide show
  1. package/dist/agent-run-engine.d.ts +71 -2
  2. package/dist/agent-run-engine.d.ts.map +1 -1
  3. package/dist/agent-run-engine.js +1048 -31
  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 +12 -0
  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 +48 -3
  14. package/dist/agent-run-store.js.map +1 -1
  15. package/dist/agentic/analyst-loop.d.ts +7 -1
  16. package/dist/agentic/analyst-loop.d.ts.map +1 -1
  17. package/dist/agentic/analyst-loop.js +211 -28
  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/research/synthesis.d.ts +4 -0
  23. package/dist/agentic/research/synthesis.d.ts.map +1 -1
  24. package/dist/agentic/research/synthesis.js +23 -8
  25. package/dist/agentic/research/synthesis.js.map +1 -1
  26. package/dist/agentic/research-agent.d.ts.map +1 -1
  27. package/dist/agentic/research-agent.js +13 -3
  28. package/dist/agentic/research-agent.js.map +1 -1
  29. package/dist/agentic/sql-authorization.d.ts +8 -0
  30. package/dist/agentic/sql-authorization.d.ts.map +1 -1
  31. package/dist/agentic/sql-authorization.js +233 -2
  32. package/dist/agentic/sql-authorization.js.map +1 -1
  33. package/dist/analytical-compatibility.d.ts.map +1 -1
  34. package/dist/analytical-compatibility.js +8 -1
  35. package/dist/analytical-compatibility.js.map +1 -1
  36. package/dist/analytical-failure-repair.d.ts.map +1 -1
  37. package/dist/analytical-failure-repair.js +6 -2
  38. package/dist/analytical-failure-repair.js.map +1 -1
  39. package/dist/analytical-frame.d.ts +23 -1
  40. package/dist/analytical-frame.d.ts.map +1 -1
  41. package/dist/analytical-frame.js +332 -17
  42. package/dist/analytical-frame.js.map +1 -1
  43. package/dist/analytical-orchestration.d.ts +613 -0
  44. package/dist/analytical-orchestration.d.ts.map +1 -1
  45. package/dist/analytical-orchestration.js +1007 -0
  46. package/dist/analytical-orchestration.js.map +1 -1
  47. package/dist/answer-loop.d.ts +110 -6
  48. package/dist/answer-loop.d.ts.map +1 -1
  49. package/dist/answer-loop.js +1180 -125
  50. package/dist/answer-loop.js.map +1 -1
  51. package/dist/answer-shape.d.ts +32 -1
  52. package/dist/answer-shape.d.ts.map +1 -1
  53. package/dist/answer-shape.js +49 -3
  54. package/dist/answer-shape.js.map +1 -1
  55. package/dist/ask-observability/index.d.ts +11 -0
  56. package/dist/ask-observability/index.d.ts.map +1 -0
  57. package/dist/ask-observability/index.js +7 -0
  58. package/dist/ask-observability/index.js.map +1 -0
  59. package/dist/ask-observability/instrumentation.d.ts +35 -0
  60. package/dist/ask-observability/instrumentation.d.ts.map +1 -0
  61. package/dist/ask-observability/instrumentation.js +472 -0
  62. package/dist/ask-observability/instrumentation.js.map +1 -0
  63. package/dist/ask-observability/observer.d.ts +62 -0
  64. package/dist/ask-observability/observer.d.ts.map +1 -0
  65. package/dist/ask-observability/observer.js +537 -0
  66. package/dist/ask-observability/observer.js.map +1 -0
  67. package/dist/ask-observability/portable.d.ts +98 -0
  68. package/dist/ask-observability/portable.d.ts.map +1 -0
  69. package/dist/ask-observability/portable.js +527 -0
  70. package/dist/ask-observability/portable.js.map +1 -0
  71. package/dist/ask-observability/store.d.ts +130 -0
  72. package/dist/ask-observability/store.d.ts.map +1 -0
  73. package/dist/ask-observability/store.js +1114 -0
  74. package/dist/ask-observability/store.js.map +1 -0
  75. package/dist/ask-observability/types.d.ts +357 -0
  76. package/dist/ask-observability/types.d.ts.map +1 -0
  77. package/dist/ask-observability/types.js +2 -0
  78. package/dist/ask-observability/types.js.map +1 -0
  79. package/dist/ask-observability/utils.d.ts +21 -0
  80. package/dist/ask-observability/utils.d.ts.map +1 -0
  81. package/dist/ask-observability/utils.js +102 -0
  82. package/dist/ask-observability/utils.js.map +1 -0
  83. package/dist/conversation/snapshot.d.ts +20 -0
  84. package/dist/conversation/snapshot.d.ts.map +1 -1
  85. package/dist/conversation/snapshot.js +30 -1
  86. package/dist/conversation/snapshot.js.map +1 -1
  87. package/dist/fixtures/ask-ai-office-shaped.d.ts +153 -0
  88. package/dist/fixtures/ask-ai-office-shaped.d.ts.map +1 -0
  89. package/dist/fixtures/ask-ai-office-shaped.js +94 -0
  90. package/dist/fixtures/ask-ai-office-shaped.js.map +1 -0
  91. package/dist/generated-analytical-proposal.d.ts +37 -1
  92. package/dist/generated-analytical-proposal.d.ts.map +1 -1
  93. package/dist/generated-analytical-proposal.js +211 -3
  94. package/dist/generated-analytical-proposal.js.map +1 -1
  95. package/dist/governed-relational-compiler.d.ts +15 -0
  96. package/dist/governed-relational-compiler.d.ts.map +1 -1
  97. package/dist/governed-relational-compiler.js +99 -1
  98. package/dist/governed-relational-compiler.js.map +1 -1
  99. package/dist/index.d.ts +11 -9
  100. package/dist/index.d.ts.map +1 -1
  101. package/dist/index.js +7 -5
  102. package/dist/index.js.map +1 -1
  103. package/dist/intent-controller.d.ts +38 -0
  104. package/dist/intent-controller.d.ts.map +1 -1
  105. package/dist/intent-controller.js +31 -1
  106. package/dist/intent-controller.js.map +1 -1
  107. package/dist/meaning-resolution.d.ts +141 -2
  108. package/dist/meaning-resolution.d.ts.map +1 -1
  109. package/dist/meaning-resolution.js +444 -30
  110. package/dist/meaning-resolution.js.map +1 -1
  111. package/dist/metadata/analysis-planner.d.ts.map +1 -1
  112. package/dist/metadata/analysis-planner.js +144 -7
  113. package/dist/metadata/analysis-planner.js.map +1 -1
  114. package/dist/metadata/block-fit.d.ts +18 -0
  115. package/dist/metadata/block-fit.d.ts.map +1 -1
  116. package/dist/metadata/block-fit.js +250 -38
  117. package/dist/metadata/block-fit.js.map +1 -1
  118. package/dist/metadata/catalog.d.ts.map +1 -1
  119. package/dist/metadata/catalog.js +58 -12
  120. package/dist/metadata/catalog.js.map +1 -1
  121. package/dist/metadata/meaning-evidence.d.ts +19 -3
  122. package/dist/metadata/meaning-evidence.d.ts.map +1 -1
  123. package/dist/metadata/meaning-evidence.js +518 -54
  124. package/dist/metadata/meaning-evidence.js.map +1 -1
  125. package/dist/plan-execution-adapter.d.ts +9 -1
  126. package/dist/plan-execution-adapter.d.ts.map +1 -1
  127. package/dist/plan-execution-adapter.js +93 -13
  128. package/dist/plan-execution-adapter.js.map +1 -1
  129. package/dist/provider-egress.d.ts +24 -10
  130. package/dist/provider-egress.d.ts.map +1 -1
  131. package/dist/provider-egress.js +22 -17
  132. package/dist/provider-egress.js.map +1 -1
  133. package/dist/providers/claude.d.ts.map +1 -1
  134. package/dist/providers/claude.js +10 -9
  135. package/dist/providers/claude.js.map +1 -1
  136. package/dist/providers/dispatch.d.ts +27 -1
  137. package/dist/providers/dispatch.d.ts.map +1 -1
  138. package/dist/providers/dispatch.js +63 -1
  139. package/dist/providers/dispatch.js.map +1 -1
  140. package/dist/providers/gemini.js +10 -10
  141. package/dist/providers/gemini.js.map +1 -1
  142. package/dist/providers/index.d.ts +2 -2
  143. package/dist/providers/index.d.ts.map +1 -1
  144. package/dist/providers/index.js +1 -1
  145. package/dist/providers/index.js.map +1 -1
  146. package/dist/providers/ollama.js +15 -15
  147. package/dist/providers/ollama.js.map +1 -1
  148. package/dist/providers/openai.js +22 -22
  149. package/dist/providers/openai.js.map +1 -1
  150. package/dist/providers/types.d.ts +52 -0
  151. package/dist/providers/types.d.ts.map +1 -1
  152. package/dist/providers/types.js.map +1 -1
  153. package/dist/relationship-proof.d.ts +81 -0
  154. package/dist/relationship-proof.d.ts.map +1 -1
  155. package/dist/relationship-proof.js +228 -4
  156. package/dist/relationship-proof.js.map +1 -1
  157. package/dist/research-loop.d.ts.map +1 -1
  158. package/dist/research-loop.js +23 -1
  159. package/dist/research-loop.js.map +1 -1
  160. package/dist/resolved-analytical-plan.d.ts +22 -1
  161. package/dist/resolved-analytical-plan.d.ts.map +1 -1
  162. package/dist/resolved-analytical-plan.js +773 -49
  163. package/dist/resolved-analytical-plan.js.map +1 -1
  164. package/dist/router.d.ts +14 -4
  165. package/dist/router.d.ts.map +1 -1
  166. package/dist/router.js +3668 -425
  167. package/dist/router.js.map +1 -1
  168. package/dist/semantic-bridge/compose.d.ts.map +1 -1
  169. package/dist/semantic-bridge/compose.js +23 -4
  170. package/dist/semantic-bridge/compose.js.map +1 -1
  171. package/dist/semantic-bridge/member-select.d.ts.map +1 -1
  172. package/dist/semantic-bridge/member-select.js +39 -21
  173. package/dist/semantic-bridge/member-select.js.map +1 -1
  174. package/package.json +5 -5
@@ -2,13 +2,15 @@ import { createHash, randomUUID } from "node:crypto";
2
2
  import { normalizeProviderEgressReceiptV1, } from '@duckcodeailabs/dql-core';
3
3
  import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
4
4
  import { dirname, join } from "node:path";
5
- import { classifyConversationalTurn, looksLikeDefinitionalAboutNamedObject, decideAgentAction, looksLikeComposeApp, } from "./intent-controller.js";
5
+ import { classifyConversationalTurn, looksLikeDefinitionalAboutNamedObject, looksLikeNamedCertifiedArtifactMetadataRequest, decideAgentAction, looksLikeComposeApp, } from "./intent-controller.js";
6
6
  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 } from './analytical-orchestration.js';
10
+ import { buildCoverageGap, classifyProviderFailure, } from './analytical-orchestration.js';
11
11
  import { evaluateAnalyticalRequestPolicy } from './analytical-request-policy.js';
12
+ import { frozenRequiredOutputBindingProofsForPlan } from './generated-analytical-proposal.js';
13
+ import { attachAskTraceObserverV1, askTraceObserverForV1, finalizeAgentRunTraceV1, noOpAskTraceObserverV1, recordAuthoritativeRouterDecisionV1, recordAuthoritativePlanFreezeV1, recordEngineTraceEventV1, recordExecutionAttemptSummaryV1, } from './ask-observability/index.js';
12
14
  /** Routes a stakeholder may never land on (analyst authoring lives in the Notebook). */
13
15
  const ANALYST_ONLY_ROUTES = new Set(["sql_cell", "dql_block_draft", "modeling_draft", "skill_draft"]);
14
16
  /**
@@ -199,21 +201,30 @@ export function resolveClarificationContinuation(request) {
199
201
  if (!reply || (!structuredSelection && !isLikelyClarificationReply(reply)))
200
202
  return undefined;
201
203
  const fromServer = latestClarificationFromConversationContext(request.conversationContext);
204
+ const serverIssuedStructuredSelection = structuredSelection
205
+ ? serverIssuedStructuredClarification(request)
206
+ : undefined;
202
207
  const fromHistory = latestClarificationFromHistory(request.history);
203
208
  // A UI selection is bound to the exact run that rendered the options. Carry
204
209
  // that run's source question explicitly so the continuation still works when
205
210
  // the optional conversation store is unavailable, after a reload, or when
206
211
  // the user selects an option on an older visible answer. Server history still
207
212
  // 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())
213
+ const pending = serverIssuedStructuredSelection
214
+ ?? (explicitSourceQuestion
215
+ ? {
216
+ sourceQuestion: explicitSourceQuestion,
217
+ clarifyingQuestion: fromServer?.clarifyingQuestion
218
+ ?? fromHistory?.clarifyingQuestion
219
+ ?? 'Which governed meaning should be used?',
220
+ }
221
+ : fromServer ?? fromHistory);
222
+ // A structured option intentionally submits the original question together
223
+ // with a stable, server-issued identifier. Treating that exact text as a
224
+ // fresh question drops the persisted typed frame and makes the router ask
225
+ // the same clarification again. The equality guard remains important for
226
+ // free-text replies, where an unchanged question carries no new meaning.
227
+ if (!pending || (!structuredSelection && pending.sourceQuestion.trim().toLowerCase() === reply.toLowerCase()))
217
228
  return undefined;
218
229
  return {
219
230
  ...pending,
@@ -226,6 +237,44 @@ export function resolveClarificationContinuation(request) {
226
237
  ].join('\n\n'),
227
238
  };
228
239
  }
240
+ /**
241
+ * Use the original server snapshot for an identifier-bound continuation when
242
+ * it carries the host-only authority record that local runtime reconstructs
243
+ * from its persisted thread. This prevents a browser-provided label (or a
244
+ * stale client source question) from replacing the typed analytical frame
245
+ * before router validation. The router still performs the final snapshot and
246
+ * option-ID validation before any plan can freeze.
247
+ */
248
+ function serverIssuedStructuredClarification(request) {
249
+ if (!request.threadId)
250
+ return undefined;
251
+ const context = clarificationRecord(request.conversationContext);
252
+ const authority = clarificationRecord(context?.serverIssuedClarificationSelection);
253
+ if (authority?.version !== 1
254
+ || clarificationString(authority.threadId) !== request.threadId)
255
+ return undefined;
256
+ const authoritySourceTurnId = clarificationString(authority.sourceTurnId);
257
+ const authoritySnapshotId = clarificationString(authority.snapshotId);
258
+ if (!authoritySourceTurnId || !authoritySnapshotId)
259
+ return undefined;
260
+ for (const source of [
261
+ clarificationRecord(context?.conversationEnvelope),
262
+ clarificationRecord(context?.serverSnapshot),
263
+ ]) {
264
+ if (clarificationString(source?.threadId) !== request.threadId)
265
+ continue;
266
+ const pending = clarificationRecord(source?.pendingClarification);
267
+ const selection = clarificationRecord(pending?.selection);
268
+ if (clarificationString(pending?.sourceTurnId) !== authoritySourceTurnId
269
+ || clarificationString(selection?.snapshotId) !== authoritySnapshotId)
270
+ continue;
271
+ const sourceQuestion = clarificationString(pending?.sourceQuestion);
272
+ const clarifyingQuestion = clarificationString(pending?.question);
273
+ if (sourceQuestion && clarifyingQuestion)
274
+ return { sourceQuestion, clarifyingQuestion };
275
+ }
276
+ return undefined;
277
+ }
229
278
  function latestClarificationFromHistory(history) {
230
279
  if (!history?.length)
231
280
  return undefined;
@@ -467,6 +516,7 @@ export class AgentRunEngine {
467
516
  budgetModel;
468
517
  maxSteps;
469
518
  routeTimeoutSignal;
519
+ traceObserverFactory;
470
520
  constructor(options = {}) {
471
521
  this.executors = options.executors ?? {};
472
522
  this.gates = options.gates ?? {};
@@ -485,6 +535,7 @@ export class AgentRunEngine {
485
535
  };
486
536
  this.maxSteps = Math.max(1, options.maxSteps ?? DEFAULT_MAX_STEPS);
487
537
  this.routeTimeoutSignal = options.routeTimeoutSignal ?? ((durationMs) => AbortSignal.timeout(durationMs));
538
+ this.traceObserverFactory = options.traceObserverFactory;
488
539
  }
489
540
  /**
490
541
  * Decide the high-level action. A forced `requestedMode` bypasses routing.
@@ -542,6 +593,59 @@ export class AgentRunEngine {
542
593
  const startedAt = this.timestamp();
543
594
  const runStartedAtMs = Date.parse(startedAt);
544
595
  const requestedMode = request.requestedMode ?? "auto";
596
+ // OBS-001/OBS-002: valid engine requests receive a server-owned trace after
597
+ // their run ID is known. The observer is explicitly non-authoritative and
598
+ // a factory/store failure becomes a no-op, never an Ask failure.
599
+ let traceObserver = noOpAskTraceObserverV1;
600
+ try {
601
+ traceObserver = this.traceObserverFactory?.({ runId, request, startedAt, requestedMode })
602
+ ?? noOpAskTraceObserverV1;
603
+ }
604
+ catch {
605
+ traceObserver = noOpAskTraceObserverV1;
606
+ }
607
+ request = attachAskTraceObserverV1({ ...request }, traceObserver);
608
+ // Continuity is relationship evidence, not new routing input. Keep only
609
+ // stable run IDs and one-way fingerprints so a trace can explain why this
610
+ // turn reused a clarification/result/derived plan without persisting chat
611
+ // text, values, SQL, or an invented parent trace.
612
+ if (clarificationContinuation) {
613
+ traceObserver.recordLink({
614
+ kind: 'clarification_continuation',
615
+ choiceFingerprint: traceLinkFingerprint(request.selectedEvidenceId ?? clarificationContinuation.sourceQuestion),
616
+ });
617
+ }
618
+ if (request.selectedResultBinding) {
619
+ traceObserver.recordLink({
620
+ kind: 'prior_result',
621
+ targetRunId: request.selectedResultBinding.sourceRunId,
622
+ choiceFingerprint: traceLinkFingerprint([
623
+ request.selectedResultBinding.sourceArtifactId,
624
+ request.selectedResultBinding.canonicalColumn,
625
+ request.selectedResultBinding.rowFingerprint,
626
+ request.selectedResultBinding.resultFingerprint,
627
+ ].join('\u0000')),
628
+ });
629
+ }
630
+ const derivedSourceRunId = traceDerivedSourceRunId(request.workspaceContext);
631
+ if (derivedSourceRunId) {
632
+ traceObserver.recordLink({
633
+ kind: 'derived_repair',
634
+ targetRunId: derivedSourceRunId,
635
+ choiceFingerprint: traceLinkFingerprint(derivedSourceRunId),
636
+ });
637
+ }
638
+ const conversationBinding = traceConversationBinding(request, clarificationContinuation);
639
+ const conversationTrace = traceObserver.startSpan({
640
+ name: 'conversation.hydrate',
641
+ stage: 'conversation',
642
+ payload: {
643
+ kind: 'conversation',
644
+ continuation: conversationBinding !== 'none',
645
+ binding: conversationBinding,
646
+ },
647
+ });
648
+ traceObserver.finishSpan(conversationTrace, { outcome: 'ok', reasonCode: 'completed' });
545
649
  const runBudget = request.runBudget ?? createAgentRunBudget({
546
650
  requestedMode,
547
651
  startedAtMs: runStartedAtMs,
@@ -549,7 +653,11 @@ export class AgentRunEngine {
549
653
  timeoutSignal: this.routeTimeoutSignal,
550
654
  nowMs: () => this.now().getTime(),
551
655
  });
552
- request = { ...request, runBudget, signal: runBudget.hardSignal };
656
+ // Preserve the non-enumerable observer across the immutable request update.
657
+ // A plain spread drops symbol properties, which previously made the router
658
+ // lose candidate/cascade/freeze evidence even though the engine still
659
+ // emitted its own outer spans.
660
+ request = attachAskTraceObserverV1({ ...request, runBudget, signal: runBudget.hardSignal }, traceObserver);
553
661
  const events = [];
554
662
  let plan;
555
663
  const executedSteps = [];
@@ -573,6 +681,7 @@ export class AgentRunEngine {
573
681
  startedAt,
574
682
  updatedAt: startedAt,
575
683
  },
684
+ ...(traceObserver.reference() ? { traceReference: traceObserver.reference() } : {}),
576
685
  };
577
686
  let checkpointQueue = Promise.resolve();
578
687
  const persistProgress = () => {
@@ -632,6 +741,12 @@ export class AgentRunEngine {
632
741
  }
633
742
  if (full.type === "step.completed")
634
743
  progress.steps = [...executedSteps];
744
+ // Event payloads are intentionally not copied: they may contain raw
745
+ // answer/tool data. The typed mapping records only stage identity.
746
+ recordEngineTraceEventV1(traceObserver, full);
747
+ const traceReference = traceObserver.reference();
748
+ if (traceReference)
749
+ progress.traceReference = traceReference;
635
750
  persistProgress();
636
751
  onEvent?.(full);
637
752
  };
@@ -728,6 +843,7 @@ export class AgentRunEngine {
728
843
  id: runId,
729
844
  question: submittedQuestion,
730
845
  requestedMode,
846
+ conversationBinding: request.conversationBinding ?? traceConversationBinding(request, clarificationContinuation),
731
847
  route: 'blocked',
732
848
  status: 'blocked',
733
849
  trustState: 'blocked',
@@ -753,7 +869,13 @@ export class AgentRunEngine {
753
869
  };
754
870
  run.diagnosticReceipt = diagnosticReceiptForRun(run);
755
871
  run.diagnosticReceiptV2 = diagnosticReceiptV2ForRun(run);
756
- run.artifacts = attachDiagnosticReceipt(run.artifacts, run.diagnosticReceipt, run.diagnosticReceiptV2);
872
+ run.diagnosticReceiptV3 = diagnosticReceiptV3ForRun(run);
873
+ run.diagnosticReceiptV4 = diagnosticReceiptV4ForRun(run);
874
+ run.artifacts = attachDiagnosticReceipt(run.artifacts, run.diagnosticReceipt, run.diagnosticReceiptV2, run.diagnosticReceiptV3, run.diagnosticReceiptV4);
875
+ // Observability is deliberately finalized only after the authoritative
876
+ // receipt exists, and before the ordinary run store persists its compact
877
+ // reference. A local trace write failure never changes this outcome.
878
+ finalizeAgentRunTraceV1(traceObserver, run);
757
879
  await checkpointQueue;
758
880
  await this.store?.save(run);
759
881
  return run;
@@ -764,6 +886,11 @@ export class AgentRunEngine {
764
886
  // pre-try await escaped the engine and left active UI runs looking endless.
765
887
  let routeDecision = buildIntentDecision(request);
766
888
  try {
889
+ const classifySpan = traceObserver.startSpan({
890
+ name: 'request.classify',
891
+ stage: 'request',
892
+ payload: { kind: 'stage', requestedMode },
893
+ });
767
894
  routeDecision = clarificationContinuation && !request.selectedEvidenceId
768
895
  ? {
769
896
  action: "answer",
@@ -774,6 +901,11 @@ export class AgentRunEngine {
774
901
  }
775
902
  : await awaitWithAbort(this.decideRoute(request), request.signal);
776
903
  routeDecision = enforceOrdinaryAnalyticalPlanBoundary(request, routeDecision);
904
+ traceObserver.finishSpan(classifySpan, { outcome: 'ok', reasonCode: 'route_selected' });
905
+ // Router/cascade evidence is captured after its authoritative decision
906
+ // is sealed. The trace adapter only projects IDs, counters, and typed
907
+ // receipts; it never participates in route selection.
908
+ recordAuthoritativeRouterDecisionV1(traceObserver, routeDecision);
777
909
  const defaultRoute = answerAnywayRoute(constrainRouteForAudience(selectRoute(request, routeDecision), audience), request, audience, routeDecision);
778
910
  const authoritativeAsk = routeDecision.resolvedAnalyticalPlan?.mode === 'authoritative'
779
911
  && requestedMode !== 'research';
@@ -858,6 +990,10 @@ export class AgentRunEngine {
858
990
  : `Re-running ${route.replaceAll("_", " ")} executor (repair attempt ${attempt}).`,
859
991
  route,
860
992
  });
993
+ // Provider readiness belongs at the provider boundary. The engine
994
+ // cannot infer it from an executor return value: a deterministic
995
+ // route may be provider-free and a provider route can fail during
996
+ // preflight before any executor result exists.
861
997
  result = await this.executeRoute({
862
998
  runId,
863
999
  request,
@@ -872,6 +1008,22 @@ export class AgentRunEngine {
872
1008
  emit,
873
1009
  emitAnswerDelta: onAnswerDelta,
874
1010
  });
1011
+ recordExecutionAttemptSummaryV1(traceObserver, result);
1012
+ // The router owns a frozen analytical tier. An executor may report a
1013
+ // same-tier execution failure, but it cannot turn a certified or
1014
+ // semantic plan into generated work (or vice versa) after execution
1015
+ // has started. Keep this guard in the engine as well as host adapters
1016
+ // so an injected/legacy executor cannot redefine durable provenance.
1017
+ const planWasFrozen = routeDecision.analyticalCascadeDecision?.planFrozen === true;
1018
+ routeDecision = applyExploratoryExecutionFreeze(routeDecision, result.analyticalExecutionFreeze);
1019
+ routeDecision = applyExploratoryExecutionFreeze(routeDecision, result.analyticalExecutionRepairFreeze);
1020
+ // The router froze the exploratory plan before SQL generation. The
1021
+ // host receipt below only authorizes this exact SQL/target against
1022
+ // that immutable plan; it never creates a second freeze transition.
1023
+ if (!planWasFrozen && routeDecision.analyticalCascadeDecision?.planFrozen) {
1024
+ recordAuthoritativePlanFreezeV1(traceObserver, routeDecision.analyticalCascadeDecision);
1025
+ }
1026
+ result = preserveFrozenAnalyticalRoute(route, routeDecision, result);
875
1027
  result = consumeRepeatedClarificationSelection(request, routeDecision, result);
876
1028
  if (result.analyticalTurnPlan)
877
1029
  progress.analyticalTurnPlan = result.analyticalTurnPlan;
@@ -900,7 +1052,7 @@ export class AgentRunEngine {
900
1052
  // A frozen analytical plan has one route and no downstream planner,
901
1053
  // rematch, route escalation, or whole-answer regeneration authority.
902
1054
  // Typed server-issued repair is a separate derived run.
903
- if (authoritativeAsk) {
1055
+ if (authoritativeAsk || routeDecision.analyticalCascadeDecision?.planFrozen === true) {
904
1056
  stepStatus = 'needs_review';
905
1057
  break;
906
1058
  }
@@ -1105,7 +1257,10 @@ export class AgentRunEngine {
1105
1257
  run.lifecycle = terminalLifecycle(progress.lifecycle, run.status === "blocked" ? "run.failed" : "run.completed", run.completedAt, events.length);
1106
1258
  run.diagnosticReceipt = diagnosticReceiptForRun(run);
1107
1259
  run.diagnosticReceiptV2 = diagnosticReceiptV2ForRun(run);
1108
- run.artifacts = attachDiagnosticReceipt(run.artifacts, run.diagnosticReceipt, run.diagnosticReceiptV2);
1260
+ run.diagnosticReceiptV3 = diagnosticReceiptV3ForRun(run);
1261
+ run.diagnosticReceiptV4 = diagnosticReceiptV4ForRun(run);
1262
+ run.artifacts = attachDiagnosticReceipt(run.artifacts, run.diagnosticReceipt, run.diagnosticReceiptV2, run.diagnosticReceiptV3, run.diagnosticReceiptV4);
1263
+ finalizeAgentRunTraceV1(traceObserver, run);
1109
1264
  await checkpointQueue;
1110
1265
  await this.store?.save(run);
1111
1266
  return run;
@@ -1157,6 +1312,7 @@ export class AgentRunEngine {
1157
1312
  id: runId,
1158
1313
  question: submittedQuestion,
1159
1314
  requestedMode,
1315
+ conversationBinding: request.conversationBinding ?? traceConversationBinding(request, clarificationContinuation),
1160
1316
  route: cancelledRoute,
1161
1317
  status: "cancelled",
1162
1318
  trustState: "not_applicable",
@@ -1191,6 +1347,9 @@ export class AgentRunEngine {
1191
1347
  lifecycle: terminalLifecycle(progress.lifecycle, "run.cancelled", completedAt, events.length),
1192
1348
  };
1193
1349
  run.diagnosticReceiptV2 = diagnosticReceiptV2ForRun(run);
1350
+ run.diagnosticReceiptV3 = diagnosticReceiptV3ForRun(run);
1351
+ run.diagnosticReceiptV4 = diagnosticReceiptV4ForRun(run);
1352
+ finalizeAgentRunTraceV1(traceObserver, run);
1194
1353
  await checkpointQueue;
1195
1354
  await this.store?.save(run);
1196
1355
  return run;
@@ -1198,7 +1357,9 @@ export class AgentRunEngine {
1198
1357
  const message = isOrchestrationBudgetExhausted(err)
1199
1358
  ? 'Ask could not complete within its bounded orchestration. Nothing was executed; narrow the metric or dimension and retry.'
1200
1359
  : err instanceof Error && err.name === "TimeoutError"
1201
- ? "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."
1360
+ ? requestedMode === 'research'
1361
+ ? '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.'
1362
+ : "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."
1202
1363
  : err instanceof Error ? err.message : String(err);
1203
1364
  const failedRoute = progress.route;
1204
1365
  const failedPhase = progress.lifecycle.phase;
@@ -1210,7 +1371,7 @@ export class AgentRunEngine {
1210
1371
  trustState: "blocked",
1211
1372
  });
1212
1373
  const completedAt = this.timestamp();
1213
- const failure = diagnosticFailureFromError(err, failedPhase);
1374
+ const failure = diagnosticFailureFromError(err, failedPhase, requestedMode);
1214
1375
  const evaluations = [
1215
1376
  ...progress.evaluations,
1216
1377
  {
@@ -1238,6 +1399,7 @@ export class AgentRunEngine {
1238
1399
  id: runId,
1239
1400
  question: submittedQuestion,
1240
1401
  requestedMode,
1402
+ conversationBinding: request.conversationBinding ?? traceConversationBinding(request, clarificationContinuation),
1241
1403
  route: "blocked",
1242
1404
  status: "blocked",
1243
1405
  trustState: "blocked",
@@ -1274,7 +1436,10 @@ export class AgentRunEngine {
1274
1436
  lifecycle: terminalLifecycle(progress.lifecycle, "run.failed", completedAt, events.length),
1275
1437
  };
1276
1438
  run.diagnosticReceiptV2 = diagnosticReceiptV2ForRun(run);
1277
- run.artifacts = attachDiagnosticReceipt(retainedArtifacts, receipt, run.diagnosticReceiptV2);
1439
+ run.diagnosticReceiptV3 = diagnosticReceiptV3ForRun(run);
1440
+ run.diagnosticReceiptV4 = diagnosticReceiptV4ForRun(run);
1441
+ run.artifacts = attachDiagnosticReceipt(retainedArtifacts, receipt, run.diagnosticReceiptV2, run.diagnosticReceiptV3, run.diagnosticReceiptV4);
1442
+ finalizeAgentRunTraceV1(traceObserver, run);
1278
1443
  await checkpointQueue;
1279
1444
  await this.store?.save(run);
1280
1445
  return run;
@@ -1291,6 +1456,7 @@ export class AgentRunEngine {
1291
1456
  id: input.runId,
1292
1457
  question: input.request.question,
1293
1458
  requestedMode: input.requestedMode,
1459
+ conversationBinding: input.request.conversationBinding ?? traceConversationBinding(input.request, undefined),
1294
1460
  route: "blocked",
1295
1461
  status: "blocked",
1296
1462
  trustState: "blocked",
@@ -1337,6 +1503,7 @@ export class AgentRunEngine {
1337
1503
  id: input.runId,
1338
1504
  question: input.request.question,
1339
1505
  requestedMode: input.requestedMode,
1506
+ conversationBinding: input.request.conversationBinding ?? traceConversationBinding(input.request, undefined),
1340
1507
  route,
1341
1508
  status: finalOutcome.status,
1342
1509
  trustState: finalOutcome.trustState,
@@ -1400,9 +1567,13 @@ export class AgentRunEngine {
1400
1567
  const signal = context.request.runBudget?.hardSignal ?? context.request.signal;
1401
1568
  if (signal?.aborted)
1402
1569
  throw signal.reason ?? routeTimeoutError();
1570
+ // Route executors own physical provider/tool/SQL boundaries. Preserve the
1571
+ // non-enumerable observer when adding the run signal; a normal object
1572
+ // spread would otherwise leave canonical routing evidence intact while
1573
+ // silently dropping every physical execution span.
1403
1574
  const execution = Promise.resolve(executor({
1404
1575
  ...context,
1405
- request: { ...context.request, ...(signal ? { signal } : {}) },
1576
+ request: attachAskTraceObserverV1({ ...context.request, ...(signal ? { signal } : {}) }, askTraceObserverForV1(context.request)),
1406
1577
  }));
1407
1578
  return awaitWithAbort(execution, signal);
1408
1579
  }
@@ -1420,6 +1591,44 @@ export class AgentRunEngine {
1420
1591
  return this.now().toISOString();
1421
1592
  }
1422
1593
  }
1594
+ function traceLinkFingerprint(value) {
1595
+ return `sha256:${createHash('sha256').update(value).digest('hex')}`;
1596
+ }
1597
+ /** Only a host-produced repair derivation is linkable; ordinary authoring context is not. */
1598
+ function traceDerivedSourceRunId(workspaceContext) {
1599
+ if (!workspaceContext || typeof workspaceContext !== 'object' || Array.isArray(workspaceContext))
1600
+ return undefined;
1601
+ const record = workspaceContext;
1602
+ const derivation = record.traceDerivation;
1603
+ if (derivation !== 'analytical_repair' && derivation !== 'derived_repair')
1604
+ return undefined;
1605
+ return typeof record.sourceRunId === 'string' && record.sourceRunId.trim()
1606
+ ? record.sourceRunId
1607
+ : undefined;
1608
+ }
1609
+ /**
1610
+ * Record only why the conversation boundary was available, never the member,
1611
+ * question, row, or free-text selection that it carried. The local runtime
1612
+ * resolves its typed follow-up before retrieval; this trace label lets an
1613
+ * office reproduction distinguish a missing binding from a later retrieval or
1614
+ * execution failure without turning traces into chat persistence.
1615
+ */
1616
+ function traceConversationBinding(request, clarification) {
1617
+ if (clarification || request.selectedEvidenceId)
1618
+ return 'structured_clarification';
1619
+ if (request.conversationBinding)
1620
+ return request.conversationBinding;
1621
+ if (request.selectedResultBinding)
1622
+ return 'prior_result';
1623
+ const context = request.conversationContext;
1624
+ if (!context || Object.keys(context).length === 0)
1625
+ return 'none';
1626
+ if ('analyticalTaskDependencyBinding' in context)
1627
+ return 'task_dependency';
1628
+ // A snapshot merely makes a prior binding *available*. It is not evidence
1629
+ // that this self-contained question selected it.
1630
+ return 'none';
1631
+ }
1423
1632
  /**
1424
1633
  * Ordinary analytical Ask cannot delegate meaning to the legacy answer
1425
1634
  * generator. Retrieval/meaning must first produce the immutable RAP consumed by
@@ -1460,17 +1669,28 @@ function rescueModelingGapForOrdinaryAsk(request, decision, options = {}) {
1460
1669
  return undefined;
1461
1670
  if (decision.terminalOutcome?.kind !== 'modeling_gap')
1462
1671
  return undefined;
1463
- // A missing modeled dimension is a factual coverage result, not an invitation
1464
- // to retry the same question through generated SQL or Research. The router
1465
- // already searched the governed sources and attached the exact field; keep it
1466
- // terminal so the UI can render the typed modeling action.
1467
- if (decision.meaningResolution?.compatibilityFailures?.some((failure) => failure.code === 'MISSING_DIMENSION'))
1672
+ // A frozen plan is authoritative. Only a PRE-FREEZE coverage observation may
1673
+ // advance through the later governed-relational/exploratory tiers. This keeps
1674
+ // compiler, policy, validation, and warehouse failures terminal after a plan
1675
+ // has been accepted while restoring the required certified → semantic →
1676
+ // relational review-required exploration cascade for missing dimensions.
1677
+ if (decision.resolvedAnalyticalPlan?.mode === 'authoritative')
1468
1678
  return undefined;
1469
1679
  // Genuine user-facing ambiguity and an explicit evidence pick stay terminal.
1470
1680
  if (decision.requiresClarification === true)
1471
1681
  return undefined;
1472
1682
  if (request.selectedEvidenceId)
1473
1683
  return undefined;
1684
+ // The router, not this host boundary, owns cascade eligibility. A typed
1685
+ // pre-freeze coverage gap advances only when the same snapshot recorded an
1686
+ // executable exploratory tier. This prevents a semantic-only candidate or a
1687
+ // forged/old terminal decision from quietly becoming generated SQL.
1688
+ const exploratoryAttempt = decision.analyticalCascadeDecision?.attempts.find((attempt) => attempt.tier === 'exploratory_sql');
1689
+ if (decision.analyticalCascadeDecision?.selectedTier !== 'exploratory_sql'
1690
+ || decision.analyticalCascadeDecision.planFrozen
1691
+ || exploratoryAttempt?.outcome !== 'executable'
1692
+ || exploratoryAttempt.candidateIds.length === 0)
1693
+ return undefined;
1474
1694
  if (options.requireGovernedEvidence) {
1475
1695
  const governedEvidence = (decision.retrievalEvidence?.candidateCount ?? 0) > 0
1476
1696
  || (decision.meaningResolution?.selectedConceptIds.length ?? 0) > 0;
@@ -1500,6 +1720,227 @@ function rescueModelingGapForOrdinaryAsk(request, decision, options = {}) {
1500
1720
  : {}),
1501
1721
  };
1502
1722
  }
1723
+ /**
1724
+ * Consume the router's immutable cascade decision without reparsing the
1725
+ * question or reconstructing a tier from route/identifier text. The selected
1726
+ * tier is intentionally sufficient for dispatch; compilation and execution
1727
+ * still validate the frozen plan or review-required exploratory SQL.
1728
+ */
1729
+ function routeFromAnalyticalCascade(decision) {
1730
+ const cascade = decision.analyticalCascadeDecision;
1731
+ if (!cascade)
1732
+ return undefined;
1733
+ if (cascade.stopReason === 'denied' || cascade.stopReason === 'coverage_gap' || cascade.stopReason === 'post_freeze_failure') {
1734
+ return 'blocked';
1735
+ }
1736
+ if (cascade.stopReason === 'ambiguous')
1737
+ return 'clarify';
1738
+ switch (cascade.selectedTier) {
1739
+ case 'certified':
1740
+ return cascade.planFrozen ? 'certified_answer' : undefined;
1741
+ case 'semantic':
1742
+ return cascade.planFrozen ? 'semantic_answer' : undefined;
1743
+ case 'governed_relational':
1744
+ case 'exploratory_sql':
1745
+ return 'generated_answer';
1746
+ default:
1747
+ return undefined;
1748
+ }
1749
+ }
1750
+ /**
1751
+ * A frozen router decision is an immutable execution contract, not a hint that
1752
+ * a downstream answer loop may replace with another meaning/tier. The executor
1753
+ * is still free to return a terminal compilation, provider, adapter, or result
1754
+ * failure, but it must retain the selected route while doing so.
1755
+ */
1756
+ function preserveFrozenAnalyticalRoute(route, decision, result) {
1757
+ const frozen = decision.analyticalCascadeDecision?.planFrozen === true
1758
+ || decision.resolvedAnalyticalPlan?.mode === 'authoritative';
1759
+ if (!frozen || !result.resolvedRoute || result.resolvedRoute === route)
1760
+ return result;
1761
+ return {
1762
+ resolvedRoute: route,
1763
+ status: 'blocked',
1764
+ trustState: 'blocked',
1765
+ stopReason: 'blocked',
1766
+ summary: `The frozen ${route.replaceAll('_', ' ')} plan could not execute as selected. DQL did not substitute another analytical tier.`,
1767
+ answer: 'The selected analytical plan could not be executed as selected. No fallback answer was returned.',
1768
+ artifacts: [],
1769
+ evaluations: [{
1770
+ id: 'frozen-plan-route-mismatch',
1771
+ label: 'Frozen analytical route',
1772
+ passed: false,
1773
+ severity: 'blocking',
1774
+ message: `The executor reported ${result.resolvedRoute.replaceAll('_', ' ')} after the router froze ${route.replaceAll('_', ' ')}.`,
1775
+ evidence: {
1776
+ selectedRoute: route,
1777
+ reportedRoute: result.resolvedRoute,
1778
+ selectedTier: decision.analyticalCascadeDecision?.selectedTier,
1779
+ planId: decision.resolvedAnalyticalPlan?.planId,
1780
+ },
1781
+ }],
1782
+ };
1783
+ }
1784
+ /**
1785
+ * Promote only an explicit host-issued exploratory freeze into the router
1786
+ * decision that will be persisted. This deliberately does not inspect route
1787
+ * names, SQL strings, or identifier patterns: a selected tier is immutable
1788
+ * only when its own candidate set, snapshot, target, and capability receipt
1789
+ * all agree.
1790
+ */
1791
+ function applyExploratoryExecutionFreeze(decision, freeze) {
1792
+ if (!freeze)
1793
+ return decision;
1794
+ const cascade = decision.analyticalCascadeDecision;
1795
+ const attempt = cascade?.attempts.find((candidate) => candidate.tier === 'exploratory_sql');
1796
+ const selectedPlan = decision.resolvedAnalyticalPlan;
1797
+ const existing = cascade?.exploratoryExecutionFreeze;
1798
+ const existingRepair = cascade?.exploratoryRepairExecutionFreeze;
1799
+ const authorizationAttempt = normalizedExploratoryAuthorizationAttempt(freeze);
1800
+ const sameCandidates = Boolean(attempt
1801
+ && attempt.candidateIds.length === freeze.candidateIds.length
1802
+ && attempt.candidateIds.every((candidate, index) => candidate === freeze.candidateIds[index]));
1803
+ const retrievalSnapshotId = decision.retrievalEvidence?.snapshotId;
1804
+ const validBaseReceipt = Boolean(cascade
1805
+ && cascade.selectedTier === 'exploratory_sql'
1806
+ && cascade.planFrozen === true
1807
+ && attempt?.outcome === 'executable'
1808
+ && attempt.planFrozen === true
1809
+ && sameCandidates
1810
+ && freeze.version === 1
1811
+ && freeze.selectedTier === 'exploratory_sql'
1812
+ && freeze.authorization === 'capability_minted'
1813
+ && freeze.planId.trim()
1814
+ && freeze.planFingerprint.trim()
1815
+ && freeze.snapshotId.trim()
1816
+ && freeze.targetFingerprint.trim()
1817
+ && freeze.sqlFingerprint.trim()
1818
+ && selectedPlan?.capability === 'bounded_exploration'
1819
+ && selectedPlan.planId === freeze.planId
1820
+ && selectedPlan.fingerprint === freeze.planFingerprint
1821
+ && selectedPlan.snapshotId === freeze.snapshotId
1822
+ && freezeCarriesRequiredOutputBindings(selectedPlan, freeze)
1823
+ && (!retrievalSnapshotId || retrievalSnapshotId === freeze.snapshotId));
1824
+ if (!validBaseReceipt) {
1825
+ throw exploratoryAuthorizationStateMismatch();
1826
+ }
1827
+ // A replay of one exact host handoff is harmless. A repair is a fresh,
1828
+ // separately-minted capability, but its receipt must name the initial SQL
1829
+ // authorization and keep every immutable plan binding identical.
1830
+ if (authorizationAttempt.index === 0) {
1831
+ if (existing) {
1832
+ if (sameExploratoryAuthorizationReceipt(existing, freeze))
1833
+ return decision;
1834
+ throw exploratoryAuthorizationStateMismatch();
1835
+ }
1836
+ if (existingRepair)
1837
+ throw exploratoryAuthorizationStateMismatch();
1838
+ return withExploratoryAuthorizationReceipt(decision, freeze, 'initial');
1839
+ }
1840
+ if (authorizationAttempt.index !== 1
1841
+ || !authorizationAttempt.parentSqlFingerprint
1842
+ || !existing
1843
+ || existingRepair
1844
+ || authorizationAttempt.parentSqlFingerprint !== existing.sqlFingerprint
1845
+ || !sameExploratoryPlanBindings(existing, freeze)) {
1846
+ throw exploratoryAuthorizationStateMismatch();
1847
+ }
1848
+ return withExploratoryAuthorizationReceipt(decision, freeze, 'repair');
1849
+ }
1850
+ function withExploratoryAuthorizationReceipt(decision, freeze, kind) {
1851
+ const cascade = decision.analyticalCascadeDecision;
1852
+ return {
1853
+ ...decision,
1854
+ analyticalCascadeDecision: {
1855
+ ...cascade,
1856
+ ...(kind === 'initial'
1857
+ ? { exploratoryExecutionFreeze: freeze }
1858
+ : { exploratoryRepairExecutionFreeze: freeze }),
1859
+ attempts: cascade.attempts.map((candidate) => candidate.tier === 'exploratory_sql'
1860
+ ? {
1861
+ ...candidate,
1862
+ // The router froze the plan before SQL generation. The host only
1863
+ // binds exact SQL/target bytes to that immutable plan. A repair
1864
+ // cannot choose another tier or mutate the analytical frame.
1865
+ reason: kind === 'repair'
1866
+ ? `${candidate.reason} Host authorized one same-plan SQL repair against frozen plan ${freeze.planId}.`
1867
+ : `${candidate.reason} Host authorized SQL execution against frozen plan ${freeze.planId}.`,
1868
+ }
1869
+ : candidate),
1870
+ },
1871
+ };
1872
+ }
1873
+ function normalizedExploratoryAuthorizationAttempt(freeze) {
1874
+ const attempt = freeze.authorizationAttempt;
1875
+ // V1/V3 persisted receipts predate explicit authorization-attempt evidence.
1876
+ // They are compatible only as the original handoff, never as a repair.
1877
+ if (!attempt)
1878
+ return { index: 0 };
1879
+ if (attempt.version !== 1 || (attempt.index !== 0 && attempt.index !== 1)) {
1880
+ throw exploratoryAuthorizationStateMismatch();
1881
+ }
1882
+ if (attempt.index === 0) {
1883
+ if ('parentSqlFingerprint' in attempt && attempt.parentSqlFingerprint) {
1884
+ throw exploratoryAuthorizationStateMismatch();
1885
+ }
1886
+ return { index: 0 };
1887
+ }
1888
+ if (!attempt.parentSqlFingerprint?.trim())
1889
+ throw exploratoryAuthorizationStateMismatch();
1890
+ return { index: 1, parentSqlFingerprint: attempt.parentSqlFingerprint };
1891
+ }
1892
+ function sameExploratoryPlanBindings(left, right) {
1893
+ return left.version === right.version
1894
+ && left.selectedTier === right.selectedTier
1895
+ && left.planId === right.planId
1896
+ && left.planFingerprint === right.planFingerprint
1897
+ && left.snapshotId === right.snapshotId
1898
+ && left.targetFingerprint === right.targetFingerprint
1899
+ && left.authorization === right.authorization
1900
+ && sameFrozenRequiredOutputBindings(left.requiredOutputBindings, right.requiredOutputBindings)
1901
+ && left.candidateIds.length === right.candidateIds.length
1902
+ && left.candidateIds.every((candidate, index) => candidate === right.candidateIds[index]);
1903
+ }
1904
+ function freezeCarriesRequiredOutputBindings(plan, freeze) {
1905
+ if (!plan)
1906
+ return false;
1907
+ // Pre-V4 persisted plans did not carry an output contract. They remain
1908
+ // readable, but newly frozen plans with explicit outputs must carry the
1909
+ // exact physical binding proofs below.
1910
+ const required = plan.outputContract?.requiredOutputs ?? [];
1911
+ if (required.length === 0)
1912
+ return true;
1913
+ const expected = frozenRequiredOutputBindingProofsForPlan(plan);
1914
+ const actual = freeze.requiredOutputBindings;
1915
+ return expected.length === required.length
1916
+ && Array.isArray(actual)
1917
+ && sameFrozenRequiredOutputBindings(actual, expected);
1918
+ }
1919
+ function sameFrozenRequiredOutputBindings(left, right) {
1920
+ const normalize = (bindings) => (bindings ?? []).map((binding) => [
1921
+ binding.version,
1922
+ binding.outputName.toLowerCase().replace(/["`\[\]]/g, ''),
1923
+ binding.qualifiedId,
1924
+ binding.relation.toLowerCase().replace(/["`\[\]]/g, '').replace(/\s*\.\s*/g, '.'),
1925
+ binding.column.toLowerCase().replace(/["`\[\]]/g, ''),
1926
+ ].join('|')).sort();
1927
+ const leftBindings = normalize(left);
1928
+ const rightBindings = normalize(right);
1929
+ return leftBindings.length === rightBindings.length
1930
+ && leftBindings.every((binding, index) => binding === rightBindings[index]);
1931
+ }
1932
+ function sameExploratoryAuthorizationReceipt(left, right) {
1933
+ const leftAttempt = normalizedExploratoryAuthorizationAttempt(left);
1934
+ const rightAttempt = normalizedExploratoryAuthorizationAttempt(right);
1935
+ return sameExploratoryPlanBindings(left, right)
1936
+ && left.sqlFingerprint === right.sqlFingerprint
1937
+ && left.authorization === right.authorization
1938
+ && leftAttempt.index === rightAttempt.index
1939
+ && leftAttempt.parentSqlFingerprint === rightAttempt.parentSqlFingerprint;
1940
+ }
1941
+ function exploratoryAuthorizationStateMismatch() {
1942
+ 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' });
1943
+ }
1503
1944
  /**
1504
1945
  * Has this exact clarification already been asked in this thread?
1505
1946
  *
@@ -1571,15 +2012,23 @@ function enforceOrdinaryAnalyticalPlanBoundary(request, decision) {
1571
2012
  // user to disambiguate the one artifact they just named. The plan cannot see
1572
2013
  // it because it reads the artifact's OWN NAME as analytical intent: that name
1573
2014
  // contains "vs", so the mode comes back `comparison`.
2015
+ const namedCertifiedArtifactMetadata = looksLikeNamedCertifiedArtifactMetadataRequest(request.question, decision.retrievalEvidence?.candidateIds ?? []);
1574
2016
  if (ordinaryAsk
1575
2017
  && !request.selectedEvidenceId
1576
- && looksLikeDefinitionalAboutNamedObject(request.question, decision.retrievalEvidence?.candidateIds ?? [])) {
2018
+ && (namedCertifiedArtifactMetadata
2019
+ || looksLikeDefinitionalAboutNamedObject(request.question, decision.retrievalEvidence?.candidateIds ?? []))) {
1577
2020
  return {
1578
2021
  ...decision,
1579
- action: 'converse',
1580
- category: 'conversational',
2022
+ // Only this explicit selected-block grammar has an artifact-local,
2023
+ // deterministic metadata result. Broader definition wording remains
2024
+ // conversational so a metric phrase cannot acquire certified trust just
2025
+ // because a similarly named block was retrieved.
2026
+ action: namedCertifiedArtifactMetadata ? 'answer' : 'converse',
2027
+ category: namedCertifiedArtifactMetadata ? 'data_lookup' : 'conversational',
1581
2028
  confidence: 1,
1582
- reason: 'This asks what a governed artifact means, so it is answered from its definition rather than by running a query.',
2029
+ reason: namedCertifiedArtifactMetadata
2030
+ ? 'This asks what one selected certified artifact means, so its artifact metadata is returned without running a query.'
2031
+ : 'This asks what a governed artifact means, so it is answered from its definition rather than by running a query.',
1583
2032
  requiresClarification: false,
1584
2033
  clarifyingQuestion: undefined,
1585
2034
  clarificationOptions: undefined,
@@ -1804,10 +2253,21 @@ function terminalLifecycle(prior, phase, completedAt, eventCursor) {
1804
2253
  completedAt,
1805
2254
  };
1806
2255
  }
1807
- function diagnosticFailureFromError(error, phase) {
2256
+ function diagnosticFailureFromError(error, phase, requestedMode) {
1808
2257
  const name = error instanceof Error ? error.name : "";
1809
2258
  const message = error instanceof Error ? error.message : String(error);
1810
2259
  const lower = `${name} ${message}`.toLowerCase();
2260
+ if (error
2261
+ && typeof error === 'object'
2262
+ && error.code === 'INTERNAL_EXPLORATORY_AUTHORIZATION_STATE_MISMATCH') {
2263
+ return {
2264
+ code: 'INTERNAL_EXPLORATORY_AUTHORIZATION_STATE_MISMATCH',
2265
+ phase: 'sql.authorize',
2266
+ message: 'The frozen exploratory plan did not match the SQL authorization receipt. Execution was not attempted.',
2267
+ recoverable: false,
2268
+ safeActions: ['export_redacted_trace'],
2269
+ };
2270
+ }
1811
2271
  if (isOrchestrationBudgetExhausted(error)) {
1812
2272
  return {
1813
2273
  code: 'orchestration_budget_exhausted',
@@ -1818,6 +2278,15 @@ function diagnosticFailureFromError(error, phase) {
1818
2278
  };
1819
2279
  }
1820
2280
  if (name === "TimeoutError" || lower.includes("time limit") || lower.includes("timeout")) {
2281
+ if (requestedMode === 'research') {
2282
+ return {
2283
+ code: 'RESEARCH_RUN_DEADLINE',
2284
+ phase: 'research.run',
2285
+ message: 'Research reached its bounded run deadline before finalization.',
2286
+ recoverable: true,
2287
+ safeActions: ['inspect_failure'],
2288
+ };
2289
+ }
1821
2290
  return {
1822
2291
  code: "TIMEOUT",
1823
2292
  phase,
@@ -1906,6 +2375,549 @@ function diagnosticReceiptV2ForRun(run) {
1906
2375
  ...(run.repairCapability ? { repairCapabilityFingerprint: receiptFingerprint(run.repairCapability) } : {}),
1907
2376
  };
1908
2377
  }
2378
+ /**
2379
+ * Build a compact V3 receipt from existing durable run state. It intentionally
2380
+ * records identifiers and outcomes, never raw metadata, result rows, provider
2381
+ * payloads, or secret-bearing URLs.
2382
+ */
2383
+ function diagnosticReceiptV3ForRun(run) {
2384
+ // The router is the sole cascade authority. Do not reconstruct a tier from
2385
+ // route names or identifier text here: that erased stale/error lane states
2386
+ // and falsely reported governed-relational success for pure exploration.
2387
+ const cascade = run.routeDecision?.analyticalCascadeDecision;
2388
+ // The router may retain a broader terminal witness for presentation, while
2389
+ // the cascade carries the only persistable, enumerated relationship-proof
2390
+ // receipt. Prefer that immutable cascade value and do not infer a gap from a
2391
+ // failure message or route label here.
2392
+ const terminalGap = cascade?.terminalGap;
2393
+ const sourceCoverage = cascade?.sourceCoverage ?? [];
2394
+ const planFrozen = cascade?.planFrozen ?? false;
2395
+ const artifactProviderDiagnostic = run.artifacts
2396
+ .map((artifact) => artifact.payload)
2397
+ .filter((payload) => Boolean(payload) && typeof payload === 'object' && !Array.isArray(payload))
2398
+ .map((payload) => payload.providerFailure)
2399
+ .find((failure) => Boolean(failure) && typeof failure === 'object' && !Array.isArray(failure));
2400
+ const persistedProviderDiagnostic = artifactProviderDiagnostic?.diagnostic;
2401
+ const provider = persistedProviderDiagnostic && typeof persistedProviderDiagnostic === 'object'
2402
+ ? persistedProviderDiagnostic
2403
+ : (() => {
2404
+ const failure = run.diagnosticReceipt?.failure;
2405
+ return failure && (failure.code === 'AI_PROVIDER_FAILURE' || /provider/i.test(failure.code))
2406
+ ? classifyProviderFailure({ message: failure.message, code: failure.code, phase: 'generation' })
2407
+ : undefined;
2408
+ })();
2409
+ return {
2410
+ version: 3,
2411
+ runId: run.id,
2412
+ sourceCoverage,
2413
+ ...(cascade ? { cascade } : {}),
2414
+ ...(terminalGap ? { terminalGap } : {}),
2415
+ planFrozen,
2416
+ ...(provider ? { provider } : {}),
2417
+ finalStopReason: run.stopReason,
2418
+ };
2419
+ }
2420
+ /**
2421
+ * Build the one canonical, content-safe Ask story. This is produced from the
2422
+ * authoritative run receipt once, then joined by both the inspector and the
2423
+ * full local trace. Neither surface is allowed to reconstruct an incident from
2424
+ * spans or a generic error string.
2425
+ */
2426
+ function diagnosticReceiptV4ForRun(run) {
2427
+ const cascade = run.routeDecision?.analyticalCascadeDecision;
2428
+ const requirements = cascade?.requirements;
2429
+ const candidates = run.routeDecision?.retrievalEvidence?.candidateTraceMetadata ?? [];
2430
+ const roleCounts = new Map();
2431
+ for (const candidate of candidates) {
2432
+ roleCounts.set(candidate.role, (roleCounts.get(candidate.role) ?? 0) + 1);
2433
+ }
2434
+ const researchBranchObservability = researchBranchObservabilityForRun(run);
2435
+ for (const evidence of researchBranchObservability.evidenceByRole) {
2436
+ roleCounts.set(evidence.role, (roleCounts.get(evidence.role) ?? 0) + evidence.candidateCount);
2437
+ }
2438
+ const terminalIncident = terminalIncidentForRun(run, cascade?.stopReason);
2439
+ const summaryInput = {
2440
+ version: 1,
2441
+ understoodRequest: {
2442
+ measures: requirements?.measures.length ?? 0,
2443
+ dimensions: requirements?.dimensions.length ?? 0,
2444
+ entityRequested: Boolean((requirements?.entityTerms.length ?? 0) || (requirements?.entityDisplayTerms.length ?? 0)),
2445
+ outputCount: requirements?.outputTerms?.length ?? 0,
2446
+ ...(requirements?.ranking
2447
+ ? { ranking: { ...requirements.ranking } }
2448
+ : {}),
2449
+ // This comes from the server-owned request admission, not a generic
2450
+ // `followsUp` heuristic. A complete question with thread history is
2451
+ // still `none` unless it explicitly selected a valid binding.
2452
+ conversationBinding: run.conversationBinding ?? 'none',
2453
+ },
2454
+ evidenceByRole: [...roleCounts.entries()]
2455
+ .map(([role, candidateCount]) => ({ role, candidateCount }))
2456
+ .sort((left, right) => left.role.localeCompare(right.role)),
2457
+ tierDecisions: (cascade?.attempts ?? []).map((attempt) => ({
2458
+ tier: attempt.tier,
2459
+ outcome: attempt.outcome,
2460
+ planFrozen: attempt.planFrozen,
2461
+ })),
2462
+ ...(cascade?.selectedTier
2463
+ ? {
2464
+ selectedPlan: {
2465
+ tier: cascade.selectedTier,
2466
+ planFrozen: cascade.planFrozen,
2467
+ reviewRequired: cascade.selectedTier === 'exploratory_sql',
2468
+ },
2469
+ }
2470
+ : {}),
2471
+ ...(terminalIncident ? { terminalIncident } : {}),
2472
+ ...(researchBranchObservability.summary ? { researchBranchSummary: researchBranchObservability.summary } : {}),
2473
+ safeNextAction: terminalIncident?.safeAction
2474
+ ?? (researchBranchObservability.summary?.partialSuccess
2475
+ ? researchBranchObservability.summary.safeAction
2476
+ : 'none'),
2477
+ };
2478
+ const summary = {
2479
+ ...summaryInput,
2480
+ summaryFingerprint: receiptFingerprint(summaryInput),
2481
+ };
2482
+ return {
2483
+ version: 4,
2484
+ runId: run.id,
2485
+ summary,
2486
+ ...(terminalIncident ? { terminalIncident } : {}),
2487
+ finalStopReason: run.stopReason,
2488
+ };
2489
+ }
2490
+ const ASK_SUMMARY_EVIDENCE_ROLES = [
2491
+ 'metric',
2492
+ 'entity_key',
2493
+ 'entity_label',
2494
+ 'categorical_dimension',
2495
+ 'time_dimension',
2496
+ 'member',
2497
+ 'relationship',
2498
+ 'context',
2499
+ ];
2500
+ const ASK_RESEARCH_BRANCH_FAILURE_CODES = [
2501
+ 'execution_failed',
2502
+ 'research_branch_timeout',
2503
+ 'budget_exhausted',
2504
+ 'run_deadline',
2505
+ 'cancelled',
2506
+ ];
2507
+ const ASK_RESEARCH_CHILD_TIERS = [
2508
+ 'certified',
2509
+ 'semantic',
2510
+ 'governed_relational',
2511
+ 'exploratory_sql',
2512
+ ];
2513
+ /**
2514
+ * Project only persisted, typed Research child evidence into V4. The root
2515
+ * result remains authoritative: this helper never promotes a failed branch
2516
+ * into a root incident or infers a missing plan from spans.
2517
+ */
2518
+ function researchBranchObservabilityForRun(run) {
2519
+ // A persisted V4 receipt can be reprojected after request normalization by
2520
+ // a host. The root route is therefore the durable authority as well as the
2521
+ // original requested mode: an explicit Research run must not lose its
2522
+ // child-story merely because an older host omitted `requestedMode` while
2523
+ // preserving the authoritative `research` route and research artifact.
2524
+ if (run.requestedMode !== 'research' && run.route !== 'research') {
2525
+ return { evidenceByRole: [] };
2526
+ }
2527
+ const payload = persistedResearchArtifactPayloadForRun(run);
2528
+ if (!payload)
2529
+ return { evidenceByRole: [] };
2530
+ const rawReceipts = Array.isArray(payload.researchBranchReceipts)
2531
+ ? payload.researchBranchReceipts
2532
+ : [];
2533
+ const receipts = new Map();
2534
+ for (const value of rawReceipts) {
2535
+ const receipt = clarificationRecord(value);
2536
+ const childRunId = clarificationString(receipt?.childRunId);
2537
+ const branchId = clarificationString(receipt?.branchId);
2538
+ const state = clarificationString(receipt?.state);
2539
+ const stopReason = clarificationString(receipt?.stopReason);
2540
+ // This field is producer-owned. A malformed imported receipt must not
2541
+ // become an apparently successful Research story.
2542
+ if (!receipt || !childRunId || !branchId || !state || !stopReason)
2543
+ continue;
2544
+ const key = `${childRunId}:${branchId}`;
2545
+ if (!receipts.has(key))
2546
+ receipts.set(key, receipt);
2547
+ }
2548
+ if (receipts.size === 0)
2549
+ return { evidenceByRole: [] };
2550
+ const childRuns = persistedResearchChildRuns(payload);
2551
+ const evidenceByRole = persistedResearchChildEvidenceByRole(childRuns);
2552
+ const receiptBackedChildIds = persistedReceiptBackedResearchChildIds(payload);
2553
+ let completedBranches = 0;
2554
+ let failedBranches = 0;
2555
+ let timedOutBranches = 0;
2556
+ let skippedBranches = 0;
2557
+ const failureReasons = new Map();
2558
+ const linkedChildRunIds = new Set();
2559
+ for (const receipt of receipts.values()) {
2560
+ const childRunId = clarificationString(receipt.childRunId);
2561
+ linkedChildRunIds.add(childRunId);
2562
+ const state = clarificationString(receipt.state);
2563
+ const stopReason = clarificationString(receipt.stopReason);
2564
+ if (state === 'completed' && stopReason === 'completed') {
2565
+ completedBranches += 1;
2566
+ continue;
2567
+ }
2568
+ if (state === 'timed_out')
2569
+ timedOutBranches += 1;
2570
+ else if (state === 'skipped')
2571
+ skippedBranches += 1;
2572
+ else
2573
+ failedBranches += 1;
2574
+ if (isAskResearchBranchFailureCode(stopReason)) {
2575
+ failureReasons.set(stopReason, (failureReasons.get(stopReason) ?? 0) + 1);
2576
+ }
2577
+ }
2578
+ const receiptBackedBranches = [...receipts.values()]
2579
+ .filter((receipt) => clarificationString(receipt.state) === 'completed'
2580
+ && clarificationString(receipt.stopReason) === 'completed'
2581
+ && receiptBackedChildIds.has(clarificationString(receipt.childRunId)))
2582
+ .length;
2583
+ const incompleteBranches = failedBranches + timedOutBranches + skippedBranches;
2584
+ const summary = {
2585
+ version: 1,
2586
+ totalBranches: receipts.size,
2587
+ completedBranches,
2588
+ receiptBackedBranches,
2589
+ failedBranches,
2590
+ timedOutBranches,
2591
+ skippedBranches,
2592
+ partialSuccess: receiptBackedBranches > 0 && incompleteBranches > 0,
2593
+ failureReasons: [...failureReasons.entries()]
2594
+ .map(([code, branchCount]) => ({ code, branchCount }))
2595
+ .sort((left, right) => left.code.localeCompare(right.code)),
2596
+ availableChildPlans: persistedResearchChildPlans(childRuns),
2597
+ linkedChildRunCount: linkedChildRunIds.size,
2598
+ safeAction: 'inspect_research_failures',
2599
+ };
2600
+ return { summary, evidenceByRole };
2601
+ }
2602
+ /** Use only the durable root research artifact with branch receipts. */
2603
+ function persistedResearchArtifactPayloadForRun(run) {
2604
+ let selected;
2605
+ let selectedCount = -1;
2606
+ for (const artifact of run.artifacts) {
2607
+ if (artifact.kind !== 'research_run')
2608
+ continue;
2609
+ const payload = clarificationRecord(artifact.payload);
2610
+ const count = Array.isArray(payload?.researchBranchReceipts) ? payload.researchBranchReceipts.length : 0;
2611
+ if (payload && count > selectedCount) {
2612
+ selected = payload;
2613
+ selectedCount = count;
2614
+ }
2615
+ }
2616
+ return selected;
2617
+ }
2618
+ function persistedResearchChildRuns(payload) {
2619
+ const candidates = [
2620
+ ...(Array.isArray(payload.researchRuns) ? payload.researchRuns : []),
2621
+ payload.researchRun,
2622
+ ];
2623
+ const byId = new Map();
2624
+ for (const value of candidates) {
2625
+ const child = clarificationRecord(value);
2626
+ const id = clarificationString(child?.id);
2627
+ if (child && id && !byId.has(id))
2628
+ byId.set(id, child);
2629
+ }
2630
+ return [...byId.values()];
2631
+ }
2632
+ function persistedResearchChildEvidenceByRole(childRuns) {
2633
+ const counts = new Map();
2634
+ for (const child of childRuns) {
2635
+ const routeDecision = clarificationRecord(child.routeDecision);
2636
+ const retrieval = clarificationRecord(routeDecision?.retrievalEvidence);
2637
+ const candidates = Array.isArray(retrieval?.candidateTraceMetadata)
2638
+ ? retrieval.candidateTraceMetadata
2639
+ : [];
2640
+ for (const value of candidates) {
2641
+ const candidate = clarificationRecord(value);
2642
+ const role = clarificationString(candidate?.role);
2643
+ if (!isAskSummaryEvidenceRole(role))
2644
+ continue;
2645
+ counts.set(role, (counts.get(role) ?? 0) + 1);
2646
+ }
2647
+ }
2648
+ return [...counts.entries()]
2649
+ .map(([role, candidateCount]) => ({ role, candidateCount }))
2650
+ .sort((left, right) => left.role.localeCompare(right.role));
2651
+ }
2652
+ function persistedReceiptBackedResearchChildIds(payload) {
2653
+ const ledger = clarificationRecord(payload.researchLedgerV2);
2654
+ const entries = Array.isArray(ledger?.entries) ? ledger.entries : [];
2655
+ const ids = new Set();
2656
+ for (const value of entries) {
2657
+ const entry = clarificationRecord(value);
2658
+ const id = clarificationString(entry?.id);
2659
+ const receipts = Array.isArray(entry?.receipts) ? entry.receipts : [];
2660
+ if (entry?.status === 'observed' && id && receipts.some((receipt) => clarificationString(receipt))) {
2661
+ ids.add(id);
2662
+ }
2663
+ }
2664
+ return ids;
2665
+ }
2666
+ function persistedResearchChildPlans(childRuns) {
2667
+ const plansByTier = new Map();
2668
+ for (const child of childRuns) {
2669
+ const childRunId = clarificationString(child.id);
2670
+ const context = clarificationRecord(child.context);
2671
+ const authority = clarificationRecord(context?.branchAuthority);
2672
+ const tier = clarificationString(authority?.selectedTier);
2673
+ const planId = clarificationString(authority?.planId);
2674
+ const planFingerprint = clarificationString(authority?.planFingerprint);
2675
+ if (!childRunId || authority?.planFrozen !== true || !isAskResearchChildTier(tier) || !planId || !planFingerprint)
2676
+ continue;
2677
+ const entry = plansByTier.get(tier) ?? { planKeys: new Set(), childRunIds: new Set() };
2678
+ entry.planKeys.add(`${planId}:${planFingerprint}`);
2679
+ entry.childRunIds.add(childRunId);
2680
+ plansByTier.set(tier, entry);
2681
+ }
2682
+ return [...plansByTier.entries()]
2683
+ .map(([tier, value]) => ({
2684
+ tier,
2685
+ frozenPlanCount: value.planKeys.size,
2686
+ branchCount: value.childRunIds.size,
2687
+ reviewRequired: tier === 'exploratory_sql',
2688
+ }))
2689
+ .sort((left, right) => left.tier.localeCompare(right.tier));
2690
+ }
2691
+ function isAskSummaryEvidenceRole(value) {
2692
+ return Boolean(value) && ASK_SUMMARY_EVIDENCE_ROLES.includes(value);
2693
+ }
2694
+ function isAskResearchBranchFailureCode(value) {
2695
+ return Boolean(value) && ASK_RESEARCH_BRANCH_FAILURE_CODES.includes(value);
2696
+ }
2697
+ function isAskResearchChildTier(value) {
2698
+ return Boolean(value) && ASK_RESEARCH_CHILD_TIERS.includes(value);
2699
+ }
2700
+ function terminalIncidentForRun(run, cascadeStopReason) {
2701
+ const executionSetupFailure = terminalConnectionSetupFailureForRun(run);
2702
+ if (executionSetupFailure) {
2703
+ return {
2704
+ version: 1,
2705
+ code: 'CONNECTION_NOT_CONFIGURED',
2706
+ boundary: 'sql.execute',
2707
+ origin: 'governance_gate',
2708
+ impact: 'execution_not_attempted',
2709
+ safeAction: 'configure_connection',
2710
+ };
2711
+ }
2712
+ const failureCode = run.diagnosticReceipt?.failure?.code;
2713
+ if (failureCode === 'INTERNAL_EXPLORATORY_AUTHORIZATION_STATE_MISMATCH') {
2714
+ return {
2715
+ version: 1,
2716
+ code: 'INTERNAL_EXPLORATORY_AUTHORIZATION_STATE_MISMATCH',
2717
+ boundary: 'sql.authorize',
2718
+ origin: 'internal_invariant',
2719
+ impact: 'execution_not_attempted',
2720
+ safeAction: 'export_redacted_trace',
2721
+ };
2722
+ }
2723
+ if (failureCode === 'RESEARCH_RUN_DEADLINE') {
2724
+ return {
2725
+ version: 1,
2726
+ code: 'RESEARCH_RUN_DEADLINE',
2727
+ boundary: 'run',
2728
+ origin: 'governance_gate',
2729
+ impact: 'answer_not_produced',
2730
+ safeAction: 'inspect_failure',
2731
+ };
2732
+ }
2733
+ // A completed root can still be materially limited when every admitted
2734
+ // Research child exhausted its bounded window. This is producer-owned
2735
+ // receipt evidence, not an incident reconstructed from trace timing. It
2736
+ // must be visible in the same V4 summary used by the inspector and full
2737
+ // trace so the user is never told there was no incident after a zero-finding
2738
+ // investigation.
2739
+ if (terminalResearchBranchTimeoutForRun(run)) {
2740
+ return {
2741
+ version: 1,
2742
+ code: 'RESEARCH_BRANCH_TIMEOUT',
2743
+ boundary: 'run',
2744
+ origin: 'governance_gate',
2745
+ impact: 'answer_not_produced',
2746
+ safeAction: 'inspect_research_failures',
2747
+ };
2748
+ }
2749
+ if (failureCode === 'RUN_CANCELLED' || run.status === 'cancelled') {
2750
+ return { version: 1, code: 'CANCELLED', boundary: 'run', origin: 'unknown', impact: 'run_cancelled', safeAction: 'none' };
2751
+ }
2752
+ if (failureCode === 'CONNECTION_NOT_CONFIGURED') {
2753
+ return { version: 1, code: 'CONNECTION_NOT_CONFIGURED', boundary: 'sql.execute', origin: 'governance_gate', impact: 'execution_not_attempted', safeAction: 'configure_connection' };
2754
+ }
2755
+ // A frozen semantic/analytical plan may fail while the compiler is resolving
2756
+ // its already-proven identifiers. That is categorically different from a
2757
+ // warehouse failure: no statement was authorized or executed. Preserve the
2758
+ // producer's typed `COMPILATION_FAILED` cause before consulting connector
2759
+ // evidence so both Ask surfaces tell the same pre-SQL story.
2760
+ const compilationFailure = terminalCompilationFailureForRun(run);
2761
+ if (compilationFailure) {
2762
+ const semantic = isSemanticCompilationForRun(run);
2763
+ return {
2764
+ version: 1,
2765
+ code: 'COMPILATION_FAILED',
2766
+ boundary: semantic ? 'semantic.compile' : 'plan.compile',
2767
+ origin: semantic ? 'semantic_compiler' : 'plan_compiler',
2768
+ impact: 'execution_not_attempted',
2769
+ safeAction: compilationFailure.safeAction,
2770
+ };
2771
+ }
2772
+ const warehouseFailure = terminalWarehouseFailureForRun(run);
2773
+ if (warehouseFailure) {
2774
+ return {
2775
+ version: 1,
2776
+ code: 'ANALYTICAL_EXECUTION_FAILED',
2777
+ boundary: 'sql.execute',
2778
+ origin: 'warehouse',
2779
+ impact: 'execution_failed',
2780
+ // A typed missing relation after a frozen plan reached the connector is
2781
+ // not a generic retry. The target may be an empty local database or a
2782
+ // different approved warehouse, so direct the operator to that target.
2783
+ safeAction: warehouseFailure.category === 'unknown_relation'
2784
+ ? 'change_authorized_connection'
2785
+ : 'inspect_failure',
2786
+ };
2787
+ }
2788
+ if (run.diagnosticReceiptV3?.provider) {
2789
+ return { version: 1, code: 'PROVIDER_FAILURE', boundary: 'provider', origin: 'provider', impact: 'answer_not_produced', safeAction: 'inspect_failure' };
2790
+ }
2791
+ if (cascadeStopReason === 'coverage_gap' || cascadeStopReason === 'ambiguous' || cascadeStopReason === 'denied') {
2792
+ return { version: 1, code: 'ANALYTICAL_COVERAGE_GAP', boundary: 'cascade', origin: 'governance_gate', impact: 'answer_not_produced', safeAction: 'inspect_failure' };
2793
+ }
2794
+ if (run.status === 'blocked') {
2795
+ return { version: 1, code: 'ANALYTICAL_EXECUTION_FAILED', boundary: 'sql.execute', origin: 'unknown', impact: 'execution_failed', safeAction: 'inspect_failure' };
2796
+ }
2797
+ return undefined;
2798
+ }
2799
+ /**
2800
+ * Read only the narrow host setup receipt emitted before a connector receives
2801
+ * SQL. This must win over the broad analytical failure payload because a
2802
+ * semantic graph can catch the host error after its compiler work completed.
2803
+ */
2804
+ function terminalConnectionSetupFailureForRun(run) {
2805
+ return run.artifacts.some((artifact) => {
2806
+ const payload = artifact.payload;
2807
+ if (!payload || typeof payload !== 'object' || Array.isArray(payload))
2808
+ return false;
2809
+ const setup = payload.observabilityExecutionFailure;
2810
+ if (!setup || typeof setup !== 'object' || Array.isArray(setup))
2811
+ return false;
2812
+ const record = setup;
2813
+ return record.version === 1
2814
+ && record.phase === 'execution'
2815
+ && record.cause === 'connection_not_configured'
2816
+ && record.safeAction === 'configure_connection';
2817
+ });
2818
+ }
2819
+ /**
2820
+ * Read only a producer-owned analytical failure. A compiler failure may have
2821
+ * prepared SQL text, but it is still pre-execution until the durable telemetry
2822
+ * records a SQL call. This guard keeps a real warehouse failure from being
2823
+ * relabeled as semantic/planning just because a legacy adapter reused a broad
2824
+ * failure code in a later stage.
2825
+ */
2826
+ function terminalCompilationFailureForRun(run) {
2827
+ if ((run.telemetry?.sqlExecutions ?? 0) > 0)
2828
+ return undefined;
2829
+ for (const artifact of run.artifacts) {
2830
+ const payload = artifact.payload;
2831
+ if (!payload || typeof payload !== 'object' || Array.isArray(payload))
2832
+ continue;
2833
+ const failure = payload.analyticalFailure;
2834
+ if (!failure || typeof failure !== 'object' || Array.isArray(failure))
2835
+ continue;
2836
+ const record = failure;
2837
+ if (record.code !== 'COMPILATION_FAILED' || record.phase !== 'compilation')
2838
+ continue;
2839
+ return {
2840
+ safeAction: terminalIncidentSafeAction(record.safeActions) ?? 'inspect_failure',
2841
+ };
2842
+ }
2843
+ return undefined;
2844
+ }
2845
+ function isSemanticCompilationForRun(run) {
2846
+ // Only the router-owned cascade may identify a semantic execution tier.
2847
+ // Direct/legacy semantic callers can still carry an immutable plan, but
2848
+ // without that authority their failure is accurately a generic plan compile
2849
+ // incident rather than a reconstructed semantic route.
2850
+ return run.routeDecision?.analyticalCascadeDecision?.selectedTier === 'semantic';
2851
+ }
2852
+ /** Keep V4's recovery action in the same compact vocabulary as trace spans. */
2853
+ function terminalIncidentSafeAction(value) {
2854
+ if (!Array.isArray(value))
2855
+ return undefined;
2856
+ const actions = [
2857
+ 'export_redacted_trace',
2858
+ 'configure_connection',
2859
+ 'change_authorized_connection',
2860
+ 'inspect_failure',
2861
+ 'retry_same_plan',
2862
+ 'refresh_snapshot',
2863
+ 'edit_dql',
2864
+ 'open_sql_notebook',
2865
+ 'request_access',
2866
+ 'reapply_semantic_runtime',
2867
+ 'review_analytical_failure',
2868
+ 'inspect_research_failures',
2869
+ 'none',
2870
+ ];
2871
+ return value.find((action) => typeof action === 'string' && actions.includes(action));
2872
+ }
2873
+ /**
2874
+ * A Research root is deliberately allowed to complete its receipt-bound
2875
+ * synthesis after child deadlines. Surface a terminal incident only when no
2876
+ * child completed an observation and all admitted children were bounded out;
2877
+ * a partially successful investigation remains a review-required answer with
2878
+ * a limited-scope note rather than a false failure.
2879
+ */
2880
+ function terminalResearchBranchTimeoutForRun(run) {
2881
+ for (const artifact of run.artifacts) {
2882
+ const payload = artifact.payload;
2883
+ if (!payload || typeof payload !== 'object' || Array.isArray(payload))
2884
+ continue;
2885
+ const receipts = payload.researchBranchReceipts;
2886
+ if (!Array.isArray(receipts) || receipts.length === 0)
2887
+ continue;
2888
+ const stopReasons = receipts
2889
+ .map((receipt) => receipt && typeof receipt === 'object'
2890
+ ? receipt.stopReason
2891
+ : undefined)
2892
+ .filter((reason) => typeof reason === 'string');
2893
+ if (stopReasons.length !== receipts.length)
2894
+ continue;
2895
+ const allBounded = stopReasons.every((reason) => reason === 'research_branch_timeout' || reason === 'budget_exhausted');
2896
+ if (allBounded && stopReasons.some((reason) => reason === 'research_branch_timeout'))
2897
+ return true;
2898
+ }
2899
+ return false;
2900
+ }
2901
+ /**
2902
+ * Read only enum evidence emitted at the real connector boundary. SQL text and
2903
+ * redacted driver diagnostics remain in the artifact inspector; they cannot
2904
+ * become routing or trace-summary authority.
2905
+ */
2906
+ function terminalWarehouseFailureForRun(run) {
2907
+ for (const artifact of run.artifacts) {
2908
+ const payload = artifact.payload;
2909
+ if (!payload || typeof payload !== 'object' || Array.isArray(payload))
2910
+ continue;
2911
+ const failure = payload.warehouseFailure;
2912
+ if (!failure || typeof failure !== 'object' || Array.isArray(failure))
2913
+ continue;
2914
+ const record = failure;
2915
+ if (record.version === 1 && record.origin === 'warehouse' && typeof record.category === 'string') {
2916
+ return { category: record.category };
2917
+ }
2918
+ }
2919
+ return undefined;
2920
+ }
1909
2921
  function emptyRunTelemetry(total, fallbackReason) {
1910
2922
  return {
1911
2923
  version: 1,
@@ -1931,7 +2943,7 @@ function durationBetweenMs(startedAt, completedAt) {
1931
2943
  function receiptFingerprint(value) {
1932
2944
  return createHash('sha256').update(JSON.stringify(value)).digest('hex');
1933
2945
  }
1934
- function attachDiagnosticReceipt(artifacts, receipt, receiptV2) {
2946
+ function attachDiagnosticReceipt(artifacts, receipt, receiptV2, receiptV3, receiptV4) {
1935
2947
  if (artifacts.length === 0) {
1936
2948
  if (!receipt.failure)
1937
2949
  return artifacts;
@@ -1940,7 +2952,7 @@ function attachDiagnosticReceipt(artifacts, receipt, receiptV2) {
1940
2952
  kind: "answer",
1941
2953
  title: "Agent run diagnostics",
1942
2954
  trustState: "blocked",
1943
- payload: { diagnosticReceipt: receipt, ...(receiptV2 ? { diagnosticReceiptV2: receiptV2 } : {}) },
2955
+ payload: { diagnosticReceipt: receipt, ...(receiptV2 ? { diagnosticReceiptV2: receiptV2 } : {}), ...(receiptV3 ? { diagnosticReceiptV3: receiptV3 } : {}), ...(receiptV4 ? { diagnosticReceiptV4: receiptV4 } : {}) },
1944
2956
  }];
1945
2957
  }
1946
2958
  const preferredIndex = Math.max(0, artifacts.findIndex((artifact) => artifact.kind === "answer"));
@@ -1956,6 +2968,8 @@ function attachDiagnosticReceipt(artifacts, receipt, receiptV2) {
1956
2968
  ...payload,
1957
2969
  diagnosticReceipt: receipt,
1958
2970
  ...(receiptV2 ? { diagnosticReceiptV2: receiptV2 } : {}),
2971
+ ...(receiptV3 ? { diagnosticReceiptV3: receiptV3 } : {}),
2972
+ ...(receiptV4 ? { diagnosticReceiptV4: receiptV4 } : {}),
1959
2973
  },
1960
2974
  };
1961
2975
  });
@@ -2192,6 +3206,9 @@ export function selectRoute(request, decision) {
2192
3206
  return 'clarify';
2193
3207
  return 'blocked';
2194
3208
  }
3209
+ const cascadeRoute = routeFromAnalyticalCascade(decision);
3210
+ if (cascadeRoute)
3211
+ return cascadeRoute;
2195
3212
  const explicitMode = request.requestedMode;
2196
3213
  if (explicitMode === 'modeling')
2197
3214
  return 'modeling_draft';