@duckcodeailabs/dql-cli 1.13.4 → 1.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (92) hide show
  1. package/dist/args.d.ts +2 -0
  2. package/dist/args.d.ts.map +1 -1
  3. package/dist/args.js +5 -0
  4. package/dist/args.js.map +1 -1
  5. package/dist/assets/dql-notebook/assets/{AgentLogPage-DYmHO6_i.js → AgentLogPage-Ch7VK20X.js} +1 -1
  6. package/dist/assets/dql-notebook/assets/{AiBuildDialog-BfhaOd1h.js → AiBuildDialog-DBr5TmyM.js} +1 -1
  7. package/dist/assets/dql-notebook/assets/{AiBuildResult-DLKFAe6R.js → AiBuildResult-jLPzQO7O.js} +1 -1
  8. package/dist/assets/dql-notebook/assets/{AiSidePanel-C6Z3URGr.js → AiSidePanel-CdlGsiVC.js} +1 -1
  9. package/dist/assets/dql-notebook/assets/{AnalyticsHome-uG3xCP_s.js → AnalyticsHome-C0DXbOwY.js} +1 -1
  10. package/dist/assets/dql-notebook/assets/{AppsView-DfieNjkh.js → AppsView-CcpwjApv.js} +4 -4
  11. package/dist/assets/dql-notebook/assets/{BlockStudio-90J_wvWL.js → BlockStudio-CFYxafw-.js} +1 -1
  12. package/dist/assets/dql-notebook/assets/{BusinessArtifactView-_zSMae5q.js → BusinessArtifactView-C0kLYg2p.js} +1 -1
  13. package/dist/assets/dql-notebook/assets/{DbtFirstModelingPage-DoXiPssu.js → DbtFirstModelingPage-CMwElXD_.js} +1 -1
  14. package/dist/assets/dql-notebook/assets/{GitPage-CUfAhS67.js → GitPage-JjhRDeWY.js} +1 -1
  15. package/dist/assets/dql-notebook/assets/{GlobalAiRail-ovtL7nQu.js → GlobalAiRail-DakE4NdR.js} +1 -1
  16. package/dist/assets/dql-notebook/assets/{GovernedContextPage-CaMiKAzA.js → GovernedContextPage-BokDqG6a.js} +1 -1
  17. package/dist/assets/dql-notebook/assets/{HelpDocsPage-CEGUx28b.js → HelpDocsPage-CjOv6_gz.js} +1 -1
  18. package/dist/assets/dql-notebook/assets/{HomePage-2v3SRf8V.js → HomePage-nGaNcwdw.js} +1 -1
  19. package/dist/assets/dql-notebook/assets/{LineageDAG-AaqLEiBN.js → LineageDAG-CO6CFJRg.js} +1 -1
  20. package/dist/assets/dql-notebook/assets/{LineageDetailView-CFc8pm91.js → LineageDetailView-BnGb7OF7.js} +1 -1
  21. package/dist/assets/dql-notebook/assets/{LineageDrawer-DOkFjPq-.js → LineageDrawer-C5Y0Ht0b.js} +1 -1
  22. package/dist/assets/dql-notebook/assets/{LineagePathBreadcrumb-BrDwCuMs.js → LineagePathBreadcrumb-Cgh3GR3F.js} +1 -1
  23. package/dist/assets/dql-notebook/assets/{MiniLineageGraph-CiHtQs3y.js → MiniLineageGraph-Kla9PYuj.js} +1 -1
  24. package/dist/assets/dql-notebook/assets/{NewBlockModal-DAgJZbws.js → NewBlockModal-BR2SnmPT.js} +1 -1
  25. package/dist/assets/dql-notebook/assets/{NewNotebookModal-xjG7nZ3O.js → NewNotebookModal-BaCHMYeB.js} +1 -1
  26. package/dist/assets/dql-notebook/assets/{NotebookEditor-CXZaKfYT.js → NotebookEditor-CBLqY8cE.js} +1 -1
  27. package/dist/assets/dql-notebook/assets/{ReadinessPage-MuJBFwwg.js → ReadinessPage-CiN0IWSS.js} +1 -1
  28. package/dist/assets/dql-notebook/assets/{SetupOnboarding-vKFtKtjh.js → SetupOnboarding-BhiCsYF-.js} +1 -1
  29. package/dist/assets/dql-notebook/assets/{SkillsPage-DCFVOV5f.js → SkillsPage-CCSf8VMm.js} +1 -1
  30. package/dist/assets/dql-notebook/assets/{TrustBadge-TcwXFFHK.js → TrustBadge-BgQmFe_x.js} +1 -1
  31. package/dist/assets/dql-notebook/assets/UnifiedAgentRunPanel-Bw5AXNDB.js +88 -0
  32. package/dist/assets/dql-notebook/assets/{answer-to-notebook-DCFWlHB7.js → answer-to-notebook-AeDYUDla.js} +1 -1
  33. package/dist/assets/dql-notebook/assets/{arrow-left-BLnlJAtp.js → arrow-left-C55x2hq_.js} +1 -1
  34. package/dist/assets/dql-notebook/assets/{arrow-right-BmUAb1aW.js → arrow-right-C1cJrhOm.js} +1 -1
  35. package/dist/assets/dql-notebook/assets/{book-open-text-bdSChSag.js → book-open-text-CQf_sdv2.js} +1 -1
  36. package/dist/assets/dql-notebook/assets/{circle-x-Cc6dEaf4.js → circle-x-X8-Z2yLY.js} +1 -1
  37. package/dist/assets/dql-notebook/assets/{dagre.esm-CPebQKQ9.js → dagre.esm-BjjNYKyY.js} +1 -1
  38. package/dist/assets/dql-notebook/assets/{external-link-rmXvubf3.js → external-link-C2zrz5DH.js} +1 -1
  39. package/dist/assets/dql-notebook/assets/{grip-vertical-Sg4zd5-Q.js → grip-vertical-qGV_PYGU.js} +1 -1
  40. package/dist/assets/dql-notebook/assets/{index-mQd_pC-P.js → index-ByTDPDaH.js} +127 -127
  41. package/dist/assets/dql-notebook/assets/{link-2-BLaPoFxW.js → link-2-VpyOxQXG.js} +1 -1
  42. package/dist/assets/dql-notebook/assets/{list-tree-j0s4asFK.js → list-tree-CH2Jhwms.js} +1 -1
  43. package/dist/assets/dql-notebook/assets/{minimize-2-Cl4pef4s.js → minimize-2-B2TZJ8BT.js} +1 -1
  44. package/dist/assets/dql-notebook/assets/{panel-right-open-Clz-FtWi.js → panel-right-open-BunF88lt.js} +1 -1
  45. package/dist/assets/dql-notebook/assets/{play-DFdazMg7.js → play-DAFVF4_G.js} +1 -1
  46. package/dist/assets/dql-notebook/assets/{rotate-ccw-C3tTaR7s.js → rotate-ccw-DGCrrqtY.js} +1 -1
  47. package/dist/assets/dql-notebook/assets/{semantic-fields-DIAhTlfy.js → semantic-fields-Ci-9QL9F.js} +1 -1
  48. package/dist/assets/dql-notebook/assets/{sliders-horizontal-BUiu2tfY.js → sliders-horizontal-BOAlXXbn.js} +1 -1
  49. package/dist/assets/dql-notebook/assets/{star--Yq77aH4.js → star-CkksZXHt.js} +1 -1
  50. package/dist/assets/dql-notebook/assets/{triangle-alert-D5Ng-xZO.js → triangle-alert-D3mjyJZE.js} +1 -1
  51. package/dist/assets/dql-notebook/assets/{upload-L8BKQAxP.js → upload-CTNOAVEO.js} +1 -1
  52. package/dist/assets/dql-notebook/assets/{usePersistedAgentThreadId-DBu97YaM.js → usePersistedAgentThreadId-DiQjc7x-.js} +4 -4
  53. package/dist/assets/dql-notebook/assets/{user-round-BK5N1cJW.js → user-round-BWd5tQRg.js} +1 -1
  54. package/dist/assets/dql-notebook/assets/{wand-sparkles-D8XDQD9z.js → wand-sparkles-CqsAv8P-.js} +1 -1
  55. package/dist/assets/dql-notebook/assets/{workflow-Bl9zwG0q.js → workflow-CSqsj-sC.js} +1 -1
  56. package/dist/assets/dql-notebook/assets/{wrench-C-zLCMbW.js → wrench-DWqzqlX8.js} +1 -1
  57. package/dist/assets/dql-notebook/assets/{x-stiJ-sP7.js → x-65M5rLCE.js} +1 -1
  58. package/dist/assets/dql-notebook/index.html +1 -1
  59. package/dist/commands/eval.d.ts +15 -1
  60. package/dist/commands/eval.d.ts.map +1 -1
  61. package/dist/commands/eval.js +32 -3
  62. package/dist/commands/eval.js.map +1 -1
  63. package/dist/index.js +1 -1
  64. package/dist/llm/answer-loop-tools.d.ts +3 -1
  65. package/dist/llm/answer-loop-tools.d.ts.map +1 -1
  66. package/dist/llm/answer-loop-tools.js +31 -23
  67. package/dist/llm/answer-loop-tools.js.map +1 -1
  68. package/dist/llm/providers/dql-agent-provider.d.ts +3 -1
  69. package/dist/llm/providers/dql-agent-provider.d.ts.map +1 -1
  70. package/dist/llm/providers/dql-agent-provider.js +315 -22
  71. package/dist/llm/providers/dql-agent-provider.js.map +1 -1
  72. package/dist/llm/types.d.ts +29 -2
  73. package/dist/llm/types.d.ts.map +1 -1
  74. package/dist/local-runtime.d.ts +200 -5
  75. package/dist/local-runtime.d.ts.map +1 -1
  76. package/dist/local-runtime.js +2280 -457
  77. package/dist/local-runtime.js.map +1 -1
  78. package/dist/package.json +10 -10
  79. package/dist/providers/oauth/claude-oauth.d.ts +1 -1
  80. package/dist/providers/oauth/claude-oauth.d.ts.map +1 -1
  81. package/dist/providers/oauth/claude-oauth.js +18 -11
  82. package/dist/providers/oauth/claude-oauth.js.map +1 -1
  83. package/dist/providers/oauth/codex-oauth.d.ts +1 -1
  84. package/dist/providers/oauth/codex-oauth.d.ts.map +1 -1
  85. package/dist/providers/oauth/codex-oauth.js +9 -1
  86. package/dist/providers/oauth/codex-oauth.js.map +1 -1
  87. package/dist/providers/subscription-cli.d.ts +1 -1
  88. package/dist/providers/subscription-cli.d.ts.map +1 -1
  89. package/dist/providers/subscription-cli.js +33 -4
  90. package/dist/providers/subscription-cli.js.map +1 -1
  91. package/package.json +10 -10
  92. package/dist/assets/dql-notebook/assets/UnifiedAgentRunPanel-Cu-YgIe4.js +0 -88
@@ -1,3 +1,4 @@
1
+ import { AsyncLocalStorage } from 'node:async_hooks';
1
2
  import { execFileSync, execSync } from "node:child_process";
2
3
  import { createHash } from "node:crypto";
3
4
  import { gzip } from "node:zlib";
@@ -15,7 +16,7 @@ import { buildMixedSourceWarehouseFallbackSql, findMentionedNotebookDataset, pla
15
16
  import { resolveNpmInvocation } from './npm-runtime.js';
16
17
  import { ensureDqlGitignore, isGovernedSourceFile, isLegacyBroadDqlIgnore, } from './git-contract.js';
17
18
  import { buildExecutionPlan, createWelcomeNotebook, deserializeNotebook, getConnectorFormSchemas, hasSemanticRefs, hasStandaloneSemanticRef, resolveSemanticRefs, } from '@duckcodeailabs/dql-notebook';
18
- import { loadSemanticLayerFromDir, normalizeDqlArtifactReference, serializeMetricDefinitionToYaml, resolveSemanticLayerAsync, resolveRepoSource, getDialect, Parser, NodeKind, blockParameterDefinitions, buildLineageGraph, buildManifest, collectInputFiles, findAppDocuments, findDashboardsForApp, isBlockIdRef, loadAppDocument, loadDashboardDocument, analyzeImpact, buildTrustChain, detectDomainFlows, getDomainTrustOverview, queryLineage, queryBusiness360, queryCompleteLineagePaths, LineageGraph, canonicalize, canonicalizeNotebook, diffDQL, diffNotebook, domainFolderSlug, previewModelingChange, previewModelingChanges, applyModelingChange, previewDbtSourcePatch, applyDbtSourcePatch, loadDomainPackageRegistry, loadDbtNodeAuthoringDetail, normalizeAnalyticalFailureV1, relationshipValidationProofFingerprint, renderSemanticBlockSource, discoverDbtDomains, collectDbtRelationshipTests, renderDomainDeclaration, ProjectSnapshotService, analyzeSqlReferences, semanticDimensionReference, modelAreaLocalId, DEFAULT_MODEL_AREA_ID, } from '@duckcodeailabs/dql-core';
19
+ import { loadSemanticLayerFromDir, normalizeDqlArtifactReference, serializeMetricDefinitionToYaml, resolveSemanticLayerAsync, resolveRepoSource, getDialect, Parser, NodeKind, blockParameterDefinitions, buildLineageGraph, buildManifest, collectInputFiles, findAppDocuments, findDashboardsForApp, isBlockIdRef, loadAppDocument, loadDashboardDocument, analyzeImpact, buildTrustChain, detectDomainFlows, getDomainTrustOverview, queryLineage, queryBusiness360, queryCompleteLineagePaths, LineageGraph, canonicalize, canonicalizeNotebook, diffDQL, diffNotebook, domainFolderSlug, previewModelingChange, previewModelingChanges, applyModelingChange, previewDbtSourcePatch, applyDbtSourcePatch, loadDomainPackageRegistry, loadDbtNodeAuthoringDetail, normalizeAnalyticalFailureV1, normalizeAnalyticalRepairCapabilityV1, relationshipValidationProofFingerprint, renderSemanticBlockSource, discoverDbtDomains, collectDbtRelationshipTests, renderDomainDeclaration, ProjectSnapshotService, analyzeSqlReferences, buildSqlAnalyticalSignature, buildGeneratedAnalyticalSqlSignature, buildSqlOutputExpressionSignature, semanticDimensionReference, semanticExecutionFingerprint, modelAreaLocalId, DEFAULT_MODEL_AREA_ID, } from '@duckcodeailabs/dql-core';
19
20
  import { load as loadYaml } from 'js-yaml';
20
21
  import { listBlockTemplates } from './block-templates.js';
21
22
  import { getRunner as getLLMRunner } from './llm/index.js';
@@ -24,7 +25,7 @@ import { fetchLatestPublishedDqlVersion, resolveDqlRuntimeVersionStatus } from '
24
25
  import { resolveRetrievalHealthStatus } from './retrieval-health.js';
25
26
  import { createDqlAgentProviderRunner, resolveAgentFollowUpContext } from './llm/providers/dql-agent-provider.js';
26
27
  import { listRemoteMcpSettings, saveRemoteMcpSettings } from './llm/mcp-config.js';
27
- import { ClaudeProvider, ConversationStore, advanceThreadState, buildConversationSnapshot, conversationHistoryFromContext, recallRelevantTurns, renderConversationEnvelopeForPrompt, GeminiProvider, MemoryStore, OllamaProvider, OpenAIProvider, buildBlockBusinessFingerprint, buildBlockSqlFingerprints, buildAnalysisQuestionPlan, composeSemanticQueryForQuestion, aggregationIntegrityIssuesForSql, buildLocalContextPack, applyContextPackCompatibility, toAgentRetrievalEvidence, prepareConversationPath, defaultMemoryPath, ensureDefaultMemoryFiles, ensureAgentProjectReady, isAgentProjectIndexReady, currentMetadataFingerprint, ensureMetadataCatalogFresh, readIndexedDomainKnowledge, readIndexedKnowledge360, compactSemanticRuntimeFailure, classifyAnalyticalFailure, normalizeWarehouseSqlFailure, parseProposal, propose, proposePlan, recordGovernedCorrection, HintStore, defaultHintIndexPath, ensureHintIndexFresh, listHintsFromGit, getHintEvaluationFromGit, getCorrectionTraceFromGit, inspectGovernedHint, editGovernedHintCandidate, reopenGovernedHint, retireHint, supersedeHint, hintsConflict, mineJoinPatterns, reviewGovernedHint, AgentRunEngine, SqliteAgentRunStore, defaultAgentRunGates, createLlmAgentRunPlanner, createHybridRouter, computeResultStats, buildDeterministicDashboardStory, synthesizeAnswer, streamOrGenerate, narrateResult, buildProposePreview, buildFromPrompt, internalRelationIdsInSql, defaultAgentRunStorePath, defaultAgentRunSqlitePath, resolveLocalOwner, resolveProposeConfig, recordQueryRun, recordRuntimeSchemaSnapshot, latestRuntimeSchemaSnapshotForProject, loadSkills, migrateLegacySkills, configuredSkillsPath, skillsDir, draftDomainSkillBootstrap, buildDomainSkillBootstrapPrompt, mergeDomainSkillBootstrapEnrichment, writeSkill, previewSkillChange, buildContextAuthoringProposal, contextAuthoringDependencyClosure, FileContextAuthoringProposalStore, deleteSkill, deriveGeneratedDraftSlug, deriveAnalyticalRepair, reindexProject, invalidateAgentProjectState, recordAgentRuntimeVersion, resolveDomainContextEnvelope, projectEmbeddingProvider, isHashedEmbeddingProvider, clearProjectEmbeddingCache, upgradeVectorIndexForProject, openMetadataCatalog, defaultKgPath, planAppFromPrompt, KGStore, planResearch, loadSemanticMetrics, cascadeTraceToEvidenceRouteSteps, createCascadeAnswerResult, createCascadeTrace, routeReasoningEffort, routeForCascadeAnswerTier, clampReasoningEffort, bumpReasoningEffort, resolveThinkingMode, coerceThinkingMode, upsertGeneratedDqlArtifactDraft, loadAgentSemanticLayer, isTrustedConversationTurn, resolveInternalRelationIds, analyticalError, tagAnalyticalError, withAnalyticalErrorOrigin, withAnalyticalErrorOriginSync, } from '@duckcodeailabs/dql-agent';
28
+ import { ClaudeProvider, ConversationStore, advanceThreadState, buildConversationSnapshot, conversationHistoryFromContext, recallRelevantTurns, renderConversationEnvelopeForPrompt, GeminiProvider, MemoryStore, OllamaProvider, OpenAIProvider, buildBlockBusinessFingerprint, buildBlockSqlFingerprints, buildAnalysisQuestionPlan, composeSemanticQueryForQuestion, aggregationIntegrityIssuesForSql, buildAggregationSafetyProof, buildLocalContextPack, applyContextPackCompatibility, toAgentRetrievalEvidence, prepareConversationPath, defaultMemoryPath, ensureDefaultMemoryFiles, ensureAgentProjectReady, isAgentProjectIndexReady, currentMetadataFingerprint, ensureMetadataCatalogFresh, readIndexedDomainKnowledge, readIndexedKnowledge360, compactSemanticRuntimeFailure, classifyAnalyticalFailure, normalizeWarehouseSqlFailure, parseProposal, propose, proposePlan, recordGovernedCorrection, HintStore, defaultHintIndexPath, ensureHintIndexFresh, listHintsFromGit, getHintEvaluationFromGit, getCorrectionTraceFromGit, inspectGovernedHint, editGovernedHintCandidate, reopenGovernedHint, retireHint, supersedeHint, hintsConflict, mineJoinPatterns, reviewGovernedHint, AgentRunEngine, SqliteAgentRunStore, defaultAgentRunGates, createLlmAgentRunPlanner, createHybridRouter, computeResultStats, buildDeterministicDashboardStory, synthesizeAnswer, streamOrGenerate, narrateResult, buildProposePreview, buildFromPrompt, internalRelationIdsInSql, defaultAgentRunStorePath, defaultAgentRunSqlitePath, resolveLocalOwner, resolveProposeConfig, recordQueryRun, recordRuntimeSchemaSnapshot, latestRuntimeSchemaSnapshotForProject, loadSkills, migrateLegacySkills, configuredSkillsPath, skillsDir, draftDomainSkillBootstrap, buildDomainSkillBootstrapPrompt, mergeDomainSkillBootstrapEnrichment, writeSkill, previewSkillChange, buildContextAuthoringProposal, contextAuthoringDependencyClosure, FileContextAuthoringProposalStore, deleteSkill, deriveGeneratedDraftSlug, deriveAnalyticalRepair, reindexProject, invalidateAgentProjectState, recordAgentRuntimeVersion, resolveDomainContextEnvelope, projectEmbeddingProvider, isHashedEmbeddingProvider, clearProjectEmbeddingCache, upgradeVectorIndexForProject, openMetadataCatalog, defaultKgPath, planAppFromPrompt, KGStore, planResearch, loadSemanticMetrics, cascadeTraceToEvidenceRouteSteps, createCascadeAnswerResult, createCascadeTrace, routeReasoningEffort, createAgentRunBudget, routeForCascadeAnswerTier, clampReasoningEffort, bumpReasoningEffort, resolveThinkingMode, coerceThinkingMode, upsertGeneratedDqlArtifactDraft, loadAgentSemanticLayer, isTrustedConversationTurn, resolveInternalRelationIds, analyticalError, tagAnalyticalError, withAnalyticalErrorOrigin, withAnalyticalErrorOriginSync, assertProviderPayloadAllowed, createProviderDispatchEgressReceipt, prepareProviderWireEnvelopeForDispatch, markProviderMetadataArray, createProviderEgressReceipt, redactProviderResultRows, composeVerifiedAnalyticalNarrative, buildCoverageGap, capResearchBranches, buildResearchEvidenceLedger, buildAnalyticalTurnPlan, DEFAULT_ASK_ROW_EGRESS_POLICY, ZERO_ROW_EGRESS_POLICY, resolveProviderResultRowEgressPolicy, normalizeCanonicalQueryResult, normalizeAnalyticalExecutionFingerprint, normalizeAnalyticalExecutionReceipt, createAgentRunCancellationError, } from '@duckcodeailabs/dql-agent';
28
29
  import { addSqlResultFilter, dashboardFilterableResultColumns, filterableResultColumns, replaceBlockStudioSql } from './sql-result-filter.js';
29
30
  import { gatherProposeEnrichment } from './propose-enrich.js';
30
31
  import { handleAppsApi, proposeAppAiBuild, recommendVisualization, } from './apps-api.js';
@@ -291,6 +292,12 @@ export function parseAgentRunRequestBody(body) {
291
292
  const workspaceContext = agentRunRecord(record.workspaceContext) ?? agentRunRecord(record.context);
292
293
  const signals = agentRunRecord(record.signals);
293
294
  const requestedMode = parseAgentRunRequestedMode(record.requestedMode) ?? parseAgentRunRequestedMode(record.mode);
295
+ if (record.researchResultRowsOptIn !== undefined && typeof record.researchResultRowsOptIn !== 'boolean') {
296
+ return { error: 'researchResultRowsOptIn must be a boolean.' };
297
+ }
298
+ if (record.researchResultRowsOptIn === true && requestedMode !== 'research') {
299
+ return { error: 'researchResultRowsOptIn is accepted only for an explicitly requested Research run.' };
300
+ }
294
301
  const audience = record.audience === 'stakeholder' || record.audience === 'analyst'
295
302
  ? record.audience
296
303
  : undefined;
@@ -306,29 +313,47 @@ export function parseAgentRunRequestBody(body) {
306
313
  selectedObject,
307
314
  executionTarget,
308
315
  workspaceContext,
309
- conversationContext: agentRunRecord(record.conversationContext),
316
+ conversationContext: sanitizeClientConversationContext(agentRunRecord(record.conversationContext)),
310
317
  history: parseAgentRunHistory(record.history),
311
318
  threadId: agentRunString(record.threadId),
312
319
  runId: agentRunString(record.runId),
313
320
  reasoningEffort: parseAgentRunReasoningEffort(record.reasoningEffort),
314
321
  analysisDepth: parseAgentRunAnalysisDepth(record.analysisDepth) ?? parseAgentRunAnalysisDepth(record.depth),
315
322
  thinkingMode: coerceThinkingMode(record.thinkingMode),
323
+ researchResultRowsOptIn: requestedMode === 'research' && record.researchResultRowsOptIn === true,
316
324
  },
317
325
  };
318
326
  }
327
+ const CLIENT_PLAN_AUTHORITY_KEYS = new Set([
328
+ 'priorResolvedAnalyticalPlan',
329
+ 'resolvedAnalyticalPlan',
330
+ 'analyticalFrame',
331
+ ]);
332
+ /**
333
+ * Browser/embedding context is useful retrieval and history input, but it is
334
+ * not a plan-authority channel. Remove plan-shaped fields recursively at HTTP
335
+ * ingress; server-retained thread context is added afterwards from local state.
336
+ */
337
+ function sanitizeClientConversationContext(context) {
338
+ if (!context)
339
+ return undefined;
340
+ const sanitize = (value) => {
341
+ if (Array.isArray(value))
342
+ return value.map(sanitize);
343
+ const record = agentRunRecord(value);
344
+ if (!record)
345
+ return value;
346
+ return Object.fromEntries(Object.entries(record).flatMap(([key, nested]) => CLIENT_PLAN_AUTHORITY_KEYS.has(key) ? [] : [[key, sanitize(nested)]]));
347
+ };
348
+ return sanitize(context);
349
+ }
319
350
  const AGENT_LOOKUP_DEADLINE_MS = 45_000;
320
351
  const AGENT_RESEARCH_DEADLINE_MS = 120_000;
321
- /** Env-tunable run deadline (e.g. slow subscription-CLI providers), clamped to sane bounds. */
322
- function resolveAgentDeadlineMs(envKey, fallback, env = process.env) {
323
- const configured = Number(env[envKey]);
324
- if (!Number.isFinite(configured) || configured <= 0)
325
- return fallback;
326
- return Math.max(15_000, Math.min(600_000, Math.floor(configured)));
327
- }
328
352
  /**
329
353
  * PERF-002: one wall-clock budget follows the request through routing, provider
330
354
  * calls, repair, and execution. Ordinary Ask never inherits Research's budget
331
- * merely because it spans two tables; explicit/deep investigation does.
355
+ * because of question wording, depth, provider, or relational shape; only an
356
+ * explicit `requestedMode: research` activates the Research budget.
332
357
  */
333
358
  /** Resolve governed display formats for result columns from the semantic layer (best-effort, cached per call). */
334
359
  function agentColumnDisplayFormats(projectRoot, columns) {
@@ -346,91 +371,63 @@ function agentColumnDisplayFormats(projectRoot, columns) {
346
371
  catch { /* formatting is presentation-only; never fail an answer over it */ }
347
372
  return formats;
348
373
  }
349
- /** Subscription CLI providers spawn a cold external process per LLM call (2-15s
350
- * startup each) across a multi-call loop, so the API-sized 45s default made even
351
- * simple questions "reach the bounded execution deadline" on office laptops with
352
- * no env override. The defaults must fit the transport's physics. */
353
- const AGENT_CLI_LOOKUP_DEADLINE_MS = 150_000;
354
- const AGENT_CLI_RESEARCH_DEADLINE_MS = 300_000;
355
- /** High reasoning effort makes every LLM call materially slower, so the same
356
- * multi-step build that fits a budget at medium effort overruns it at high. The
357
- * budget must scale with effort or "High · thorough" reliably times out. */
358
- function reasoningEffortBudgetMultiplier(effort) {
359
- return effort === 'high' ? 1.8 : effort === 'low' ? 0.9 : 1;
374
+ export function agentRunDeadlineMs(request, env = process.env, activeProviderId) {
375
+ void env;
376
+ void activeProviderId;
377
+ return request.requestedMode === 'research'
378
+ ? AGENT_RESEARCH_DEADLINE_MS
379
+ : AGENT_LOOKUP_DEADLINE_MS;
360
380
  }
361
381
  /**
362
- * A ranking/breakdown/filtered question needs LLM SQL generation (the grain
363
- * dimension usually lives on a joined model the semantic layer can't compose),
364
- * and that multi-call build does NOT fit the bare lookup budget on a slow or
365
- * high-effort provider it dead-ends at the deadline. It is not "research", but
366
- * it needs research-sized time to finish. Detected from the requested SHAPE (a
367
- * top-N, a member filter, a breakdown dimension, or a time window) rather than a
368
- * recognized metric word, since office questions like "top bcm customers from
369
- * the south region" name no metric the planner knows yet. A bare scalar
370
- * ("total revenue") has none of these and stays on the short budget.
382
+ * Decide how a settled answer gets its business-facing prose.
383
+ *
384
+ * This deliberately does NOT read `requestedMode`. Gating narration on
385
+ * `requestedMode === 'research'` meant every ordinary Ask which the UI sends
386
+ * as `'auto'` skipped synthesis entirely and shipped the answer loop's
387
+ * deterministic fact-join as the primary answer: the reported `column: value`
388
+ * dump. Narration is owed to any run that actually produced values.
389
+ *
390
+ * Certification, a DQL artifact, and the exploratory candidate are no longer
391
+ * vetoes. EXP-001's grain concern is real, but the answer to "the model might
392
+ * relabel an entity-level measure" is to VERIFY the claims against the fact set
393
+ * (`verified_facts`) and pass the grain statement in as a caveat — not to refuse
394
+ * to write a sentence.
371
395
  */
372
- function isAnalyticalBuildQuestion(plan) {
373
- if (!plan.needsGeneratedSql)
374
- return false;
375
- return plan.requestedShape.dimensions.length > 0
376
- || plan.dimensionTerms.length > 0
377
- || plan.requestedShape.filters.length > 0
378
- || plan.timeTerms.length > 0
379
- || Boolean(plan.requestedShape.topN);
380
- }
381
- export function agentRunDeadlineMs(request, env = process.env, activeProviderId) {
382
- const cliProvider = activeProviderId === 'claude-code' || activeProviderId === 'codex';
383
- const lookupDeadline = resolveAgentDeadlineMs('DQL_AGENT_LOOKUP_DEADLINE_MS', cliProvider ? AGENT_CLI_LOOKUP_DEADLINE_MS : AGENT_LOOKUP_DEADLINE_MS, env);
384
- const researchDeadline = resolveAgentDeadlineMs('DQL_AGENT_RESEARCH_DEADLINE_MS', cliProvider ? AGENT_CLI_RESEARCH_DEADLINE_MS : AGENT_RESEARCH_DEADLINE_MS, env);
385
- // The "Thinking" control sends a thinkingMode; explicit reasoningEffort/
386
- // analysisDepth (CLI flags) take precedence but the mode is the common case.
387
- // The deadline MUST see the resolved values — "High" both slows every call
388
- // AND means deep — or picking High reliably times out at the lookup budget.
389
- const resolvedMode = request.thinkingMode ? resolveThinkingMode(request.thinkingMode) : {};
390
- const effectiveEffort = request.reasoningEffort ?? resolvedMode.reasoningEffort;
391
- const effectiveDepth = request.analysisDepth ?? resolvedMode.analysisDepth;
392
- // The budget is a CEILING, not a target — a fast answer returns immediately;
393
- // the larger tiers only matter when the build genuinely needs the time.
394
- const scale = (ms) => Math.min(600_000, Math.round(ms * reasoningEffortBudgetMultiplier(effectiveEffort)));
395
- if (request.requestedMode === 'research' || effectiveDepth === 'deep') {
396
- return scale(researchDeadline);
397
- }
398
- // A structured clarification chip displays a compact metric/path label, but
399
- // the engine restores and executes the original analytical question. Budget
400
- // from that same original question as well. Otherwise a complex ranking or
401
- // cross-model request can incorrectly inherit the 45s scalar-lookup budget
402
- // merely because the selected label is short, then time out after the user
403
- // already supplied the exact governed meaning.
404
- const deadlineQuestion = request.selectedEvidenceId
405
- ? request.clarificationSourceQuestion?.trim() || request.question
406
- : request.question;
407
- const plan = buildAnalysisQuestionPlan(deadlineQuestion);
408
- if (plan.needsResearchWorkspace || isAnalyticalBuildQuestion(plan)) {
409
- return scale(researchDeadline);
410
- }
411
- return scale(lookupDeadline);
412
- }
413
- export function shouldSynthesizeAgentRunAnswer(governedAnswer) {
396
+ export function planAgentRunNarration(governedAnswer, context) {
397
+ // A refusal is not narrated. It owes the user a reason or a clarifying
398
+ // question, which is the clarification lane's job, not the narrator's.
414
399
  if (governedAnswer.kind === 'no_answer')
415
- return false;
416
- // The executed rows are the presentation boundary: certified, semantic,
417
- // DQL-first, and exploratory routes all deserve a separate stakeholder-facing
418
- // explanation grounded in those values. Trust and query mechanics stay in the
419
- // artifact/inspector instead of leaking into the primary answer.
400
+ return { mode: 'skip', reason: 'no_answer' };
401
+ if (!context.providerAvailable)
402
+ return { mode: 'skip', reason: 'no_provider' };
403
+ const maxRows = Math.max(0, context.rowEgress.maxNarrationRows);
404
+ // The fact set is the strongest grounding available: every sentence can be
405
+ // tied back to a fact id and rejected when it is not.
406
+ if (governedAnswer.analyticalFacts)
407
+ return { mode: 'verified_facts', maxRows };
420
408
  if (governedAnswer.result)
421
- return true;
422
- if (governedAnswer.kind === 'certified' || governedAnswer.certification === 'certified')
423
- return false;
424
- // EXP-001: exploratory prose is part of the trust boundary. A free-form
425
- // synthesis pass can accidentally relabel an entity-level lifetime measure
426
- // as a child-level allocation or total repeated values. Keep the host's
427
- // deterministic grain statement instead.
428
- if (governedAnswer.exploratoryCandidate)
429
- return false;
430
- const finalText = (governedAnswer.answer ?? governedAnswer.text ?? '').trim();
431
- if (governedAnswer.dqlArtifact && finalText)
432
- return false;
433
- return true;
409
+ return { mode: 'preview_grounded', maxRows };
410
+ return { mode: 'skip', reason: 'nothing_to_narrate' };
411
+ }
412
+ /**
413
+ * Boolean view of {@link planAgentRunNarration}, kept for callers and tests that
414
+ * only need "does this run narrate at all".
415
+ */
416
+ export function shouldSynthesizeAgentRunAnswer(governedAnswer, requestedMode = 'ask') {
417
+ return planAgentRunNarration(governedAnswer, {
418
+ requestedMode,
419
+ providerAvailable: true,
420
+ rowEgress: DEFAULT_ASK_ROW_EGRESS_POLICY,
421
+ }).mode !== 'skip';
422
+ }
423
+ /**
424
+ * AGT-010 — the semantic route label is descriptive, while the exact
425
+ * route-specific aggregation proof is authoritative for governed trust.
426
+ * Missing proof remains blocked for legacy or malformed results.
427
+ */
428
+ export function semanticAnswerHasPassedAggregationProof(governedAnswer) {
429
+ return governedAnswer.route?.tier === 'semantic_metric'
430
+ && governedAnswer.aggregationSafetyProof?.status === 'safe';
434
431
  }
435
432
  export function agentAnswerHasExecutionFailure(governedAnswer) {
436
433
  return typeof governedAnswer.executionError === 'string'
@@ -544,6 +541,194 @@ export function warehouseFailureFromAgentRun(run) {
544
541
  }
545
542
  return undefined;
546
543
  }
544
+ const AUTOMATIC_ASK_REPAIR_FAILURE_CODES = new Set([
545
+ 'COLUMN_NOT_FOUND',
546
+ 'RELATION_NOT_FOUND',
547
+ 'AMBIGUOUS_COLUMN',
548
+ 'DIALECT_ERROR',
549
+ ]);
550
+ /** Validate the immutable custom wrapper before any automatic repair is offered. */
551
+ export function validAskRepairDqlWrapper(source) {
552
+ try {
553
+ const program = new Parser(source, '<ask-repair-capability>').parse();
554
+ if (program.statements.length !== 1)
555
+ return false;
556
+ const block = program.statements[0];
557
+ return block.kind === NodeKind.BlockDecl
558
+ && block.blockType === 'custom'
559
+ && Boolean(extractBlockStudioSql(source)?.trim());
560
+ }
561
+ catch {
562
+ return false;
563
+ }
564
+ }
565
+ /** Build retained automatic-repair authority from analytical failure state only. */
566
+ export function analyticalRepairCapabilityForAgentRun(run, resolvedTargetFingerprint) {
567
+ if (run.status !== 'blocked' || run.stopReason !== 'blocked')
568
+ return undefined;
569
+ const failedRun = analyticalFailedRunFromAgentRun(run);
570
+ const failure = failedRun?.failure;
571
+ const dqlArtifact = run.artifacts.flatMap((artifact) => {
572
+ const payload = agentRunRecord(artifact.payload);
573
+ const candidate = normalizeDqlArtifactReference(payload?.dqlArtifact);
574
+ return candidate?.kind === 'sql_block' && candidate.source?.trim() ? [candidate] : [];
575
+ })[0];
576
+ const dqlSource = dqlArtifact?.source?.trim();
577
+ const sql = dqlArtifact?.compiledSql?.trim() || repairableSqlFromAgentRun(run);
578
+ const target = run.executionTarget;
579
+ const planFingerprint = failure?.planFingerprint?.trim();
580
+ const hash = (value) => `sha256:${createHash('sha256').update(stableExecutionValue(value)).digest('hex')}`;
581
+ const sourceArtifact = run.artifacts.find((artifact) => {
582
+ const payload = agentRunRecord(artifact.payload);
583
+ return Boolean(normalizeDqlArtifactReference(payload?.dqlArtifact)?.source?.trim());
584
+ });
585
+ const retainedPlanFingerprint = sourceArtifact
586
+ ? agentRunString(agentRunRecord(agentRunRecord(sourceArtifact.payload)?.resolvedAnalyticalPlan)?.fingerprint)
587
+ : undefined;
588
+ const sourceFingerprint = sourceArtifact && dqlArtifact
589
+ ? hash({
590
+ runId: run.id,
591
+ artifactId: sourceArtifact.id,
592
+ dqlArtifact: {
593
+ kind: dqlArtifact.kind,
594
+ name: dqlArtifact.name,
595
+ source: dqlArtifact.source?.trim(),
596
+ parameterValues: dqlArtifact.parameterValues ?? {},
597
+ trustState: dqlArtifact.trustState,
598
+ persistence: dqlArtifact.persistence,
599
+ },
600
+ })
601
+ : undefined;
602
+ const manualActions = (failure?.safeActions ?? []).flatMap((action) => {
603
+ if (action === 'edit_dql' || action === 'open_sql_notebook' || action === 'refresh_snapshot'
604
+ || action === 'retry_same_plan' || action === 'change_authorized_connection' || action === 'request_access') {
605
+ return [action];
606
+ }
607
+ return [];
608
+ });
609
+ const ineligibilityReason = !failure
610
+ ? 'missing_failure_authority'
611
+ : failure.phase !== 'execution' || !AUTOMATIC_ASK_REPAIR_FAILURE_CODES.has(failure.code)
612
+ ? 'failure_not_eligible'
613
+ : !dqlSource
614
+ ? 'missing_dql_wrapper'
615
+ : !validAskRepairDqlWrapper(dqlSource)
616
+ ? 'invalid_dql_wrapper'
617
+ : !sql
618
+ ? 'missing_compiled_sql'
619
+ : !planFingerprint || !retainedPlanFingerprint || retainedPlanFingerprint !== planFingerprint
620
+ ? 'missing_plan_fingerprint'
621
+ : !sourceFingerprint
622
+ ? 'missing_dql_wrapper'
623
+ : !target || !resolvedTargetFingerprint
624
+ ? 'missing_execution_target'
625
+ : undefined;
626
+ const eligible = ineligibilityReason === undefined;
627
+ return {
628
+ version: 1,
629
+ automatic: {
630
+ eligible,
631
+ action: eligible ? 'repair_embedded_sql' : 'none',
632
+ correctionCode: eligible ? 'SQL_EXECUTION_REPAIR' : 'MANUAL_REVIEW_REQUIRED',
633
+ attemptsRemaining: eligible ? 1 : 0,
634
+ },
635
+ failureFingerprint: hash(failure ?? { unavailable: 'analytical_failure', runId: run.id }),
636
+ sourceFingerprint: sourceFingerprint ?? hash({ unavailable: 'source', runId: run.id }),
637
+ planFingerprint: retainedPlanFingerprint ?? planFingerprint ?? hash({ unavailable: 'plan', runId: run.id }),
638
+ dqlFingerprint: dqlSource ? hash(dqlSource) : hash({ unavailable: 'dql', runId: run.id }),
639
+ sqlFingerprint: sql ? hash(sql) : hash({ unavailable: 'sql', runId: run.id }),
640
+ targetFingerprint: resolvedTargetFingerprint ?? hash({ unavailable: 'target', runId: run.id }),
641
+ routeLocked: true,
642
+ targetLocked: true,
643
+ sourceImmutable: true,
644
+ manualActions: Array.from(new Set(manualActions)),
645
+ ...(ineligibilityReason ? { ineligibilityReason } : {}),
646
+ };
647
+ }
648
+ function attachAnalyticalRepairCapability(run, resolvedTargetFingerprint) {
649
+ const capability = analyticalRepairCapabilityForAgentRun(run, resolvedTargetFingerprint);
650
+ if (!capability)
651
+ return run;
652
+ run.repairCapability = capability;
653
+ if (run.diagnosticReceipt)
654
+ run.diagnosticReceipt = { ...run.diagnosticReceipt, repairCapability: capability };
655
+ run.artifacts = run.artifacts.map((artifact) => {
656
+ const payload = agentRunRecord(artifact.payload);
657
+ if (!payload)
658
+ return artifact;
659
+ const diagnostic = agentRunRecord(payload.diagnosticReceipt);
660
+ return {
661
+ ...artifact,
662
+ payload: {
663
+ ...payload,
664
+ repairCapability: capability,
665
+ ...(diagnostic ? { diagnosticReceipt: { ...diagnostic, repairCapability: capability } } : {}),
666
+ },
667
+ };
668
+ });
669
+ return run;
670
+ }
671
+ export function targetGenerationFingerprint(connection, connectionName) {
672
+ const identity = {
673
+ driver: connection.driver,
674
+ connectionName: connectionName ?? null,
675
+ host: connection.host ?? null,
676
+ port: connection.port ?? null,
677
+ username: connection.username ?? null,
678
+ authMethod: connection.authMethod ?? null,
679
+ authenticator: connection.authenticator ?? null,
680
+ profile: connection.profile ?? null,
681
+ ssl: connection.ssl ?? null,
682
+ account: connection.account ?? null,
683
+ database: connection.database ?? null,
684
+ schema: connection.schema ?? null,
685
+ warehouse: connection.warehouse ?? null,
686
+ role: connection.role ?? null,
687
+ catalog: connection.catalog ?? null,
688
+ httpPath: connection.httpPath ?? null,
689
+ filepath: connection.filepath ?? null,
690
+ projectId: connection.projectId ?? null,
691
+ region: connection.region ?? null,
692
+ workgroup: connection.workgroup ?? null,
693
+ location: connection.location ?? null,
694
+ outputLocation: connection.outputLocation ?? null,
695
+ accessUrl: connection.accessUrl ?? null,
696
+ application: connection.application ?? null,
697
+ queryTag: connection.queryTag ?? null,
698
+ workloadIdentityProvider: connection.workloadIdentityProvider ?? null,
699
+ workloadIdentityAzureClientId: connection.workloadIdentityAzureClientId ?? null,
700
+ workloadIdentityImpersonationPath: connection.workloadIdentityImpersonationPath ?? null,
701
+ oauthAuthorizationUrl: connection.oauthAuthorizationUrl ?? null,
702
+ oauthClientId: connection.oauthClientId ?? null,
703
+ oauthRedirectUri: connection.oauthRedirectUri ?? null,
704
+ oauthScope: connection.oauthScope ?? null,
705
+ oauthTokenRequestUrl: connection.oauthTokenRequestUrl ?? null,
706
+ keyFilename: connection.keyFilename ?? null,
707
+ privateKeyPath: connection.privateKeyPath ?? null,
708
+ accessKeyId: connection.accessKeyId ?? null,
709
+ proxyHost: connection.proxyHost ?? null,
710
+ proxyPort: connection.proxyPort ?? null,
711
+ proxyProtocol: connection.proxyProtocol ?? null,
712
+ proxyUser: connection.proxyUser ?? null,
713
+ noProxy: connection.noProxy ?? null,
714
+ credentialCacheDir: connection.credentialCacheDir ?? null,
715
+ moduleSearchPaths: connection.moduleSearchPaths ?? null,
716
+ clientRequestMFAToken: connection.clientRequestMFAToken ?? null,
717
+ clientStoreTemporaryCredential: connection.clientStoreTemporaryCredential ?? null,
718
+ clientSessionKeepAlive: connection.clientSessionKeepAlive ?? null,
719
+ clientSessionKeepAliveHeartbeatFrequency: connection.clientSessionKeepAliveHeartbeatFrequency ?? null,
720
+ keepAlive: connection.keepAlive ?? null,
721
+ passcodeInPassword: connection.passcodeInPassword ?? null,
722
+ browserActionTimeout: connection.browserActionTimeout ?? null,
723
+ timeout: connection.timeout ?? null,
724
+ waitTimeout: connection.waitTimeout ?? null,
725
+ byteLimit: connection.byteLimit ?? null,
726
+ };
727
+ return executionFingerprint(stableExecutionValue(identity));
728
+ }
729
+ function normalizeSqlForComparison(sql) {
730
+ return sql.trim().replace(/;\s*$/, '').replace(/\s+/g, ' ');
731
+ }
547
732
  /**
548
733
  * Retain planning and retrieval evidence for a successful derived repair while
549
734
  * deliberately dropping the source failure and any result-bound facts. This
@@ -656,7 +841,7 @@ async function conversationContextFromThread(store, threadId, clientContext, que
656
841
  });
657
842
  }
658
843
  return {
659
- ...(clientContext ?? {}),
844
+ ...(sanitizeClientConversationContext(clientContext) ?? {}),
660
845
  conversationStateVersion: 1,
661
846
  threadId,
662
847
  ...(serverSnapshot ? { conversationEnvelope: serverSnapshot } : {}),
@@ -778,12 +963,43 @@ export function slimAgentRunForTransport(run) {
778
963
  : {}),
779
964
  };
780
965
  }
966
+ /**
967
+ * INDEX projection for `GET /api/agent-runs` — strictly lighter than the
968
+ * presentation projection above.
969
+ *
970
+ * A run history list renders one ROW per run: question, route, status, trust,
971
+ * timing, summary. It never renders an answer body, an event stream, or a step
972
+ * trace. Shipping those anyway dominated the response: on 300 real stored runs
973
+ * a 20-run page was 47.61 MB whole and still 6.80 MB under the presentation
974
+ * projection, of which 6.35 MB was `artifacts[].payload` alone.
975
+ *
976
+ * Artifact identity (`id`/`kind`/`title`/`trustState`) is kept so a row can say
977
+ * what it produced; only the payload body is dropped. The complete immutable
978
+ * record stays available from `GET /api/agent-runs/:id`.
979
+ *
980
+ * Acceptance: PERF-003, E2E-022.
981
+ */
982
+ export function agentRunListEntryForTransport(run) {
983
+ const slim = slimAgentRunForTransport(run);
984
+ const artifacts = (slim.artifacts ?? []).map((artifact) => {
985
+ const record = agentRunRecord(artifact);
986
+ if (!record)
987
+ return artifact;
988
+ const { payload: _payload, ...rest } = record;
989
+ return rest;
990
+ });
991
+ return { ...slim, artifacts, steps: [], events: [] };
992
+ }
781
993
  export function conversationTurnInputFromRun(run) {
782
994
  const artifact = run.artifacts.find((candidate) => candidate.kind === 'answer')
783
995
  ?? run.artifacts.find((candidate) => candidate.kind === 'research_run')
784
996
  ?? run.artifacts[0];
785
997
  const payload = agentRunRecord(artifact?.payload);
786
- const result = agentRunRecord(payload?.result);
998
+ // A blocked run may retain diagnostic artifacts, but their result-shaped
999
+ // payload is never accepted conversation evidence or prose authority.
1000
+ const result = run.status === 'blocked' || run.status === 'cancelled'
1001
+ ? undefined
1002
+ : agentRunRecord(payload?.result);
787
1003
  const columns = conversationResultColumns(result?.columns);
788
1004
  // The visual preview stays tiny, but member resolution needs a wider bounded
789
1005
  // value window. Deriving dimensions from only the eight preview rows caused a
@@ -924,6 +1140,230 @@ function apiErrorCode(error, fallback) {
924
1140
  function apiErrorMessage(error) {
925
1141
  return error instanceof Error ? error.message : String(error);
926
1142
  }
1143
+ /**
1144
+ * Slowest provider dispatch we assume before any have been observed, and the
1145
+ * settle time reserved after the last one. A subscription-CLI provider costs
1146
+ * roughly 10-15s per call, so these keep the first admission decision honest.
1147
+ */
1148
+ const ASSUMED_PROVIDER_DISPATCH_MS = 12_000;
1149
+ const DISPATCH_SETTLE_MARGIN_MS = 4_000;
1150
+ export class RunScopedProviderDispatchEvidence {
1151
+ policy;
1152
+ runBudget;
1153
+ rowEgress;
1154
+ receipts = [];
1155
+ phaseCounts = new Map();
1156
+ currentRoute;
1157
+ /** Wall-clock start of the previous dispatch, used to learn this provider's cost. */
1158
+ lastDispatchStartedAtMs;
1159
+ observedDispatchDurations = [];
1160
+ /**
1161
+ * How long the NEXT dispatch is likely to take, learned from this run.
1162
+ *
1163
+ * Counting dispatches is not a time budget. A provider costing ~13s a call
1164
+ * will burn a 45s deadline in three calls and get killed mid-flight, so the
1165
+ * run ends with nothing — strictly worse than stopping early and answering
1166
+ * from what it already has.
1167
+ */
1168
+ expectedDispatchMs() {
1169
+ if (this.observedDispatchDurations.length === 0)
1170
+ return ASSUMED_PROVIDER_DISPATCH_MS;
1171
+ const sorted = [...this.observedDispatchDurations].sort((left, right) => left - right);
1172
+ // The slowest observed call is the honest predictor: an optimistic median
1173
+ // still admits a dispatch that the deadline then kills.
1174
+ return sorted[sorted.length - 1];
1175
+ }
1176
+ /** True when the remaining wall clock cannot fit another provider call. */
1177
+ cannotFitAnotherDispatch() {
1178
+ if (!this.runBudget)
1179
+ return false;
1180
+ return this.runBudget.remainingMs() < this.expectedDispatchMs() + DISPATCH_SETTLE_MARGIN_MS;
1181
+ }
1182
+ /**
1183
+ * The run budget's own clock when there is one, so dispatch cost is measured
1184
+ * against the same timeline the deadline is enforced on (and stays injectable
1185
+ * for deterministic tests).
1186
+ */
1187
+ nowMs() {
1188
+ return this.runBudget ? this.runBudget.elapsedMs() : Date.now();
1189
+ }
1190
+ recordDispatchStart() {
1191
+ const now = this.nowMs();
1192
+ if (this.lastDispatchStartedAtMs !== undefined) {
1193
+ this.observedDispatchDurations.push(Math.max(0, now - this.lastDispatchStartedAtMs));
1194
+ }
1195
+ this.lastDispatchStartedAtMs = now;
1196
+ }
1197
+ constructor(policy, runBudget, rowEgress = ZERO_ROW_EGRESS_POLICY) {
1198
+ this.policy = policy;
1199
+ this.runBudget = runBudget;
1200
+ this.rowEgress = rowEgress;
1201
+ this.currentRoute = runBudget?.mode === 'research' ? 'research' : 'generated_answer';
1202
+ }
1203
+ setRoute(route) {
1204
+ if (route)
1205
+ this.currentRoute = route;
1206
+ }
1207
+ mayStartToolCall() {
1208
+ return !this.runBudget || this.runBudget.mayStartDiscovery(this.currentRoute);
1209
+ }
1210
+ observe(event, context) {
1211
+ const softRoute = context.dispatchPhase === 'meaning_resolution' ? 'clarify' : this.currentRoute;
1212
+ // Admission control BEFORE the phase targets: starting a call that the hard
1213
+ // deadline will kill mid-flight wastes the remaining budget and ends the run
1214
+ // with nothing. Stopping here lets the caller answer from what it has.
1215
+ // Narration is exempt: it is the step that produces the answer, and it is
1216
+ // separately bounded by its own soft target below.
1217
+ if (context.dispatchPhase !== 'narration' && this.cannotFitAnotherDispatch()) {
1218
+ throw Object.assign(new Error(`Only ${Math.round((this.runBudget?.remainingMs() ?? 0) / 1_000)}s of the run deadline remain, which is not enough for another ~${Math.round(this.expectedDispatchMs() / 1_000)}s provider call. Answering from what has already been gathered.`), { code: 'RUN_DEADLINE_INSUFFICIENT' });
1219
+ }
1220
+ if (context.dispatchPhase === 'narration') {
1221
+ if (this.runBudget && !this.runBudget.mayStartNarration()) {
1222
+ throw Object.assign(new Error(`The ${Math.round(this.runBudget.narrationSoftTargetMs() / 1_000)}-second narration target elapsed before the answer could be written.`), { code: 'RUN_SOFT_TARGET_EXCEEDED' });
1223
+ }
1224
+ }
1225
+ else if (this.runBudget && !this.runBudget.mayStartDiscovery(softRoute)) {
1226
+ throw Object.assign(new Error(`The ${Math.round(this.runBudget.softTargetMs(softRoute) / 1_000)}-second soft target elapsed before this provider dispatch could start.`), { code: 'RUN_SOFT_TARGET_EXCEEDED' });
1227
+ }
1228
+ this.recordDispatchStart();
1229
+ if (this.receipts.length >= this.policy.total) {
1230
+ throw Object.assign(new Error(`Run-wide provider dispatch budget exhausted after ${this.policy.total} physical attempts.`), { code: 'PROVIDER_DISPATCH_BUDGET_EXHAUSTED' });
1231
+ }
1232
+ const phaseCount = this.phaseCounts.get(context.dispatchPhase) ?? 0;
1233
+ const phaseLimit = context.dispatchPhase === 'meaning_resolution'
1234
+ ? this.policy.meaningResolution
1235
+ : context.dispatchPhase === 'repair'
1236
+ ? this.policy.repair
1237
+ : context.dispatchPhase === 'narration'
1238
+ ? this.policy.narration
1239
+ : this.policy.generationGroup;
1240
+ // Planning and generation share one ledger because they compete for the
1241
+ // same "work out the query" budget. Narration does not: it is the step that
1242
+ // turns a settled result into an answer, and starving it produces a run
1243
+ // that computed the right numbers and then could not say them.
1244
+ const generationGroupCount = this.receipts.filter((receipt) => receipt.dispatchPhase === 'planning'
1245
+ || receipt.dispatchPhase === 'generation').length;
1246
+ if (phaseCount >= phaseLimit || ((context.dispatchPhase === 'planning' || context.dispatchPhase === 'generation')
1247
+ && generationGroupCount >= this.policy.generationGroup)) {
1248
+ throw Object.assign(new Error(`Provider dispatch budget exhausted for ${context.dispatchPhase} after ${phaseLimit} physical attempts.`), { code: 'PROVIDER_DISPATCH_BUDGET_EXHAUSTED' });
1249
+ }
1250
+ const envelope = prepareProviderWireEnvelopeForDispatch(event.provider, event.envelope);
1251
+ const projectedRowCount = context.serializedResultShape?.resultRowCount ?? 0;
1252
+ const projectedCumulativeRowCount = context.cumulativeResultRowCount ?? projectedRowCount;
1253
+ // Ordinary Ask narration reads its ceiling from the resolved project policy;
1254
+ // Research keeps its own explicit opt-in limits.
1255
+ const permittedRowLimit = context.purpose === 'answer_narration'
1256
+ ? this.rowEgress.maxNarrationRows
1257
+ : context.optIn && context.purpose === 'research_narration'
1258
+ ? 20
1259
+ : context.optIn && context.purpose === 'research_tool'
1260
+ ? 200
1261
+ : 0;
1262
+ if (projectedRowCount > permittedRowLimit || projectedCumulativeRowCount > permittedRowLimit) {
1263
+ throw Object.assign(new Error(permittedRowLimit === 0
1264
+ ? 'Provider egress blocked result rows without explicit Research run consent.'
1265
+ : `Provider egress blocked result rows beyond the cumulative Research limit of ${permittedRowLimit}.`), { code: permittedRowLimit === 0 ? 'PROVIDER_RESULT_ROWS_BLOCKED' : 'PROVIDER_RESULT_ROWS_LIMIT_EXCEEDED' });
1266
+ }
1267
+ assertProviderPayloadAllowed(envelope, {
1268
+ allowResultRows: false,
1269
+ maxResultRows: 0,
1270
+ purpose: context.purpose,
1271
+ });
1272
+ const rowCount = projectedRowCount;
1273
+ this.receipts.push(createProviderDispatchEgressReceipt({
1274
+ purpose: context.purpose,
1275
+ dispatchPhase: context.dispatchPhase,
1276
+ provider: event.provider,
1277
+ ...(event.model ? { model: event.model } : {}),
1278
+ operation: event.operation,
1279
+ attemptIndex: event.attemptIndex,
1280
+ options: event.options,
1281
+ permittedCategories: rowCount > 0
1282
+ ? ['instructions', 'question', 'schema_metadata', 'governed_context', 'result_rows']
1283
+ : ['instructions', 'question', 'schema_metadata', 'governed_context'],
1284
+ optIn: context.optIn && rowCount > 0,
1285
+ envelope,
1286
+ ...(context.serializedResultShape ? { serializedResultShape: context.serializedResultShape } : {}),
1287
+ ...(typeof context.cumulativeResultRowCount === 'number'
1288
+ ? { cumulativeResultRowCount: context.cumulativeResultRowCount }
1289
+ : {}),
1290
+ }));
1291
+ this.phaseCounts.set(context.dispatchPhase, phaseCount + 1);
1292
+ return envelope;
1293
+ }
1294
+ snapshot(fallbackReason = 'none') {
1295
+ return {
1296
+ providerEgressReceipts: [...this.receipts],
1297
+ providerRoundTrips: this.receipts.length,
1298
+ toolCalls: 0,
1299
+ sqlExecutions: 0,
1300
+ repairs: 0,
1301
+ fallbackReason,
1302
+ };
1303
+ }
1304
+ }
1305
+ const agentRunProviderEvidenceContext = new AsyncLocalStorage();
1306
+ function mergeRunScopedProviderDispatchEvidence(run, evidence) {
1307
+ const providerEgressReceipts = [...evidence.providerEgressReceipts];
1308
+ const elapsed = Math.max(0, Date.parse(run.completedAt) - Date.parse(run.startedAt));
1309
+ const telemetry = {
1310
+ version: 1,
1311
+ stageDurationsMs: {
1312
+ ...(run.telemetry?.stageDurationsMs ?? {}),
1313
+ total: Number.isFinite(elapsed) ? Math.min(86_400_000, elapsed) : 0,
1314
+ },
1315
+ providerRoundTrips: evidence.providerRoundTrips,
1316
+ toolCalls: run.telemetry?.toolCalls ?? evidence.toolCalls,
1317
+ sqlExecutions: run.telemetry?.sqlExecutions ?? evidence.sqlExecutions,
1318
+ repairs: run.telemetry?.repairs ?? evidence.repairs,
1319
+ egressReceipts: providerEgressReceipts.length,
1320
+ ...(run.telemetry?.warehouseDurationMs !== undefined
1321
+ ? { warehouseDurationMs: run.telemetry.warehouseDurationMs }
1322
+ : {}),
1323
+ ...(run.telemetry?.fallbackReason
1324
+ ? { fallbackReason: run.telemetry.fallbackReason }
1325
+ : evidence.fallbackReason !== 'none'
1326
+ ? { fallbackReason: evidence.fallbackReason }
1327
+ : {}),
1328
+ };
1329
+ const diagnosticReceipt = run.diagnosticReceipt
1330
+ ? { ...run.diagnosticReceipt, providerEgressReceipts }
1331
+ : undefined;
1332
+ const diagnosticReceiptV2 = {
1333
+ version: 2,
1334
+ runId: run.id,
1335
+ route: run.route,
1336
+ status: run.status,
1337
+ telemetry,
1338
+ providerEgressReceiptFingerprints: providerEgressReceipts.map((receipt) => executionFingerprint(stableExecutionValue(receipt))),
1339
+ ...(run.diagnosticReceiptV2?.repairCapabilityFingerprint
1340
+ ? { repairCapabilityFingerprint: run.diagnosticReceiptV2.repairCapabilityFingerprint }
1341
+ : {}),
1342
+ };
1343
+ const artifacts = run.artifacts.map((artifact) => {
1344
+ if (!artifact.payload || typeof artifact.payload !== 'object' || Array.isArray(artifact.payload))
1345
+ return artifact;
1346
+ const payload = artifact.payload;
1347
+ if (!('diagnosticReceipt' in payload) && !('diagnosticReceiptV2' in payload))
1348
+ return artifact;
1349
+ return {
1350
+ ...artifact,
1351
+ payload: {
1352
+ ...payload,
1353
+ ...(diagnosticReceipt ? { diagnosticReceipt } : {}),
1354
+ diagnosticReceiptV2,
1355
+ },
1356
+ };
1357
+ });
1358
+ return {
1359
+ ...run,
1360
+ artifacts,
1361
+ providerEgressReceipts,
1362
+ telemetry,
1363
+ ...(diagnosticReceipt ? { diagnosticReceipt } : {}),
1364
+ diagnosticReceiptV2,
1365
+ };
1366
+ }
927
1367
  export async function startLocalServer(opts) {
928
1368
  const { rootDir, executor, connection: rawConnection, preferredPort, projectRoot = process.cwd() } = opts;
929
1369
  const bindHost = opts.host ?? process.env.DQL_HOST ?? '127.0.0.1';
@@ -945,6 +1385,11 @@ export async function startLocalServer(opts) {
945
1385
  if (gitRoot)
946
1386
  ensureLocalRuntimeGitignore(projectRoot);
947
1387
  let projectConfig = loadProjectConfig(projectRoot);
1388
+ const analyticalExecutionService = new ExecutionService({
1389
+ executor,
1390
+ projectRoot,
1391
+ projectConfig: () => projectConfig,
1392
+ });
948
1393
  recordAgentRuntimeVersion(projectRoot, runtimeVersion);
949
1394
  // A clone carries governed Hint Graph files in Git, never the rebuildable
950
1395
  // `.dql/cache` database. Materialize that projection as soon as the Notebook
@@ -1566,23 +2011,81 @@ export async function startLocalServer(opts) {
1566
2011
  };
1567
2012
  // Provider-backed narration for stakeholder stories. Reuses the same provider
1568
2013
  // adapter as the planner; narrateResult always returns (deterministic fallback).
1569
- const narrateForAgentRun = async (input) => narrateResult(input, {
1570
- complete: async ({ system, user, signal }) => {
1571
- const provider = await createBlockStudioAssistProvider(projectRoot);
1572
- if (!provider)
1573
- throw new Error('No AI provider configured for narration.');
1574
- return provider.generate([{ role: 'system', content: system }, { role: 'user', content: user }], { maxTokens: 600, temperature: 0.2, signal });
1575
- },
1576
- });
2014
+ const narrateForAgentRun = async (input, allowProviderResultRows = false) => {
2015
+ // Without caller consent, keep narration deterministic and make no physical
2016
+ // provider dispatch. With consent, serialize only the bounded, redacted
2017
+ // sample the transport receipt will account for — bounded by the project's
2018
+ // own egress policy, so an admin kill-switch is not silently bypassed here.
2019
+ if (!allowProviderResultRows || !input.result)
2020
+ return narrateResult(input);
2021
+ const narrationRowEgress = resolveProviderResultRowEgressPolicy({
2022
+ projectSetting: projectConfig?.agent?.providerResultRowEgress,
2023
+ });
2024
+ if (narrationRowEgress.maxNarrationRows === 0)
2025
+ return narrateResult(input);
2026
+ const safeResult = {
2027
+ ...input.result,
2028
+ rows: redactProviderResultRows(input.result.rows, narrationRowEgress.maxNarrationRows),
2029
+ };
2030
+ const safeInput = {
2031
+ ...input,
2032
+ result: safeResult,
2033
+ };
2034
+ return narrateResult(safeInput, {
2035
+ complete: async ({ system, user, signal }) => {
2036
+ const provider = await createBlockStudioAssistProvider(projectRoot);
2037
+ if (!provider)
2038
+ throw new Error('No AI provider configured for narration.');
2039
+ return provider.generate([{ role: 'system', content: system }, { role: 'user', content: user }], {
2040
+ maxTokens: 600,
2041
+ temperature: 0.2,
2042
+ signal,
2043
+ maxProviderDispatches: 2,
2044
+ ...(agentRunProviderEvidenceContext.getStore() ? {
2045
+ // This lane narrates App/notebook results, not Research. It used to
2046
+ // declare `research_narration` to clear the row check, which made
2047
+ // receipt analytics conflate two different lanes.
2048
+ onProviderDispatch: (event) => agentRunProviderEvidenceContext.getStore().observe(event, {
2049
+ purpose: 'answer_narration',
2050
+ dispatchPhase: 'narration',
2051
+ optIn: true,
2052
+ serializedResultShape: {
2053
+ resultRowCount: safeResult.rows.length,
2054
+ columnCount: safeResult.columns.length,
2055
+ },
2056
+ cumulativeResultRowCount: safeResult.rows.length,
2057
+ }),
2058
+ } : {}),
2059
+ });
2060
+ },
2061
+ });
2062
+ };
1577
2063
  async function runGovernedAgentAnswerForRun(request, repair, route = 'generated_answer', onProgress, routeDecision) {
1578
2064
  const governed = resolveGovernedAnswerRunner(projectRoot);
1579
- const resolvedProvider = governed?.provider ?? null;
1580
- const runner = governed?.runner ?? null;
2065
+ let resolvedProvider = governed?.provider ?? null;
2066
+ let runner = governed?.runner ?? null;
2067
+ const exactProviderFreePlan = routeDecision?.resolvedAnalyticalPlan?.mode === 'authoritative'
2068
+ && (routeDecision.resolvedAnalyticalPlan.capability === 'certified_execution'
2069
+ || routeDecision.resolvedAnalyticalPlan.capability === 'semantic_execution');
2070
+ if ((!resolvedProvider || !runner) && exactProviderFreePlan) {
2071
+ const deterministicProvider = {
2072
+ name: 'ollama',
2073
+ available: async () => true,
2074
+ generate: async () => {
2075
+ throw Object.assign(new Error('Exact certified/semantic execution must not dispatch a provider.'), {
2076
+ code: 'EXACT_ROUTE_PROVIDER_DISPATCH_FORBIDDEN',
2077
+ });
2078
+ },
2079
+ };
2080
+ resolvedProvider = 'ollama';
2081
+ runner = createDqlAgentProviderRunner('ollama', deterministicProvider);
2082
+ }
1581
2083
  if (!resolvedProvider || !runner) {
1582
2084
  throw new Error('No AI provider is configured. Configure a subscription (Claude Code / Codex), OpenAI, Gemini, Ollama, or a custom OpenAI-compatible endpoint in Settings.');
1583
2085
  }
1584
2086
  let governedAnswer;
1585
2087
  let providerError;
2088
+ let providerDispatchEvidence;
1586
2089
  const isRepair = (repair?.attempt ?? 0) > 0 && Boolean(repair?.repairHint);
1587
2090
  // The chat composer sends a `thinkingMode` (auto/low/medium/high); resolve it
1588
2091
  // into the effort+depth bundle it stands for. An explicit `reasoningEffort` /
@@ -1642,6 +2145,9 @@ export async function startLocalServer(opts) {
1642
2145
  const semanticTableMapping = semanticLayer && semanticConnection
1643
2146
  ? await resolveSemanticTableMapping(executor, semanticConnection, semanticLayer, projectRoot, semanticConnectionName)
1644
2147
  : undefined;
2148
+ const generatedProposalTargetIdentity = semanticConnection
2149
+ ? await observeWarehouseTargetIdentity(executor, semanticConnection)
2150
+ : undefined;
1645
2151
  const requestedDomain = normalizeAgentRunDomain(agentRunWorkspaceValue(request, 'domain'));
1646
2152
  const requestedPurpose = agentRunWorkspaceValue(request, 'purpose');
1647
2153
  const requestedModelAreaId = agentRunWorkspaceValue(request, 'modelAreaId');
@@ -1659,6 +2165,9 @@ export async function startLocalServer(opts) {
1659
2165
  : undefined;
1660
2166
  await runner.run({
1661
2167
  provider: resolvedProvider,
2168
+ ...(agentRunProviderEvidenceContext.getStore()
2169
+ ? { providerDispatchEvidenceSink: agentRunProviderEvidenceContext.getStore() }
2170
+ : {}),
1662
2171
  messages: [
1663
2172
  // No-thread fallback path: bound the raw client history so follow-ups
1664
2173
  // cannot inflate every provider call (8 turns × 4k chars).
@@ -1678,6 +2187,8 @@ export async function startLocalServer(opts) {
1678
2187
  },
1679
2188
  reasoningEffort,
1680
2189
  ...(analysisDepth ? { analysisDepth } : {}),
2190
+ allowProviderSemanticMemberSelection: route === 'research',
2191
+ researchResultRowsOptIn: route === 'research' && request.researchResultRowsOptIn === true,
1681
2192
  projectRoot,
1682
2193
  preparedContextPack: preparedAgentContextPacks.get(request),
1683
2194
  domainContext,
@@ -1799,9 +2310,20 @@ export async function startLocalServer(opts) {
1799
2310
  : projectConfig.dbt?.projectDir;
1800
2311
  throw new SemanticRuntimeRequiredError(await explainMissingSemanticRuntime(projectRoot, dbtProjectPath));
1801
2312
  }
1802
- compiledSemanticQueries.set(executionFingerprint(compiled.sql), compiled);
2313
+ // The semantic bridge freezes the compiler output after trimming
2314
+ // transport-only surrounding whitespace. Bind the compiler receipt
2315
+ // and execution lookup to that exact consumed SQL identity.
2316
+ const governedCompiledSql = compiled.sql.trim();
2317
+ compiledSemanticQueries.set(executionFingerprint(governedCompiledSql), compiled);
2318
+ const aggregationCompilerReceipt = buildSemanticAggregationCompilerReceipt({
2319
+ semanticLayer: semanticLayer,
2320
+ metricNames: compiled.effectiveRequest.metrics,
2321
+ compiledSql: governedCompiledSql,
2322
+ plan: routeDecision?.resolvedAnalyticalPlan,
2323
+ targetFingerprint: targetBinding?.executionTarget.identityFingerprint,
2324
+ });
1803
2325
  return {
1804
- sql: compiled.sql,
2326
+ sql: governedCompiledSql,
1805
2327
  engine: compiled.engine,
1806
2328
  selection: {
1807
2329
  ...selection,
@@ -1814,7 +2336,9 @@ export async function startLocalServer(opts) {
1814
2336
  trace: {
1815
2337
  ...compiled.semanticTrace,
1816
2338
  ...(targetBinding ? { targetBinding } : {}),
2339
+ ...(aggregationCompilerReceipt ? { aggregationCompilerReceipt } : {}),
1817
2340
  },
2341
+ ...(aggregationCompilerReceipt ? { aggregationCompilerReceipt } : {}),
1818
2342
  };
1819
2343
  },
1820
2344
  } : {}),
@@ -1827,6 +2351,9 @@ export async function startLocalServer(opts) {
1827
2351
  ...(routeDecision?.resolvedAnalyticalPlan
1828
2352
  ? { resolvedAnalyticalPlan: routeDecision.resolvedAnalyticalPlan }
1829
2353
  : {}),
2354
+ ...(generatedProposalTargetIdentity?.identityFingerprint
2355
+ ? { generatedProposalTargetFingerprint: generatedProposalTargetIdentity.identityFingerprint }
2356
+ : {}),
1830
2357
  analyticalReferenceInstant: new Date().toISOString(),
1831
2358
  executeCertifiedBlock: (node, invocation) => executeCertifiedBlockForAgent(node, invocation, semanticConnection, semanticConnectionName),
1832
2359
  executeGeneratedSql: (sql, artifact) => executeGeneratedArtifactForAgent(request.question, sql, artifact, semanticConnection, semanticConnectionName),
@@ -1843,10 +2370,11 @@ export async function startLocalServer(opts) {
1843
2370
  }
1844
2371
  if (turn.kind === 'error') {
1845
2372
  providerError = turn.message;
2373
+ providerDispatchEvidence = turn.dispatchEvidence;
1846
2374
  }
1847
2375
  }, runSignal);
1848
2376
  if (!governedAnswer) {
1849
- throw new Error(providerError ?? 'The AI provider did not return a governed answer.');
2377
+ throw Object.assign(new Error(providerError ?? 'The AI provider did not return a governed answer.'), ...(providerDispatchEvidence ? [{ providerDispatchEvidence }] : []));
1850
2378
  }
1851
2379
  return governedAnswer;
1852
2380
  }
@@ -2073,7 +2601,11 @@ export async function startLocalServer(opts) {
2073
2601
  surface: 'ask_ai',
2074
2602
  });
2075
2603
  const certified = governedAnswer.certification === 'certified' || governedAnswer.kind === 'certified';
2076
- const semantic = governedAnswer.route?.tier === 'semantic_metric';
2604
+ // A semantic route is governed only when its route-specific aggregation
2605
+ // proof passed. The route label is not authority: treating a blocked or
2606
+ // missing proof as governed caused the CLI to persist a successful badge
2607
+ // even though the answer artifact recorded insufficient context.
2608
+ const semantic = semanticAnswerHasPassedAggregationProof(governedAnswer);
2077
2609
  governedAnswer.dqlArtifact = {
2078
2610
  ...artifact,
2079
2611
  ...(invocation.parameters.length > 0 ? { parameters: invocation.parameters } : {}),
@@ -2082,14 +2614,156 @@ export async function startLocalServer(opts) {
2082
2614
  trustState: certified ? 'certified' : semantic ? 'governed' : 'review_required',
2083
2615
  ...(governedAnswer.result?.sql ? { compiledSql: governedAnswer.result.sql } : originalSql ? { compiledSql: originalSql } : {}),
2084
2616
  ...(governedAnswer.result?.executionReceipt ? { executionReceipt: governedAnswer.result.executionReceipt } : {}),
2617
+ ...(governedAnswer.result?.executableArtifact ? {
2618
+ executableArtifact: {
2619
+ ...governedAnswer.result.executableArtifact,
2620
+ kind: artifact.kind,
2621
+ trustState: certified ? 'certified' : semantic ? 'governed' : 'review_required',
2622
+ },
2623
+ } : {}),
2085
2624
  };
2086
2625
  if (governedAnswer.result)
2087
2626
  delete governedAnswer.result.dqlArtifact;
2088
2627
  }
2089
2628
  const answerRunExecutor = async ({ request, route, routeDecision, attempt, repairHint, emit }) => {
2629
+ const runStartedAtMs = Date.now();
2630
+ const turnPlan = buildAnalyticalTurnPlan({
2631
+ question: request.question,
2632
+ turnId: request.runId,
2633
+ candidateIds: routeDecision?.retrievalEvidence?.candidateIds ?? [],
2634
+ frozen: routeDecision?.resolvedAnalyticalPlan?.mode === 'authoritative',
2635
+ snapshotId: routeDecision?.resolvedAnalyticalPlan?.snapshotId ?? routeDecision?.retrievalEvidence?.snapshotId,
2636
+ sourceFingerprint: routeDecision?.resolvedAnalyticalPlan?.sourceFingerprint ?? routeDecision?.retrievalEvidence?.sourceFingerprint,
2637
+ });
2638
+ const childTurn = Boolean(request.workspaceContext && typeof request.workspaceContext === 'object'
2639
+ && request.workspaceContext.analyticalTaskChild === true);
2640
+ // Compound questions are a bounded task graph, not one query whose answer
2641
+ // is copied into several labels. Independent children share the parent's
2642
+ // signal/deadline and return truthful partial success.
2643
+ if (turnPlan.tasks.length > 1 && !childTurn && (attempt ?? 0) === 0) {
2644
+ const childResults = await Promise.all(turnPlan.tasks.slice(0, 6).map(async (task) => {
2645
+ if (request.signal?.aborted)
2646
+ rethrowIfCancelled(request.signal.reason, request.signal);
2647
+ try {
2648
+ const childRequest = {
2649
+ ...request,
2650
+ question: task.question,
2651
+ workspaceContext: {
2652
+ ...(request.workspaceContext && typeof request.workspaceContext === 'object' ? request.workspaceContext : {}),
2653
+ analyticalTaskChild: true,
2654
+ analyticalParentRunId: request.runId,
2655
+ analyticalTaskId: task.id,
2656
+ },
2657
+ };
2658
+ const answer = await runGovernedAgentAnswerForRun(childRequest, { attempt: 0, repairHint }, route, (message) => emit({ type: 'executor.started', message: `Task ${task.id}: ${message}`, route }), undefined);
2659
+ if (answer.result) {
2660
+ const canonical = normalizeCanonicalQueryResult({
2661
+ ...answer.result,
2662
+ resultFingerprint: answer.result.resultFingerprint ?? answer.result.executionReceipt?.resultFingerprint,
2663
+ executionReceipt: answer.result.executionReceipt,
2664
+ answerTier: answer.route?.tier ?? answer.sourceTier,
2665
+ });
2666
+ answer.result = {
2667
+ ...answer.result,
2668
+ columns: canonical.columns,
2669
+ rows: canonical.rows,
2670
+ rowCount: canonical.rowCount,
2671
+ resultFingerprint: canonical.resultFingerprint,
2672
+ ...(canonical.executionReceipt ? { executionReceipt: canonical.executionReceipt } : {}),
2673
+ ...(canonical.answerTier ? { answerTier: canonical.answerTier } : {}),
2674
+ };
2675
+ }
2676
+ return { task, answer };
2677
+ }
2678
+ catch (error) {
2679
+ rethrowIfCancelled(error, request.signal);
2680
+ return { task, error: error instanceof Error ? error.message : String(error) };
2681
+ }
2682
+ }));
2683
+ const outcomes = childResults.map(({ task, answer, error }) => ({
2684
+ version: 1,
2685
+ taskId: task.id,
2686
+ status: error || answer?.kind === 'no_answer' ? 'gap' : 'completed',
2687
+ ...(answer?.answer || answer?.text ? { summary: answer.answer ?? answer.text } : {}),
2688
+ ...(answer?.result?.resultFingerprint ? { resultFingerprint: answer.result.resultFingerprint } : {}),
2689
+ ...(error || answer?.kind === 'no_answer' ? {
2690
+ gap: buildCoverageGap({
2691
+ code: answer?.refusalCode === 'ambiguous' ? 'AMBIGUOUS_MEANING' : 'EXECUTION_FAILED',
2692
+ phase: answer?.executionError ? 'execution' : 'meaning',
2693
+ message: error ?? answer?.answer ?? answer?.text ?? 'The task did not produce an accepted analytical result.',
2694
+ searchedSources: routeDecision?.retrievalEvidence?.candidateIds ?? [],
2695
+ attemptedRoutes: ['certified', 'semantic', 'governed_relational', 'generated'],
2696
+ missing: [],
2697
+ recoverable: false,
2698
+ planFrozen: turnPlan.frozen,
2699
+ nextActions: ['Review the task context and retry the clause.'],
2700
+ }),
2701
+ } : {}),
2702
+ }));
2703
+ const completedCount = outcomes.filter((outcome) => outcome.status === 'completed').length;
2704
+ const tasks = turnPlan.tasks.map((task) => ({
2705
+ ...task,
2706
+ status: outcomes.find((outcome) => outcome.taskId === task.id)?.status === 'completed' ? 'completed' : 'gap',
2707
+ }));
2708
+ const answerText = childResults.map(({ task, answer, error }) => `${task.question}: ${error ?? answer?.answer ?? answer?.text ?? 'No accepted result was produced.'}`).join('\n\n');
2709
+ return {
2710
+ summary: completedCount === outcomes.length
2711
+ ? `Answered ${completedCount} independent analytical clauses.`
2712
+ : `Answered ${completedCount} of ${outcomes.length} analytical clauses; the remaining clauses need review.`,
2713
+ answer: answerText,
2714
+ status: completedCount === outcomes.length ? 'completed' : completedCount > 0 ? 'needs_review' : 'needs_clarification',
2715
+ trustState: completedCount === outcomes.length ? 'governed' : 'review_required',
2716
+ stopReason: completedCount === outcomes.length ? 'governed_semantic_answer' : 'human_review_required',
2717
+ artifacts: childResults.map(({ task, answer, error }) => agentRunArtifact('answer', `Task: ${task.question}`, {
2718
+ taskId: task.id,
2719
+ question: task.question,
2720
+ answer: answer?.answer ?? answer?.text,
2721
+ resultFingerprint: answer?.result?.resultFingerprint,
2722
+ error,
2723
+ })),
2724
+ evaluations: outcomes.map((outcome) => agentRunEvaluation(`analytical-task-${outcome.taskId}`, `Analytical task ${outcome.taskId}`, outcome.status === 'completed', outcome.status === 'completed' ? 'info' : 'warning', outcome.summary ?? outcome.gap?.message ?? 'Task outcome recorded.', outcome)),
2725
+ analyticalTurnPlan: { ...turnPlan, tasks, frozen: true },
2726
+ analyticalTaskOutcomes: outcomes,
2727
+ };
2728
+ }
2090
2729
  let governedAnswer;
2091
2730
  try {
2092
2731
  governedAnswer = await runGovernedAgentAnswerForRun(request, { attempt, repairHint }, route, (message) => emit({ type: 'executor.started', message, route }), routeDecision);
2732
+ // Keep the canonical result contract on the answer itself, not only on
2733
+ // the narration preview. Conversation persistence, follow-up member
2734
+ // resolution, Apply, and the notebook table all read this payload; if a
2735
+ // connector returned positional rows, dropping normalization here makes
2736
+ // the next question lose its customer/product/region binding (AGT-032).
2737
+ if (governedAnswer.result) {
2738
+ const canonical = normalizeCanonicalQueryResult({
2739
+ ...governedAnswer.result,
2740
+ resultFingerprint: governedAnswer.result.resultFingerprint
2741
+ ?? governedAnswer.result.executionReceipt?.resultFingerprint,
2742
+ executionReceipt: governedAnswer.result.executionReceipt,
2743
+ answerTier: governedAnswer.route?.tier ?? governedAnswer.sourceTier,
2744
+ trustState: governedAnswer.certification === 'certified'
2745
+ ? 'certified'
2746
+ : governedAnswer.kind === 'no_answer'
2747
+ ? 'blocked'
2748
+ : governedAnswer.reviewStatus === 'analyst_review_required'
2749
+ ? 'review_required'
2750
+ : 'governed',
2751
+ });
2752
+ governedAnswer.result = {
2753
+ ...governedAnswer.result,
2754
+ columns: canonical.columns,
2755
+ rows: canonical.rows,
2756
+ rowCount: canonical.rowCount,
2757
+ // Preserve the execution-service/receipt fingerprint. A local UI
2758
+ // digest is only a legacy fallback in normalizeCanonicalQueryResult;
2759
+ // it must never replace the cryptographic identity of this run.
2760
+ resultFingerprint: canonical.resultFingerprint,
2761
+ ...(canonical.executionTime !== undefined ? { executionTime: canonical.executionTime } : {}),
2762
+ ...(canonical.truncated ? { truncated: true } : {}),
2763
+ ...(canonical.executionReceipt ? { executionReceipt: canonical.executionReceipt } : {}),
2764
+ ...(canonical.answerTier ? { answerTier: canonical.answerTier } : {}),
2765
+ };
2766
+ }
2093
2767
  // Surface the approved Hint-Graph corrections that shaped this answer so the
2094
2768
  // UI can show an "applied learnings" chip (memoryContext is already on the answer).
2095
2769
  if (!governedAnswer.appliedHints) {
@@ -2222,39 +2896,67 @@ export async function startLocalServer(opts) {
2222
2896
  // Deadline/cancellation propagates to the engine, which renders the
2223
2897
  // graceful "bounded execution deadline" outcome — never a provider error.
2224
2898
  rethrowIfCancelled(error, request.signal);
2225
- const message = formatAgentRunInfrastructureError(error, 'AI answer provider');
2899
+ const dispatchEvidence = providerDispatchTerminalEvidence(error);
2900
+ const dispatchBudgetExhausted = Boolean((error && typeof error === 'object'
2901
+ && error.code === 'PROVIDER_DISPATCH_BUDGET_EXHAUSTED')
2902
+ || dispatchEvidence?.fallbackReason === 'orchestration_budget_exhausted');
2903
+ const message = dispatchBudgetExhausted
2904
+ ? 'Ask reached its internal provider-dispatch limit before it froze one executable analytical plan. Nothing was run. Narrow the metric or dimension and retry; this is an orchestration-budget diagnostic, not a provider outage.'
2905
+ : formatAgentRunInfrastructureError(error, 'AI answer provider');
2226
2906
  return {
2227
2907
  summary: message,
2228
2908
  status: 'blocked',
2229
2909
  trustState: 'blocked',
2230
2910
  stopReason: 'blocked',
2231
- artifacts: [agentRunArtifact('answer', 'AI answer provider failed', {
2911
+ artifacts: [agentRunArtifact('answer', dispatchBudgetExhausted ? 'Internal orchestration budget exhausted' : 'AI answer provider failed', {
2232
2912
  kind: 'no_answer',
2233
- refusalCode: 'provider_error',
2913
+ refusalCode: dispatchBudgetExhausted ? 'orchestration_budget_exhausted' : 'provider_error',
2234
2914
  text: message,
2235
2915
  answer: message,
2236
2916
  ...(routeDecision?.resolvedAnalyticalPlan
2237
2917
  ? { resolvedAnalyticalPlan: routeDecision.resolvedAnalyticalPlan }
2238
2918
  : {}),
2239
2919
  providerFailure: {
2240
- code: 'AI_PROVIDER_FAILURE',
2920
+ code: dispatchBudgetExhausted ? 'orchestration_budget_exhausted' : 'AI_PROVIDER_FAILURE',
2241
2921
  message,
2242
- recoverable: true,
2922
+ recoverable: !dispatchBudgetExhausted,
2243
2923
  },
2244
2924
  }, undefined, 'blocked')],
2245
2925
  evaluations: [
2246
2926
  agentRunEvaluation('route-decision', 'Route decision', true, 'info', routeDecision?.reason ?? 'Routed request to governed answer.'),
2247
- agentRunEvaluation('ai-provider', 'AI provider', false, 'blocking', message, { originalErrorType: error instanceof Error ? error.name : typeof error }),
2927
+ agentRunEvaluation(dispatchBudgetExhausted ? 'orchestration-budget' : 'ai-provider', dispatchBudgetExhausted ? 'Internal orchestration budget' : 'AI provider', false, 'blocking', message, { originalErrorType: error instanceof Error ? error.name : typeof error }),
2248
2928
  ],
2249
2929
  nextActions: [
2250
- { id: 'retry-ask-after-provider', label: 'Retry after provider setup', route: 'generated_answer' },
2251
- { id: 'research-without-answer', label: 'Research with available metadata', route: 'research', artifactKind: 'research_run' },
2930
+ ...(dispatchBudgetExhausted
2931
+ ? [{ id: 'narrow-and-retry-ask', label: 'Narrow the metric or dimension and retry', route: 'generated_answer' }]
2932
+ : [
2933
+ { id: 'retry-ask-after-provider', label: 'Retry after provider setup', route: 'generated_answer' },
2934
+ { id: 'research-without-answer', label: 'Research with available metadata', route: 'research', artifactKind: 'research_run' },
2935
+ ]),
2252
2936
  ],
2937
+ ...(dispatchEvidence?.providerEgressReceipts.length
2938
+ ? { providerEgressReceipts: dispatchEvidence.providerEgressReceipts }
2939
+ : {}),
2940
+ telemetry: {
2941
+ version: 1,
2942
+ stageDurationsMs: { total: Date.now() - runStartedAtMs },
2943
+ providerRoundTrips: dispatchEvidence?.providerRoundTrips ?? 0,
2944
+ toolCalls: dispatchEvidence?.toolCalls ?? 0,
2945
+ sqlExecutions: dispatchEvidence?.sqlExecutions ?? 0,
2946
+ repairs: Math.max(attempt, dispatchEvidence?.repairs ?? 0),
2947
+ egressReceipts: dispatchEvidence?.providerEgressReceipts.length ?? 0,
2948
+ fallbackReason: dispatchBudgetExhausted
2949
+ ? 'provider_dispatch_budget_exhausted'
2950
+ : dispatchEvidence?.fallbackReason ?? 'provider_error',
2951
+ },
2253
2952
  };
2254
2953
  }
2255
2954
  const answeredRoute = resolvedRunRouteFromAnswer(governedAnswer) ?? route;
2256
2955
  const isCertified = governedAnswer.certification === 'certified' || governedAnswer.kind === 'certified';
2257
- const isSemantic = governedAnswer.route?.tier === 'semantic_metric';
2956
+ const semanticRouteClaimed = governedAnswer.route?.tier === 'semantic_metric';
2957
+ const semanticAggregationProofPassed = semanticAnswerHasPassedAggregationProof(governedAnswer);
2958
+ const semanticAggregationBlocked = semanticRouteClaimed && !semanticAggregationProofPassed;
2959
+ const isSemantic = semanticRouteClaimed && semanticAggregationProofPassed;
2258
2960
  const requestedNotebookDataset = findMentionedNotebookDataset(request.question, datasetWorkspace.list());
2259
2961
  const governedAssetMissesLocalSource = Boolean(requestedNotebookDataset && (isCertified
2260
2962
  || isSemantic
@@ -2271,13 +2973,46 @@ export async function startLocalServer(opts) {
2271
2973
  // governance boundary, not an ambiguous user question and not a repairable
2272
2974
  // retrieval miss. Keep the precise policy detail visible, but never burn two
2273
2975
  // more provider calls retrying the same incompatible candidate.
2274
- const isPolicyBlocked = governedAnswer.kind === 'no_answer' && governedAnswer.refusalCode === 'policy_blocked';
2976
+ const isPolicyBlocked = (governedAnswer.kind === 'no_answer' && governedAnswer.refusalCode === 'policy_blocked')
2977
+ || semanticAggregationBlocked;
2275
2978
  const isTerminalFailure = isProviderError || isExecutionFailure || isAnalyticalFailure || isPolicyBlocked;
2276
2979
  // The model tried to compose a governed query and declined despite having usable
2277
2980
  // context (e.g. it wasn't confident about a multi-table join). The answer loop
2278
2981
  // has already spent its one evidence-aware repair. Keep this terminal and
2279
2982
  // inspectable; an ordinary Ask must never silently become a second Research run.
2280
2983
  const isModelDeclined = governedAnswer.kind === 'no_answer' && governedAnswer.refusalCode === 'model_declined';
2984
+ // Before an analytical plan is frozen, a generated Ask gap is a recoverable
2985
+ // coverage result rather than a terminal governed error. Let the run engine
2986
+ // consume the typed evaluation and make one bounded cascade decision. Frozen
2987
+ // certified/semantic plans and provider/policy/execution failures remain
2988
+ // fail-closed.
2989
+ const canRecoverPreFreezeGap = (isGroundingGap || isModelDeclined)
2990
+ && route === 'generated_answer'
2991
+ && (request.requestedMode === undefined || request.requestedMode === 'auto' || request.requestedMode === 'ask')
2992
+ && routeDecision?.resolvedAnalyticalPlan?.mode !== 'authoritative';
2993
+ const typedCoverageGap = (isGroundingGap || isModelDeclined)
2994
+ ? buildCoverageGap({
2995
+ code: governedAnswer.refusalCode === 'modeling_gap' ? 'MISSING_RELATIONSHIP' : 'MISSING_RUNTIME_CAPABILITY',
2996
+ phase: 'planning',
2997
+ message: governedAnswer.refusalDetails?.message
2998
+ ?? (isModelDeclined
2999
+ ? 'The available context did not produce a safe analytical tuple.'
3000
+ : 'The retrieved context did not prove the metadata required for this analytical question.'),
3001
+ searchedSources: ['certified_blocks', 'semantic_metrics', 'dbt_manifest', 'relationship_graph', 'warehouse_metadata'],
3002
+ attemptedRoutes: ['certified', 'semantic', 'governed_relational', 'generated'],
3003
+ missing: governedAnswer.refusalDetails?.offending
3004
+ ? [
3005
+ governedAnswer.refusalDetails.offending.relation,
3006
+ governedAnswer.refusalDetails.offending.column,
3007
+ ].filter((value) => Boolean(value))
3008
+ : ['an executable metric/dimension/relationship tuple'],
3009
+ recoverable: canRecoverPreFreezeGap,
3010
+ planFrozen: routeDecision?.resolvedAnalyticalPlan?.mode === 'authoritative',
3011
+ nextActions: canRecoverPreFreezeGap
3012
+ ? ['continue through DBT-grounded relational context', 'run review-required generated SQL', 'start bounded Research if coverage remains incomplete']
3013
+ : ['review the retained metadata gap', 'select a governed metric or dimension', 'repair modeling if the relationship is missing'],
3014
+ })
3015
+ : undefined;
2281
3016
  // Only a genuinely AMBIGUOUS question is surfaced as "needs clarification".
2282
3017
  // Grounding/compiler gaps are terminal review states with their evidence trace;
2283
3018
  // provider outages are blocked so the UI can offer an explicit retry.
@@ -2290,48 +3025,137 @@ export async function startLocalServer(opts) {
2290
3025
  // reply ran context-free and was clarified again: the reported clarify loop.
2291
3026
  const resolvedRoute = needsClarification ? 'clarify' : answeredRoute;
2292
3027
  const sql = governedAnswer.proposedSql ?? governedAnswer.sql;
2293
- const runnableSql = governedAnswer.kind === 'no_answer' || isExecutionFailure || (isExploratory && !governedAnswer.result)
3028
+ const runnableSql = governedAnswer.kind === 'no_answer'
3029
+ || isExecutionFailure
3030
+ || semanticAggregationBlocked
3031
+ || (isExploratory && !governedAnswer.result)
2294
3032
  ? undefined
2295
3033
  : sql;
2296
- // Render executed rows deterministically for ordinary lookups. A second LLM
2297
- // call is reserved for an explicit research route; certified, semantic, and
2298
- // generated lookup answers must not pay another provider round-trip merely
2299
- // to restate values the host already has.
3034
+ // Narrate any run that actually produced values. This used to be gated on
3035
+ // `requestedMode === 'research'`, and because the UI sends ordinary Ask as
3036
+ // `'auto'`, every normal question skipped synthesis and shipped the answer
3037
+ // loop's deterministic fact-join as the primary answer — the reported
3038
+ // `column: value` dump. A model that never sees the result cannot describe
3039
+ // it, so a bounded, redacted row sample goes with the request unless a
3040
+ // project admin has switched row egress off.
2300
3041
  let synthesizedAnswer;
2301
- if (shouldSynthesizeAgentRunAnswer(governedAnswer)) {
3042
+ const providerEgressReceipts = [
3043
+ ...(governedAnswer.providerEgressReceipts ?? []),
3044
+ ];
3045
+ let narrationDurationMs = 0;
3046
+ const researchRowsOptIn = route === 'research' && request.researchResultRowsOptIn === true;
3047
+ const rowEgress = resolveProviderResultRowEgressPolicy({
3048
+ projectSetting: projectConfig?.agent?.providerResultRowEgress,
3049
+ researchOptIn: researchRowsOptIn,
3050
+ });
3051
+ // `createBlockStudioAssistProvider` returns null when no AI provider is
3052
+ // configured; a narration failure must never take the governed answer down.
3053
+ const narrationProvider = await createBlockStudioAssistProvider(projectRoot).catch(() => null);
3054
+ const narrationPlan = planAgentRunNarration(governedAnswer, {
3055
+ requestedMode: request.requestedMode,
3056
+ providerAvailable: Boolean(narrationProvider),
3057
+ rowEgress,
3058
+ });
3059
+ const narrationMaxRows = narrationPlan.mode === 'skip' ? 0 : narrationPlan.maxRows;
3060
+ const preview = agentResultToSynthesisPreview(governedAnswer.result);
3061
+ const providerPreview = preview
3062
+ ? { ...preview, rows: redactProviderResultRows(preview.rows, narrationMaxRows) }
3063
+ : undefined;
3064
+ let narrationSource;
3065
+ let narrationValidationFailures = [];
3066
+ if (narrationPlan.mode !== 'skip' && narrationProvider) {
3067
+ const narrationStartedAtMs = Date.now();
3068
+ const draft = governedAnswer.answer ?? governedAnswer.text;
3069
+ const observeNarrationDispatch = (event) => agentRunProviderEvidenceContext.getStore().observe(event, {
3070
+ purpose: 'answer_narration',
3071
+ dispatchPhase: 'narration',
3072
+ optIn: narrationMaxRows > 0,
3073
+ serializedResultShape: {
3074
+ resultRowCount: providerPreview?.rows.length ?? 0,
3075
+ columnCount: providerPreview?.columns.length ?? 0,
3076
+ },
3077
+ });
3078
+ const narrationDispatchOptions = () => ({
3079
+ maxTokens: 350,
3080
+ temperature: 0.3,
3081
+ maxProviderDispatches: 2,
3082
+ ...(agentRunProviderEvidenceContext.getStore()
3083
+ ? { onProviderDispatch: observeNarrationDispatch }
3084
+ : {}),
3085
+ });
2302
3086
  try {
2303
- const provider = route === 'research'
2304
- ? await createBlockStudioAssistProvider(projectRoot)
2305
- : null;
2306
- const preview = agentResultToSynthesisPreview(governedAnswer.result);
2307
- const draft = governedAnswer.answer ?? governedAnswer.text;
2308
- const result = await synthesizeAnswer({
2309
- question: request.question,
2310
- category: routeDecision?.category,
2311
- // The primary Ask reply is always business-facing. Analysts keep
2312
- // the full DQL, SQL, lineage, gates, and grain in the inspector.
2313
- audience: 'stakeholder',
2314
- resultPreview: preview,
2315
- sql: sql,
2316
- draftText: draft,
2317
- gaps: businessNarrativeGaps(governedAnswer.validationWarnings),
2318
- rankingDirection: governedAnswer.contextPack?.questionPlan.requestedShape.rankingDirection,
2319
- // Governed display contract: metric/measure displayFormat metadata
2320
- // beats column-name guessing, so $ and decimals stay consistent
2321
- // between the narration and the table for names like total_bcm.
2322
- ...(preview ? { columnFormats: agentColumnDisplayFormats(projectRoot, preview.columns) } : {}),
2323
- }, provider
2324
- ? {
2325
- complete: ({ system, user, signal, onDelta }) => streamOrGenerate(provider, [{ role: 'system', content: system }, { role: 'user', content: user }], { maxTokens: 350, temperature: 0.3, signal }, onDelta ?? (() => { })),
2326
- }
2327
- : {});
2328
- if (result.text)
2329
- synthesizedAnswer = result.text;
3087
+ const frame = governedAnswer.resolvedAnalyticalPlan?.analyticalFrame;
3088
+ if (narrationPlan.mode === 'verified_facts' && governedAnswer.analyticalFacts && frame) {
3089
+ // The fact set is the grounding contract: the model drafts, and every
3090
+ // claim is resolved against a fact id before it can reach the user.
3091
+ const composed = await composeVerifiedAnalyticalNarrative({
3092
+ frame,
3093
+ factSet: governedAnswer.analyticalFacts,
3094
+ question: request.question,
3095
+ maxRows: narrationPlan.maxRows,
3096
+ complete: async ({ system, user }) => streamOrGenerate(narrationProvider, [{ role: 'system', content: system }, { role: 'user', content: user }], narrationDispatchOptions(), () => { }),
3097
+ });
3098
+ narrationSource = composed.source;
3099
+ narrationValidationFailures = composed.validationFailures;
3100
+ synthesizedAnswer = composed.source === 'llm'
3101
+ ? composed.narrative.text
3102
+ // A verification failure is not silent: the deterministic join is a
3103
+ // fallback, and the reader is told so rather than handed a dump that
3104
+ // looks like it was written on purpose.
3105
+ : `_Verified narration was unavailable, so this is the deterministic result record._\n\n${composed.narrative.text}`;
3106
+ }
3107
+ else {
3108
+ const result = await synthesizeAnswer({
3109
+ question: request.question,
3110
+ category: routeDecision?.category,
3111
+ // The primary Ask reply is always business-facing. Analysts keep
3112
+ // the full DQL, SQL, lineage, gates, and grain in the inspector.
3113
+ audience: 'stakeholder',
3114
+ resultPreview: providerPreview ?? preview,
3115
+ sql: sql,
3116
+ draftText: draft,
3117
+ gaps: businessNarrativeGaps(governedAnswer.validationWarnings),
3118
+ rankingDirection: governedAnswer.contextPack?.questionPlan.requestedShape.rankingDirection,
3119
+ // Governed display contract: metric/measure displayFormat metadata
3120
+ // beats column-name guessing, so $ and decimals stay consistent
3121
+ // between the narration and the table for names like total_bcm.
3122
+ ...(preview ? { columnFormats: agentColumnDisplayFormats(projectRoot, preview.columns) } : {}),
3123
+ }, {
3124
+ complete: ({ system, user, signal, onDelta }) => streamOrGenerate(narrationProvider, [{ role: 'system', content: system }, { role: 'user', content: user }], { ...narrationDispatchOptions(), signal }, onDelta ?? (() => { })),
3125
+ });
3126
+ narrationSource = result.source;
3127
+ if (result.text)
3128
+ synthesizedAnswer = result.text;
3129
+ }
2330
3130
  }
2331
3131
  catch {
2332
- // Keep the governed draft on any synthesis failure.
3132
+ // Keep the governed draft on any narration failure.
2333
3133
  synthesizedAnswer = undefined;
2334
3134
  }
3135
+ finally {
3136
+ narrationDurationMs = Date.now() - narrationStartedAtMs;
3137
+ }
3138
+ }
3139
+ // Every run accounts for its narration egress, including the runs that did
3140
+ // not narrate — "no receipt" must never be ambiguous between "no rows left
3141
+ // the host" and "nobody looked".
3142
+ {
3143
+ const narrated = narrationSource !== undefined;
3144
+ const rowsSent = narrated ? providerPreview?.rows.length ?? 0 : 0;
3145
+ providerEgressReceipts.push(createProviderEgressReceipt({
3146
+ purpose: 'answer_narration',
3147
+ provider: narrated ? narrationProvider?.name ?? 'none' : 'none',
3148
+ permittedCategories: rowsSent > 0
3149
+ ? ['instructions', 'question', 'governed_context', 'result_rows']
3150
+ : ['instructions', 'question', 'governed_context'],
3151
+ optIn: rowsSent > 0,
3152
+ redactionPolicyId: rowEgress.policyId,
3153
+ payload: narrated
3154
+ ? { question: request.question, resultPreview: rowsSent > 0 ? providerPreview : undefined }
3155
+ : { resultRows: 0, providerDisabled: !narrationProvider },
3156
+ resultRowCount: rowsSent,
3157
+ columnCount: narrated ? providerPreview?.columns.length ?? 0 : 0,
3158
+ }));
2335
3159
  }
2336
3160
  // A grounding/modeling gap is a REFUSAL. It was computed above but never
2337
3161
  // reached this ternary, so it fell through to `needs_review` — the same
@@ -2342,7 +3166,7 @@ export async function startLocalServer(opts) {
2342
3166
  ? 'blocked'
2343
3167
  : needsClarification
2344
3168
  ? 'needs_clarification'
2345
- : isGroundingGap || isModelDeclined
3169
+ : (isGroundingGap || isModelDeclined) && !canRecoverPreFreezeGap
2346
3170
  ? 'blocked'
2347
3171
  : isCertified || isSemantic
2348
3172
  ? 'completed'
@@ -2351,7 +3175,7 @@ export async function startLocalServer(opts) {
2351
3175
  ? 'blocked'
2352
3176
  : needsClarification
2353
3177
  ? 'not_applicable'
2354
- : isGroundingGap || isModelDeclined
3178
+ : (isGroundingGap || isModelDeclined) && !canRecoverPreFreezeGap
2355
3179
  ? 'blocked'
2356
3180
  : isCertified
2357
3181
  ? 'certified'
@@ -2365,7 +3189,7 @@ export async function startLocalServer(opts) {
2365
3189
  // A gap is terminal, but it is NOT a provider outage: it still owes the
2366
3190
  // user its evidence trace, and its next-actions below stay the
2367
3191
  // grounding-gap set rather than "retry the provider".
2368
- : isGroundingGap || isModelDeclined
3192
+ : (isGroundingGap || isModelDeclined) && !canRecoverPreFreezeGap
2369
3193
  ? 'human_review_required'
2370
3194
  : isCertified
2371
3195
  ? 'certified_answer_found'
@@ -2415,9 +3239,21 @@ export async function startLocalServer(opts) {
2415
3239
  artifactKind: 'sql_cell',
2416
3240
  }] : []),
2417
3241
  ];
3242
+ const runDispatchEvidence = agentRunProviderEvidenceContext.getStore()?.snapshot(governedAnswer.refusalCode ?? 'none');
3243
+ const finalProviderEgressReceipts = runDispatchEvidence?.providerEgressReceipts
3244
+ ?? providerEgressReceipts;
3245
+ const finalTelemetry = agentRunTelemetryForAnswer(governedAnswer, finalProviderEgressReceipts, Date.now() - runStartedAtMs, narrationDurationMs, attempt);
3246
+ if (runDispatchEvidence) {
3247
+ finalTelemetry.providerRoundTrips = runDispatchEvidence.providerRoundTrips;
3248
+ finalTelemetry.egressReceipts = finalProviderEgressReceipts.length;
3249
+ }
2418
3250
  return {
2419
3251
  resolvedRoute,
2420
- answerRefusalCode: governedAnswer.kind === 'no_answer' ? governedAnswer.refusalCode : undefined,
3252
+ answerRefusalCode: governedAnswer.kind === 'no_answer'
3253
+ ? governedAnswer.refusalCode
3254
+ : semanticAggregationBlocked
3255
+ ? 'policy_blocked'
3256
+ : undefined,
2421
3257
  answerTier: governedAnswer.route?.tier,
2422
3258
  summary: isTerminalFailure
2423
3259
  ? analyticalFailureSummary(governedAnswer.analyticalFailure)
@@ -2442,7 +3278,7 @@ export async function startLocalServer(opts) {
2442
3278
  ? (governedAnswer.semanticExecutionTrace
2443
3279
  ? [agentRunArtifact('answer', governedAnswer.refusalCode === 'ambiguous'
2444
3280
  ? 'Semantic path selection required'
2445
- : 'Semantic compilation details', governedAnswer, undefined, needsClarification ? 'not_applicable' : 'review_required')]
3281
+ : 'Semantic compilation details', typedCoverageGap ? { ...governedAnswer, coverageGap: typedCoverageGap } : governedAnswer, undefined, needsClarification ? 'not_applicable' : 'review_required')]
2446
3282
  : governedAnswer.dqlArtifact && !isProviderError && !isGroundingGap && !isModelDeclined && !isPolicyBlocked
2447
3283
  ? [agentRunArtifact('dql_block_draft', 'DQL draft (review required)', governedAnswer.dqlArtifact, undefined, 'review_required')]
2448
3284
  // Every refusal still owes the user an account of itself. Emitting no
@@ -2451,7 +3287,7 @@ export async function startLocalServer(opts) {
2451
3287
  // answered", the DQL draft and the compiled SQL all become
2452
3288
  // unreachable exactly when the user most needs to see why it stopped
2453
3289
  // and carry the query into a notebook.
2454
- : [agentRunArtifact('answer', 'No answer was accepted', governedAnswer, undefined, needsClarification ? 'not_applicable' : 'blocked')])
3290
+ : [agentRunArtifact('answer', 'No answer was accepted', typedCoverageGap ? { ...governedAnswer, coverageGap: typedCoverageGap } : governedAnswer, undefined, needsClarification ? 'not_applicable' : 'blocked')])
2455
3291
  : [agentRunArtifact('answer', isCertified ? 'Certified answer' : isSemantic ? 'Governed semantic answer' : isExploratory ? 'Exploratory DBT-grounded answer' : 'Review-required answer', governedAnswer, governedAnswer.sourceCertifiedBlock ?? governedAnswer.block?.name, isCertified ? 'certified' : isSemantic ? 'governed' : 'review_required')],
2456
3292
  evaluations: [
2457
3293
  agentRunEvaluation('route-decision', 'Route decision', true, 'info', routeDecision?.reason ?? 'Routed request to governed answer.', {
@@ -2477,36 +3313,53 @@ export async function startLocalServer(opts) {
2477
3313
  : needsClarification
2478
3314
  ? 'The answer loop needs more context before producing a governed answer.'
2479
3315
  : isPolicyBlocked
2480
- ? `DQL blocked the generated path under its relationship policy: ${governedAnswer.refusalDetails?.message ?? 'the relationship is not authorized for this analysis.'}`
3316
+ ? semanticAggregationBlocked
3317
+ ? `DQL blocked semantic execution because its exact aggregation proof did not pass: ${governedAnswer.aggregationSafetyProof?.issueCodes.join(', ') || 'required proof evidence is missing.'}`
3318
+ : `DQL blocked the generated path under its relationship policy: ${governedAnswer.refusalDetails?.message ?? 'the relationship is not authorized for this analysis.'}`
2481
3319
  : isExploratory
2482
3320
  ? 'The answer used bounded DBT/schema evidence because no certified modeled relationship path covered the request. It is review-required and was not certified.'
2483
3321
  : 'The answer is generated or semantic-layer backed and remains review-required.', governedAnswer.route),
2484
3322
  ...(isExecutionFailure ? [agentRunEvaluation('query-execution', 'Query execution', false, 'blocking', `The governed query failed before it produced a result: ${governedAnswer.executionError}`)] : []),
2485
3323
  ...(isGroundingGap ? [
2486
3324
  {
2487
- ...agentRunEvaluation('grounding-gap', 'Metadata grounding', false, 'warning', 'The bounded lookup could not prove the required metadata grounding. No automatic retry or Research escalation was started.', {
3325
+ ...agentRunEvaluation('grounding-gap', 'Metadata grounding', false, 'warning', canRecoverPreFreezeGap
3326
+ ? 'The first governed lookup did not prove the required metadata grounding. Continue through the bounded relational/generated cascade before asking the user to repair modeling.'
3327
+ : 'The bounded lookup could not prove the required metadata grounding. This plan is already frozen, so no automatic Research escalation was started.', {
2488
3328
  refusalCode: governedAnswer.refusalCode,
2489
3329
  refusalDetails: governedAnswer.refusalDetails,
2490
3330
  validationWarnings: governedAnswer.validationWarnings,
2491
3331
  route: governedAnswer.route,
3332
+ coverageGap: typedCoverageGap,
2492
3333
  }),
3334
+ ...(canRecoverPreFreezeGap ? {
3335
+ suggestedRepair: 'Continue the unfrozen Ask cascade through DBT-grounded relational context and review-required generated SQL.',
3336
+ } : {}),
2493
3337
  },
2494
3338
  ] : []),
2495
3339
  ...(isModelDeclined ? [
2496
3340
  {
2497
- ...agentRunEvaluation('declined-despite-context', 'Answer grounding', false, 'blocking', 'The bounded lookup could not compose a governed query after its in-lane repair. Start Research explicitly to investigate beyond this lookup budget.', {
3341
+ ...agentRunEvaluation('declined-despite-context', 'Answer grounding', false, 'blocking', canRecoverPreFreezeGap
3342
+ ? 'The governed lookup could not compose a query after its bounded in-lane repair. Continue with the Research context ledger before returning a typed gap.'
3343
+ : 'The bounded lookup could not compose a governed query after its in-lane repair. Start Research explicitly to investigate beyond this lookup budget.', {
2498
3344
  refusalCode: governedAnswer.refusalCode,
2499
3345
  refusalDetails: governedAnswer.refusalDetails,
2500
3346
  validationWarnings: governedAnswer.validationWarnings,
2501
3347
  route: governedAnswer.route,
3348
+ coverageGap: typedCoverageGap,
2502
3349
  }),
3350
+ ...(canRecoverPreFreezeGap ? {
3351
+ suggestedRepair: 'Search the surrounding metadata and relationship context before giving up on the analytical turn.',
3352
+ } : {}),
2503
3353
  },
2504
3354
  ] : []),
2505
3355
  ...(isPolicyBlocked ? [
2506
- agentRunEvaluation('relationship-policy', 'Relationship policy', false, 'warning', governedAnswer.refusalDetails?.message
2507
- ?? 'The selected relationship is not authorized for this analysis.', {
3356
+ agentRunEvaluation(semanticAggregationBlocked ? 'aggregation-safety-proof' : 'relationship-policy', semanticAggregationBlocked ? 'Aggregation safety proof' : 'Relationship policy', false, semanticAggregationBlocked ? 'blocking' : 'warning', semanticAggregationBlocked
3357
+ ? `Semantic execution was refused because the exact aggregation proof did not pass: ${governedAnswer.aggregationSafetyProof?.issueCodes.join(', ') || 'required proof evidence is missing.'}`
3358
+ : governedAnswer.refusalDetails?.message
3359
+ ?? 'The selected relationship is not authorized for this analysis.', {
2508
3360
  refusalCode: governedAnswer.refusalCode,
2509
3361
  refusalDetails: governedAnswer.refusalDetails,
3362
+ aggregationSafetyProof: governedAnswer.aggregationSafetyProof,
2510
3363
  validationWarnings: governedAnswer.validationWarnings,
2511
3364
  route: governedAnswer.route,
2512
3365
  }),
@@ -2514,8 +3367,18 @@ export async function startLocalServer(opts) {
2514
3367
  ...(governedAnswer.executionError ? [
2515
3368
  agentRunEvaluation('execution-error', 'Execution error', false, 'warning', governedAnswer.executionError),
2516
3369
  ] : []),
3370
+ // A rejected narration must say WHY it was rejected. The failures were
3371
+ // computed and then dropped, so a reader saw "Verified narration was
3372
+ // unavailable" with no way to tell whether the model invented a number,
3373
+ // cited a fact id that does not exist, or the provider simply failed.
3374
+ // The fallback itself stays: this only makes its reason inspectable.
3375
+ ...(narrationSource === 'deterministic' && narrationValidationFailures.length > 0 ? [
3376
+ agentRunEvaluation('narration-verification', 'Narration verification', false, 'warning', `The drafted narration was rejected against the result fact set, so the deterministic record was shown instead: ${narrationValidationFailures.join('; ')}`, { narrationSource, validationFailures: narrationValidationFailures }),
3377
+ ] : []),
2517
3378
  ],
2518
3379
  nextActions,
3380
+ providerEgressReceipts: finalProviderEgressReceipts,
3381
+ telemetry: finalTelemetry,
2519
3382
  };
2520
3383
  };
2521
3384
  const conversationRunExecutor = async ({ request, routeDecision, emitAnswerDelta }) => {
@@ -2549,13 +3412,28 @@ export async function startLocalServer(opts) {
2549
3412
  ...(request.history ?? []).slice(-6).map((message) => ({ role: message.role, content: message.text })),
2550
3413
  { role: 'user', content: request.question },
2551
3414
  ];
2552
- text = (await provider.generate(messages, { maxTokens: 320, temperature: 0.6 })).trim();
3415
+ text = (await provider.generate(messages, {
3416
+ maxTokens: 320,
3417
+ temperature: 0.6,
3418
+ maxProviderDispatches: 2,
3419
+ ...(agentRunProviderEvidenceContext.getStore() ? {
3420
+ onProviderDispatch: (event) => agentRunProviderEvidenceContext.getStore().observe(event, {
3421
+ purpose: 'answer_generation',
3422
+ dispatchPhase: 'generation',
3423
+ optIn: false,
3424
+ }),
3425
+ } : {}),
3426
+ })).trim();
2553
3427
  // Perceived-latency: surface the reply as one delta for surfaces wired to stream.
2554
3428
  if (text)
2555
3429
  emitAnswerDelta?.(text);
2556
3430
  }
2557
3431
  }
2558
- catch {
3432
+ catch (error) {
3433
+ if (error && typeof error === 'object'
3434
+ && error.code === 'PROVIDER_DISPATCH_BUDGET_EXHAUSTED') {
3435
+ throw error;
3436
+ }
2559
3437
  // Provider unavailable / errored — fall through to the deterministic reply.
2560
3438
  text = undefined;
2561
3439
  }
@@ -3024,6 +3902,7 @@ export async function startLocalServer(opts) {
3024
3902
  plan,
3025
3903
  };
3026
3904
  let researchRun;
3905
+ const researchRuns = [];
3027
3906
  let researchWorkspaceError;
3028
3907
  if (!needsClarification) {
3029
3908
  try {
@@ -3048,26 +3927,106 @@ export async function startLocalServer(opts) {
3048
3927
  });
3049
3928
  emit({
3050
3929
  type: 'artifact.created',
3051
- message: 'Saved notebook research workspace record.',
3930
+ message: 'Saved the immutable root research plan; executing bounded child branches.',
3052
3931
  route: 'research',
3053
3932
  trustState: 'review_required',
3054
- payload: { researchRunId: created.id, notebookPath },
3933
+ payload: { researchRunId: created.id, notebookPath, branchCap: 6 },
3055
3934
  });
3056
- const executed = await runNotebookResearch(storage, created, {
3057
- domain: agentRunWorkspaceValue(request, 'domain'),
3058
- owner: agentRunWorkspaceValue(request, 'owner'),
3059
- sourceCellFingerprint,
3060
- question: request.question,
3061
- intent: researchIntent,
3062
- context: researchContextEnvelope,
3063
- executionConnection: researchExecutionConnection,
3064
- executionConnectionName: researchExecutionConnectionName,
3065
- signal: request.signal,
3066
- baselineSql: agentRunString(researchSource?.sql),
3067
- baselineDqlArtifact: researchSource?.dqlArtifact,
3068
- baselineRunId: agentRunString(researchSource?.runId),
3069
- });
3070
- researchRun = withNotebookResearchChecklist(executed);
3935
+ // The root record is a plan/dossier parent. Only child runs are
3936
+ // observed research executions. This prevents one root result from
3937
+ // being copied into six fabricated ledger entries (AGT-016/033).
3938
+ // An explicit Research request still gets one real child when the
3939
+ // catalog planner has no grounded step (for example, an empty
3940
+ // starter project). The child is an observed metadata/baseline
3941
+ // attempt, not a fabricated successful finding; its durable status
3942
+ // and receipt determine the ledger entry.
3943
+ const fallbackBranch = {
3944
+ thought: 'Inspect the requested analytical question against the frozen root context.',
3945
+ action: {
3946
+ kind: 'lookup_metric',
3947
+ target: routeDecision?.resolvedAnalyticalPlan?.executionId ?? request.question,
3948
+ },
3949
+ expectation: 'Whether the frozen context contains enough evidence for a bounded answer.',
3950
+ };
3951
+ const branches = capResearchBranches(plan.steps.length > 0 ? plan.steps : [fallbackBranch], 6);
3952
+ for (let index = 0; index < branches.length; index += 1) {
3953
+ const step = branches[index];
3954
+ if (request.signal?.aborted)
3955
+ rethrowIfCancelled(request.signal.reason, request.signal);
3956
+ const branchId = `${step.action.kind}:${step.action.target}`;
3957
+ const branchQuestion = `${request.question}\nResearch branch ${index + 1} (${branchId}): ${step.expectation}`;
3958
+ const childId = `${created.id}:research:${index + 1}`;
3959
+ const child = storage.createRun({
3960
+ id: childId,
3961
+ notebookPath,
3962
+ title: `${agentRunTitle(request.question, 'Agent research')} · branch ${index + 1}`,
3963
+ question: branchQuestion,
3964
+ sourceCell,
3965
+ sourceCellId,
3966
+ sourceCellName,
3967
+ sourceCellFingerprint,
3968
+ intent: researchIntent,
3969
+ domain: agentRunWorkspaceValue(request, 'domain'),
3970
+ owner: agentRunWorkspaceValue(request, 'owner'),
3971
+ context: {
3972
+ ...researchContextEnvelope,
3973
+ rootRunId: created.id,
3974
+ rootPlanId: plan.rootPlanId,
3975
+ branch: {
3976
+ id: branchId,
3977
+ index: index + 1,
3978
+ expectation: step.expectation,
3979
+ action: step.action,
3980
+ },
3981
+ },
3982
+ });
3983
+ emit({
3984
+ type: 'artifact.created',
3985
+ message: `Started research branch ${index + 1} of ${branches.length}.`,
3986
+ route: 'research',
3987
+ trustState: 'review_required',
3988
+ payload: { researchRunId: child.id, parentResearchRunId: created.id, branchId },
3989
+ });
3990
+ try {
3991
+ const executed = await runNotebookResearch(storage, child, {
3992
+ domain: agentRunWorkspaceValue(request, 'domain'),
3993
+ owner: agentRunWorkspaceValue(request, 'owner'),
3994
+ sourceCellFingerprint,
3995
+ question: branchQuestion,
3996
+ intent: researchIntent,
3997
+ context: {
3998
+ ...researchContextEnvelope,
3999
+ rootRunId: created.id,
4000
+ rootPlanId: plan.rootPlanId,
4001
+ branch: { id: branchId, index: index + 1, expectation: step.expectation, action: step.action },
4002
+ },
4003
+ executionConnection: researchExecutionConnection,
4004
+ executionConnectionName: researchExecutionConnectionName,
4005
+ signal: request.signal,
4006
+ baselineSql: agentRunString(researchSource?.sql),
4007
+ baselineDqlArtifact: researchSource?.dqlArtifact,
4008
+ baselineRunId: agentRunString(researchSource?.runId),
4009
+ });
4010
+ researchRuns.push(withNotebookResearchChecklist(executed));
4011
+ }
4012
+ catch (error) {
4013
+ // A child is a real durable run even when cancellation stops the
4014
+ // shared branch budget. Persist the truthful stop before
4015
+ // propagating cancellation to the parent run.
4016
+ const message = error instanceof Error ? error.message : String(error);
4017
+ storage.updateRun(child.id, {
4018
+ status: 'error',
4019
+ error: message,
4020
+ summary: 'Research branch stopped before producing a result.',
4021
+ reviewStatus: 'needs_review',
4022
+ });
4023
+ const stopped = storage.getRun(child.id);
4024
+ if (stopped)
4025
+ researchRuns.push(withNotebookResearchChecklist(stopped));
4026
+ rethrowIfCancelled(error, request.signal);
4027
+ }
4028
+ }
4029
+ researchRun = researchRuns[0];
3071
4030
  }
3072
4031
  finally {
3073
4032
  storage.close();
@@ -3078,15 +4037,81 @@ export async function startLocalServer(opts) {
3078
4037
  researchWorkspaceError = formatNotebookResearchStorageError(error);
3079
4038
  }
3080
4039
  }
3081
- const researchResultPreview = researchRun?.resultPreview;
4040
+ const researchResultPreview = researchRuns
4041
+ .map((run) => run.resultPreview)
4042
+ // Keep zero-row executions in the proof path. `coerceNarrateResultData`
4043
+ // intentionally omits empty row sets for prose, but an empty result is
4044
+ // still an executed result when it carries its execution identity.
4045
+ .find((preview) => {
4046
+ const record = agentRunRecord(preview);
4047
+ return Boolean(record && Array.isArray(record.rows));
4048
+ })
4049
+ ?? researchRun?.resultPreview;
3082
4050
  const researchResultData = coerceNarrateResultData(researchResultPreview);
3083
4051
  const researchResultRecord = agentRunRecord(researchResultPreview);
4052
+ // Keep each bounded research branch inspectable as an evidence ledger.
4053
+ // The notebook workspace remains the durable execution record; this
4054
+ // additive projection gives Ask/Research narration a stable list of
4055
+ // observations, failures, facts, and receipts without exposing provider
4056
+ // chain-of-thought. Six is the hard branch budget for one root question.
4057
+ const researchLedger = buildResearchEvidenceLedger({
4058
+ rootQuestion: request.question,
4059
+ planId: plan.rootPlanId,
4060
+ snapshotId: routeDecision?.resolvedAnalyticalPlan?.snapshotId,
4061
+ entries: researchRuns.slice(0, 6).map((branch, index) => {
4062
+ const branchContext = agentRunRecord(branch.context)?.branch;
4063
+ const branchPreviewRecord = agentRunRecord(branch.resultPreview);
4064
+ const branchPreview = coerceNarrateResultData(branchPreviewRecord);
4065
+ const previewRecord = branchPreviewRecord;
4066
+ const executionReceipt = normalizeAnalyticalExecutionReceipt(previewRecord?.executionReceipt);
4067
+ const resultFingerprint = normalizeAnalyticalExecutionFingerprint(previewRecord?.resultFingerprint)
4068
+ ?? executionReceipt?.resultFingerprint;
4069
+ // A child run ID or context-pack ID proves that planning happened,
4070
+ // not that a query executed. Only the canonical result fingerprint
4071
+ // (on the result or its execution receipt) can make a branch
4072
+ // observed (AGT-016/033).
4073
+ const executionProof = resultFingerprint;
4074
+ const observed = branch.status === 'ready' && Boolean(executionProof);
4075
+ return {
4076
+ id: branch.id,
4077
+ branchId: agentRunString(branchContext?.id) ?? `branch:${index + 1}`,
4078
+ question: branch.question,
4079
+ status: observed ? 'observed' : branch.status === 'error' ? 'failed' : 'skipped',
4080
+ ...(branchPreviewRecord && Array.isArray(branchPreviewRecord.rows)
4081
+ ? { rowCount: branchPreviewRecord.rows.length }
4082
+ : {}),
4083
+ ...(resultFingerprint ? { resultFingerprint } : {}),
4084
+ ...(executionReceipt ? { executionReceipt } : {}),
4085
+ facts: [agentRunString(branchContext?.expectation) ?? branch.question, ...(branch.evidence?.citations ?? []).flatMap((item) => typeof item === 'string' ? [item] : [])].slice(0, 8),
4086
+ // A context-pack ID or child run ID is not execution evidence. Keep
4087
+ // the receipt list empty until the execution service supplies a
4088
+ // receipt/fingerprint (AGT-016/033).
4089
+ receipts: executionProof ? [executionProof] : [],
4090
+ ...(!observed ? {
4091
+ error: branch.error
4092
+ ?? (branch.status === 'error'
4093
+ ? branch.summary
4094
+ : 'Research branch did not produce an execution receipt or result fingerprint.'),
4095
+ } : {}),
4096
+ };
4097
+ }),
4098
+ stoppingReason: needsClarification
4099
+ ? 'not_started'
4100
+ : researchRuns.some((run) => run.status === 'error')
4101
+ ? 'insufficient_evidence'
4102
+ : plan.steps.length > 6
4103
+ ? 'budget'
4104
+ : 'completed',
4105
+ });
3084
4106
  // A query that ran and matched 0 rows STILL executed — treat it as a clean,
3085
4107
  // grounded execution (not "no result"), so an empty answer is surfaced as
3086
4108
  // "0 rows matched" rather than silently downgraded to review-required.
3087
4109
  const researchDidExecute = Boolean(researchResultData) || Boolean(researchResultRecord && Array.isArray(researchResultRecord.rows));
3088
4110
  const researchZeroRows = !researchResultData && researchDidExecute;
3089
- const researchExecutedCleanly = researchDidExecute && !researchWorkspaceError && researchRun?.status !== 'error';
4111
+ const researchExecutedCleanly = researchDidExecute
4112
+ && !researchWorkspaceError
4113
+ && researchRuns.length > 0
4114
+ && researchRuns.every((run) => run.status === 'ready');
3090
4115
  const narration = !needsClarification && researchResultData
3091
4116
  ? await narrateForAgentRun({
3092
4117
  question: request.question,
@@ -3094,7 +4119,7 @@ export async function startLocalServer(opts) {
3094
4119
  result: researchResultData,
3095
4120
  evidence: plan.sources,
3096
4121
  reviewRequired: true,
3097
- })
4122
+ }, request.researchResultRowsOptIn === true)
3098
4123
  : undefined;
3099
4124
  const summary = needsClarification
3100
4125
  ? 'Needs clarification before running deeper research.'
@@ -3122,8 +4147,11 @@ export async function startLocalServer(opts) {
3122
4147
  ? []
3123
4148
  : [agentRunArtifact('research_run', 'Research plan', {
3124
4149
  plan,
4150
+ researchLedger,
3125
4151
  researchRun,
4152
+ researchRuns,
3126
4153
  researchRunId: researchRun?.id,
4154
+ researchRunIds: researchRuns.map((run) => run.id),
3127
4155
  notebookPath,
3128
4156
  workspaceError: researchWorkspaceError,
3129
4157
  routeDecision,
@@ -3154,7 +4182,7 @@ export async function startLocalServer(opts) {
3154
4182
  : [
3155
4183
  ...(researchRun?.id ? [{ id: 'open-research', label: 'Open research dossier', artifactKind: 'research_run' }] : []),
3156
4184
  { id: 'create-block', label: 'Review DQL draft', route: 'dql_block_draft', artifactKind: 'dql_block_draft' },
3157
- ...(researchRun?.generatedSql || researchRun?.reviewedSql ? [{ id: 'insert-sql', label: 'Insert SQL preview', route: 'sql_cell', artifactKind: 'sql_cell' }] : []),
4185
+ ...(researchRuns.some((run) => run.generatedSql || run.reviewedSql) ? [{ id: 'insert-sql', label: 'Insert SQL preview', route: 'sql_cell', artifactKind: 'sql_cell' }] : []),
3158
4186
  ],
3159
4187
  };
3160
4188
  },
@@ -3609,15 +4637,28 @@ export async function startLocalServer(opts) {
3609
4637
  return provider.generate([
3610
4638
  { role: 'system', content: system },
3611
4639
  { role: 'user', content: user },
3612
- ], { maxTokens: 700, temperature: 0.1, signal });
4640
+ ], {
4641
+ maxTokens: 700,
4642
+ temperature: 0.1,
4643
+ signal,
4644
+ maxProviderDispatches: 2,
4645
+ ...(agentRunProviderEvidenceContext.getStore() ? {
4646
+ onProviderDispatch: (event) => agentRunProviderEvidenceContext.getStore().observe(event, {
4647
+ purpose: 'answer_generation',
4648
+ dispatchPhase: 'planning',
4649
+ optIn: false,
4650
+ }),
4651
+ } : {}),
4652
+ });
3613
4653
  },
3614
4654
  getCatalogContext: buildRankedAgentRunCatalogContext,
3615
4655
  });
3616
- // Hybrid router: keep the deterministic decision when it is confident (certified
3617
- // fast paths + greetings stay 0-LLM); spend one cheap classification call only for
3618
- // the ambiguous middle so Auto reliably picks quick-answer vs deep research without
3619
- // the user clicking "Dig deeper". Same provider completion as the planner.
4656
+ // Explicit selections and conversation-only turns remain deterministic;
4657
+ // each fresh natural-language analytical turn gets one bounded candidate-ID
4658
+ // interpretation call by default. The rollback is host-owned and cannot be
4659
+ // supplied by an HTTP/MCP client.
3620
4660
  const agentRunRouter = createHybridRouter({
4661
+ requireMeaningCallForNaturalLanguage: opts.requireMeaningCallForNaturalLanguage ?? true,
3621
4662
  complete: async ({ system, user, signal }) => {
3622
4663
  const provider = await createBlockStudioAssistProvider(projectRoot);
3623
4664
  if (!provider)
@@ -3633,6 +4674,14 @@ export async function startLocalServer(opts) {
3633
4674
  // instead of leaving the UI in "Generating and validating SQL" for a
3634
4675
  // minute or more.
3635
4676
  signal: boundedAgentMeaningSignal(signal),
4677
+ maxProviderDispatches: 1,
4678
+ ...(agentRunProviderEvidenceContext.getStore() ? {
4679
+ onProviderDispatch: (event) => agentRunProviderEvidenceContext.getStore().observe(event, {
4680
+ purpose: 'answer_generation',
4681
+ dispatchPhase: 'meaning_resolution',
4682
+ optIn: false,
4683
+ }),
4684
+ } : {}),
3636
4685
  });
3637
4686
  },
3638
4687
  getEvidence: buildAgentRunEvidence,
@@ -3649,6 +4698,27 @@ export async function startLocalServer(opts) {
3649
4698
  // A run may outlive its streaming browser connection, so cancellation is
3650
4699
  // server-owned and keyed by run id rather than relying on fetch abort alone.
3651
4700
  const activeAgentRunControllers = new Map();
4701
+ const cancelActiveAgentRun = (id) => {
4702
+ const controller = activeAgentRunControllers.get(id);
4703
+ if (!controller)
4704
+ return false;
4705
+ const progress = agentRunStore.getProgress(id);
4706
+ if (progress) {
4707
+ agentRunStore.saveProgress({
4708
+ ...progress,
4709
+ lifecycle: {
4710
+ ...progress.lifecycle,
4711
+ state: 'cancelling',
4712
+ revision: progress.lifecycle.revision + 1,
4713
+ updatedAt: new Date().toISOString(),
4714
+ },
4715
+ });
4716
+ }
4717
+ controller.abort(createAgentRunCancellationError());
4718
+ return true;
4719
+ };
4720
+ const activeAnalyticalRepairReservations = new Set();
4721
+ const consumedAnalyticalRepairCapabilities = new Set();
3652
4722
  // Server-side conversation threads: persisted multi-turn state (survives refresh).
3653
4723
  // Lazy so a failed native-sqlite load never blocks the runtime; conversation
3654
4724
  // persistence degrades to the per-request context instead.
@@ -3739,14 +4809,21 @@ export async function startLocalServer(opts) {
3739
4809
  snapshotCells.push({ cellId, status: 'idle', executionCount: 0, executedAt });
3740
4810
  continue;
3741
4811
  }
3742
- const prepared = prepareLocalExecution(executableSql, activeConnection, projectRoot, projectConfig);
3743
4812
  assertAppAccess({ app, domain: resolved.domain ?? app.domain, level: 'execute' });
3744
- const rawResult = await executor.executeQuery(prepared.sql, plan?.sqlParams ?? [], runtimeVariables(plan?.variables ?? {}), prepared.connection);
3745
- const result = normalizeQueryResult(rawResult);
4813
+ const execution = await analyticalExecutionService.execute({
4814
+ sql: executableSql,
4815
+ subject: 'App notebook query',
4816
+ connection: activeConnection,
4817
+ sqlParams: plan?.sqlParams,
4818
+ variables: plan?.variables,
4819
+ });
4820
+ const result = execution.result;
3746
4821
  snapshotCells.push({
3747
4822
  cellId,
3748
4823
  status: 'success',
3749
4824
  result,
4825
+ compiledSqlFingerprint: execution.compiledSqlFingerprint,
4826
+ resultFingerprint: execution.resultFingerprint,
3750
4827
  executionCount: 1,
3751
4828
  executedAt,
3752
4829
  });
@@ -3835,44 +4912,81 @@ export async function startLocalServer(opts) {
3835
4912
  const semanticError = semanticCompose?.diagnostics.find((diagnostic) => diagnostic.severity === 'error')?.message;
3836
4913
  throw analyticalError(semanticError ?? `DQL artifact "${metadata.name ?? 'draft'}" produced no executable SQL.`, { origin: 'dql_compilation', stage: 'compile' });
3837
4914
  }
3838
- const preparation = await prepareAnalyticalExecutionSql({
4915
+ const app = loadRuntimeApp(projectRoot, activePersonaAppId());
4916
+ const sourceDomain = metadata.domain ?? source.match(/\bdomain\s*=\s*"([^"]+)"/i)?.[1];
4917
+ assertAppAccess({ app, domain: sourceDomain ?? app?.domain, level: 'execute' });
4918
+ const pinnedSemanticCompile = semanticCompose?.sql
4919
+ ? compiledSemanticQueries.get(executionFingerprint(semanticCompose.sql))
4920
+ : undefined;
4921
+ const semanticExecutionHolder = { value: null };
4922
+ const execution = await analyticalExecutionService.execute({
3839
4923
  sql: semanticCompose?.sql ?? plan.sql,
3840
4924
  subject: 'DQL artifact query',
3841
- executor,
3842
4925
  connection: activeConnection,
3843
- projectRoot,
3844
- projectConfig,
3845
4926
  // Preserve the existing contract: previewed Ask artifacts are explicitly
3846
4927
  // read-only and bounded; saved/manual DQL execution keeps its compiler-
3847
4928
  // governed behavior when no preview bound was requested.
3848
4929
  enforceReadOnly: invocationInput?.rowLimit !== undefined,
3849
4930
  rowLimit: invocationInput?.rowLimit,
4931
+ sqlParams: plan?.sqlParams,
4932
+ variables: { ...(plan?.variables ?? {}), ...invocation.values },
4933
+ executePrepared: async (preparation) => {
4934
+ if (pinnedSemanticCompile) {
4935
+ const semanticExecution = await executeTargetBoundSemanticQuery({
4936
+ executor,
4937
+ connection: activeConnection,
4938
+ projectRoot,
4939
+ plannedAdapter: pinnedSemanticCompile.engine,
4940
+ metricFlow: pinnedSemanticCompile.engine === 'metricflow-cli'
4941
+ ? resolveMetricFlowTargetMetadata(projectRoot, projectConfig)
4942
+ : undefined,
4943
+ compile: async () => pinnedSemanticCompile,
4944
+ prepareSql: () => ({ sql: preparation.executedSql, connection: preparation.connection }),
4945
+ rowBound: invocationInput?.rowLimit ?? pinnedSemanticCompile.effectiveRequest.limit,
4946
+ });
4947
+ if (semanticExecution) {
4948
+ semanticExecutionHolder.value = semanticExecution;
4949
+ return semanticExecution.result;
4950
+ }
4951
+ }
4952
+ return executor.executeQuery(preparation.executedSql, plan?.sqlParams ?? [], runtimeVariables({ ...(plan?.variables ?? {}), ...invocation.values }), preparation.connection);
4953
+ },
3850
4954
  });
3851
- const prepared = { sql: preparation.preparedSql, connection: preparation.connection };
4955
+ const semanticExecution = semanticExecutionHolder.value;
4956
+ const { preparation, result: normalized } = execution;
3852
4957
  const executionSql = preparation.executedSql;
3853
- const app = loadRuntimeApp(projectRoot, activePersonaAppId());
3854
- const sourceDomain = metadata.domain ?? source.match(/\bdomain\s*=\s*"([^"]+)"/i)?.[1];
3855
- assertAppAccess({ app, domain: sourceDomain ?? app?.domain, level: 'execute' });
3856
- const pinnedSemanticCompile = semanticCompose?.sql
3857
- ? compiledSemanticQueries.get(executionFingerprint(semanticCompose.sql))
3858
- : undefined;
3859
- const semanticExecution = pinnedSemanticCompile
3860
- ? await executeTargetBoundSemanticQuery({
3861
- executor,
3862
- connection: activeConnection,
3863
- projectRoot,
3864
- plannedAdapter: pinnedSemanticCompile.engine,
3865
- metricFlow: pinnedSemanticCompile.engine === 'metricflow-cli'
3866
- ? resolveMetricFlowTargetMetadata(projectRoot, projectConfig)
3867
- : undefined,
3868
- compile: async () => pinnedSemanticCompile,
3869
- prepareSql: () => ({ sql: executionSql, connection: prepared.connection }),
3870
- rowBound: invocationInput?.rowLimit ?? pinnedSemanticCompile.effectiveRequest.limit,
3871
- })
3872
- : null;
3873
- const rawResult = semanticExecution?.result ?? await executor.executeQuery(executionSql, plan?.sqlParams ?? [], runtimeVariables({ ...(plan?.variables ?? {}), ...invocation.values }), prepared.connection);
3874
- const normalized = normalizeQueryResult(rawResult);
3875
4958
  const executionReceipt = createDqlArtifactExecutionReceipt(source, executionSql, invocation.values, normalized);
4959
+ const executableArtifact = {
4960
+ version: 1,
4961
+ kind: precompiledSemanticQuery || semanticCompose?.sql ? 'semantic_block' : 'sql_block',
4962
+ dqlFingerprint: executionFingerprint(source),
4963
+ sourceFingerprint: executionFingerprint(stableExecutionValue({
4964
+ name: metadata.name ?? null,
4965
+ path: metadata.path ?? null,
4966
+ domain: sourceDomain ?? null,
4967
+ })),
4968
+ compiledSqlFingerprint: executionFingerprint(semanticCompose?.sql ?? plan?.sql ?? preparation.sourceSql),
4969
+ normalizedSqlFingerprint: executionFingerprint(preparation.decodedSql),
4970
+ parameterFingerprint: executionReceipt.parameterFingerprint,
4971
+ provenanceFingerprint: executionFingerprint(stableExecutionValue(invocation.resolvedParameters.map((parameter) => ({
4972
+ name: parameter.name,
4973
+ source: parameter.source,
4974
+ })))),
4975
+ targetFingerprint: targetGenerationFingerprint(activeConnection, executionConnectionName),
4976
+ snapshotFingerprint: executionFingerprint(projectSnapshot().snapshotId),
4977
+ planFingerprint: executionFingerprint(stableExecutionValue({
4978
+ sqlFingerprint: executionFingerprint(semanticCompose?.sql ?? plan?.sql ?? preparation.sourceSql),
4979
+ parameterCount: plan?.sqlParams?.length ?? 0,
4980
+ variableNames: Object.keys(plan?.variables ?? {}).sort(),
4981
+ chartConfig: plan?.chartConfig ?? null,
4982
+ })),
4983
+ semanticAdapter: pinnedSemanticCompile?.engine ?? 'native',
4984
+ previewPolicy: invocationInput?.rowLimit === undefined
4985
+ ? { mode: 'compiler_governed' }
4986
+ : { mode: 'read_only_bounded', rowLimit: invocationInput.rowLimit },
4987
+ trustState: 'review_required',
4988
+ receipt: executionReceipt,
4989
+ };
3876
4990
  return {
3877
4991
  columns: normalized.columns,
3878
4992
  rows: normalized.rows,
@@ -3885,6 +4999,7 @@ export async function startLocalServer(opts) {
3885
4999
  parameters: invocation.resolvedParameters,
3886
5000
  auditId: invocation.auditId,
3887
5001
  executionReceipt,
5002
+ executableArtifact,
3888
5003
  ...(semanticExecution ? {
3889
5004
  semanticExecutionReceipt: semanticExecution.executionReceipt,
3890
5005
  semanticTargetBinding: semanticExecution.targetBinding,
@@ -3919,6 +5034,9 @@ export async function startLocalServer(opts) {
3919
5034
  trustState: 'certified',
3920
5035
  compiledSql: result.sql,
3921
5036
  executionReceipt: result.executionReceipt,
5037
+ ...(result.executableArtifact ? {
5038
+ executableArtifact: { ...result.executableArtifact, kind: 'certified_block', trustState: 'certified' },
5039
+ } : {}),
3922
5040
  ...(invocationInput?.rowLimit ? { limit: invocationInput.rowLimit } : {}),
3923
5041
  ...(invocationInput?.parameters && Object.keys(invocationInput.parameters).length > 0
3924
5042
  ? { parameterValues: invocationInput.parameters }
@@ -3960,6 +5078,7 @@ export async function startLocalServer(opts) {
3960
5078
  ...(Object.keys(invocation.values).length > 0 ? { parameterValues: invocation.values } : {}),
3961
5079
  compiledSql: result.sql,
3962
5080
  executionReceipt: result.executionReceipt,
5081
+ ...(result.executableArtifact ? { executableArtifact: result.executableArtifact } : {}),
3963
5082
  },
3964
5083
  };
3965
5084
  };
@@ -4036,7 +5155,7 @@ export async function startLocalServer(opts) {
4036
5155
  * string, so the only remaining reasons to differ are the connection and the
4037
5156
  * governance gates, both of which report themselves honestly.
4038
5157
  */
4039
- const executeGeneratedSqlDirect = async (question, sql, seed, executionConnection, bindings) => {
5158
+ const executeGeneratedSqlDirect = async (question, sql, seed, executionConnection, bindings, executionConnectionName) => {
4040
5159
  const activeConnection = requireActiveConnection(executionConnection);
4041
5160
  const rowBound = clampAnalyticalRowBound(seed?.limit ?? 200);
4042
5161
  const trimmed = sql.trim().replace(/;\s*$/, '').trim();
@@ -4058,18 +5177,6 @@ export async function startLocalServer(opts) {
4058
5177
  if (semantic.unresolvedRefs.length > 0) {
4059
5178
  throw analyticalError(`Unknown semantic reference${semantic.unresolvedRefs.length > 1 ? 's' : ''}: ${semantic.unresolvedRefs.join(', ')}`, { origin: 'dql_compilation', stage: 'compile', code: 'semantic_ref' });
4060
5179
  }
4061
- const preparation = await prepareAnalyticalExecutionSql({
4062
- sql: semantic.sql,
4063
- subject: 'Generated SQL',
4064
- executor,
4065
- connection: activeConnection,
4066
- projectRoot,
4067
- projectConfig,
4068
- enforceReadOnly: true,
4069
- rowLimit: rowBound,
4070
- });
4071
- const prepared = { sql: preparation.preparedSql, connection: preparation.connection };
4072
- const bounded = preparation.rowBound;
4073
5180
  const app = loadRuntimeApp(projectRoot, activePersonaAppId());
4074
5181
  // Generated SQL is governed as 'uncategorized' unless its seed declares a
4075
5182
  // domain — the same value `generatedSqlArtifactContract` stamps into the
@@ -4080,25 +5187,43 @@ export async function startLocalServer(opts) {
4080
5187
  assertAppAccess({ app, domain: sourceDomain, level: 'execute' });
4081
5188
  // A semantic query the loop already compiled (MetricFlow / dbt Cloud) must
4082
5189
  // execute through its pinned target binding, not as loose SQL.
4083
- const pinnedSemanticCompile = compiledSemanticQueries.get(executionFingerprint(prepared.sql))
4084
- ?? compiledSemanticQueries.get(executionFingerprint(trimmed));
4085
- const semanticExecution = pinnedSemanticCompile
4086
- ? await executeTargetBoundSemanticQuery({
4087
- executor,
4088
- connection: activeConnection,
4089
- projectRoot,
4090
- plannedAdapter: pinnedSemanticCompile.engine,
4091
- metricFlow: pinnedSemanticCompile.engine === 'metricflow-cli'
4092
- ? resolveMetricFlowTargetMetadata(projectRoot, projectConfig)
4093
- : undefined,
4094
- compile: async () => pinnedSemanticCompile,
4095
- prepareSql: () => ({ sql: bounded.sql, connection: prepared.connection }),
4096
- rowBound,
4097
- })
4098
- : null;
4099
- const rawResult = semanticExecution?.result
4100
- ?? await executor.executeQuery(preparation.executedSql, bindings?.sqlParams ?? [], runtimeVariables(bindings?.variables ?? {}), preparation.connection);
4101
- const normalized = normalizeQueryResult(rawResult, semantic.semanticRefs);
5190
+ const semanticExecutionHolder = { value: null };
5191
+ const execution = await analyticalExecutionService.execute({
5192
+ sql: semantic.sql,
5193
+ subject: 'Generated SQL',
5194
+ connection: activeConnection,
5195
+ enforceReadOnly: true,
5196
+ rowLimit: rowBound,
5197
+ sqlParams: bindings?.sqlParams,
5198
+ variables: bindings?.variables,
5199
+ semanticRefs: semantic.semanticRefs,
5200
+ executePrepared: async (preparation) => {
5201
+ const pinnedSemanticCompile = compiledSemanticQueries.get(executionFingerprint(preparation.preparedSql))
5202
+ ?? compiledSemanticQueries.get(executionFingerprint(trimmed));
5203
+ if (pinnedSemanticCompile) {
5204
+ const semanticExecution = await executeTargetBoundSemanticQuery({
5205
+ executor,
5206
+ connection: activeConnection,
5207
+ projectRoot,
5208
+ plannedAdapter: pinnedSemanticCompile.engine,
5209
+ metricFlow: pinnedSemanticCompile.engine === 'metricflow-cli'
5210
+ ? resolveMetricFlowTargetMetadata(projectRoot, projectConfig)
5211
+ : undefined,
5212
+ compile: async () => pinnedSemanticCompile,
5213
+ prepareSql: () => ({ sql: preparation.executedSql, connection: preparation.connection }),
5214
+ rowBound,
5215
+ });
5216
+ if (semanticExecution) {
5217
+ semanticExecutionHolder.value = semanticExecution;
5218
+ return semanticExecution.result;
5219
+ }
5220
+ }
5221
+ return executor.executeQuery(preparation.executedSql, bindings?.sqlParams ?? [], runtimeVariables(bindings?.variables ?? {}), preparation.connection);
5222
+ },
5223
+ });
5224
+ const semanticExecution = semanticExecutionHolder.value;
5225
+ const { preparation, result: normalized } = execution;
5226
+ const bounded = preparation.rowBound;
4102
5227
  // The authoritative bound. `buildRowBoundedSql` may legitimately decline to
4103
5228
  // touch the statement, and only one of 15 drivers honours a maxRows hint, so
4104
5229
  // the host is the only layer correctness may depend on.
@@ -4106,6 +5231,38 @@ export async function startLocalServer(opts) {
4106
5231
  const rows = truncated ? normalized.rows.slice(0, rowBound) : normalized.rows;
4107
5232
  const warnings = [];
4108
5233
  const artifact = buildVerifiedGeneratedArtifact(question, trimmed, bounded.sql, seed, warnings, normalized.columns, activeConnection.driver);
5234
+ const receiptSource = artifact?.source ?? trimmed;
5235
+ const executionBindings = bindings && ((bindings.sqlParams?.length ?? 0) > 0 || Object.keys(bindings.variables ?? {}).length > 0)
5236
+ ? { sqlParams: bindings.sqlParams ?? [], variables: bindings.variables ?? {} }
5237
+ : {};
5238
+ const executionReceipt = createDqlArtifactExecutionReceipt(receiptSource, bounded.sql, executionBindings, { columns: normalized.columns, rows, rowCount: rows.length });
5239
+ const executableArtifact = {
5240
+ version: 1,
5241
+ kind: 'sql_block',
5242
+ dqlFingerprint: executionFingerprint(receiptSource),
5243
+ sourceFingerprint: executionFingerprint(stableExecutionValue({
5244
+ name: artifact?.name ?? seed?.name ?? null,
5245
+ path: artifact?.sourcePath ?? seed?.sourcePath ?? null,
5246
+ })),
5247
+ compiledSqlFingerprint: executionFingerprint(semantic.sql),
5248
+ normalizedSqlFingerprint: executionFingerprint(preparation.decodedSql),
5249
+ parameterFingerprint: executionReceipt.parameterFingerprint,
5250
+ provenanceFingerprint: executionFingerprint(stableExecutionValue({
5251
+ sqlParamCount: bindings?.sqlParams?.length ?? 0,
5252
+ variableNames: Object.keys(bindings?.variables ?? {}).sort(),
5253
+ })),
5254
+ targetFingerprint: targetGenerationFingerprint(activeConnection, executionConnectionName),
5255
+ snapshotFingerprint: executionFingerprint(projectSnapshot().snapshotId),
5256
+ planFingerprint: executionFingerprint(stableExecutionValue({
5257
+ compiledSqlFingerprint: executionFingerprint(semantic.sql),
5258
+ executedSqlFingerprint: executionFingerprint(bounded.sql),
5259
+ rowBound,
5260
+ })),
5261
+ semanticAdapter: semanticExecution?.targetBinding.adapterId ?? 'native',
5262
+ previewPolicy: { mode: 'read_only_bounded', rowLimit: rowBound },
5263
+ trustState: 'review_required',
5264
+ receipt: executionReceipt,
5265
+ };
4109
5266
  return {
4110
5267
  columns: normalized.columns,
4111
5268
  rows,
@@ -4113,9 +5270,10 @@ export async function startLocalServer(opts) {
4113
5270
  executionTime: normalized.executionTime,
4114
5271
  sql: bounded.sql,
4115
5272
  ...(truncated ? { truncated: true } : {}),
4116
- ...(artifact ? { dqlArtifact: { ...artifact, limit: rowBound } } : {}),
5273
+ ...(artifact ? { dqlArtifact: { ...artifact, limit: rowBound, executionReceipt, executableArtifact } } : {}),
4117
5274
  ...(warnings.length > 0 ? { validationWarnings: warnings } : {}),
4118
- executionReceipt: createDqlArtifactExecutionReceipt(artifact?.source ?? trimmed, bounded.sql, {}, { columns: normalized.columns, rows, rowCount: rows.length }),
5275
+ executionReceipt,
5276
+ executableArtifact,
4119
5277
  };
4120
5278
  };
4121
5279
  const executeGeneratedArtifactForAgent = async (question, sql, seed, executionConnection, executionConnectionName) => {
@@ -4125,7 +5283,7 @@ export async function startLocalServer(opts) {
4125
5283
  if (seed && seed.kind !== 'sql_block') {
4126
5284
  return executeArtifactReferenceForAgent({ ...seed, limit: seed.limit ?? 200 }, question, executionConnection, executionConnectionName);
4127
5285
  }
4128
- return executeGeneratedSqlDirect(question, sql, seed, executionConnection);
5286
+ return executeGeneratedSqlDirect(question, sql, seed, executionConnection, undefined, executionConnectionName);
4129
5287
  };
4130
5288
  /**
4131
5289
  * EXP-001: execution host for the deliberately narrow non-governed lane.
@@ -4432,12 +5590,33 @@ export async function startLocalServer(opts) {
4432
5590
  * conversation, writes a DQL file, changes targets, or promotes trust.
4433
5591
  */
4434
5592
  const executeBoundedSqlRepair = async (input) => {
5593
+ let sourceSql = input.sourceSql;
5594
+ let rewrites = [];
5595
+ if (internalRelationIdsInSql(sourceSql).length > 0) {
5596
+ const normalizedSource = await prepareAnalyticalExecutionSql({
5597
+ sql: sourceSql,
5598
+ subject: 'Repair source SQL',
5599
+ executor,
5600
+ connection: input.targetConnection,
5601
+ projectRoot,
5602
+ projectConfig,
5603
+ enforceReadOnly: true,
5604
+ });
5605
+ sourceSql = normalizedSource.decodedSql;
5606
+ rewrites = normalizedSource.rewrites;
5607
+ }
5608
+ const sourceSignature = buildSqlAnalyticalSignature(sourceSql, input.targetConnection.driver);
5609
+ if (!sourceSignature) {
5610
+ throw boundedRepairError('DQL could not prove an immutable analytical signature for the source SQL. Repair it manually.', 409);
5611
+ }
4435
5612
  let result;
4436
- let repairedSql = input.sourceSql;
5613
+ let repairedSql = sourceSql;
4437
5614
  let repairMode = 'deterministic';
4438
5615
  let firstFailure;
5616
+ const providerEgressReceipts = [];
5617
+ let providerRoundTrips = 0;
4439
5618
  try {
4440
- result = await executeGeneratedSqlDirect(input.question, input.sourceSql, undefined, input.targetConnection, { sqlParams: input.sqlParams, variables: input.variables });
5619
+ result = await executeGeneratedSqlDirect(input.question, sourceSql, undefined, input.targetConnection, { sqlParams: input.sqlParams, variables: input.variables }, input.targetConnectionName);
4441
5620
  }
4442
5621
  catch (error) {
4443
5622
  firstFailure = normalizeWarehouseSqlFailure(error, input.targetConnection.driver);
@@ -4458,50 +5637,89 @@ export async function startLocalServer(opts) {
4458
5637
  const failure = normalizeWarehouseSqlFailure(error, input.targetConnection.driver);
4459
5638
  throw boundedRepairError(failure.redactedMessage, 422, failure);
4460
5639
  }
4461
- const boundedSchema = schemaContext.slice(0, 16).map((table) => ({
5640
+ const boundedSchema = markProviderMetadataArray(schemaContext.slice(0, 16).map((table) => ({
4462
5641
  relation: table.relation,
4463
- columns: table.columns.slice(0, 40).map((column) => ({ name: column.name, type: column.type })),
4464
- }));
5642
+ columns: markProviderMetadataArray(table.columns.slice(0, 40).map((column) => ({ name: column.name, type: column.type }))),
5643
+ })));
4465
5644
  // CTX-008: a repair that re-derives joins from column names can undo a
4466
5645
  // join the original query got right. Give it the authored key pairs.
4467
- const governedJoins = governedJoinFactsForRelations(schemaContext.slice(0, 16).map((table) => table.relation));
5646
+ const governedJoins = markProviderMetadataArray(governedJoinFactsForRelations(schemaContext.slice(0, 16).map((table) => table.relation)));
5647
+ const repairPayload = {
5648
+ question: input.question,
5649
+ driver: input.targetConnection.driver,
5650
+ failure: firstFailure.redactedMessage,
5651
+ sql: sourceSql,
5652
+ parameters: input.sqlParams ? markProviderMetadataArray(input.sqlParams.map((parameter) => ({
5653
+ name: parameter.name,
5654
+ placeholder: `$${parameter.position}`,
5655
+ }))) : undefined,
5656
+ schema: boundedSchema,
5657
+ ...(governedJoins.length ? { governedJoins } : {}),
5658
+ };
5659
+ const repairMessages = [
5660
+ {
5661
+ role: 'system',
5662
+ content: [
5663
+ 'Repair one failed read-only analytical SQL statement.',
5664
+ 'Return exactly one JSON object in a ```json fence with keys summary, sql, viz, outputs.',
5665
+ 'Preserve the requested grain, filters, output columns, and aggregation semantics.',
5666
+ input.sqlParams?.length
5667
+ ? 'Preserve every positional parameter placeholder exactly; do not add, remove, or renumber placeholders.'
5668
+ : '',
5669
+ 'Use only relations and columns present in the supplied schema context.',
5670
+ 'When governedJoins is present, join on those exact key pairs rather than inferring a join from column names.',
5671
+ 'Never emit internal DQL relation identities such as source::, model::, seed::, or snapshot::.',
5672
+ 'Do not add writes, DDL, multiple statements, or commentary outside the JSON fence.',
5673
+ ].join(' '),
5674
+ },
5675
+ {
5676
+ role: 'user',
5677
+ content: JSON.stringify(repairPayload),
5678
+ },
5679
+ ];
5680
+ const repairEnvelope = prepareProviderWireEnvelopeForDispatch(provider.name, { messages: repairMessages });
5681
+ assertProviderPayloadAllowed(repairEnvelope, {
5682
+ allowResultRows: false,
5683
+ maxResultRows: 0,
5684
+ purpose: 'repair_sql',
5685
+ });
4468
5686
  let raw;
4469
5687
  try {
4470
- raw = await provider.generate([
4471
- {
4472
- role: 'system',
4473
- content: [
4474
- 'Repair one failed read-only analytical SQL statement.',
4475
- 'Return exactly one JSON object in a ```json fence with keys summary, sql, viz, outputs.',
4476
- 'Preserve the requested grain, filters, output columns, and aggregation semantics.',
4477
- input.sqlParams?.length
4478
- ? 'Preserve every positional parameter placeholder exactly; do not add, remove, or renumber placeholders.'
4479
- : '',
4480
- 'Use only relations and columns present in the supplied schema context.',
4481
- 'When governedJoins is present, join on those exact key pairs rather than inferring a join from column names.',
4482
- 'Never emit internal DQL relation identities such as source::, model::, seed::, or snapshot::.',
4483
- 'Do not add writes, DDL, multiple statements, or commentary outside the JSON fence.',
4484
- ].join(' '),
4485
- },
4486
- {
4487
- role: 'user',
4488
- content: JSON.stringify({
4489
- question: input.question,
4490
- driver: input.targetConnection.driver,
4491
- failure: firstFailure.redactedMessage,
4492
- sql: input.sourceSql,
4493
- parameters: input.sqlParams?.map((parameter) => ({
4494
- name: parameter.name,
4495
- placeholder: `$${parameter.position}`,
4496
- })),
4497
- schema: boundedSchema,
4498
- ...(governedJoins.length ? { governedJoins } : {}),
4499
- }),
5688
+ raw = await provider.generate(repairEnvelope.messages, {
5689
+ maxTokens: 1200,
5690
+ temperature: 0,
5691
+ maxProviderDispatches: 2,
5692
+ onProviderDispatch: (event) => {
5693
+ const envelope = prepareProviderWireEnvelopeForDispatch(provider.name, event.envelope);
5694
+ assertProviderPayloadAllowed(envelope, {
5695
+ allowResultRows: false,
5696
+ maxResultRows: 0,
5697
+ purpose: 'repair_sql',
5698
+ });
5699
+ providerRoundTrips += 1;
5700
+ providerEgressReceipts.push(createProviderDispatchEgressReceipt({
5701
+ purpose: 'repair_sql',
5702
+ dispatchPhase: 'repair',
5703
+ provider: provider.name,
5704
+ permittedCategories: ['instructions', 'question', 'schema_metadata', 'governed_context'],
5705
+ optIn: false,
5706
+ envelope,
5707
+ }));
5708
+ return envelope;
4500
5709
  },
4501
- ], { maxTokens: 1200, temperature: 0 });
5710
+ });
4502
5711
  }
4503
5712
  catch {
4504
- throw boundedRepairError('The AI provider could not complete this repair. Check Provider Settings and retry.', 502);
5713
+ throw Object.assign(boundedRepairError('The AI provider could not complete this repair. Check Provider Settings and retry.', 502), {
5714
+ providerDispatchEvidence: {
5715
+ providerEgressReceipts: [...providerEgressReceipts],
5716
+ providerRoundTrips,
5717
+ toolCalls: 0,
5718
+ sqlExecutions: 0,
5719
+ repairs: 1,
5720
+ fallbackReason: 'repair_provider_error',
5721
+ },
5722
+ });
4505
5723
  }
4506
5724
  const proposal = parseProposal(raw);
4507
5725
  if (!proposal.sql?.trim()) {
@@ -4512,9 +5730,17 @@ export async function startLocalServer(opts) {
4512
5730
  if (requiredPlaceholders.some((placeholder) => !new RegExp(`${placeholder.replace('$', '\\$')}\\b`).test(repairedSql))) {
4513
5731
  throw boundedRepairError('The repair changed this cell\'s parameter contract, so DQL stopped it before execution.', 422);
4514
5732
  }
5733
+ const repairedSignature = buildSqlAnalyticalSignature(repairedSql, input.targetConnection.driver);
5734
+ if (!repairedSignature || stableExecutionValue(repairedSignature) !== stableExecutionValue(sourceSignature)) {
5735
+ throw boundedRepairError('The provider repair changed or could not prove the immutable analytical plan, so DQL stopped before execution.', 409);
5736
+ }
5737
+ const aggregationProof = buildAggregationSafetyProof(repairedSql, undefined, input.targetConnection.driver);
5738
+ if (aggregationProof.status !== 'safe') {
5739
+ throw boundedRepairError(`The repaired SQL lacks complete aggregation-safety evidence (${aggregationProof.issueCodes.join(', ')}). Repair it manually.`, 409);
5740
+ }
4515
5741
  repairMode = 'ai';
4516
5742
  try {
4517
- result = await executeGeneratedSqlDirect(input.question, repairedSql, undefined, input.targetConnection, { sqlParams: input.sqlParams, variables: input.variables });
5743
+ result = await executeGeneratedSqlDirect(input.question, repairedSql, undefined, input.targetConnection, { sqlParams: input.sqlParams, variables: input.variables }, input.targetConnectionName);
4518
5744
  }
4519
5745
  catch (error) {
4520
5746
  const failure = normalizeWarehouseSqlFailure(error, input.targetConnection.driver);
@@ -4524,7 +5750,6 @@ export async function startLocalServer(opts) {
4524
5750
  // Keep preview-only row bounds out of editable notebook source. The one
4525
5751
  // deterministic source rewrite we do persist is decoding internal graph
4526
5752
  // identities, because those can never execute as warehouse SQL.
4527
- let rewrites = [];
4528
5753
  if (internalRelationIdsInSql(repairedSql).length > 0) {
4529
5754
  const normalizedSource = await prepareAnalyticalExecutionSql({
4530
5755
  sql: repairedSql,
@@ -4536,9 +5761,9 @@ export async function startLocalServer(opts) {
4536
5761
  enforceReadOnly: true,
4537
5762
  });
4538
5763
  repairedSql = normalizedSource.decodedSql;
4539
- rewrites = normalizedSource.rewrites;
5764
+ rewrites.push(...normalizedSource.rewrites);
4540
5765
  }
4541
- return { result, repairedSql, repairMode, firstFailure, rewrites };
5766
+ return { result, repairedSql, repairMode, firstFailure, providerEgressReceipts, providerRoundTrips, rewrites };
4542
5767
  };
4543
5768
  /**
4544
5769
  * Repair a DQL wrapper that cannot compile far enough to expose executable
@@ -4551,6 +5776,8 @@ export async function startLocalServer(opts) {
4551
5776
  if (!provider) {
4552
5777
  throw boundedRepairError('Configure an available AI provider in Settings to repair this DQL draft.', 409);
4553
5778
  }
5779
+ const providerEgressReceipts = [];
5780
+ let providerRoundTrips = 0;
4554
5781
  let schemaContext;
4555
5782
  try {
4556
5783
  // Include source identifiers in retrieval so a terse cell title still
@@ -4596,10 +5823,41 @@ export async function startLocalServer(opts) {
4596
5823
  ...(governedJoins.length ? { governedJoins } : {}),
4597
5824
  }),
4598
5825
  },
4599
- ], { maxTokens: 2400, temperature: 0 });
5826
+ ], {
5827
+ maxTokens: 2400,
5828
+ temperature: 0,
5829
+ maxProviderDispatches: 2,
5830
+ onProviderDispatch: (event) => {
5831
+ const envelope = prepareProviderWireEnvelopeForDispatch(provider.name, event.envelope);
5832
+ assertProviderPayloadAllowed(envelope, {
5833
+ allowResultRows: false,
5834
+ maxResultRows: 0,
5835
+ purpose: 'repair_sql',
5836
+ });
5837
+ providerRoundTrips += 1;
5838
+ providerEgressReceipts.push(createProviderDispatchEgressReceipt({
5839
+ purpose: 'repair_sql',
5840
+ dispatchPhase: 'repair',
5841
+ provider: provider.name,
5842
+ permittedCategories: ['instructions', 'question', 'schema_metadata', 'governed_context'],
5843
+ optIn: false,
5844
+ envelope,
5845
+ }));
5846
+ return envelope;
5847
+ },
5848
+ });
4600
5849
  }
4601
5850
  catch {
4602
- throw boundedRepairError('The AI provider could not complete this DQL repair. Check Provider Settings and retry.', 502);
5851
+ throw Object.assign(boundedRepairError('The AI provider could not complete this DQL repair. Check Provider Settings and retry.', 502), {
5852
+ providerDispatchEvidence: {
5853
+ providerEgressReceipts: [...providerEgressReceipts],
5854
+ providerRoundTrips,
5855
+ toolCalls: 0,
5856
+ sqlExecutions: 0,
5857
+ repairs: 1,
5858
+ fallbackReason: 'repair_provider_error',
5859
+ },
5860
+ });
4603
5861
  }
4604
5862
  let repairedSource;
4605
5863
  try {
@@ -4703,6 +5961,8 @@ export async function startLocalServer(opts) {
4703
5961
  repairedSql: plan.sql,
4704
5962
  repairedSource,
4705
5963
  repairMode: 'ai',
5964
+ providerEgressReceipts,
5965
+ providerRoundTrips,
4706
5966
  rewrites: [],
4707
5967
  sqlParams: plan.sqlParams,
4708
5968
  variables: { ...plan.variables, ...invocation.values },
@@ -5238,6 +6498,19 @@ export async function startLocalServer(opts) {
5238
6498
  : notebookResearchString(governedAnswer?.answer)
5239
6499
  ?? notebookResearchString(governedAnswer?.text)
5240
6500
  ?? notebookResearchSummary(question, resultPreview, previewError);
6501
+ const previewRecord = agentRunRecord(resultPreview);
6502
+ const executionReceipt = normalizeAnalyticalExecutionReceipt(previewRecord?.executionReceipt);
6503
+ // Do not treat a child run ID as execution evidence. The canonical
6504
+ // fingerprint is the only proof that this Research branch produced a
6505
+ // result; planning, SQL text, and a durable run record remain review
6506
+ // required until that proof exists (AGT-016/033).
6507
+ const executionProof = normalizeAnalyticalExecutionFingerprint(previewRecord?.resultFingerprint)
6508
+ ?? executionReceipt?.resultFingerprint;
6509
+ const executionUnavailable = !previewError && !executionProof;
6510
+ const terminalError = previewError
6511
+ ?? (executionUnavailable
6512
+ ? 'Research did not produce an executed result or execution receipt; the branch remains review-required.'
6513
+ : undefined);
5241
6514
  const recommendation = previewError
5242
6515
  ? 'Review the SQL, selected metadata, and connection context before rerunning.'
5243
6516
  : dqlArtifact && !reviewedSql
@@ -5262,8 +6535,14 @@ export async function startLocalServer(opts) {
5262
6535
  question,
5263
6536
  intent,
5264
6537
  context,
5265
- status: previewError ? 'error' : 'ready',
5266
- summary,
6538
+ // A plan, generated SQL, or DQL artifact is not an observed execution.
6539
+ // Only a result carrying an execution fingerprint/receipt may become
6540
+ // `ready`; otherwise persist a failed review state with no fabricated
6541
+ // evidence (AGT-016/033).
6542
+ status: terminalError ? 'error' : 'ready',
6543
+ summary: terminalError && executionUnavailable
6544
+ ? 'Research branch stopped without an executed result; no observation was recorded.'
6545
+ : summary,
5267
6546
  recommendation,
5268
6547
  resultPreview,
5269
6548
  evidence,
@@ -5283,7 +6562,7 @@ export async function startLocalServer(opts) {
5283
6562
  ...(display && display.ok ? display.warnings : []),
5284
6563
  ],
5285
6564
  reviewStatus: 'needs_review',
5286
- error: previewError,
6565
+ error: terminalError,
5287
6566
  lastRunAt: startedAt,
5288
6567
  }) ?? run;
5289
6568
  }
@@ -5701,11 +6980,18 @@ export async function startLocalServer(opts) {
5701
6980
  tableMapping,
5702
6981
  semanticSql: semanticCompose?.sql ?? undefined,
5703
6982
  });
5704
- const prepared = prepareLocalExecution(semanticCompose?.sql ?? plan?.sql ?? executableSql, activeConnection, projectRoot, projectConfig);
5705
- const result = await executor.executeQuery(prepared.sql, plan?.sqlParams ?? [], runtimeVariables({ ...(plan?.variables ?? {}), ...invocation.values }), prepared.connection);
6983
+ const execution = await analyticalExecutionService.execute({
6984
+ sql: semanticCompose?.sql ?? plan?.sql ?? executableSql,
6985
+ subject: 'Block Studio preview query',
6986
+ connection: activeConnection,
6987
+ sqlParams: plan?.sqlParams,
6988
+ variables: { ...(plan?.variables ?? {}), ...invocation.values },
6989
+ });
5706
6990
  return {
5707
- sql: prepared.sql,
5708
- result: normalizeQueryResult(result),
6991
+ sql: execution.preparation.executedSql,
6992
+ result: execution.result,
6993
+ compiledSqlFingerprint: execution.compiledSqlFingerprint,
6994
+ resultFingerprint: execution.resultFingerprint,
5709
6995
  chartConfig: plan?.chartConfig ?? validation.chartConfig ?? null,
5710
6996
  invocation,
5711
6997
  tests: plan?.tests ?? [],
@@ -5756,12 +7042,14 @@ export async function startLocalServer(opts) {
5756
7042
  if (tests.length === 0) {
5757
7043
  return { passed: 0, failed: 0, skipped: 0, duration: Date.now() - start, assertions: [], runAt: new Date() };
5758
7044
  }
5759
- const prepared = prepareLocalExecution(semanticCompose?.sql ?? plan.sql, activeConnection, projectRoot, projectConfig);
5760
- const rawResult = await executor.executeQuery(prepared.sql, plan.sqlParams ?? [], runtimeVariables(plan.variables ?? {}), prepared.connection);
5761
- const rows = Array.isArray(rawResult?.rows) ? rawResult.rows : [];
5762
- const columns = Array.isArray(rawResult?.columns)
5763
- ? rawResult.columns.map((column) => typeof column === 'string' ? column : column?.name ?? String(column))
5764
- : [];
7045
+ const execution = await analyticalExecutionService.execute({
7046
+ sql: semanticCompose?.sql ?? plan.sql,
7047
+ subject: 'Block Studio certification test query',
7048
+ connection: activeConnection,
7049
+ sqlParams: plan.sqlParams,
7050
+ variables: plan.variables,
7051
+ });
7052
+ const { rows, columns } = execution.result;
5765
7053
  return evaluateBlockStudioTests(tests, rows, columns, start);
5766
7054
  };
5767
7055
  const saveDqlGenerationDraft = (importId, candidate) => saveDqlGenerationDraftForProject(projectRoot, importId, candidate);
@@ -6101,7 +7389,12 @@ export async function startLocalServer(opts) {
6101
7389
  return;
6102
7390
  }
6103
7391
  if (operationMatch && req.method === 'DELETE') {
6104
- const operation = operationCoordinator.cancel(decodeURIComponent(operationMatch[1]));
7392
+ const operationId = decodeURIComponent(operationMatch[1]);
7393
+ const existingOperation = operationCoordinator.get(operationId);
7394
+ if (existingOperation?.type === 'agent_run' && existingOperation.scope.startsWith('agent-run:')) {
7395
+ cancelActiveAgentRun(existingOperation.scope.slice('agent-run:'.length));
7396
+ }
7397
+ const operation = operationCoordinator.cancel(operationId);
6105
7398
  res.writeHead(operation ? 200 : 404, { 'Content-Type': 'application/json; charset=utf-8' });
6106
7399
  res.end(serializeJSON(operation ?? { error: 'Operation not found.' }));
6107
7400
  return;
@@ -7214,12 +8507,17 @@ export async function startLocalServer(opts) {
7214
8507
  const limit = Number.isFinite(rawLimit) && rawLimit > 0
7215
8508
  ? Math.min(200, Math.floor(rawLimit))
7216
8509
  : 50;
7217
- const runs = agentRunStore
7218
- .list()
8510
+ const stored = agentRunStore.list();
8511
+ // This is an INDEX payload: one row per run, no answer bodies. Shipping
8512
+ // stored runs whole meant a measured 47.61 MB for 20 real runs.
8513
+ // `GET /api/agent-runs/:id` still serves the complete immutable record.
8514
+ const runs = stored
8515
+ .slice()
7219
8516
  .sort((a, b) => b.startedAt.localeCompare(a.startedAt))
7220
- .slice(0, limit);
8517
+ .slice(0, limit)
8518
+ .map(agentRunListEntryForTransport);
7221
8519
  res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
7222
- res.end(serializeJSON({ runs, total: agentRunStore.list().length, limit }));
8520
+ res.end(serializeJSON({ runs, total: stored.length, limit }));
7223
8521
  return;
7224
8522
  }
7225
8523
  /**
@@ -7243,23 +8541,81 @@ export async function startLocalServer(opts) {
7243
8541
  res.end(serializeJSON({ error: 'Agent run not found.' }));
7244
8542
  return;
7245
8543
  }
8544
+ const capability = normalizeAnalyticalRepairCapabilityV1(sourceRun.repairCapability);
8545
+ if (!capability?.automatic.eligible || capability.automatic.attemptsRemaining < 1) {
8546
+ const manualActions = capability?.manualActions?.length
8547
+ ? capability.manualActions
8548
+ : ['edit_dql', 'open_sql_notebook'];
8549
+ res.writeHead(409, { 'Content-Type': 'application/json; charset=utf-8' });
8550
+ res.end(serializeJSON({
8551
+ code: 'REPAIR_CAPABILITY_REQUIRED',
8552
+ error: 'This run does not retain eligible automatic execution-repair authority.',
8553
+ manualActions,
8554
+ ...(capability?.ineligibilityReason ? { ineligibilityReason: capability.ineligibilityReason } : {}),
8555
+ }));
8556
+ return;
8557
+ }
8558
+ // Rebind every server-issued capability identity to the currently
8559
+ // persisted immutable source run before checking idempotence or reserving
8560
+ // an attempt. A copied/stale capability must never authorize provider or
8561
+ // SQL work against a mutated plan, failure, or source artifact.
8562
+ const canonicalCapability = analyticalRepairCapabilityForAgentRun(sourceRun, capability.targetFingerprint);
8563
+ const bindingMatches = canonicalCapability
8564
+ && canonicalCapability.automatic.eligible
8565
+ && canonicalCapability.planFingerprint === capability.planFingerprint
8566
+ && canonicalCapability.failureFingerprint === capability.failureFingerprint
8567
+ && canonicalCapability.sourceFingerprint === capability.sourceFingerprint;
8568
+ if (!bindingMatches) {
8569
+ res.writeHead(409, { 'Content-Type': 'application/json; charset=utf-8' });
8570
+ res.end(serializeJSON({
8571
+ code: 'REPAIR_CAPABILITY_REQUIRED',
8572
+ error: 'The persisted source plan, failure, or artifact no longer matches its server-issued repair capability.',
8573
+ manualActions: capability.manualActions,
8574
+ }));
8575
+ return;
8576
+ }
7246
8577
  const derivedRunId = `${sourceRun.id}:repair:1`;
8578
+ if (activeAnalyticalRepairReservations.has(derivedRunId)) {
8579
+ res.writeHead(409, { 'Content-Type': 'application/json; charset=utf-8' });
8580
+ res.end(serializeJSON({
8581
+ code: 'REPAIR_IN_PROGRESS',
8582
+ error: 'The single permitted repair attempt is already in progress.',
8583
+ manualActions: capability.manualActions,
8584
+ }));
8585
+ return;
8586
+ }
7247
8587
  const existing = await agentRunStore.get(derivedRunId);
7248
8588
  if (existing) {
7249
- res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
7250
- res.end(serializeJSON({ run: existing }));
8589
+ const succeeded = existing.status === 'needs_review' && existing.trustState === 'review_required';
8590
+ res.writeHead(succeeded ? 200 : 409, { 'Content-Type': 'application/json; charset=utf-8' });
8591
+ res.end(serializeJSON(succeeded
8592
+ ? { run: existing }
8593
+ : {
8594
+ code: 'REPAIR_ATTEMPT_EXHAUSTED',
8595
+ error: 'The single permitted repair attempt was already consumed and did not complete successfully.',
8596
+ manualActions: capability.manualActions,
8597
+ run: existing,
8598
+ }));
7251
8599
  return;
7252
8600
  }
7253
- const sourceSql = repairableSqlFromAgentRun(sourceRun);
7254
8601
  const sourceDqlArtifact = repairableDqlArtifactFromAgentRun(sourceRun);
7255
8602
  const retainedFailure = warehouseFailureFromAgentRun(sourceRun);
7256
- if ((!sourceSql && !sourceDqlArtifact) || !analyticalFailureAllowsDeterministicRetry(retainedFailure)) {
8603
+ if (!sourceDqlArtifact || !validAskRepairDqlWrapper(sourceDqlArtifact.source)) {
8604
+ res.writeHead(409, { 'Content-Type': 'application/json; charset=utf-8' });
8605
+ res.end(serializeJSON({
8606
+ code: 'REPAIR_CAPABILITY_REQUIRED',
8607
+ error: 'The retained DQL wrapper is unavailable or invalid. Keep it editable and repair it manually.',
8608
+ manualActions: capability.manualActions,
8609
+ }));
8610
+ return;
8611
+ }
8612
+ const capabilityHash = (value) => `sha256:${createHash('sha256').update(stableExecutionValue(value)).digest('hex')}`;
8613
+ if (capability.dqlFingerprint !== capabilityHash(sourceDqlArtifact.source.trim())) {
7257
8614
  res.writeHead(409, { 'Content-Type': 'application/json; charset=utf-8' });
7258
8615
  res.end(serializeJSON({
7259
- error: sourceSql || sourceDqlArtifact
7260
- ? 'This failure requires authorized access or a policy change and cannot be repaired by AI.'
7261
- : 'This run has no retained SQL statement or editable DQL artifact to repair.',
7262
- ...(retainedFailure ? { warehouseFailure: retainedFailure } : {}),
8616
+ code: 'REPAIR_CAPABILITY_REQUIRED',
8617
+ error: 'The retained DQL source no longer matches its immutable repair capability.',
8618
+ manualActions: capability.manualActions,
7263
8619
  }));
7264
8620
  return;
7265
8621
  }
@@ -7282,32 +8638,250 @@ export async function startLocalServer(opts) {
7282
8638
  : sourceRun.executionTarget?.target === 'local'
7283
8639
  ? 'local'
7284
8640
  : projectConfig.defaultConnectionName;
8641
+ const resolvedTargetFingerprint = targetGenerationFingerprint(targetConnection, targetConnectionName);
8642
+ if (resolvedTargetFingerprint !== capability.targetFingerprint) {
8643
+ res.writeHead(409, { 'Content-Type': 'application/json; charset=utf-8' });
8644
+ res.end(serializeJSON({
8645
+ code: 'REPAIR_TARGET_DRIFT',
8646
+ error: 'The named execution target now resolves to a different connection generation. Automatic repair was not attempted.',
8647
+ manualActions: capability.manualActions,
8648
+ }));
8649
+ return;
8650
+ }
8651
+ if (consumedAnalyticalRepairCapabilities.has(derivedRunId)) {
8652
+ res.writeHead(409, { 'Content-Type': 'application/json; charset=utf-8' });
8653
+ res.end(serializeJSON({
8654
+ code: 'REPAIR_ATTEMPT_EXHAUSTED',
8655
+ error: 'The single permitted repair attempt has already been consumed.',
8656
+ manualActions: capability.manualActions,
8657
+ }));
8658
+ return;
8659
+ }
8660
+ activeAnalyticalRepairReservations.add(derivedRunId);
8661
+ consumedAnalyticalRepairCapabilities.add(derivedRunId);
8662
+ const releaseRepairReservation = () => { activeAnalyticalRepairReservations.delete(derivedRunId); };
8663
+ res.once('finish', releaseRepairReservation);
8664
+ res.once('close', releaseRepairReservation);
8665
+ const repairStartedAt = new Date().toISOString();
8666
+ const repairStartedAtMs = Date.now();
8667
+ const sourceFailureForReservation = analyticalFailedRunFromAgentRun(sourceRun)?.failure;
8668
+ const reservationEvent = {
8669
+ id: `${derivedRunId}:repair`,
8670
+ runId: derivedRunId,
8671
+ type: 'repair.attempted',
8672
+ at: repairStartedAt,
8673
+ message: 'Reserved the single bounded repair attempt.',
8674
+ route: 'generated_answer',
8675
+ };
8676
+ const reservedRun = {
8677
+ id: derivedRunId,
8678
+ question: sourceRun.question,
8679
+ requestedMode: sourceRun.requestedMode,
8680
+ route: 'generated_answer',
8681
+ status: 'blocked',
8682
+ trustState: 'blocked',
8683
+ stopReason: 'blocked',
8684
+ startedAt: repairStartedAt,
8685
+ completedAt: repairStartedAt,
8686
+ selectedObject: sourceRun.selectedObject,
8687
+ executionTarget: sourceRun.executionTarget,
8688
+ routeDecision: sourceRun.routeDecision,
8689
+ plan: sourceRun.plan,
8690
+ steps: [],
8691
+ summary: 'The single repair attempt was reserved but has not completed successfully.',
8692
+ artifacts: [],
8693
+ evaluations: [],
8694
+ events: [reservationEvent],
8695
+ nextActions: sourceRun.nextActions,
8696
+ repairAttempts: 1,
8697
+ lifecycle: {
8698
+ version: 1,
8699
+ state: 'terminal',
8700
+ phase: 'repair.attempted',
8701
+ revision: 1,
8702
+ eventCursor: 1,
8703
+ startedAt: repairStartedAt,
8704
+ updatedAt: repairStartedAt,
8705
+ completedAt: repairStartedAt,
8706
+ },
8707
+ derivation: {
8708
+ version: 1,
8709
+ kind: 'analytical_repair',
8710
+ sourceRunId: sourceRun.id,
8711
+ ...(sourceFailureForReservation?.failureId ? { sourceFailureId: sourceFailureForReservation.failureId } : {}),
8712
+ attempt: 1,
8713
+ },
8714
+ };
8715
+ try {
8716
+ await agentRunStore.save(reservedRun);
8717
+ }
8718
+ catch (error) {
8719
+ releaseRepairReservation();
8720
+ throw error;
8721
+ }
7285
8722
  let repaired;
7286
8723
  try {
7287
- // Prefer the editable DQL contract even when the failed run also
7288
- // retained compiled SQL. This keeps parameters, visualization, tests,
7289
- // and the corrected wrapper attached to the repaired Ask result.
7290
- repaired = sourceDqlArtifact
7291
- ? await executeBoundedDqlRepair({
7292
- question: sourceRun.question,
7293
- source: sourceDqlArtifact.source,
7294
- failure: retainedFailure?.redactedMessage
7295
- ?? sourceRun.diagnosticReceipt?.failure?.message
7296
- ?? sourceRun.summary,
7297
- targetConnection,
7298
- targetConnectionName,
7299
- parameters: sourceDqlArtifact.parameterValues,
7300
- artifactName: sourceDqlArtifact.name,
7301
- })
7302
- : await executeBoundedSqlRepair({
7303
- question: sourceRun.question,
7304
- sourceSql: sourceSql,
7305
- targetConnection,
7306
- targetConnectionName,
7307
- });
8724
+ // Compile the immutable original wrapper first. A provider receives only
8725
+ // this embedded SQL, never the DQL wrapper or its surrounding fields.
8726
+ const invocation = prepareBlockInvocation({
8727
+ source: sourceDqlArtifact.source,
8728
+ parameters: sourceDqlArtifact.parameterValues,
8729
+ question: sourceRun.question,
8730
+ surface: 'ask_ai',
8731
+ });
8732
+ if (invocation.errors.length || invocation.unresolvedParameters.length) {
8733
+ throw boundedRepairError(invocation.errors.join(' ') || `Provide required parameters before retrying: ${invocation.unresolvedParameters.join(', ')}.`, 409);
8734
+ }
8735
+ const originalPlan = buildExecutionPlan({
8736
+ id: 'ask-execution-repair-source',
8737
+ type: 'dql',
8738
+ source: sourceDqlArtifact.source,
8739
+ title: sourceDqlArtifact.name,
8740
+ }, {
8741
+ semanticLayer,
8742
+ driver: targetConnection.driver,
8743
+ parameters: invocation.values,
8744
+ });
8745
+ if (!originalPlan?.sql?.trim())
8746
+ throw boundedRepairError('The original DQL wrapper did not compile to SQL.', 409);
8747
+ if (capability.sqlFingerprint !== capabilityHash(originalPlan.sql.trim())) {
8748
+ throw boundedRepairError('The original compiled SQL no longer matches its immutable repair capability.', 409);
8749
+ }
8750
+ const sqlRepair = await executeBoundedSqlRepair({
8751
+ question: sourceRun.question,
8752
+ sourceSql: originalPlan.sql,
8753
+ targetConnection,
8754
+ targetConnectionName,
8755
+ sqlParams: originalPlan.sqlParams,
8756
+ variables: { ...originalPlan.variables, ...invocation.values },
8757
+ });
8758
+ const embeddedSql = restoreNotebookDqlParameterInterpolations(sqlRepair.repairedSql, originalPlan.sqlParams);
8759
+ const repairedSource = replaceNotebookDqlQueryForRepair(sourceDqlArtifact.source, embeddedSql);
8760
+ if (!repairedSource)
8761
+ throw boundedRepairError('DQL could not reinsert the repaired SQL into the immutable wrapper.', 422);
8762
+ const repairedInvocation = prepareBlockInvocation({
8763
+ source: repairedSource,
8764
+ parameters: sourceDqlArtifact.parameterValues,
8765
+ question: sourceRun.question,
8766
+ surface: 'ask_ai',
8767
+ });
8768
+ if (repairedInvocation.errors.length || repairedInvocation.unresolvedParameters.length) {
8769
+ throw boundedRepairError('The SQL-only repair did not preserve a compilable DQL wrapper.', 422);
8770
+ }
8771
+ const repairedPlan = buildExecutionPlan({
8772
+ id: 'ask-execution-repair-derived',
8773
+ type: 'dql',
8774
+ source: repairedSource,
8775
+ title: sourceDqlArtifact.name,
8776
+ }, {
8777
+ semanticLayer,
8778
+ driver: targetConnection.driver,
8779
+ parameters: repairedInvocation.values,
8780
+ });
8781
+ if (!repairedPlan?.sql?.trim() || normalizeSqlForComparison(repairedPlan.sql) !== normalizeSqlForComparison(sqlRepair.repairedSql)) {
8782
+ throw boundedRepairError('The repaired wrapper recompiled to different SQL, so DQL stopped before publishing it.', 422);
8783
+ }
8784
+ const repairedExecutionReceipt = createDqlArtifactExecutionReceipt(repairedSource, sqlRepair.result.sql ?? repairedPlan.sql, repairedInvocation.values, sqlRepair.result);
8785
+ const repairedExecutableArtifact = sqlRepair.result.executableArtifact
8786
+ ? {
8787
+ ...sqlRepair.result.executableArtifact,
8788
+ kind: 'sql_block',
8789
+ dqlFingerprint: executionFingerprint(repairedSource),
8790
+ sourceFingerprint: executionFingerprint(stableExecutionValue({
8791
+ name: sourceDqlArtifact.name ?? null,
8792
+ path: sourceDqlArtifact.sourcePath ?? null,
8793
+ })),
8794
+ compiledSqlFingerprint: executionFingerprint(repairedPlan.sql),
8795
+ normalizedSqlFingerprint: executionFingerprint(sqlRepair.repairedSql),
8796
+ parameterFingerprint: repairedExecutionReceipt.parameterFingerprint,
8797
+ provenanceFingerprint: executionFingerprint(stableExecutionValue(repairedInvocation.resolvedParameters.map((parameter) => ({
8798
+ name: parameter.name,
8799
+ source: parameter.source,
8800
+ })))),
8801
+ planFingerprint: executionFingerprint(stableExecutionValue({
8802
+ compiledSqlFingerprint: executionFingerprint(repairedPlan.sql),
8803
+ parameterCount: repairedPlan.sqlParams?.length ?? 0,
8804
+ variableNames: Object.keys(repairedPlan.variables ?? {}).sort(),
8805
+ })),
8806
+ trustState: 'review_required',
8807
+ receipt: repairedExecutionReceipt,
8808
+ }
8809
+ : undefined;
8810
+ const repairedArtifact = {
8811
+ ...sourceDqlArtifact,
8812
+ source: repairedSource,
8813
+ compiledSql: repairedPlan.sql,
8814
+ trustState: 'review_required',
8815
+ persistence: 'transient',
8816
+ executionReceipt: repairedExecutionReceipt,
8817
+ ...(repairedExecutableArtifact ? { executableArtifact: repairedExecutableArtifact } : {}),
8818
+ ...(repairedInvocation.parameters.length ? { parameters: repairedInvocation.parameters } : {}),
8819
+ ...(Object.keys(repairedInvocation.values).length ? { parameterValues: repairedInvocation.values } : {}),
8820
+ };
8821
+ repaired = {
8822
+ ...sqlRepair,
8823
+ result: {
8824
+ ...sqlRepair.result,
8825
+ executionReceipt: repairedExecutionReceipt,
8826
+ ...(repairedExecutableArtifact ? { executableArtifact: repairedExecutableArtifact } : {}),
8827
+ dqlArtifact: repairedArtifact,
8828
+ },
8829
+ repairedSql: repairedPlan.sql,
8830
+ repairedSource,
8831
+ sqlParams: repairedPlan.sqlParams,
8832
+ variables: { ...repairedPlan.variables, ...repairedInvocation.values },
8833
+ };
7308
8834
  }
7309
8835
  catch (error) {
7310
8836
  const repairError = error;
8837
+ const dispatchEvidence = providerDispatchTerminalEvidence(error);
8838
+ const failedAt = new Date().toISOString();
8839
+ const failedTelemetry = {
8840
+ version: 1,
8841
+ stageDurationsMs: { repair: Date.now() - repairStartedAtMs, total: Date.now() - repairStartedAtMs },
8842
+ providerRoundTrips: dispatchEvidence?.providerRoundTrips ?? 0,
8843
+ toolCalls: dispatchEvidence?.toolCalls ?? 0,
8844
+ sqlExecutions: dispatchEvidence?.sqlExecutions ?? 0,
8845
+ repairs: Math.max(1, dispatchEvidence?.repairs ?? 0),
8846
+ egressReceipts: dispatchEvidence?.providerEgressReceipts.length ?? 0,
8847
+ fallbackReason: dispatchEvidence?.fallbackReason ?? 'repair_failed',
8848
+ };
8849
+ const failedReceipts = dispatchEvidence?.providerEgressReceipts ?? [];
8850
+ await agentRunStore.save({
8851
+ ...reservedRun,
8852
+ completedAt: failedAt,
8853
+ summary: repairError.message,
8854
+ lifecycle: reservedRun.lifecycle ? {
8855
+ ...reservedRun.lifecycle,
8856
+ phase: 'run.failed',
8857
+ updatedAt: failedAt,
8858
+ completedAt: failedAt,
8859
+ } : undefined,
8860
+ events: [
8861
+ reservationEvent,
8862
+ {
8863
+ id: `${derivedRunId}:failed`,
8864
+ runId: derivedRunId,
8865
+ type: 'run.failed',
8866
+ at: failedAt,
8867
+ message: repairError.message,
8868
+ route: 'generated_answer',
8869
+ status: 'blocked',
8870
+ trustState: 'blocked',
8871
+ },
8872
+ ],
8873
+ telemetry: failedTelemetry,
8874
+ ...(failedReceipts.length ? { providerEgressReceipts: failedReceipts } : {}),
8875
+ diagnosticReceiptV2: {
8876
+ version: 2,
8877
+ runId: derivedRunId,
8878
+ route: 'generated_answer',
8879
+ status: 'blocked',
8880
+ telemetry: failedTelemetry,
8881
+ providerEgressReceiptFingerprints: failedReceipts.map((receipt) => executionFingerprint(stableExecutionValue(receipt))),
8882
+ repairCapabilityFingerprint: executionFingerprint(stableExecutionValue(capability)),
8883
+ },
8884
+ });
7311
8885
  res.writeHead(repairError.status ?? 500, { 'Content-Type': 'application/json; charset=utf-8' });
7312
8886
  res.end(serializeJSON({
7313
8887
  error: repairError.message,
@@ -7315,27 +8889,39 @@ export async function startLocalServer(opts) {
7315
8889
  }));
7316
8890
  return;
7317
8891
  }
7318
- const { result, repairedSql, repairMode, firstFailure } = repaired;
8892
+ const { result, repairedSql, repairMode, firstFailure, providerEgressReceipts, providerRoundTrips } = repaired;
7319
8893
  const completedAt = new Date().toISOString();
7320
- const preview = agentResultToSynthesisPreview(result);
7321
- const synthesis = await synthesizeAnswer({
7322
- question: sourceRun.question,
7323
- audience: 'stakeholder',
7324
- resultPreview: preview,
7325
- sql: repairedSql,
7326
- draftText: `The repaired query returned ${result.rowCount} row${result.rowCount === 1 ? '' : 's'}.`,
7327
- ...(preview ? { columnFormats: agentColumnDisplayFormats(projectRoot, preview.columns) } : {}),
7328
- });
8894
+ // Repair never grants a second answer/narration call and never sends rows
8895
+ // to a provider. Present the validated contract deterministically.
8896
+ const presentationText = `The repaired query returned ${result.rowCount} row${result.rowCount === 1 ? '' : 's'}. Review the derived SQL before reuse.`;
7329
8897
  const sourceFailure = analyticalFailedRunFromAgentRun(sourceRun)?.failure;
7330
8898
  const presentationContext = repairPresentationContextFromAgentRun(sourceRun);
7331
8899
  const executionResultFingerprint = result.executionReceipt?.resultFingerprint;
8900
+ const repairTotalDurationMs = Date.now() - repairStartedAtMs;
8901
+ const repairTelemetry = {
8902
+ version: 1,
8903
+ stageDurationsMs: {
8904
+ retrieval: 0,
8905
+ meaning: 0,
8906
+ repair: repairTotalDurationMs,
8907
+ ...(typeof result.executionTime === 'number' ? { execution: result.executionTime } : {}),
8908
+ narration: 0,
8909
+ total: repairTotalDurationMs,
8910
+ },
8911
+ providerRoundTrips,
8912
+ toolCalls: 0,
8913
+ sqlExecutions: firstFailure ? 2 : 1,
8914
+ repairs: 1,
8915
+ egressReceipts: providerEgressReceipts.length,
8916
+ ...(typeof result.executionTime === 'number' ? { warehouseDurationMs: result.executionTime } : {}),
8917
+ };
7332
8918
  const artifact = agentRunArtifact('answer', 'Repaired analytical answer', {
7333
8919
  kind: 'uncertified',
7334
8920
  certification: 'analyst_review_required',
7335
8921
  reviewStatus: 'analyst_review_required',
7336
8922
  trustLabel: 'AI-generated · review required',
7337
- answer: synthesis.text,
7338
- text: synthesis.text,
8923
+ answer: presentationText,
8924
+ text: presentationText,
7339
8925
  ...presentationContext,
7340
8926
  result,
7341
8927
  proposedSql: repairedSql,
@@ -7356,10 +8942,11 @@ export async function startLocalServer(opts) {
7356
8942
  rowCount: result.rowCount,
7357
8943
  ...(executionResultFingerprint ? { resultFingerprint: executionResultFingerprint } : {}),
7358
8944
  },
8945
+ providerEgressReceipts,
7359
8946
  },
7360
8947
  previousAttempt: {
7361
8948
  sourceRunId: sourceRun.id,
7362
- ...(sourceSql ? { sql: sourceSql } : {}),
8949
+ sql: repairableSqlFromAgentRun(sourceRun),
7363
8950
  ...(sourceDqlArtifact?.source ? { dql: sourceDqlArtifact.source } : {}),
7364
8951
  failure: retainedFailure ?? firstFailure,
7365
8952
  },
@@ -7386,7 +8973,7 @@ export async function startLocalServer(opts) {
7386
8973
  artifacts: [artifact],
7387
8974
  };
7388
8975
  const events = [
7389
- { id: `${derivedRunId}:repair`, runId: derivedRunId, type: 'repair.attempted', at: completedAt, message: 'Started one bounded query repair.', route: 'generated_answer' },
8976
+ { id: `${derivedRunId}:repair`, runId: derivedRunId, type: 'repair.attempted', at: repairStartedAt, message: 'Started one bounded query repair.', route: 'generated_answer' },
7390
8977
  { id: `${derivedRunId}:artifact`, runId: derivedRunId, type: 'artifact.created', at: completedAt, message: 'Created the repaired result artifact.', route: 'generated_answer', trustState: 'review_required' },
7391
8978
  { id: `${derivedRunId}:complete`, runId: derivedRunId, type: 'run.completed', at: completedAt, message: 'The repaired query executed successfully.', route: 'generated_answer', status: 'needs_review', trustState: 'review_required' },
7392
8979
  ];
@@ -7398,15 +8985,15 @@ export async function startLocalServer(opts) {
7398
8985
  status: 'needs_review',
7399
8986
  trustState: 'review_required',
7400
8987
  stopReason: 'generated_review_required',
7401
- startedAt: completedAt,
8988
+ startedAt: repairStartedAt,
7402
8989
  completedAt,
7403
8990
  selectedObject: sourceRun.selectedObject,
7404
8991
  executionTarget: sourceRun.executionTarget,
7405
8992
  routeDecision: sourceRun.routeDecision,
7406
8993
  plan: sourceRun.plan,
7407
8994
  steps: [repairStep],
7408
- summary: synthesis.text,
7409
- answer: synthesis.text,
8995
+ summary: presentationText,
8996
+ answer: presentationText,
7410
8997
  answerKind: 'governed',
7411
8998
  artifacts: [artifact],
7412
8999
  evaluations: [evaluation],
@@ -7419,7 +9006,7 @@ export async function startLocalServer(opts) {
7419
9006
  phase: 'run.completed',
7420
9007
  revision: 1,
7421
9008
  eventCursor: events.length,
7422
- startedAt: completedAt,
9009
+ startedAt: repairStartedAt,
7423
9010
  updatedAt: completedAt,
7424
9011
  completedAt,
7425
9012
  },
@@ -7432,7 +9019,19 @@ export async function startLocalServer(opts) {
7432
9019
  steps: [repairStep],
7433
9020
  artifacts: [artifact],
7434
9021
  evaluations: [evaluation],
9022
+ providerEgressReceipts,
7435
9023
  },
9024
+ diagnosticReceiptV2: {
9025
+ version: 2,
9026
+ runId: derivedRunId,
9027
+ route: 'generated_answer',
9028
+ status: 'needs_review',
9029
+ telemetry: repairTelemetry,
9030
+ providerEgressReceiptFingerprints: providerEgressReceipts.map((receipt) => executionFingerprint(stableExecutionValue(receipt))),
9031
+ repairCapabilityFingerprint: executionFingerprint(stableExecutionValue(capability)),
9032
+ },
9033
+ telemetry: repairTelemetry,
9034
+ providerEgressReceipts,
7436
9035
  derivation: {
7437
9036
  version: 1,
7438
9037
  kind: 'analytical_repair',
@@ -7456,6 +9055,14 @@ export async function startLocalServer(opts) {
7456
9055
  res.end(serializeJSON({ error: 'Agent run not found.' }));
7457
9056
  return;
7458
9057
  }
9058
+ if (run.status !== 'blocked' || run.stopReason !== 'blocked') {
9059
+ res.writeHead(409, { 'Content-Type': 'application/json; charset=utf-8' });
9060
+ res.end(serializeJSON({
9061
+ code: 'REPAIR_CAPABILITY_REQUIRED',
9062
+ error: 'Only a terminal blocked run with a blocked stop reason may derive an analytical repair.',
9063
+ }));
9064
+ return;
9065
+ }
7459
9066
  const source = analyticalFailedRunFromAgentRun(run);
7460
9067
  if (!source) {
7461
9068
  res.writeHead(409, { 'Content-Type': 'application/json; charset=utf-8' });
@@ -7494,25 +9101,11 @@ export async function startLocalServer(opts) {
7494
9101
  if (req.method === 'POST' && /^\/api\/agent-runs\/[^/]+\/cancel$/.test(path)) {
7495
9102
  const match = path.match(/^\/api\/agent-runs\/([^/]+)\/cancel$/);
7496
9103
  const id = decodeURIComponent(match?.[1] ?? '');
7497
- const controller = activeAgentRunControllers.get(id);
7498
- if (!controller) {
9104
+ if (!cancelActiveAgentRun(id)) {
7499
9105
  res.writeHead(404, { 'Content-Type': 'application/json; charset=utf-8' });
7500
9106
  res.end(serializeJSON({ ok: false, error: 'This run is no longer active.' }));
7501
9107
  return;
7502
9108
  }
7503
- const progress = agentRunStore.getProgress(id);
7504
- if (progress) {
7505
- agentRunStore.saveProgress({
7506
- ...progress,
7507
- lifecycle: {
7508
- ...progress.lifecycle,
7509
- state: 'cancelling',
7510
- revision: progress.lifecycle.revision + 1,
7511
- updatedAt: new Date().toISOString(),
7512
- },
7513
- });
7514
- }
7515
- controller.abort(new Error('Stopped by user.'));
7516
9109
  res.writeHead(202, { 'Content-Type': 'application/json; charset=utf-8' });
7517
9110
  res.end(serializeJSON({ ok: true, id }));
7518
9111
  return;
@@ -7578,21 +9171,59 @@ export async function startLocalServer(opts) {
7578
9171
  let runError;
7579
9172
  try {
7580
9173
  const executeRun = async (operationSignal, report) => {
7581
- parsed.request.signal = AbortSignal.any([
9174
+ const ingressSignal = AbortSignal.any([
7582
9175
  runController.signal,
7583
9176
  ...(operationSignal ? [operationSignal] : []),
7584
- AbortSignal.timeout(agentRunDeadlineMs(parsed.request, process.env, getActiveProvider(projectRoot))),
7585
9177
  ]);
9178
+ parsed.request.runBudget = createAgentRunBudget({
9179
+ requestedMode: parsed.request.requestedMode,
9180
+ inheritedSignal: ingressSignal,
9181
+ });
9182
+ parsed.request.signal = parsed.request.runBudget.hardSignal;
9183
+ // Ordinary Ask needs room to think, write, and recover: meaning (1)
9184
+ // + planning/generation (3) + narration (2) + one repair. The old
9185
+ // `total: 2` left nothing for narration once generation had run,
9186
+ // which is why the answer arrived as a deterministic fact-join.
9187
+ const runProviderEvidence = new RunScopedProviderDispatchEvidence(parsed.request.requestedMode === 'research'
9188
+ ? { total: 14, meaningResolution: 1, generationGroup: 11, narration: 2, repair: 1 }
9189
+ : { total: 6, meaningResolution: 1, generationGroup: 3, narration: 2, repair: 1 }, parsed.request.runBudget, resolveProviderResultRowEgressPolicy({
9190
+ projectSetting: projectConfig?.agent?.providerResultRowEgress,
9191
+ researchOptIn: parsed.request.requestedMode === 'research'
9192
+ && parsed.request.researchResultRowsOptIn === true,
9193
+ }));
7586
9194
  report?.({ phase: 'reasoning', progress: 8, message: 'Grounding the request and selecting the governed route.' });
7587
9195
  try {
7588
- const run = await agentRunEngine.run(parsed.request, (event) => {
9196
+ const engineRun = await agentRunProviderEvidenceContext.run(runProviderEvidence, () => agentRunEngine.run(parsed.request, (event) => {
9197
+ runProviderEvidence.setRoute(event.route);
7589
9198
  if (wantsStream)
7590
9199
  writeStream('agent-run-event', event);
7591
9200
  report?.({ phase: event.type, progress: 45, message: event.message });
7592
9201
  }, (delta) => {
7593
9202
  if (wantsStream)
7594
9203
  writeStream('agent-run-answer-delta', { runId: parsed.request.runId, delta });
7595
- });
9204
+ }));
9205
+ const rawRun = mergeRunScopedProviderDispatchEvidence(engineRun, runProviderEvidence.snapshot(engineRun.telemetry?.fallbackReason ?? engineRun.stopReason));
9206
+ let resolvedTargetFingerprint;
9207
+ try {
9208
+ const resolvedTarget = await resolveAgentRunExecutionConnection({
9209
+ question: rawRun.question,
9210
+ executionTarget: rawRun.executionTarget,
9211
+ });
9212
+ const resolvedTargetName = rawRun.executionTarget?.target === 'connection'
9213
+ ? rawRun.executionTarget.connectionName
9214
+ : rawRun.executionTarget?.target === 'local'
9215
+ ? 'local'
9216
+ : projectConfig.defaultConnectionName;
9217
+ resolvedTargetFingerprint = targetGenerationFingerprint(resolvedTarget, resolvedTargetName);
9218
+ }
9219
+ catch {
9220
+ // Missing target-generation evidence keeps automatic repair
9221
+ // ineligible; the failed run itself remains fully inspectable.
9222
+ }
9223
+ const run = attachAnalyticalRepairCapability(rawRun, resolvedTargetFingerprint);
9224
+ // The engine persists before the host can validate the DQL wrapper;
9225
+ // replace that row with the server-owned capability atomically.
9226
+ await agentRunStore.save(run);
7596
9227
  completedRun = run;
7597
9228
  recordConversationTurn(conversationStore, parsed.request.threadId, run);
7598
9229
  return { runId: run.id, threadId: parsed.request.threadId, status: run.status };
@@ -7619,7 +9250,10 @@ export async function startLocalServer(opts) {
7619
9250
  return;
7620
9251
  }
7621
9252
  res.writeHead(201, { 'Content-Type': 'application/json; charset=utf-8' });
7622
- res.end(serializeJSON({ run: completedRun }));
9253
+ // Same run, same reader as the stream above, so it ships the same
9254
+ // projection. Sending the stored record here instead made an ordinary
9255
+ // completed Ask a 4.66 MB reply.
9256
+ res.end(serializeJSON({ run: slimAgentRunForTransport(completedRun) }));
7623
9257
  }
7624
9258
  finally {
7625
9259
  if (runId)
@@ -9658,13 +11292,22 @@ export async function startLocalServer(opts) {
9658
11292
  });
9659
11293
  if (!composed)
9660
11294
  throw new Error('The governed semantic query could not be composed.');
9661
- const prepared = prepareLocalExecution(composed.sql, targetConnection, projectRoot, projectConfig);
9662
11295
  let normalizedResult;
9663
11296
  let repair;
9664
11297
  let executedSql = composed.sql;
11298
+ let executionFingerprints;
9665
11299
  try {
9666
- const result = await executor.executeQuery(prepared.sql, [], {}, prepared.connection);
9667
- normalizedResult = normalizeQueryResult(result);
11300
+ const execution = await analyticalExecutionService.execute({
11301
+ sql: composed.sql,
11302
+ subject: 'App tile semantic query',
11303
+ connection: targetConnection,
11304
+ });
11305
+ normalizedResult = execution.result;
11306
+ executedSql = execution.preparation.executedSql;
11307
+ executionFingerprints = {
11308
+ compiledSqlFingerprint: execution.compiledSqlFingerprint,
11309
+ resultFingerprint: execution.resultFingerprint,
11310
+ };
9668
11311
  }
9669
11312
  catch (executionError) {
9670
11313
  const repaired = await repairFailedAppTileExecution({
@@ -9706,6 +11349,7 @@ export async function startLocalServer(opts) {
9706
11349
  title: item.title ?? item.semantic.id,
9707
11350
  viz: item.viz,
9708
11351
  result: normalizedResult,
11352
+ ...(executionFingerprints ?? {}),
9709
11353
  trustState: 'review_required',
9710
11354
  citation: { kind: repair ? 'semantic_query_repaired' : 'semantic_query', name: item.semantic.id },
9711
11355
  ...(repair ? { repair } : {}),
@@ -9839,8 +11483,13 @@ export async function startLocalServer(opts) {
9839
11483
  tileId: item.i,
9840
11484
  tileFilterBindings: item.filterBindings,
9841
11485
  });
9842
- const prepared = prepareLocalExecution(blockFilterApplication.sql, blockTargetConnection, projectRoot, projectConfig);
9843
- const result = await executor.executeQuery(prepared.sql, blockFilterApplication.sqlParams, runtimeVariables({ ...blockFilterApplication.variables, ...blockInvocation.values }), prepared.connection);
11486
+ const execution = await analyticalExecutionService.execute({
11487
+ sql: blockFilterApplication.sql,
11488
+ subject: 'App tile block query',
11489
+ connection: blockTargetConnection,
11490
+ sqlParams: blockFilterApplication.sqlParams,
11491
+ variables: { ...blockFilterApplication.variables, ...blockInvocation.values },
11492
+ });
9844
11493
  tiles.push({
9845
11494
  tileId: item.i,
9846
11495
  status: 'ok',
@@ -9850,7 +11499,9 @@ export async function startLocalServer(opts) {
9850
11499
  title: item.title ?? block.name,
9851
11500
  viz: item.viz,
9852
11501
  chartConfig: mergeDashboardChartConfig(blockPlan?.chartConfig, item),
9853
- result: normalizeQueryResult(result),
11502
+ result: execution.result,
11503
+ compiledSqlFingerprint: execution.compiledSqlFingerprint,
11504
+ resultFingerprint: execution.resultFingerprint,
9854
11505
  filters: {
9855
11506
  applied: blockFilterApplication.appliedFilters,
9856
11507
  skipped: blockFilterApplication.skippedFilters,
@@ -14761,20 +16412,18 @@ export async function startLocalServer(opts) {
14761
16412
  }));
14762
16413
  return;
14763
16414
  }
14764
- const preparation = await prepareAnalyticalExecutionSql({
16415
+ const app = loadRuntimeApp(projectRoot, typeof body.appId === 'string' ? body.appId : activePersonaAppId());
16416
+ const domain = typeof body.domain === 'string' ? body.domain : app?.domain;
16417
+ assertAppAccess({ app, domain, level: 'execute' });
16418
+ const execution = await analyticalExecutionService.execute({
14765
16419
  sql: semantic.sql,
14766
16420
  subject: 'Notebook query',
14767
- executor,
14768
16421
  connection: executionConnection,
14769
- projectRoot,
14770
- projectConfig,
16422
+ sqlParams: Array.isArray(body.sqlParams) ? body.sqlParams : [],
16423
+ variables: body.variables && typeof body.variables === 'object' ? body.variables : {},
16424
+ semanticRefs: semantic.semanticRefs,
14771
16425
  });
14772
- const prepared = { sql: preparation.preparedSql, connection: preparation.connection };
14773
- const app = loadRuntimeApp(projectRoot, typeof body.appId === 'string' ? body.appId : activePersonaAppId());
14774
- const domain = typeof body.domain === 'string' ? body.domain : app?.domain;
14775
- assertAppAccess({ app, domain, level: 'execute' });
14776
- const result = await executor.executeQuery(prepared.sql, Array.isArray(body.sqlParams) ? body.sqlParams : [], runtimeVariables(body.variables && typeof body.variables === 'object' ? body.variables : {}), prepared.connection);
14777
- const normalized = normalizeQueryResult(result, semantic.semanticRefs);
16426
+ const { preparation, result: normalized } = execution;
14778
16427
  if (execContext?.notebookPath) {
14779
16428
  recordNotebookQueryRun(projectRoot, {
14780
16429
  notebookPath: execContext.notebookPath,
@@ -15448,7 +17097,7 @@ export async function startLocalServer(opts) {
15448
17097
  const invocation = prepareBlockInvocation({ source, surface: 'ask_ai' });
15449
17098
  if (invocation.errors.length > 0)
15450
17099
  throw new Error(invocation.errors.join(' '));
15451
- const { executionReceipt: _receipt, compiledSql: _compiled, ...unexecuted } = artifact;
17100
+ const { executionReceipt: _receipt, executableArtifact: _executable, compiledSql: _compiled, ...unexecuted } = artifact;
15452
17101
  res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
15453
17102
  res.end(serializeJSON({
15454
17103
  artifact: {
@@ -15564,6 +17213,13 @@ export async function startLocalServer(opts) {
15564
17213
  trustState: certified ? 'certified' : semantic ? 'governed' : 'review_required',
15565
17214
  compiledSql: result.sql,
15566
17215
  executionReceipt: result.executionReceipt,
17216
+ ...(result.executableArtifact ? {
17217
+ executableArtifact: {
17218
+ ...result.executableArtifact,
17219
+ kind: certified ? 'certified_block' : semantic ? 'semantic_block' : 'sql_block',
17220
+ trustState: certified ? 'certified' : semantic ? 'governed' : 'review_required',
17221
+ },
17222
+ } : {}),
15567
17223
  ...(requestedArtifact?.limit ? { limit: requestedArtifact.limit } : {}),
15568
17224
  },
15569
17225
  }));
@@ -15686,37 +17342,43 @@ export async function startLocalServer(opts) {
15686
17342
  res.end(serializeJSON({ cellType: cell.type, result: null }));
15687
17343
  return;
15688
17344
  }
15689
- const preparation = await prepareAnalyticalExecutionSql({
17345
+ const app = loadRuntimeApp(projectRoot, typeof body.appId === 'string' ? body.appId : activePersonaAppId());
17346
+ assertAppAccess({ app, domain: resolved.domain ?? app?.domain, level: 'execute' });
17347
+ const semanticExecutionHolder = { value: null };
17348
+ const execution = await analyticalExecutionService.execute({
15690
17349
  sql: executableSql,
15691
17350
  subject: cell.type === 'dql' ? 'DQL query' : 'Notebook query',
15692
- executor,
15693
17351
  connection: cellConnection,
15694
- projectRoot,
15695
- projectConfig,
17352
+ sqlParams: plan?.sqlParams,
17353
+ variables: { ...(plan?.variables ?? {}), ...(invocation?.values ?? {}) },
17354
+ executePrepared: semanticCompose?.compiled
17355
+ ? async (preparation) => {
17356
+ const semanticExecution = await executeTargetBoundSemanticQuery({
17357
+ executor,
17358
+ connection: cellConnection,
17359
+ projectRoot,
17360
+ plannedAdapter: semanticCompose.compiled.engine,
17361
+ metricFlow: semanticCompose.compiled.engine === 'metricflow-cli'
17362
+ ? resolveMetricFlowTargetMetadata(projectRoot, projectConfig)
17363
+ : undefined,
17364
+ // Compilation already produced the exact pinned adapter result
17365
+ // used by the plan. The gateway validates target identity before
17366
+ // accepting that result for preflight and execution.
17367
+ compile: async () => semanticCompose.compiled,
17368
+ prepareSql: () => ({ sql: preparation.executedSql, connection: preparation.connection }),
17369
+ rowBound: semanticCompose.compiled.effectiveRequest.limit,
17370
+ });
17371
+ if (semanticExecution) {
17372
+ semanticExecutionHolder.value = semanticExecution;
17373
+ return semanticExecution.result;
17374
+ }
17375
+ return executor.executeQuery(preparation.executedSql, plan?.sqlParams ?? [], runtimeVariables({ ...(plan?.variables ?? {}), ...(invocation?.values ?? {}) }), preparation.connection);
17376
+ }
17377
+ : undefined,
15696
17378
  });
17379
+ const { preparation, result: normalized } = execution;
15697
17380
  const decodedSql = preparation.decodedSql;
15698
- const prepared = { sql: preparation.preparedSql, connection: preparation.connection };
15699
- const app = loadRuntimeApp(projectRoot, typeof body.appId === 'string' ? body.appId : activePersonaAppId());
15700
- assertAppAccess({ app, domain: resolved.domain ?? app?.domain, level: 'execute' });
15701
- const semanticExecution = semanticCompose?.compiled
15702
- ? await executeTargetBoundSemanticQuery({
15703
- executor,
15704
- connection: cellConnection,
15705
- projectRoot,
15706
- plannedAdapter: semanticCompose.compiled.engine,
15707
- metricFlow: semanticCompose.compiled.engine === 'metricflow-cli'
15708
- ? resolveMetricFlowTargetMetadata(projectRoot, projectConfig)
15709
- : undefined,
15710
- // Compilation already produced the exact pinned adapter result
15711
- // used by the plan. The gateway validates target identity before
15712
- // accepting that result for preflight and execution.
15713
- compile: async () => semanticCompose.compiled,
15714
- prepareSql: () => ({ sql: prepared.sql, connection: prepared.connection }),
15715
- rowBound: semanticCompose.compiled.effectiveRequest.limit,
15716
- })
15717
- : null;
15718
- const rawResult = semanticExecution?.result ?? await executor.executeQuery(prepared.sql, plan?.sqlParams ?? [], runtimeVariables({ ...(plan?.variables ?? {}), ...(invocation?.values ?? {}) }), prepared.connection);
15719
- const normalized = normalizeQueryResult(rawResult);
17381
+ const semanticExecution = semanticExecutionHolder.value;
15720
17382
  // Enforce the block's declared invariants against the result set. This
15721
17383
  // is additive: blocks without invariants produce `null` and the
15722
17384
  // response is unchanged. The agent surface (`query_via_block`) reads
@@ -15967,6 +17629,26 @@ table: ${table}${tagList}
15967
17629
  });
15968
17630
  });
15969
17631
  }
17632
+ function providerDispatchTerminalEvidence(value) {
17633
+ if (!value || typeof value !== 'object')
17634
+ return undefined;
17635
+ const candidate = value.providerDispatchEvidence;
17636
+ if (!candidate || typeof candidate !== 'object')
17637
+ return undefined;
17638
+ const evidence = candidate;
17639
+ if (!Array.isArray(evidence.providerEgressReceipts)
17640
+ || !Number.isInteger(evidence.providerRoundTrips)
17641
+ || (evidence.providerRoundTrips ?? -1) < 0)
17642
+ return undefined;
17643
+ return {
17644
+ providerEgressReceipts: evidence.providerEgressReceipts,
17645
+ providerRoundTrips: evidence.providerRoundTrips,
17646
+ toolCalls: Number.isInteger(evidence.toolCalls) && (evidence.toolCalls ?? -1) >= 0 ? evidence.toolCalls : 0,
17647
+ sqlExecutions: Number.isInteger(evidence.sqlExecutions) && (evidence.sqlExecutions ?? -1) >= 0 ? evidence.sqlExecutions : 0,
17648
+ repairs: Number.isInteger(evidence.repairs) && (evidence.repairs ?? -1) >= 0 ? evidence.repairs : 0,
17649
+ fallbackReason: typeof evidence.fallbackReason === 'string' ? evidence.fallbackReason : 'provider_error',
17650
+ };
17651
+ }
15970
17652
  async function validateModelingRelationship(relationship, manifest, execute, quoteIdentifier = quoteSqlIdentifier) {
15971
17653
  const fromEntity = manifest.modeling?.entities[relationship.from];
15972
17654
  const toEntity = manifest.modeling?.entities[relationship.to];
@@ -16256,21 +17938,31 @@ function connectionDriverLabel(connection) {
16256
17938
  * The notebook SPA expects columns as string[] (just names).
16257
17939
  */
16258
17940
  function normalizeQueryResult(result, semanticRefs) {
16259
- const rawCols = Array.isArray(result?.columns) ? result.columns : [];
16260
- const columns = rawCols.map((c) => typeof c === 'string' ? c : typeof c?.name === 'string' ? c.name : String(c));
17941
+ const canonical = normalizeCanonicalQueryResult({
17942
+ columns: result?.columns,
17943
+ rows: result?.rows,
17944
+ rowCount: result?.rowCount,
17945
+ executionTime: result?.executionTime,
17946
+ executionTimeMs: result?.executionTimeMs,
17947
+ truncated: result?.truncated,
17948
+ resultFingerprint: result?.resultFingerprint,
17949
+ executionReceipt: result?.executionReceipt,
17950
+ trustState: result?.trustState,
17951
+ answerTier: result?.answerTier,
17952
+ });
16261
17953
  const rawRows = Array.isArray(result?.rows) ? result.rows : [];
16262
- const rows = rawRows.slice(0, NOTEBOOK_EXECUTE_PREVIEW_ROW_LIMIT);
17954
+ const rows = canonical.rows.slice(0, NOTEBOOK_EXECUTE_PREVIEW_ROW_LIMIT);
16263
17955
  const hasRefs = semanticRefs && (semanticRefs.metrics.length > 0 || semanticRefs.dimensions.length > 0);
16264
17956
  return {
16265
- columns,
17957
+ columns: canonical.columns,
16266
17958
  rows,
16267
- rowCount: typeof result?.rowCount === 'number' ? result.rowCount : rawRows.length,
16268
- executionTime: typeof result?.executionTimeMs === 'number'
16269
- ? result.executionTimeMs
16270
- : typeof result?.executionTime === 'number'
16271
- ? result.executionTime
16272
- : 0,
16273
- ...(rawRows.length > rows.length ? { truncated: true } : {}),
17959
+ rowCount: canonical.rowCount,
17960
+ resultFingerprint: canonical.resultFingerprint,
17961
+ executionTime: canonical.executionTime ?? 0,
17962
+ ...(rawRows.length > rows.length || canonical.truncated ? { truncated: true } : {}),
17963
+ ...(canonical.executionReceipt ? { executionReceipt: canonical.executionReceipt } : {}),
17964
+ ...(canonical.trustState ? { trustState: canonical.trustState } : {}),
17965
+ ...(canonical.answerTier ? { answerTier: canonical.answerTier } : {}),
16274
17966
  ...(hasRefs ? { semanticRefs } : {}),
16275
17967
  };
16276
17968
  }
@@ -18651,9 +20343,104 @@ function createDqlArtifactExecutionReceipt(source, compiledSql, parameters, resu
18651
20343
  })),
18652
20344
  };
18653
20345
  }
20346
+ function agentRunTelemetryForAnswer(answer, egressReceipts, totalDurationMs, narrationDurationMs, engineRepairAttempts) {
20347
+ const timings = new Map((answer.evidence?.timings ?? []).map((timing) => [timing.phase, timing.durationMs]));
20348
+ const toolCalls = answer.evidence?.toolCalls ?? [];
20349
+ const toolDurationMs = toolCalls.reduce((sum, call) => sum + Math.max(0, call.durationMs ?? 0), 0);
20350
+ const warehouseDurationMs = answer.evidence?.execution?.executionTime ?? answer.result?.executionTime;
20351
+ const counters = answer.evidence?.runtimeCounters;
20352
+ const stageDurationsMs = {
20353
+ ...(timings.has('project_state') ? { snapshot: timings.get('project_state') } : {}),
20354
+ ...(timings.has('context_retrieval') ? { retrieval: timings.get('context_retrieval') } : {}),
20355
+ ...(timings.has('runtime_schema') ? { schema: timings.get('runtime_schema') } : {}),
20356
+ ...(timings.has('source_search') ? { meaning: timings.get('source_search') } : {}),
20357
+ ...(timings.has('answer_resolution') ? { provider: timings.get('answer_resolution') } : {}),
20358
+ ...(toolCalls.length > 0 ? { tools: toolDurationMs } : {}),
20359
+ ...(typeof warehouseDurationMs === 'number' ? { execution: Math.max(0, warehouseDurationMs) } : {}),
20360
+ ...(narrationDurationMs > 0 ? { narration: narrationDurationMs } : {}),
20361
+ total: Math.max(0, totalDurationMs),
20362
+ };
20363
+ return {
20364
+ version: 1,
20365
+ stageDurationsMs,
20366
+ providerRoundTrips: counters?.providerRoundTrips ?? 0,
20367
+ toolCalls: counters?.toolCalls ?? toolCalls.length,
20368
+ sqlExecutions: counters?.sqlExecutions ?? 0,
20369
+ repairs: Math.max(engineRepairAttempts, counters?.repairs ?? 0),
20370
+ egressReceipts: egressReceipts.length,
20371
+ ...(typeof warehouseDurationMs === 'number' ? { warehouseDurationMs: Math.max(0, warehouseDurationMs) } : {}),
20372
+ ...(answer.refusalCode ? { fallbackReason: answer.refusalCode } : {}),
20373
+ };
20374
+ }
18654
20375
  function executionFingerprint(value) {
18655
20376
  return createHash('sha256').update(value).digest('hex');
18656
20377
  }
20378
+ function buildSemanticAggregationCompilerReceipt(input) {
20379
+ const plan = input.plan;
20380
+ const metricId = plan?.analyticalFrame?.metricConceptIds[0];
20381
+ if (input.metricNames.length !== 1 || !plan || !metricId || !plan.executionId
20382
+ || !plan.selectedCapabilityFingerprint || !input.targetFingerprint)
20383
+ return undefined;
20384
+ const metric = input.semanticLayer.getMetric(input.metricNames[0]);
20385
+ if (!metric || (metric.aggregation ?? metric.metricType)?.toLowerCase() !== 'ratio')
20386
+ return undefined;
20387
+ const refName = (value) => {
20388
+ if (typeof value === 'string' && value.trim())
20389
+ return value.trim();
20390
+ if (!value || typeof value !== 'object' || Array.isArray(value))
20391
+ return undefined;
20392
+ const name = value.name;
20393
+ return typeof name === 'string' && name.trim() ? name.trim() : undefined;
20394
+ };
20395
+ const numeratorName = refName(metric.typeParams?.numerator);
20396
+ const denominatorName = refName(metric.typeParams?.denominator);
20397
+ if (!numeratorName || !denominatorName || !metric.sql.trim())
20398
+ return undefined;
20399
+ const measures = [numeratorName, denominatorName].map((name) => {
20400
+ const matches = input.semanticLayer.listMeasures().filter((measure) => measure.name === name
20401
+ && (!metric.cube || !measure.cube || measure.cube === metric.cube));
20402
+ return matches.length === 1 ? matches[0] : undefined;
20403
+ });
20404
+ if (measures.some((measure) => !measure))
20405
+ return undefined;
20406
+ const relations = Array.from(new Set(measures.map((measure) => measure.table.trim()).filter(Boolean)));
20407
+ if (relations.length !== 1)
20408
+ return undefined;
20409
+ const outputAlias = metricId.split(/[:.]/).at(-1);
20410
+ if (!outputAlias)
20411
+ return undefined;
20412
+ const orderedMeasureIds = measures.map((measure) => `semantic:${measure.domain || 'uncategorized'}:measure:${measure.cube ? `${measure.cube}.` : ''}${measure.name}`);
20413
+ const compiledSqlFingerprint = semanticExecutionFingerprint(input.compiledSql);
20414
+ const compiledOutputExpression = buildSqlOutputExpressionSignature(input.compiledSql, outputAlias);
20415
+ if (!compiledOutputExpression)
20416
+ return undefined;
20417
+ const compiledExpressionFingerprint = semanticExecutionFingerprint(compiledOutputExpression.canonicalExpression);
20418
+ const compiledSignature = buildGeneratedAnalyticalSqlSignature(input.compiledSql);
20419
+ if (!compiledSignature)
20420
+ return undefined;
20421
+ const body = {
20422
+ version: 1,
20423
+ executionId: plan.executionId,
20424
+ capabilityFingerprint: plan.selectedCapabilityFingerprint,
20425
+ metricId,
20426
+ metricExpressionSql: metric.sql,
20427
+ operator: 'ratio',
20428
+ orderedMeasureIds,
20429
+ physicalRelation: relations[0],
20430
+ relationAliases: Array.from(new Set([metric.cube, ...measures.map((measure) => measure.cube)].filter((value) => Boolean(value)))),
20431
+ outputAlias,
20432
+ dimensionIds: [...new Set(plan.analyticalFrame?.dimensions.map((dimension) => dimension.dimensionId) ?? [])].sort(),
20433
+ relationshipPathIds: [...plan.relationshipPathIds].sort(),
20434
+ relationshipProofFingerprints: [...new Set(plan.relationshipProofs?.map((proof) => proof.authorityFingerprint) ?? [])].sort(),
20435
+ orderBy: compiledSignature.orderBy,
20436
+ compiledSqlFingerprint,
20437
+ compiledExpressionFingerprint,
20438
+ planFingerprint: plan.fingerprint,
20439
+ snapshotId: plan.snapshotId,
20440
+ targetFingerprint: input.targetFingerprint,
20441
+ };
20442
+ return { ...body, receiptFingerprint: semanticExecutionFingerprint(body) };
20443
+ }
18657
20444
  /**
18658
20445
  * Older clients normalized executable DQL with trim(), while the receipt was
18659
20446
  * correctly created from the original renderer output ending in one newline.
@@ -19071,6 +20858,43 @@ export function prepareLocalExecution(sql, connection, projectRoot, projectConfi
19071
20858
  connection: normalizedConnection,
19072
20859
  };
19073
20860
  }
20861
+ /**
20862
+ * The single connector boundary for Ask, Notebook, and artifact execution.
20863
+ * Callers compile their route-specific source first; this service owns target
20864
+ * preparation, parameters, execution, and result normalization after that.
20865
+ */
20866
+ export class ExecutionService {
20867
+ host;
20868
+ constructor(host) {
20869
+ this.host = host;
20870
+ }
20871
+ async execute(input) {
20872
+ const preparation = await prepareAnalyticalExecutionSql({
20873
+ sql: input.sql,
20874
+ subject: input.subject,
20875
+ executor: this.host.executor,
20876
+ connection: input.connection,
20877
+ projectRoot: this.host.projectRoot,
20878
+ projectConfig: this.host.projectConfig(),
20879
+ enforceReadOnly: input.enforceReadOnly,
20880
+ rowLimit: input.rowLimit,
20881
+ });
20882
+ const raw = input.executePrepared
20883
+ ? await input.executePrepared(preparation)
20884
+ : await this.host.executor.executeQuery(preparation.executedSql, input.sqlParams ?? [], runtimeVariables(input.variables ?? {}), preparation.connection);
20885
+ const result = normalizeQueryResult(raw, input.semanticRefs);
20886
+ return {
20887
+ preparation,
20888
+ result,
20889
+ compiledSqlFingerprint: executionFingerprint(preparation.executedSql),
20890
+ resultFingerprint: executionFingerprint(stableExecutionValue({
20891
+ columns: result.columns,
20892
+ rows: result.rows,
20893
+ rowCount: result.rowCount,
20894
+ })),
20895
+ };
20896
+ }
20897
+ }
19074
20898
  /**
19075
20899
  * One host-owned SQL preparation boundary shared by Ask and Notebook.
19076
20900
  *
@@ -27485,26 +29309,25 @@ function notebookResearchContextPreview(contextPack) {
27485
29309
  };
27486
29310
  }
27487
29311
  function normalizeNotebookAgentResult(result) {
27488
- const columns = Array.isArray(result.columns)
27489
- ? result.columns.map((column) => {
27490
- if (typeof column === 'string')
27491
- return column;
27492
- if (column && typeof column === 'object' && typeof column.name === 'string') {
27493
- return String(column.name);
27494
- }
27495
- return String(column);
27496
- })
27497
- : [];
27498
- const rows = Array.isArray(result.rows)
27499
- ? result.rows
27500
- .filter((row) => Boolean(row && typeof row === 'object' && !Array.isArray(row)))
27501
- .map((row) => row)
27502
- : [];
29312
+ const canonical = normalizeCanonicalQueryResult({
29313
+ columns: result.columns,
29314
+ rows: result.rows,
29315
+ rowCount: result.rowCount,
29316
+ executionTime: result.executionTime,
29317
+ resultFingerprint: result.resultFingerprint,
29318
+ executionReceipt: result.executionReceipt,
29319
+ trustState: result.executableArtifact?.trustState,
29320
+ answerTier: result.answerTier,
29321
+ });
27503
29322
  return {
27504
- columns,
27505
- rows,
27506
- rowCount: typeof result.rowCount === 'number' ? result.rowCount : rows.length,
27507
- executionTime: typeof result.executionTime === 'number' ? result.executionTime : 0,
29323
+ columns: canonical.columns,
29324
+ rows: canonical.rows,
29325
+ rowCount: canonical.rowCount,
29326
+ resultFingerprint: canonical.resultFingerprint,
29327
+ executionTime: canonical.executionTime ?? 0,
29328
+ ...(canonical.truncated ? { truncated: true } : {}),
29329
+ ...(canonical.executionReceipt ? { executionReceipt: canonical.executionReceipt } : {}),
29330
+ ...(canonical.answerTier ? { answerTier: canonical.answerTier } : {}),
27508
29331
  };
27509
29332
  }
27510
29333
  function notebookResearchSummary(question, result, error) {