@duckcodeailabs/dql-cli 1.14.2 → 1.14.3-rc.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (114) hide show
  1. package/dist/args.d.ts +4 -0
  2. package/dist/args.d.ts.map +1 -1
  3. package/dist/args.js +16 -0
  4. package/dist/args.js.map +1 -1
  5. package/dist/assets/dql-notebook/assets/{AgentLogPage-DKbGpRQS.js → AgentLogPage-Jbyb4so-.js} +1 -1
  6. package/dist/assets/dql-notebook/assets/{AiBuildDialog-DPSu0Mly.js → AiBuildDialog-Du07X1qO.js} +1 -1
  7. package/dist/assets/dql-notebook/assets/{AiBuildResult-1uaGnpi1.js → AiBuildResult-xKtGthWv.js} +1 -1
  8. package/dist/assets/dql-notebook/assets/{AiSidePanel-BwMREwa7.js → AiSidePanel-CE_L04dK.js} +1 -1
  9. package/dist/assets/dql-notebook/assets/AnalyticsHome-CiUE10uF.js +6 -0
  10. package/dist/assets/dql-notebook/assets/{AppsView-CM1tPywy.js → AppsView-DlNaTuwD.js} +30 -35
  11. package/dist/assets/dql-notebook/assets/AskObservabilityPage-DracZPI9.js +1 -0
  12. package/dist/assets/dql-notebook/assets/AskTracePage-CSMoMw9a.js +8 -0
  13. package/dist/assets/dql-notebook/assets/{BlockStudio--S6WFVO4.js → BlockStudio-DQS5Hcq9.js} +9 -14
  14. package/dist/assets/dql-notebook/assets/BusinessArtifactView-CqV4i9l_.js +1 -0
  15. package/dist/assets/dql-notebook/assets/{DbtFirstModelingPage-CNyU5MBX.js → DbtFirstModelingPage-JKhB_Y2H.js} +3 -3
  16. package/dist/assets/dql-notebook/assets/{GitPage-IcydAai3.js → GitPage-DjaNp0o3.js} +1 -1
  17. package/dist/assets/dql-notebook/assets/{GlobalAiRail-CSKW-5eD.js → GlobalAiRail-CsV3zlc8.js} +1 -1
  18. package/dist/assets/dql-notebook/assets/{GovernedContextPage-CFen0eFT.js → GovernedContextPage-uXUHUOIJ.js} +3 -3
  19. package/dist/assets/dql-notebook/assets/{HelpDocsPage-D0D9UCIz.js → HelpDocsPage-Hbh9IeYB.js} +1 -1
  20. package/dist/assets/dql-notebook/assets/{HomePage-CmSxapR3.js → HomePage-CRRl-R_v.js} +1 -1
  21. package/dist/assets/dql-notebook/assets/LineageDAG-OMUP2sbl.js +1 -0
  22. package/dist/assets/dql-notebook/assets/LineageDetailView-C964BLuV.js +1 -0
  23. package/dist/assets/dql-notebook/assets/LineageDrawer-CzY0nfpD.js +1 -0
  24. package/dist/assets/dql-notebook/assets/{LineagePathBreadcrumb-CTZJp4_r.js → LineagePathBreadcrumb-0Gi-Fm1B.js} +1 -1
  25. package/dist/assets/dql-notebook/assets/MiniLineageGraph-wn0wLc9m.js +1 -0
  26. package/dist/assets/dql-notebook/assets/{NewBlockModal-DMBFB7nE.js → NewBlockModal-kMAzk91d.js} +1 -1
  27. package/dist/assets/dql-notebook/assets/{NewNotebookModal-DsC9CWlW.js → NewNotebookModal-D_cWd_6M.js} +1 -1
  28. package/dist/assets/dql-notebook/assets/{NotebookEditor-hs-kw8v9.js → NotebookEditor-Bax0lxaz.js} +25 -30
  29. package/dist/assets/dql-notebook/assets/{ReadinessPage-DJ83cMik.js → ReadinessPage-DEUIXQQY.js} +1 -1
  30. package/dist/assets/dql-notebook/assets/{SetupOnboarding-B1Pu_Bvv.js → SetupOnboarding-CyE-Ex41.js} +1 -1
  31. package/dist/assets/dql-notebook/assets/{SkillsPage-BHKDay8n.js → SkillsPage-QgvKGPxE.js} +1 -1
  32. package/dist/assets/dql-notebook/assets/{TrustBadge-BkyGgob2.js → TrustBadge-D5CGp0cu.js} +1 -1
  33. package/dist/assets/dql-notebook/assets/UnifiedAgentRunPanel-BjsTE6yf.js +89 -0
  34. package/dist/assets/dql-notebook/assets/{answer-to-notebook-DmNiuLQA.js → answer-to-notebook-tACdCsTV.js} +1 -1
  35. package/dist/assets/dql-notebook/assets/{arrow-left--1rsrxm8.js → arrow-left-CF6wxXU5.js} +1 -1
  36. package/dist/assets/dql-notebook/assets/{arrow-right-D5TdqY1G.js → arrow-right-C7G3M_S8.js} +1 -1
  37. package/dist/assets/dql-notebook/assets/{book-open-text-Bw7nHbzg.js → book-open-text-DDBO8G7L.js} +1 -1
  38. package/dist/assets/dql-notebook/assets/chevron-left-DxajrX2v.js +6 -0
  39. package/dist/assets/dql-notebook/assets/{circle-x-DLe6NNM4.js → circle-x-HBvXjnT6.js} +1 -1
  40. package/dist/assets/dql-notebook/assets/clock-3-CJIdsPLt.js +6 -0
  41. package/dist/assets/dql-notebook/assets/dagre.esm-B6nvU4OB.js +1 -0
  42. package/dist/assets/dql-notebook/assets/{external-link-C9Q97sA3.js → external-link-WciAU8Bu.js} +1 -1
  43. package/dist/assets/dql-notebook/assets/{grip-vertical-CztvkIgo.js → grip-vertical-DVu7ok6x.js} +1 -1
  44. package/dist/assets/dql-notebook/assets/index-B_kaoARS.css +1 -0
  45. package/dist/assets/dql-notebook/assets/{index-zHHzDn6l.js → index-D3wnucQC.js} +133 -128
  46. package/dist/assets/dql-notebook/assets/{link-2-CiKAvumL.js → link-2-C6uQx50X.js} +1 -1
  47. package/dist/assets/dql-notebook/assets/{list-tree-BtnP2nQ5.js → list-tree-DapsIuQB.js} +1 -1
  48. package/dist/assets/dql-notebook/assets/{minimize-2-TSFGxcCP.js → minimize-2-BlVoipnT.js} +1 -1
  49. package/dist/assets/dql-notebook/assets/{panel-right-open-BfXIUWy0.js → panel-right-open-DcnKWxfx.js} +1 -1
  50. package/dist/assets/dql-notebook/assets/{play-DVbSFJHD.js → play-CNuRuaAs.js} +1 -1
  51. package/dist/assets/dql-notebook/assets/{rotate-ccw-D_cesDcX.js → rotate-ccw-B2p953Zh.js} +1 -1
  52. package/dist/assets/dql-notebook/assets/{semantic-fields-CoVStdYB.js → semantic-fields-2fWgkTJc.js} +1 -1
  53. package/dist/assets/dql-notebook/assets/{sliders-horizontal-l7xV9K5A.js → sliders-horizontal-DwRTu1w4.js} +1 -1
  54. package/dist/assets/dql-notebook/assets/{star-CSBS0H3b.js → star-Cbaj4F3i.js} +1 -1
  55. package/dist/assets/dql-notebook/assets/style-NlN9F6JE.js +23 -0
  56. package/dist/assets/dql-notebook/assets/{triangle-alert-BTrnyY4q.js → triangle-alert-BPHvH1wA.js} +1 -1
  57. package/dist/assets/dql-notebook/assets/{upload-sySLq9zb.js → upload-DIqK0KE1.js} +1 -1
  58. package/dist/assets/dql-notebook/assets/{usePersistedAgentThreadId-CzwgGdus.js → usePersistedAgentThreadId-DpQXOEDw.js} +1 -1
  59. package/dist/assets/dql-notebook/assets/{user-round-ChlgXi9j.js → user-round-DukNupB_.js} +1 -1
  60. package/dist/assets/dql-notebook/assets/{wand-sparkles-CGw0ytyT.js → wand-sparkles-CAwLX0b9.js} +1 -1
  61. package/dist/assets/dql-notebook/assets/{workflow-C_RltiK5.js → workflow-bOT-NqdO.js} +1 -1
  62. package/dist/assets/dql-notebook/assets/{wrench-D-wLfeu0.js → wrench-1m0_kcza.js} +1 -1
  63. package/dist/assets/dql-notebook/assets/{x-B28hIJIC.js → x-fp82BETM.js} +1 -1
  64. package/dist/assets/dql-notebook/index.html +2 -2
  65. package/dist/commands/agent-eval-runtime.d.ts +9 -0
  66. package/dist/commands/agent-eval-runtime.d.ts.map +1 -1
  67. package/dist/commands/agent-eval-runtime.js +7 -0
  68. package/dist/commands/agent-eval-runtime.js.map +1 -1
  69. package/dist/commands/agent-trace.d.ts +3 -0
  70. package/dist/commands/agent-trace.d.ts.map +1 -0
  71. package/dist/commands/agent-trace.js +169 -0
  72. package/dist/commands/agent-trace.js.map +1 -0
  73. package/dist/commands/agent.d.ts +28 -2
  74. package/dist/commands/agent.d.ts.map +1 -1
  75. package/dist/commands/agent.js +408 -35
  76. package/dist/commands/agent.js.map +1 -1
  77. package/dist/commands/notebook.d.ts +2 -0
  78. package/dist/commands/notebook.d.ts.map +1 -1
  79. package/dist/commands/notebook.js +4 -0
  80. package/dist/commands/notebook.js.map +1 -1
  81. package/dist/index.js +2 -0
  82. package/dist/index.js.map +1 -1
  83. package/dist/llm/providers/dql-agent-provider.d.ts +1 -1
  84. package/dist/llm/providers/dql-agent-provider.d.ts.map +1 -1
  85. package/dist/llm/providers/dql-agent-provider.js +759 -145
  86. package/dist/llm/providers/dql-agent-provider.js.map +1 -1
  87. package/dist/llm/types.d.ts +21 -2
  88. package/dist/llm/types.d.ts.map +1 -1
  89. package/dist/local-runtime.d.ts +256 -33
  90. package/dist/local-runtime.d.ts.map +1 -1
  91. package/dist/local-runtime.js +2844 -490
  92. package/dist/local-runtime.js.map +1 -1
  93. package/dist/package.json +10 -10
  94. package/dist/providers/oauth/claude-oauth.d.ts.map +1 -1
  95. package/dist/providers/oauth/claude-oauth.js +52 -18
  96. package/dist/providers/oauth/claude-oauth.js.map +1 -1
  97. package/dist/providers/oauth/codex-oauth.d.ts.map +1 -1
  98. package/dist/providers/oauth/codex-oauth.js +72 -44
  99. package/dist/providers/oauth/codex-oauth.js.map +1 -1
  100. package/dist/providers/subscription-cli.d.ts +20 -0
  101. package/dist/providers/subscription-cli.d.ts.map +1 -1
  102. package/dist/providers/subscription-cli.js +69 -11
  103. package/dist/providers/subscription-cli.js.map +1 -1
  104. package/package.json +10 -10
  105. package/dist/assets/dql-notebook/assets/AnalyticsHome-BGfey_ve.js +0 -6
  106. package/dist/assets/dql-notebook/assets/BusinessArtifactView-BEAJ-yNW.js +0 -1
  107. package/dist/assets/dql-notebook/assets/LineageDAG-BGUcIt1B.js +0 -1
  108. package/dist/assets/dql-notebook/assets/LineageDetailView-Dx48Zfzb.js +0 -1
  109. package/dist/assets/dql-notebook/assets/LineageDrawer-zaBfSLZO.js +0 -1
  110. package/dist/assets/dql-notebook/assets/MiniLineageGraph-CdivNR1S.js +0 -1
  111. package/dist/assets/dql-notebook/assets/UnifiedAgentRunPanel-BYdrTaEW.js +0 -89
  112. package/dist/assets/dql-notebook/assets/dagre.esm-CW5QZdBt.js +0 -23
  113. package/dist/assets/dql-notebook/assets/index-B3shyZsg.css +0 -1
  114. /package/dist/assets/dql-notebook/assets/{dagre-BZV40eAE.css → style-BZV40eAE.css} +0 -0
@@ -16,17 +16,17 @@ import { buildMixedSourceWarehouseFallbackSql, findMentionedNotebookDataset, pla
16
16
  import { resolveNpmInvocation } from './npm-runtime.js';
17
17
  import { ensureDqlGitignore, isGovernedSourceFile, isLegacyBroadDqlIgnore, } from './git-contract.js';
18
18
  import { buildExecutionPlan, createWelcomeNotebook, deserializeNotebook, getConnectorFormSchemas, hasSemanticRefs, hasStandaloneSemanticRef, resolveSemanticRefs, } from '@duckcodeailabs/dql-notebook';
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
+ 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, normalizeProviderEgressReceiptV1, relationshipValidationProofFingerprint, renderSemanticBlockSource, discoverDbtDomains, collectDbtRelationshipTests, renderDomainDeclaration, ProjectSnapshotService, analyzeSqlReferences, buildSqlAnalyticalSignature, buildGeneratedAnalyticalSqlSignature, buildSqlOutputExpressionSignature, semanticDimensionReference, semanticExecutionFingerprint, modelAreaLocalId, DEFAULT_MODEL_AREA_ID, } from '@duckcodeailabs/dql-core';
20
20
  import { load as loadYaml } from 'js-yaml';
21
21
  import { listBlockTemplates } from './block-templates.js';
22
22
  import { getRunner as getLLMRunner } from './llm/index.js';
23
23
  import { rethrowIfCancelled } from './llm/cancellation.js';
24
24
  import { fetchLatestPublishedDqlVersion, resolveDqlRuntimeVersionStatus } from './version-status.js';
25
25
  import { resolveRetrievalHealthStatus } from './retrieval-health.js';
26
- import { applyFinding, createResearchState, narrationMaxTokensForFacts, nextHypothesis, rerankCandidates, synthesizeResearchNarrative, AgenticExecutionCapabilityGate, createAgenticSqlExecutionCapability, mintFinalSqlAuthorization, verifyAgenticSqlExecutionCapability, qualifyAuthorizationReferences, scopeContextPackToExploratoryCandidateClosure, validateSqlAgainstLocalContext as validateAuthorizedSqlReferences, verifyFinalSql, } from '@duckcodeailabs/dql-agent';
26
+ import { applyFinding, createResearchState, narrationMaxTokensForFacts, nextHypothesis, synthesizeResearchNarrative, AgenticExecutionCapabilityGate, createAgenticSqlExecutionCapability, mintFinalSqlAuthorization, verifyAgenticSqlExecutionCapability, qualifyAuthorizationReferences, scopeContextPackToExploratoryCandidateClosure, validateFrozenRequiredOutputProjection, validateSqlAgainstLocalContext as validateAuthorizedSqlReferences, verifyFinalSql, attachAskTraceObserverV1, askTraceObserverForV1, } from '@duckcodeailabs/dql-agent';
27
27
  import { applyEvalCassette, createDqlAgentProviderRunner, createEvalCassetteReplayProvider, createGovernedTextProvider, resolveAgentFollowUpContext } from './llm/providers/dql-agent-provider.js';
28
28
  import { listRemoteMcpSettings, saveRemoteMcpSettings } from './llm/mcp-config.js';
29
- import { composeBusinessExplanation, 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, isProbeSafeColumn, deadlineScale, routeForCascadeAnswerTier, clampReasoningEffort, bumpReasoningEffort, resolveThinkingMode, coerceThinkingMode, upsertGeneratedDqlArtifactDraft, loadAgentSemanticLayer, isTrustedConversationTurn, resolveInternalRelationIds, analyticalError, tagAnalyticalError, withAnalyticalErrorOrigin, withAnalyticalErrorOriginSync, assertProviderPayloadAllowed, createProviderDispatchEgressReceipt, prepareProviderWireEnvelopeForDispatch, markProviderMetadataArray, createProviderEgressReceipt, redactProviderResultRows, composeVerifiedAnalyticalNarrative, classifyProviderFailure, buildCoverageGap, capResearchBranches, buildResearchEvidenceLedger, buildResearchEvidenceLedgerV2, buildResearchHypothesisPlanV2, inferResearchValidatorKind, buildAnalyticalTurnPlan, buildAnalyticalRequirementSet, resolveTopRankedRegionDependency, DEFAULT_ASK_ROW_EGRESS_POLICY, ZERO_ROW_EGRESS_POLICY, resolveProviderResultRowEgressPolicy, normalizeCanonicalQueryResult, normalizeAnalyticalExecutionFingerprint, normalizeAnalyticalExecutionReceipt, createAgentRunCancellationError, } from '@duckcodeailabs/dql-agent';
29
+ import { composeBusinessExplanation, 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, AskTraceSqliteStoreV1, createAskTraceObserverV1, defaultAskTraceSqlitePath, createAskTracePortableBundleV1, 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, selectRoute, isProbeSafeColumn, deadlineScale, routeForCascadeAnswerTier, clampReasoningEffort, bumpReasoningEffort, resolveThinkingMode, coerceThinkingMode, upsertGeneratedDqlArtifactDraft, loadAgentSemanticLayer, isTrustedConversationTurn, resolveInternalRelationIds, analyticalError, analyticalErrorDetail, tagAnalyticalError, withAnalyticalErrorOrigin, withAnalyticalErrorOriginSync, assertProviderPayloadAllowed, createProviderDispatchEgressReceipt, prepareProviderWireEnvelopeForDispatch, markProviderMetadataArray, redactProviderResultRows, composeVerifiedAnalyticalNarrative, classifyProviderFailure, buildCoverageGap, capResearchBranches, buildResearchEvidenceLedger, buildResearchEvidenceLedgerV2, buildResearchHypothesisPlanV2, inferResearchValidatorKind, buildAnalyticalTurnPlan, buildAnalyticalRequirementSeedV1, buildAnalyticalRequirementSet, resolveTopRankedRegionDependency, DEFAULT_ASK_ROW_EGRESS_POLICY, ZERO_ROW_EGRESS_POLICY, resolveProviderResultRowEgressPolicy, normalizeCanonicalQueryResult, normalizeAnalyticalExecutionFingerprint, normalizeAnalyticalExecutionReceipt, createAgentRunCancellationError, isAgentRunUserCancellation, } from '@duckcodeailabs/dql-agent';
30
30
  import { addSqlResultFilter, dashboardFilterableResultColumns, filterableResultColumns, replaceBlockStudioSql } from './sql-result-filter.js';
31
31
  import { gatherProposeEnrichment } from './propose-enrich.js';
32
32
  import { handleAppsApi, proposeAppAiBuild, recommendVisualization, } from './apps-api.js';
@@ -90,6 +90,56 @@ function hasDbtSemanticArtifacts(projectRoot, dbtProjectDir, configuredManifestP
90
90
  }
91
91
  return false;
92
92
  }
93
+ /** One-time local capability lifetime. It is never persisted or sent to a provider. */
94
+ export const CLI_ASK_TRACE_CAPABILITY_TTL_MS = 30_000;
95
+ function isLoopbackRemoteAddress(value) {
96
+ return value === '127.0.0.1'
97
+ || value === '::1'
98
+ || value === '::ffff:127.0.0.1';
99
+ }
100
+ /**
101
+ * Host-owned, one-shot attribution capabilities for an already-local runtime.
102
+ * A plain client header is never enough: it must be minted by this process,
103
+ * unexpired, scoped to AgentRun admission, and arrive over loopback.
104
+ */
105
+ export function createLocalCliAskTraceCapabilityRegistryV1(options = {}) {
106
+ const records = new Map();
107
+ const now = options.now ?? Date.now;
108
+ const mint = options.mint ?? randomUUID;
109
+ const purge = (at) => {
110
+ for (const [capability, record] of records) {
111
+ if (record.expiresAtMs <= at)
112
+ records.delete(capability);
113
+ }
114
+ };
115
+ return {
116
+ issue(input = {}) {
117
+ const issuedAt = input.nowMs ?? now();
118
+ purge(issuedAt);
119
+ const capability = input.capability ?? mint();
120
+ const expiresAtMs = issuedAt + Math.max(1, Math.min(CLI_ASK_TRACE_CAPABILITY_TTL_MS, input.ttlMs ?? CLI_ASK_TRACE_CAPABILITY_TTL_MS));
121
+ records.set(capability, { expiresAtMs, scope: 'agent-runs' });
122
+ return {
123
+ capability,
124
+ expiresAt: new Date(expiresAtMs).toISOString(),
125
+ scope: 'agent-runs',
126
+ };
127
+ },
128
+ consume(input) {
129
+ const at = input.nowMs ?? now();
130
+ purge(at);
131
+ if (!input.loopbackServer || !isLoopbackRemoteAddress(input.remoteAddress) || typeof input.capability !== 'string')
132
+ return undefined;
133
+ const record = records.get(input.capability);
134
+ if (!record || record.scope !== input.scope || record.expiresAtMs <= at)
135
+ return undefined;
136
+ // A capability represents one concrete request admission. Deleting it
137
+ // prevents copied local headers from relabelling later browser requests.
138
+ records.delete(input.capability);
139
+ return 'cli';
140
+ },
141
+ };
142
+ }
93
143
  // Every member of `AgentRunRequestedMode`. The `Record` (rather than a bare
94
144
  // Set literal) is deliberate: a `Set<AgentRunRequestedMode>` happily accepts a
95
145
  // subset, which is how 'modeling' and 'skill' went missing here — the parser
@@ -172,6 +222,101 @@ function agentRunRecord(value) {
172
222
  function agentRunString(value) {
173
223
  return typeof value === 'string' && value.trim().length > 0 ? value.trim() : undefined;
174
224
  }
225
+ /**
226
+ * Catalog previews are display-only run-store joins; the trace store and
227
+ * strict exports remain prompt-free. Returning a partially redacted arbitrary
228
+ * prompt is not safe: member values, SQL literals, URLs, filesystem paths, and
229
+ * secrets are all meaningful diagnostics in an analytics system. Therefore a
230
+ * preview is optional and all-or-nothing: it is returned only for a short
231
+ * generic analytic question composed entirely of this deliberately small
232
+ * vocabulary. Every other question is represented by its typed scenario label.
233
+ */
234
+ const ASK_TRACE_PREVIEW_MAX_CHARS = 160;
235
+ const ASK_TRACE_PREVIEW_ALLOWED_WORDS = new Set([
236
+ 'a', 'account', 'accounts', 'all', 'amount', 'an', 'and', 'are', 'average',
237
+ 'by', 'categories', 'category', 'compare', 'count', 'current', 'customer',
238
+ 'customers', 'daily', 'data', 'date', 'dates', 'dimension', 'dimensions',
239
+ 'fiscal', 'for', 'from', 'growth', 'have', 'highest', 'how', 'is', 'last',
240
+ 'lowest', 'me', 'metric', 'metrics', 'month', 'monthly', 'of', 'order',
241
+ 'orders', 'our', 'previous', 'product', 'products', 'quarter', 'quarterly',
242
+ 'region', 'regions', 'result', 'results', 'revenue', 'sales', 'show',
243
+ 'spend', 'the', 'their', 'these', 'this', 'to', 'top', 'total', 'trend',
244
+ 'trends', 'what', 'when', 'where', 'which', 'who', 'why', 'with', 'year',
245
+ ]);
246
+ const ASK_TRACE_PREVIEW_SENSITIVE_PATTERNS = [
247
+ /\b(?:select|insert|update|delete|drop|alter|create|grant|revoke|truncate|merge|with)\b/i,
248
+ /(?:--|\/\*|\*\/|;)/,
249
+ /(?:https?|ftp):\/\/|\bwww\./i,
250
+ /(?:^|[\s"'`])(?:~\/|\/(?:Users|home|var|tmp|private|etc|opt|Volumes)\/|[A-Za-z]:[\\/])/,
251
+ /\b\d{3}-\d{2}-\d{4}\b/,
252
+ /\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b/i,
253
+ /(?:\+?\d[\d().\-\s]{7,}\d)/,
254
+ /\b(?:api[_ -]?key|access[_ -]?token|refresh[_ -]?token|authorization|password|secret|client[_ -]?secret|private[_ -]?key|session|cookie)\b/i,
255
+ /\bbearer\s+[a-z0-9._~+\/-]{8,}/i,
256
+ /\b(?:sk|pk|rk)_[a-z0-9_-]{8,}\b/i,
257
+ /\b(?:AKIA|ASIA)[A-Z0-9]{12,}\b/,
258
+ /\bgh[pousr]_[A-Za-z0-9]{16,}\b/,
259
+ /\beyJ[A-Za-z0-9_-]{16,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\b/,
260
+ ];
261
+ export function askTraceQuestionPreview(question) {
262
+ const normalized = question.replace(/\s+/g, ' ').trim();
263
+ if (!normalized || normalized.length > ASK_TRACE_PREVIEW_MAX_CHARS)
264
+ return undefined;
265
+ if (ASK_TRACE_PREVIEW_SENSITIVE_PATTERNS.some((pattern) => pattern.test(normalized)))
266
+ return undefined;
267
+ // Do not attempt to preserve literals, values, handles, or unclassified
268
+ // business nouns. The catalog can still identify its typed scenario without
269
+ // exposing those inputs.
270
+ if (!/^[A-Za-z ?-]+$/.test(normalized))
271
+ return undefined;
272
+ const words = normalized.toLowerCase().match(/[a-z]+/g);
273
+ if (!words?.length || words.some((word) => !ASK_TRACE_PREVIEW_ALLOWED_WORDS.has(word)))
274
+ return undefined;
275
+ return normalized;
276
+ }
277
+ function askTraceScenarioLabel(run) {
278
+ if (run.requestedMode === 'research' || run.route === 'research')
279
+ return 'Research';
280
+ const labels = {
281
+ certified_answer: 'Certified answer',
282
+ semantic_answer: 'Semantic answer',
283
+ generated_answer: 'Review-required SQL',
284
+ clarify: 'Clarification',
285
+ blocked: 'Blocked',
286
+ conversation: 'Conversation',
287
+ };
288
+ return labels[run.route] ?? 'Ask run';
289
+ }
290
+ /**
291
+ * The notebook has exactly two trace client routes. Decode only the single
292
+ * detail segment for validation so a valid encoded run id (for example
293
+ * `run%3Aoffice-42`) reaches the SPA, while encoded slashes, malformed escapes,
294
+ * and nested paths stay ordinary 404s.
295
+ */
296
+ export function isAskTraceClientDetailPath(pathname) {
297
+ const prefix = '/ask/traces/';
298
+ if (!pathname.startsWith(prefix))
299
+ return false;
300
+ const encodedRunId = pathname.slice(prefix.length);
301
+ if (!encodedRunId || encodedRunId.includes('/'))
302
+ return false;
303
+ try {
304
+ const runId = decodeURIComponent(encodedRunId);
305
+ return /^[0-9A-Za-z][0-9A-Za-z:_-]{0,255}$/.test(runId);
306
+ }
307
+ catch {
308
+ return false;
309
+ }
310
+ }
311
+ /**
312
+ * Ask owns only these client-side routes. Keep the static fallback explicit so
313
+ * a notebook reload works while an arbitrary missing path remains a real 404.
314
+ */
315
+ export function isAskClientRoutePath(pathname) {
316
+ return pathname === '/ask'
317
+ || pathname === '/ask/traces'
318
+ || isAskTraceClientDetailPath(pathname);
319
+ }
175
320
  /** UI catalog fallback labels are not declared project domains. */
176
321
  /**
177
322
  * Resolve a UI-pinned domain scope, tolerating one that no longer exists.
@@ -452,23 +597,20 @@ export async function scheduleCompoundAnalyticalTasks(input) {
452
597
  /**
453
598
  * Decide how a settled answer gets its business-facing prose.
454
599
  *
455
- * This deliberately does NOT read `requestedMode`. Gating narration on
456
- * `requestedMode === 'research'` meant every ordinary Ask which the UI sends
457
- * as `'auto'` skipped synthesis entirely and shipped the answer loop's
458
- * deterministic fact-join as the primary answer: the reported `column: value`
459
- * dump. Narration is owed to any run that actually produced values.
460
- *
461
- * Certification, a DQL artifact, and the exploratory candidate are no longer
462
- * vetoes. EXP-001's grain concern is real, but the answer to "the model might
463
- * relabel an entity-level measure" is to VERIFY the claims against the fact set
464
- * (`verified_facts`) and pass the grain statement in as a caveat — not to refuse
465
- * to write a sentence.
600
+ * An ordinary Ask already spends its one permitted model call resolving meaning.
601
+ * Its settled answer is the deterministic, receipt-bound answer produced by the
602
+ * selected analytical tier; sending result rows to a second narrator would add
603
+ * a hidden provider phase, change the egress receipt, and make a governed
604
+ * semantic run look like Research. Only an explicit Research request may run
605
+ * this optional, fact-checked narration stage.
466
606
  */
467
607
  export function planAgentRunNarration(governedAnswer, context) {
468
608
  // A refusal is not narrated. It owes the user a reason or a clarifying
469
609
  // question, which is the clarification lane's job, not the narrator's.
470
610
  if (governedAnswer.kind === 'no_answer')
471
611
  return { mode: 'skip', reason: 'no_answer' };
612
+ if (context.requestedMode !== 'research')
613
+ return { mode: 'skip', reason: 'ordinary_ask' };
472
614
  if (!context.providerAvailable)
473
615
  return { mode: 'skip', reason: 'no_provider' };
474
616
  const maxRows = Math.max(0, context.rowEgress.maxNarrationRows);
@@ -518,6 +660,54 @@ export function trustStateForAgentAnswer(answer) {
518
660
  return 'certified';
519
661
  return semanticAnswerHasPassedAggregationProof(answer) ? 'governed' : 'review_required';
520
662
  }
663
+ /**
664
+ * Persisted result payloads outlive the in-memory answer object, so they need
665
+ * the same trust gate as the visible Ask card. In particular, an exploratory
666
+ * result used to inherit the old `governed` fallback merely because it had
667
+ * rows. Completion is not governance: only an exact certified answer or a
668
+ * semantic answer carrying its aggregation proof may be persisted as governed.
669
+ *
670
+ * The `answer` overload is the normal Ask path and therefore has the proof.
671
+ * Generic notebook/result callers deliberately fail closed: route/status words
672
+ * such as `ai_generated`, `draft_ready`, and `analyst_review_required` are all
673
+ * review-required, and a bare legacy `governed` string never manufactures
674
+ * proof after the fact.
675
+ */
676
+ function canonicalPersistedTrustState(input) {
677
+ if (input.answer) {
678
+ return input.answer.kind === 'no_answer'
679
+ ? 'blocked'
680
+ : trustStateForAgentAnswer(input.answer);
681
+ }
682
+ const rawTrust = typeof input.trustState === 'string' ? input.trustState.trim().toLowerCase() : '';
683
+ const tier = typeof input.answerTier === 'string' ? input.answerTier.trim().toLowerCase() : '';
684
+ // These state names describe generated or analyst-review workflows, never a
685
+ // governed proof. Keep this list explicit so a newly added presentation
686
+ // label cannot silently gain governed trust through the old fallback.
687
+ if (rawTrust === 'ai_generated'
688
+ || rawTrust === 'draft_ready'
689
+ || rawTrust === 'analyst_review_required'
690
+ || rawTrust === 'exploratory'
691
+ || tier === 'exploratory_sql'
692
+ || tier === 'generated_sql'
693
+ || tier === 'ai_generated'
694
+ || tier === 'draft_ready'
695
+ || tier === 'analyst_review_required')
696
+ return 'review_required';
697
+ if (rawTrust === 'blocked')
698
+ return 'blocked';
699
+ if (rawTrust === 'not_applicable')
700
+ return 'not_applicable';
701
+ if (rawTrust === 'review_required' || rawTrust === 'grounded')
702
+ return rawTrust;
703
+ // A bare persisted `governed` label (or an unqualified `certified` label)
704
+ // lacks the in-memory certified block / semantic aggregation proof. It is
705
+ // safe to display as review-required until a current Ask answer supplies
706
+ // that proof above.
707
+ if (rawTrust === 'governed' || rawTrust === 'certified')
708
+ return 'review_required';
709
+ return undefined;
710
+ }
521
711
  const TRUST_RANK = {
522
712
  certified: 3,
523
713
  governed: 2,
@@ -1387,12 +1577,59 @@ function apiErrorMessage(error) {
1387
1577
  */
1388
1578
  const ASSUMED_PROVIDER_DISPATCH_MS = 12_000;
1389
1579
  const DISPATCH_SETTLE_MARGIN_MS = 4_000;
1580
+ /**
1581
+ * The one runtime authority for provider-send caps.
1582
+ *
1583
+ * Research is limited to twelve physical sends total, one planner, and one
1584
+ * narrator. Ordinary generated lookup normally gets two sends across
1585
+ * candidate-ID meaning and planning/generation. A router-frozen bounded
1586
+ * exploratory plan may use one additional, same-plan provider correction only
1587
+ * after the generation response declines SQL. That third send is a `repair`,
1588
+ * not an LLM replan: it retains the frozen snapshot, target, closure, output
1589
+ * tuple, and route. Classification is a separate legacy/no-evidence phase,
1590
+ * but still consumes the total cap and cannot coexist with candidate-ID
1591
+ * meaning resolution in the ledger.
1592
+ */
1593
+ export function agentRunProviderDispatchBudgetForMode(requestedMode) {
1594
+ if (requestedMode === 'research') {
1595
+ return {
1596
+ total: 12,
1597
+ classification: 1,
1598
+ // A bounded Research root may plan up to six independently routed
1599
+ // hypothesis children. Each child can make one candidate-ID meaning
1600
+ // resolution, all of which remain charged to this same twelve-send
1601
+ // ledger. Ordinary Ask deliberately remains one below.
1602
+ meaningResolution: 6,
1603
+ planning: 1,
1604
+ // One planner plus at most eight generation/tool sends. Together with
1605
+ // one meaning, one narrator, and one repair this cannot exceed twelve.
1606
+ generationGroup: 9,
1607
+ narration: 1,
1608
+ repair: 1,
1609
+ };
1610
+ }
1611
+ return {
1612
+ // One interpretation (candidate-ID meaning OR legacy classification), one
1613
+ // planning/generation transport, and exactly one eligible frozen-plan
1614
+ // correction. The phase-specific limits below keep the exceptional third
1615
+ // attempt from becoming a general Ask retry budget.
1616
+ total: 3,
1617
+ classification: 1,
1618
+ meaningResolution: 1,
1619
+ planning: 1,
1620
+ generationGroup: 1,
1621
+ narration: 0,
1622
+ repair: 1,
1623
+ };
1624
+ }
1390
1625
  export class RunScopedProviderDispatchEvidence {
1391
1626
  policy;
1392
1627
  runBudget;
1393
1628
  rowEgress;
1394
1629
  receipts = [];
1395
1630
  phaseCounts = new Map();
1631
+ /** At most one physical same-provider transient retry may be admitted per run. */
1632
+ retryCount = 0;
1396
1633
  currentRoute;
1397
1634
  /** Wall-clock start of the previous dispatch, used to learn this provider's cost. */
1398
1635
  lastDispatchStartedAtMs;
@@ -1443,7 +1680,9 @@ export class RunScopedProviderDispatchEvidence {
1443
1680
  return !this.runBudget || this.runBudget.mayStartDiscovery(this.currentRoute);
1444
1681
  }
1445
1682
  observe(event, context) {
1446
- const softRoute = context.dispatchPhase === 'meaning_resolution' ? 'clarify' : this.currentRoute;
1683
+ const isInterpretationPhase = context.dispatchPhase === 'classification'
1684
+ || context.dispatchPhase === 'meaning_resolution';
1685
+ const softRoute = isInterpretationPhase ? 'clarify' : this.currentRoute;
1447
1686
  // Admission control BEFORE the phase targets: starting a call that the hard
1448
1687
  // deadline will kill mid-flight wastes the remaining budget and ends the run
1449
1688
  // with nothing. Stopping here lets the caller answer from what it has.
@@ -1460,40 +1699,77 @@ export class RunScopedProviderDispatchEvidence {
1460
1699
  else if (this.runBudget && !this.runBudget.mayStartDiscovery(softRoute)) {
1461
1700
  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' });
1462
1701
  }
1463
- this.recordDispatchStart();
1464
1702
  if (this.receipts.length >= this.policy.total) {
1465
1703
  throw Object.assign(new Error(`Run-wide provider dispatch budget exhausted after ${this.policy.total} physical attempts.`), { code: 'PROVIDER_DISPATCH_BUDGET_EXHAUSTED' });
1466
1704
  }
1705
+ const retryOfAttemptIndex = context.retryOfAttemptIndex;
1706
+ const retryRequested = retryOfAttemptIndex !== undefined;
1707
+ const retryParent = retryRequested
1708
+ ? this.receipts.find((receipt) => (receipt.provider === event.provider
1709
+ && receipt.dispatchPhase === context.dispatchPhase
1710
+ && receipt.purpose === context.purpose
1711
+ && receipt.attemptIndex === retryOfAttemptIndex))
1712
+ : undefined;
1713
+ // A retry is transport recovery, not another generation, repair, or route
1714
+ // decision. It must be tied to the same admitted physical provider phase,
1715
+ // and the frozen exploratory repair lane is deliberately excluded so its
1716
+ // one reserved repair transport cannot be spent by a network retry.
1717
+ if (retryRequested && (!Number.isInteger(retryOfAttemptIndex)
1718
+ || retryOfAttemptIndex < 1
1719
+ || !retryParent
1720
+ || this.retryCount >= 1
1721
+ || context.dispatchPhase === 'repair')) {
1722
+ throw Object.assign(new Error('A provider retry must be the one permitted same-provider retry of an admitted non-repair physical attempt.'), { code: 'PROVIDER_DISPATCH_RETRY_NOT_ALLOWED' });
1723
+ }
1724
+ const admittedTransientRetry = retryRequested && Boolean(retryParent);
1467
1725
  const phaseCount = this.phaseCounts.get(context.dispatchPhase) ?? 0;
1468
- const phaseLimit = context.dispatchPhase === 'meaning_resolution'
1469
- ? this.policy.meaningResolution
1470
- : context.dispatchPhase === 'repair'
1471
- ? this.policy.repair
1472
- : context.dispatchPhase === 'narration'
1473
- ? this.policy.narration
1474
- : this.policy.generationGroup;
1726
+ const phaseLimit = context.dispatchPhase === 'classification'
1727
+ ? (this.policy.classification ?? 1)
1728
+ : context.dispatchPhase === 'meaning_resolution'
1729
+ ? this.policy.meaningResolution
1730
+ : context.dispatchPhase === 'planning'
1731
+ ? (this.policy.planning ?? this.policy.generationGroup)
1732
+ : context.dispatchPhase === 'repair'
1733
+ ? this.policy.repair
1734
+ : context.dispatchPhase === 'narration'
1735
+ ? this.policy.narration
1736
+ : this.policy.generationGroup;
1737
+ // Category-only classification is a legacy/no-evidence fallback, not an
1738
+ // alternate route into analytical binding. A run must record one or the
1739
+ // other: allowing both would turn an opaque category prompt into a false
1740
+ // claim that candidate IDs were resolved.
1741
+ const conflictingInterpretationPhase = isInterpretationPhase
1742
+ && this.receipts.some((receipt) => (receipt.dispatchPhase === 'classification'
1743
+ || receipt.dispatchPhase === 'meaning_resolution') && receipt.dispatchPhase !== context.dispatchPhase);
1744
+ if (conflictingInterpretationPhase) {
1745
+ throw Object.assign(new Error('The legacy category classifier and candidate-ID meaning resolution cannot both dispatch in one Ask run.'), { code: 'PROVIDER_INTERPRETATION_PHASE_CONFLICT' });
1746
+ }
1475
1747
  // Planning and generation share one ledger because they compete for the
1476
1748
  // same "work out the query" budget. Narration does not: it is the step that
1477
1749
  // turns a settled result into an answer, and starving it produces a run
1478
1750
  // that computed the right numbers and then could not say them.
1479
1751
  const generationGroupCount = this.receipts.filter((receipt) => receipt.dispatchPhase === 'planning'
1480
1752
  || receipt.dispatchPhase === 'generation').length;
1481
- if (phaseCount >= phaseLimit || ((context.dispatchPhase === 'planning' || context.dispatchPhase === 'generation')
1482
- && generationGroupCount >= this.policy.generationGroup)) {
1753
+ if (!admittedTransientRetry && (phaseCount >= phaseLimit || ((context.dispatchPhase === 'planning' || context.dispatchPhase === 'generation')
1754
+ && generationGroupCount >= this.policy.generationGroup))) {
1483
1755
  throw Object.assign(new Error(`Provider dispatch budget exhausted for ${context.dispatchPhase} after ${phaseLimit} physical attempts.`), { code: 'PROVIDER_DISPATCH_BUDGET_EXHAUSTED' });
1484
1756
  }
1485
1757
  const envelope = prepareProviderWireEnvelopeForDispatch(event.provider, event.envelope);
1486
1758
  const projectedRowCount = context.serializedResultShape?.resultRowCount ?? 0;
1487
1759
  const projectedCumulativeRowCount = context.cumulativeResultRowCount ?? projectedRowCount;
1488
- // Ordinary Ask narration reads its ceiling from the resolved project policy;
1489
- // Research keeps its own explicit opt-in limits.
1490
- const permittedRowLimit = context.purpose === 'answer_narration'
1760
+ // The resolver is the single privacy authority. A legacy
1761
+ // `answer_narration` receipt can remain content-free for backwards
1762
+ // readability, but it can never disclose result rows. Research narration
1763
+ // requires both the explicit per-run consent and authority minted by that
1764
+ // resolver; a hand-built positive limit is not enough.
1765
+ const researchNarrationRowsAuthorized = context.purpose === 'research_narration'
1766
+ && context.optIn
1767
+ && this.rowEgress.resultRowAuthority === 'research_run_opt_in';
1768
+ const permittedRowLimit = researchNarrationRowsAuthorized
1491
1769
  ? this.rowEgress.maxNarrationRows
1492
- : context.optIn && context.purpose === 'research_narration'
1493
- ? 20
1494
- : context.optIn && context.purpose === 'research_tool'
1495
- ? 200
1496
- : 0;
1770
+ : context.optIn && context.purpose === 'research_tool'
1771
+ ? this.rowEgress.maxToolRows
1772
+ : 0;
1497
1773
  if (projectedRowCount > permittedRowLimit || projectedCumulativeRowCount > permittedRowLimit) {
1498
1774
  throw Object.assign(new Error(permittedRowLimit === 0
1499
1775
  ? 'Provider egress blocked result rows without explicit Research run consent.'
@@ -1505,6 +1781,11 @@ export class RunScopedProviderDispatchEvidence {
1505
1781
  purpose: context.purpose,
1506
1782
  });
1507
1783
  const rowCount = projectedRowCount;
1784
+ // A rejected admission is not a physical send. Do not let a rejected
1785
+ // egress/budget guard teach the run budget that the provider was slow, or
1786
+ // consume a receipt/phase count that a later trace would present as a
1787
+ // completed attempt.
1788
+ this.recordDispatchStart();
1508
1789
  this.receipts.push(createProviderDispatchEgressReceipt({
1509
1790
  purpose: context.purpose,
1510
1791
  dispatchPhase: context.dispatchPhase,
@@ -1512,6 +1793,7 @@ export class RunScopedProviderDispatchEvidence {
1512
1793
  ...(event.model ? { model: event.model } : {}),
1513
1794
  operation: event.operation,
1514
1795
  attemptIndex: event.attemptIndex,
1796
+ ...(retryOfAttemptIndex !== undefined ? { retryOfAttemptIndex } : {}),
1515
1797
  options: event.options,
1516
1798
  permittedCategories: rowCount > 0
1517
1799
  ? ['instructions', 'question', 'schema_metadata', 'governed_context', 'result_rows']
@@ -1523,7 +1805,13 @@ export class RunScopedProviderDispatchEvidence {
1523
1805
  ? { cumulativeResultRowCount: context.cumulativeResultRowCount }
1524
1806
  : {}),
1525
1807
  }));
1808
+ // Admission accounting belongs to this ledger only. The Ask provider
1809
+ // wrapper owns trace spans because it is the only boundary that observes
1810
+ // the matching HTTP completion/failure. Recording `ok` here would turn an
1811
+ // admitted-but-failed physical send into a false provider success.
1526
1812
  this.phaseCounts.set(context.dispatchPhase, phaseCount + 1);
1813
+ if (admittedTransientRetry)
1814
+ this.retryCount += 1;
1527
1815
  return envelope;
1528
1816
  }
1529
1817
  snapshot(fallbackReason = 'none') {
@@ -1538,6 +1826,327 @@ export class RunScopedProviderDispatchEvidence {
1538
1826
  }
1539
1827
  }
1540
1828
  const agentRunProviderEvidenceContext = new AsyncLocalStorage();
1829
+ /**
1830
+ * Request-local trace context. It is intentionally independent of provider
1831
+ * accounting: tracing cannot admit a dispatch, consume a budget, or change a
1832
+ * connector call. Nested host callbacks can only append typed observations.
1833
+ */
1834
+ const agentRunAskTraceContext = new AsyncLocalStorage();
1835
+ function activeAskTraceObserver() {
1836
+ const observer = agentRunAskTraceContext.getStore();
1837
+ return observer?.enabled ? observer : undefined;
1838
+ }
1839
+ function runtimeTraceFingerprint(value) {
1840
+ return `sha256:${createHash('sha256').update(value).digest('hex')}`;
1841
+ }
1842
+ function providerTracePhase(phase) {
1843
+ switch (phase) {
1844
+ case 'classification': return 'classification';
1845
+ case 'meaning_resolution': return 'meaning_resolution';
1846
+ case 'planning': return 'planning';
1847
+ case 'generation': return 'generation';
1848
+ case 'repair': return 'repair';
1849
+ case 'narration': return 'narration';
1850
+ default: return 'unknown';
1851
+ }
1852
+ }
1853
+ /**
1854
+ * Attach one physical provider transport to the current redacted Ask trace.
1855
+ * The caller remains the authority for admission and egress; this wrapper only
1856
+ * records the same physical send/settlement with its server-owned phase and
1857
+ * purpose. It is intentionally reusable for meaning and Research narration
1858
+ * so trace provider-attempt counts cannot drift from egress receipts.
1859
+ */
1860
+ /**
1861
+ * @internal Exported solely for the local runtime boundary harness. It is not
1862
+ * an HTTP or durable API: production callers use it to pair the one provider
1863
+ * transport with its same-run trace span and egress receipt.
1864
+ */
1865
+ export function createProviderDispatchTrace(input) {
1866
+ const observer = input.observer;
1867
+ const pending = new Map();
1868
+ // A transport failure is reported by `onProviderDispatchComplete` and removes
1869
+ // its pending entry before the outer promise rejects. Keep the number of
1870
+ // observed provider boundaries independent of `pending`: a completed
1871
+ // transport failure and a pre-send denial both already have a span, so
1872
+ // `settle(error)` must not manufacture a second synthetic attempt.
1873
+ let observedBoundaryCount = 0;
1874
+ const deniedKeys = new Set();
1875
+ // Some provider adapters emit a rejection notification after reporting the
1876
+ // same HTTP failure through completion. Once admission succeeded, that
1877
+ // notification is not a second denied send; it is the same physical
1878
+ // attempted transport and must retain its admitted/error span only.
1879
+ const admittedKeys = new Set();
1880
+ const key = (event) => `${event.provider}:${event.operation}:${event.attemptIndex}`;
1881
+ const diagnostic = (event, error) => classifyProviderFailure({
1882
+ message: error instanceof Error ? error.message : String(error ?? 'provider completion failed'),
1883
+ code: error && typeof error === 'object' ? String(error.code ?? '') : undefined,
1884
+ phase: providerTracePhase(input.phase),
1885
+ ...(event?.provider ? { providerFingerprint: runtimeTraceFingerprint(event.provider) } : {}),
1886
+ ...(event?.model ? { modelFingerprint: runtimeTraceFingerprint(event.model) } : {}),
1887
+ });
1888
+ const start = (event, admission, failure) => {
1889
+ observedBoundaryCount += 1;
1890
+ const failureDiagnostic = failure === undefined ? undefined : diagnostic(event, failure);
1891
+ const attempt = {
1892
+ version: 1,
1893
+ phase: providerTracePhase(input.phase),
1894
+ purpose: input.purpose,
1895
+ physicalAttemptIndex: event.attemptIndex,
1896
+ providerFingerprint: runtimeTraceFingerprint(event.provider),
1897
+ ...(event.model ? { modelFingerprint: runtimeTraceFingerprint(event.model) } : {}),
1898
+ readiness: admission === 'admitted' ? 'ready' : 'unknown',
1899
+ admission,
1900
+ ...(failureDiagnostic?.httpStatusClass ? { httpStatusClass: failureDiagnostic.httpStatusClass } : {}),
1901
+ ...(failureDiagnostic?.retryable !== undefined ? { retryable: failureDiagnostic.retryable } : {}),
1902
+ ...(failureDiagnostic?.safeAction ? { safeAction: failureDiagnostic.safeAction } : {}),
1903
+ ...(failureDiagnostic?.cause ? { cause: failureDiagnostic.cause } : {}),
1904
+ provenance: 'live',
1905
+ };
1906
+ const spanId = observer?.startSpan({
1907
+ name: 'provider.attempt',
1908
+ stage: 'provider',
1909
+ reasonCode: failureDiagnostic ? 'provider_failure' : 'started',
1910
+ payload: { kind: 'provider', attempt },
1911
+ });
1912
+ return { spanId, provider: event.provider, ...(event.model ? { model: event.model } : {}), attempt: event.attemptIndex };
1913
+ };
1914
+ const finish = (entry, outcome, error, httpStatus) => {
1915
+ if (!entry.spanId)
1916
+ return;
1917
+ if (outcome === 'ok') {
1918
+ observer?.finishSpan(entry.spanId, { outcome: 'ok', reasonCode: 'completed' });
1919
+ return;
1920
+ }
1921
+ const failureDiagnostic = diagnostic({ provider: entry.provider, ...(entry.model ? { model: entry.model } : {}) }, error ?? (typeof httpStatus === 'number'
1922
+ ? Object.assign(new Error(`HTTP ${httpStatus}`), { code: `HTTP_${httpStatus}` })
1923
+ : undefined));
1924
+ observer?.finishSpan(entry.spanId, {
1925
+ outcome: outcome === 'cancelled' ? 'cancelled' : 'error',
1926
+ reasonCode: outcome === 'cancelled' ? 'cancelled' : 'provider_failure',
1927
+ payload: {
1928
+ kind: 'provider',
1929
+ attempt: {
1930
+ version: 1,
1931
+ phase: providerTracePhase(input.phase),
1932
+ purpose: input.purpose,
1933
+ physicalAttemptIndex: entry.attempt,
1934
+ providerFingerprint: runtimeTraceFingerprint(entry.provider),
1935
+ ...(entry.model ? { modelFingerprint: runtimeTraceFingerprint(entry.model) } : {}),
1936
+ readiness: 'ready',
1937
+ admission: 'admitted',
1938
+ ...(failureDiagnostic.httpStatusClass ? { httpStatusClass: failureDiagnostic.httpStatusClass } : {}),
1939
+ retryable: failureDiagnostic.retryable,
1940
+ safeAction: failureDiagnostic.safeAction,
1941
+ cause: outcome === 'cancelled' ? 'cancelled' : failureDiagnostic.cause,
1942
+ provenance: 'live',
1943
+ },
1944
+ },
1945
+ });
1946
+ };
1947
+ const recordDenied = (event, error) => {
1948
+ const eventKey = key(event);
1949
+ // A provider adapter can notify a rejection after our host admission
1950
+ // already threw, or after reporting a completed physical HTTP failure.
1951
+ // The first case retains one denied span and no receipt; the second is
1952
+ // already represented by its admitted/error span and must not be doubled.
1953
+ if (admittedKeys.has(eventKey))
1954
+ return;
1955
+ if (deniedKeys.has(eventKey))
1956
+ return;
1957
+ deniedKeys.add(eventKey);
1958
+ const entry = start(event, 'denied', error);
1959
+ const failureDiagnostic = diagnostic(event, error);
1960
+ observer?.finishSpan(entry.spanId, {
1961
+ outcome: 'denied',
1962
+ reasonCode: 'provider_failure',
1963
+ payload: {
1964
+ kind: 'provider',
1965
+ attempt: {
1966
+ version: 1,
1967
+ phase: providerTracePhase(input.phase),
1968
+ purpose: input.purpose,
1969
+ physicalAttemptIndex: event.attemptIndex,
1970
+ providerFingerprint: runtimeTraceFingerprint(event.provider),
1971
+ ...(event.model ? { modelFingerprint: runtimeTraceFingerprint(event.model) } : {}),
1972
+ readiness: 'unknown',
1973
+ admission: 'denied',
1974
+ ...(failureDiagnostic.httpStatusClass ? { httpStatusClass: failureDiagnostic.httpStatusClass } : {}),
1975
+ retryable: failureDiagnostic.retryable,
1976
+ safeAction: failureDiagnostic.safeAction,
1977
+ cause: failureDiagnostic.cause,
1978
+ provenance: 'live',
1979
+ },
1980
+ },
1981
+ });
1982
+ };
1983
+ const options = {
1984
+ onProviderDispatch: (event) => {
1985
+ // Admission is the physical-send boundary. First let the ledger accept
1986
+ // and receipt the exact envelope; only then start an admitted trace
1987
+ // span. If it rejects, record a denied boundary with no pending entry,
1988
+ // receipt, or claim that bytes left the process.
1989
+ try {
1990
+ const envelope = input.admit(event);
1991
+ const entry = start(event, 'admitted');
1992
+ const eventKey = key(event);
1993
+ admittedKeys.add(eventKey);
1994
+ pending.set(eventKey, [...(pending.get(eventKey) ?? []), entry]);
1995
+ return envelope;
1996
+ }
1997
+ catch (error) {
1998
+ recordDenied(event, error);
1999
+ throw error;
2000
+ }
2001
+ },
2002
+ onProviderDispatchComplete: (event) => {
2003
+ // A transport/process success is not yet an accepted meaning result. The
2004
+ // provider promise settles after parsing; close the matching physical
2005
+ // span from `settle` so malformed output remains a visible failure.
2006
+ if (event.outcome === 'ok')
2007
+ return;
2008
+ const eventKey = key(event);
2009
+ const entries = pending.get(eventKey) ?? [];
2010
+ const entry = entries.shift();
2011
+ if (entries.length > 0)
2012
+ pending.set(eventKey, entries);
2013
+ else
2014
+ pending.delete(eventKey);
2015
+ if (entry)
2016
+ finish(entry, event.outcome === 'cancelled' ? 'cancelled' : 'error', event.error, event.httpStatus);
2017
+ },
2018
+ onProviderDispatchRejected: (event) => {
2019
+ recordDenied(event, event.error);
2020
+ },
2021
+ };
2022
+ return {
2023
+ options,
2024
+ settle: (outcome, error) => {
2025
+ const entries = [...pending.values()].flat();
2026
+ pending.clear();
2027
+ for (const entry of entries)
2028
+ finish(entry, outcome, error);
2029
+ // A provider can fail before it reaches the dispatch observer (for
2030
+ // example subscription CLI readiness). Record that as one typed
2031
+ // provider boundary instead of allowing the root trace to fall back to
2032
+ // `unknown`.
2033
+ if (observedBoundaryCount === 0 && outcome !== 'ok' && observer?.enabled) {
2034
+ const failureDiagnostic = diagnostic(undefined, error);
2035
+ const span = observer.startSpan({
2036
+ name: 'provider.attempt',
2037
+ stage: 'provider',
2038
+ reasonCode: 'provider_failure',
2039
+ payload: {
2040
+ kind: 'provider',
2041
+ attempt: {
2042
+ version: 1,
2043
+ phase: providerTracePhase(input.phase),
2044
+ purpose: input.purpose,
2045
+ physicalAttemptIndex: 1,
2046
+ readiness: 'unknown',
2047
+ admission: 'unknown',
2048
+ retryable: failureDiagnostic.retryable,
2049
+ safeAction: failureDiagnostic.safeAction,
2050
+ cause: failureDiagnostic.cause,
2051
+ provenance: 'live',
2052
+ },
2053
+ },
2054
+ });
2055
+ observer.finishSpan(span, { outcome: outcome === 'cancelled' ? 'cancelled' : 'error', reasonCode: outcome === 'cancelled' ? 'cancelled' : 'provider_failure' });
2056
+ }
2057
+ },
2058
+ };
2059
+ }
2060
+ /**
2061
+ * Router interpretation happens before the answer runner's AsyncLocal trace
2062
+ * scope exists. Its request already carries the server-owned observer, so
2063
+ * adapt the physical category-classification or candidate-ID meaning call to
2064
+ * the shared physical-send trace wrapper without changing router authority.
2065
+ */
2066
+ function createRouterInterpretationProviderTrace(input) {
2067
+ const purpose = input.routerPhase === 'classification'
2068
+ ? 'classification'
2069
+ : 'answer_generation';
2070
+ return createProviderDispatchTrace({
2071
+ observer: askTraceObserverForV1(input.request),
2072
+ phase: input.routerPhase,
2073
+ purpose,
2074
+ admit: (event) => {
2075
+ const ledger = agentRunProviderEvidenceContext.getStore();
2076
+ if (ledger) {
2077
+ return ledger.observe(event, {
2078
+ purpose,
2079
+ dispatchPhase: input.routerPhase,
2080
+ optIn: false,
2081
+ });
2082
+ }
2083
+ const envelope = prepareProviderWireEnvelopeForDispatch(event.provider, event.envelope);
2084
+ assertProviderPayloadAllowed(envelope, {
2085
+ allowResultRows: false,
2086
+ maxResultRows: 0,
2087
+ purpose,
2088
+ });
2089
+ return envelope;
2090
+ },
2091
+ });
2092
+ }
2093
+ /**
2094
+ * The hypothesis planner is a real Research provider dispatch, not a local
2095
+ * planning convenience. Keep its one bounded call on the same server-owned
2096
+ * ledger and trace as meaning, generation, and narration so it cannot evade
2097
+ * the Research-12 cap or disappear from the run receipt.
2098
+ *
2099
+ * `planResearchHypotheses` deliberately accepts a small `generate`-only
2100
+ * provider interface. This adapter preserves that seam while keeping the
2101
+ * physical transport authority at the local-runtime boundary.
2102
+ *
2103
+ * @internal Exported for the local runtime planner/egress regression only.
2104
+ */
2105
+ export function createResearchHypothesisPlanningProvider(input) {
2106
+ const { provider, request, ledger } = input;
2107
+ return {
2108
+ name: provider.name,
2109
+ available: () => provider.available(),
2110
+ generate: async (messages, options) => {
2111
+ const planningTrace = createProviderDispatchTrace({
2112
+ observer: askTraceObserverForV1(request),
2113
+ phase: 'planning',
2114
+ purpose: 'answer_generation',
2115
+ admit: (event) => {
2116
+ if (ledger) {
2117
+ return ledger.observe(event, {
2118
+ purpose: 'answer_generation',
2119
+ dispatchPhase: 'planning',
2120
+ optIn: false,
2121
+ });
2122
+ }
2123
+ const envelope = prepareProviderWireEnvelopeForDispatch(event.provider, event.envelope);
2124
+ assertProviderPayloadAllowed(envelope, {
2125
+ allowResultRows: false,
2126
+ maxResultRows: 0,
2127
+ purpose: 'answer_generation',
2128
+ });
2129
+ return envelope;
2130
+ },
2131
+ });
2132
+ try {
2133
+ const response = await provider.generate(messages, {
2134
+ ...options,
2135
+ // Hypothesis planning is exactly one preparation transport. The
2136
+ // shared run ledger enforces the remaining Research-12 ceiling.
2137
+ maxProviderDispatches: 1,
2138
+ ...planningTrace.options,
2139
+ });
2140
+ planningTrace.settle('ok');
2141
+ return response;
2142
+ }
2143
+ catch (error) {
2144
+ planningTrace.settle(request.signal?.aborted || options?.signal?.aborted ? 'cancelled' : 'error', error);
2145
+ throw error;
2146
+ }
2147
+ },
2148
+ };
2149
+ }
1541
2150
  function mergeRunScopedProviderDispatchEvidence(run, evidence) {
1542
2151
  const providerEgressReceipts = [...evidence.providerEgressReceipts];
1543
2152
  const elapsed = Math.max(0, Date.parse(run.completedAt) - Date.parse(run.startedAt));
@@ -1611,8 +2220,46 @@ function mergeRunScopedProviderDispatchEvidence(run, evidence) {
1611
2220
  * @internal
1612
2221
  */
1613
2222
  export async function executePreparedAgenticSqlBoundary(input) {
2223
+ const trace = input.traceObserver ?? activeAskTraceObserver();
2224
+ const sqlFingerprint = runtimeTraceFingerprint(input.preparedSql);
2225
+ const sqlPayload = {
2226
+ kind: 'sql',
2227
+ execution: {
2228
+ version: 1,
2229
+ sqlFingerprint,
2230
+ reviewRequired: true,
2231
+ },
2232
+ };
2233
+ // The statement reached this boundary from the frozen generated-plan path.
2234
+ // Do not infer a generation success later from execution counters: this span
2235
+ // records the actual prepared statement handoff (fingerprint only).
2236
+ const generationSpan = trace?.startSpan({
2237
+ name: 'sql.generate',
2238
+ stage: 'sql',
2239
+ reasonCode: 'started',
2240
+ payload: sqlPayload,
2241
+ });
2242
+ trace?.finishSpan(generationSpan, { outcome: 'ok', reasonCode: 'completed', payload: sqlPayload });
1614
2243
  const capability = input.capability;
2244
+ const validationSpan = trace?.startSpan({
2245
+ name: 'sql.validate',
2246
+ stage: 'sql',
2247
+ reasonCode: 'started',
2248
+ payload: sqlPayload,
2249
+ });
2250
+ const validation = validateAuthorizedSqlReferences(input.preparedSql, undefined);
2251
+ if (!validation.ok) {
2252
+ trace?.finishSpan(validationSpan, { outcome: 'denied', reasonCode: 'sql_denied', payload: sqlPayload });
2253
+ throw analyticalError('The generated statement did not pass read-only SQL validation, so it was not executed.', { origin: 'governance_gate', stage: 'validation', code: 'unauthorized_sql' });
2254
+ }
2255
+ trace?.finishSpan(validationSpan, { outcome: 'ok', reasonCode: 'completed', payload: sqlPayload });
1615
2256
  if (capability) {
2257
+ const authorizationSpan = trace?.startSpan({
2258
+ name: 'sql.authorize',
2259
+ stage: 'sql',
2260
+ reasonCode: 'started',
2261
+ payload: sqlPayload,
2262
+ });
1616
2263
  const authorization = mintFinalSqlAuthorization({
1617
2264
  sql: input.preparedSql,
1618
2265
  proven: capability.provenIdentifiers.map((identifier) => ({
@@ -1626,7 +2273,6 @@ export async function executePreparedAgenticSqlBoundary(input) {
1626
2273
  targetFingerprint: capability.targetFingerprint,
1627
2274
  bindings: input.bindings,
1628
2275
  });
1629
- const validation = validateAuthorizedSqlReferences(input.preparedSql, undefined);
1630
2276
  const verdict = verifyFinalSql(authorization, input.preparedSql, qualifyAuthorizationReferences(input.preparedSql, {
1631
2277
  relations: validation.referencedRelations ?? [],
1632
2278
  columns: validation.referencedColumns ?? [],
@@ -1638,16 +2284,71 @@ export async function executePreparedAgenticSqlBoundary(input) {
1638
2284
  console.warn(`[dql] execution authorization: ${verdict.ok ? 'admitted' : 'REFUSED'} proven=${authorization.provenIdentifiers.length}${verdict.ok ? '' : ` reason=${verdict.reason}`}`);
1639
2285
  }
1640
2286
  if (!verdict.ok) {
2287
+ trace?.finishSpan(authorizationSpan, { outcome: 'denied', reasonCode: 'sql_denied', payload: sqlPayload });
1641
2288
  throw analyticalError(verdict.reason ?? 'The statement was not authorized for execution.', { origin: 'governance_gate', stage: 'execute', code: 'unauthorized_sql' });
1642
2289
  }
2290
+ trace?.finishSpan(authorizationSpan, { outcome: 'ok', reasonCode: 'completed', payload: sqlPayload });
2291
+ }
2292
+ const executionSpan = trace?.startSpan({
2293
+ name: 'sql.execute',
2294
+ stage: 'sql',
2295
+ reasonCode: 'started',
2296
+ payload: sqlPayload,
2297
+ });
2298
+ try {
2299
+ const result = await input.execute();
2300
+ trace?.finishSpan(executionSpan, { outcome: 'ok', reasonCode: 'completed', payload: sqlPayload });
2301
+ return result;
2302
+ }
2303
+ catch (error) {
2304
+ trace?.finishSpan(executionSpan, { outcome: 'error', reasonCode: 'sql_failure', payload: sqlPayload });
2305
+ throw error;
2306
+ }
2307
+ }
2308
+ /**
2309
+ * The DQL-artifact executor reaches this callback only after the existing
2310
+ * compiler/read-only validation completed. Record that physical boundary when
2311
+ * an Ask trace is active, without teaching the tracer to choose a tier or
2312
+ * authorize a statement. Generated SQL keeps its stronger capability-bound
2313
+ * boundary above.
2314
+ */
2315
+ async function executePreparedArtifactTraceBoundary(input) {
2316
+ const trace = activeAskTraceObserver();
2317
+ const payload = {
2318
+ kind: 'sql',
2319
+ execution: {
2320
+ version: 1,
2321
+ sqlFingerprint: runtimeTraceFingerprint(input.preparedSql),
2322
+ reviewRequired: input.reviewRequired,
2323
+ },
2324
+ };
2325
+ // Certified/semantic artifact execution arrives after its own authoritative
2326
+ // compiler checks. This wrapper has no independent validation or capability
2327
+ // verdict to observe, so it records only the physical execution instead of
2328
+ // manufacturing successful validate/authorize stages after the fact.
2329
+ const execution = trace?.startSpan({ name: 'sql.execute', stage: 'sql', reasonCode: 'started', payload });
2330
+ try {
2331
+ const result = await input.execute();
2332
+ trace?.finishSpan(execution, { outcome: 'ok', reasonCode: 'completed', payload });
2333
+ return result;
2334
+ }
2335
+ catch (error) {
2336
+ trace?.finishSpan(execution, { outcome: 'error', reasonCode: 'sql_failure', payload });
2337
+ throw error;
1643
2338
  }
1644
- return input.execute();
1645
2339
  }
1646
2340
  export async function startLocalServer(opts) {
1647
2341
  const { rootDir, executor, connection: rawConnection, preferredPort, projectRoot = process.cwd() } = opts;
1648
2342
  const bindHost = opts.host ?? process.env.DQL_HOST ?? '127.0.0.1';
1649
2343
  const loopback = bindHost === '127.0.0.1' || bindHost === 'localhost' || bindHost === '::1';
1650
2344
  const authToken = opts.authToken ?? process.env.DQL_SERVER_TOKEN;
2345
+ const trustedCliTraceToken = opts.trustedCliTraceToken;
2346
+ const cliAskTraceCapabilities = createLocalCliAskTraceCapabilityRegistryV1();
2347
+ // An ephemeral `dql agent ask` runtime receives its one-shot capability from
2348
+ // its parent process. Register it through the same short-lived, scoped
2349
+ // registry used by an already-running loopback runtime's challenge endpoint.
2350
+ if (trustedCliTraceToken)
2351
+ cliAskTraceCapabilities.issue({ capability: trustedCliTraceToken });
1651
2352
  const runtimeVersion = readDqlRuntimeVersion();
1652
2353
  // Warm the latest-version cache in the background (2s cap, 24h cache; offline → unknown).
1653
2354
  void fetchLatestPublishedDqlVersion();
@@ -1664,6 +2365,9 @@ export async function startLocalServer(opts) {
1664
2365
  if (gitRoot)
1665
2366
  ensureLocalRuntimeGitignore(projectRoot);
1666
2367
  let projectConfig = loadProjectConfig(projectRoot);
2368
+ // This opaque value lets the Ask browser cache distinguish a newly started
2369
+ // project/runtime on the same browser origin without exposing `projectRoot`.
2370
+ const conversationProjectIdentity = askConversationProjectIdentity(projectRoot);
1667
2371
  const analyticalExecutionService = new ExecutionService({
1668
2372
  executor,
1669
2373
  projectRoot,
@@ -1959,7 +2663,10 @@ export async function startLocalServer(opts) {
1959
2663
  };
1960
2664
  const requireActiveConnection = (candidate = connection) => {
1961
2665
  if (!candidate) {
1962
- throw new Error('No database connection is configured yet. Open Connections, add a warehouse or local DuckDB/file connection, then retry.');
2666
+ // This happens before DQL compiles an artifact or hands SQL to a
2667
+ // connector. Preserve that physical boundary for the Ask trace: callers
2668
+ // can project the typed setup failure without claiming that SQL executed.
2669
+ throw analyticalError('No database connection is configured yet. Open Connections, add a warehouse or local DuckDB/file connection, then retry.', { origin: 'host', stage: 'execute', code: 'connection_not_configured' });
1963
2670
  }
1964
2671
  assertConnectionNodeCompatibility(candidate);
1965
2672
  return candidate;
@@ -2288,17 +2995,20 @@ export async function startLocalServer(opts) {
2288
2995
  return undefined;
2289
2996
  return { columns: columns.length > 0 ? columns : Object.keys(rows[0]), rows };
2290
2997
  };
2291
- // Provider-backed narration for stakeholder stories. Reuses the same provider
2292
- // adapter as the planner; narrateResult always returns (deterministic fallback).
2293
- const narrateForAgentRun = async (input, allowProviderResultRows = false) => {
2998
+ // Provider-backed narration for an explicitly consented Research result.
2999
+ // Ordinary Ask never enters this helper; deterministic narration remains the
3000
+ // fallback when Research has no row-egress authority.
3001
+ const narrateForAgentRun = async (input, researchResultRowsOptIn = false, traceObserver) => {
2294
3002
  // Without caller consent, keep narration deterministic and make no physical
2295
3003
  // provider dispatch. With consent, serialize only the bounded, redacted
2296
3004
  // sample the transport receipt will account for — bounded by the project's
2297
3005
  // own egress policy, so an admin kill-switch is not silently bypassed here.
2298
- if (!allowProviderResultRows || !input.result)
3006
+ if (!researchResultRowsOptIn || !input.result)
2299
3007
  return narrateResult(input);
2300
3008
  const narrationRowEgress = resolveProviderResultRowEgressPolicy({
2301
3009
  projectSetting: projectConfig?.agent?.providerResultRowEgress,
3010
+ requestedMode: 'research',
3011
+ researchOptIn: researchResultRowsOptIn,
2302
3012
  });
2303
3013
  if (narrationRowEgress.maxNarrationRows === 0)
2304
3014
  return narrateResult(input);
@@ -2310,32 +3020,57 @@ export async function startLocalServer(opts) {
2310
3020
  ...input,
2311
3021
  result: safeResult,
2312
3022
  };
3023
+ const narrationTrace = createProviderDispatchTrace({
3024
+ // This Research handler can execute after its async trace scope has
3025
+ // returned. Carry the server-owned observer rather than relying on
3026
+ // AsyncLocalStorage lifetime, so the physical narration receipt and
3027
+ // provider.attempt span remain one-to-one.
3028
+ observer: traceObserver ?? activeAskTraceObserver(),
3029
+ phase: 'narration',
3030
+ purpose: 'research_narration',
3031
+ admit: (event) => {
3032
+ const ledger = agentRunProviderEvidenceContext.getStore();
3033
+ if (ledger) {
3034
+ return ledger.observe(event, {
3035
+ purpose: 'research_narration',
3036
+ dispatchPhase: 'narration',
3037
+ optIn: true,
3038
+ serializedResultShape: {
3039
+ resultRowCount: safeResult.rows.length,
3040
+ columnCount: safeResult.columns.length,
3041
+ },
3042
+ cumulativeResultRowCount: safeResult.rows.length,
3043
+ });
3044
+ }
3045
+ const envelope = prepareProviderWireEnvelopeForDispatch(event.provider, event.envelope);
3046
+ assertProviderPayloadAllowed(envelope, {
3047
+ allowResultRows: false,
3048
+ maxResultRows: 0,
3049
+ purpose: 'research_narration',
3050
+ });
3051
+ return envelope;
3052
+ },
3053
+ });
2313
3054
  return narrateResult(safeInput, {
2314
3055
  complete: async ({ system, user, signal }) => {
2315
3056
  const provider = await createBlockStudioAssistProvider(projectRoot);
2316
3057
  if (!provider)
2317
3058
  throw new Error('No AI provider configured for narration.');
2318
- return provider.generate([{ role: 'system', content: system }, { role: 'user', content: user }], {
2319
- maxTokens: 600,
2320
- temperature: 0.2,
2321
- signal,
2322
- maxProviderDispatches: 2,
2323
- ...(agentRunProviderEvidenceContext.getStore() ? {
2324
- // This lane narrates App/notebook results, not Research. It used to
2325
- // declare `research_narration` to clear the row check, which made
2326
- // receipt analytics conflate two different lanes.
2327
- onProviderDispatch: (event) => agentRunProviderEvidenceContext.getStore().observe(event, {
2328
- purpose: 'answer_narration',
2329
- dispatchPhase: 'narration',
2330
- optIn: true,
2331
- serializedResultShape: {
2332
- resultRowCount: safeResult.rows.length,
2333
- columnCount: safeResult.columns.length,
2334
- },
2335
- cumulativeResultRowCount: safeResult.rows.length,
2336
- }),
2337
- } : {}),
2338
- });
3059
+ try {
3060
+ const response = await provider.generate([{ role: 'system', content: system }, { role: 'user', content: user }], {
3061
+ maxTokens: 600,
3062
+ temperature: 0.2,
3063
+ signal,
3064
+ maxProviderDispatches: 2,
3065
+ ...narrationTrace.options,
3066
+ });
3067
+ narrationTrace.settle('ok');
3068
+ return response;
3069
+ }
3070
+ catch (error) {
3071
+ narrationTrace.settle(signal?.aborted ? 'cancelled' : 'error', error);
3072
+ throw error;
3073
+ }
2339
3074
  },
2340
3075
  });
2341
3076
  };
@@ -2343,18 +3078,32 @@ export async function startLocalServer(opts) {
2343
3078
  return runGovernedAgentAnswerForRunInner(request, repair, route, onProgress, routeDecision);
2344
3079
  }
2345
3080
  async function runGovernedAgentAnswerForRunInner(request, repair, route = 'generated_answer', onProgress, routeDecision) {
2346
- const governed = resolveGovernedAnswerRunner(projectRoot);
3081
+ const researchBranch = request.researchBranch;
3082
+ const isResearchChild = researchBranch?.childRunId === request.runId
3083
+ && Boolean(researchBranch?.rootRunId)
3084
+ && Boolean(researchBranch?.branchId);
3085
+ // The CLI may request one known provider, but the server remains the
3086
+ // authority for what that means. An unknown id is deliberately retained
3087
+ // long enough to become a typed model-selection/preflight diagnostic;
3088
+ // otherwise the old no-provider branch mislabeled every selection failure
3089
+ // as authentication.
3090
+ const requestedProvider = agentRunWorkspaceValue(request, 'provider');
3091
+ const governed = resolveGovernedAnswerRunner(projectRoot, requestedProvider);
2347
3092
  let resolvedProvider = governed?.provider ?? null;
2348
3093
  let runner = governed?.runner ?? null;
3094
+ const exactCertifiedProviderFreePlan = routeDecision?.resolvedAnalyticalPlan?.mode === 'authoritative'
3095
+ && routeDecision.resolvedAnalyticalPlan.capability === 'certified_execution'
3096
+ && routeDecision.analyticalCascadeDecision?.selectedTier === 'certified'
3097
+ && routeDecision.analyticalCascadeDecision.planFrozen === true;
2349
3098
  const exactProviderFreePlan = routeDecision?.resolvedAnalyticalPlan?.mode === 'authoritative'
2350
3099
  && (routeDecision.resolvedAnalyticalPlan.capability === 'certified_execution'
2351
3100
  || routeDecision.resolvedAnalyticalPlan.capability === 'semantic_execution');
2352
- if ((!resolvedProvider || !runner) && exactProviderFreePlan) {
3101
+ if (exactCertifiedProviderFreePlan || ((!resolvedProvider || !runner) && exactProviderFreePlan)) {
2353
3102
  const deterministicProvider = {
2354
3103
  name: 'ollama',
2355
3104
  available: async () => true,
2356
3105
  generate: async () => {
2357
- throw Object.assign(new Error('Exact certified/semantic execution must not dispatch a provider.'), {
3106
+ throw Object.assign(new Error('Exact certified execution must not dispatch a provider.'), {
2358
3107
  code: 'EXACT_ROUTE_PROVIDER_DISPATCH_FORBIDDEN',
2359
3108
  });
2360
3109
  },
@@ -2363,9 +3112,65 @@ export async function startLocalServer(opts) {
2363
3112
  runner = createDqlAgentProviderRunner('ollama', deterministicProvider);
2364
3113
  }
2365
3114
  if (!resolvedProvider || !runner) {
2366
- throw Object.assign(new Error('No AI provider is configured. Configure a subscription (Claude Code / Codex), OpenAI, Gemini, Ollama, or a custom OpenAI-compatible endpoint in Settings.'), { code: 'AUTHENTICATION_FAILED', providerPhase: 'preflight' });
3115
+ const error = governedProviderPreflightError(requestedProvider);
3116
+ // This is the only no-provider path. It has no physical send, but it is
3117
+ // still a real readiness failure and must be visible as such rather than
3118
+ // manufactured later by the engine from an executor outcome.
3119
+ const trace = askTraceObserverForV1(request);
3120
+ const diagnostic = classifyProviderFailure({
3121
+ message: error.message,
3122
+ code: error.code,
3123
+ phase: 'preflight',
3124
+ });
3125
+ const span = trace.startSpan({
3126
+ name: 'provider.preflight',
3127
+ stage: 'provider',
3128
+ reasonCode: 'provider_preflight',
3129
+ payload: {
3130
+ kind: 'provider',
3131
+ attempt: {
3132
+ version: 1,
3133
+ phase: 'preflight',
3134
+ physicalAttemptIndex: 0,
3135
+ readiness: 'unavailable',
3136
+ admission: 'unknown',
3137
+ cause: diagnostic.cause,
3138
+ retryable: diagnostic.retryable,
3139
+ safeAction: diagnostic.safeAction,
3140
+ provenance: 'live',
3141
+ },
3142
+ },
3143
+ });
3144
+ trace.finishSpan(span, {
3145
+ outcome: 'unavailable',
3146
+ reasonCode: 'provider_preflight',
3147
+ });
3148
+ throw error;
2367
3149
  }
2368
3150
  let governedAnswer;
3151
+ // The answer loop intentionally owns its own user-facing execution
3152
+ // failure. Some tool adapters serialize that error before returning the
3153
+ // governed answer, which means the non-enumerable analytical error tag is
3154
+ // no longer available there. Keep the tiny, typed fact at the physical
3155
+ // frozen-plan callback boundary instead of parsing the returned text. This
3156
+ // covers every frozen execution tier; it is trace-only evidence and neither
3157
+ // changes the answer, its route, nor its trust state.
3158
+ let frozenExecutionSetupFailure;
3159
+ const captureFrozenConnectionSetupFailure = (error) => {
3160
+ const frozen = routeDecision?.analyticalCascadeDecision?.planFrozen === true;
3161
+ const detail = analyticalErrorDetail(error);
3162
+ if (frozen
3163
+ && detail?.origin === 'host'
3164
+ && detail.stage === 'execute'
3165
+ && detail.code === 'connection_not_configured') {
3166
+ frozenExecutionSetupFailure = {
3167
+ version: 1,
3168
+ phase: 'execution',
3169
+ cause: 'connection_not_configured',
3170
+ safeAction: 'configure_connection',
3171
+ };
3172
+ }
3173
+ };
2369
3174
  let providerError;
2370
3175
  let providerDispatchEvidence;
2371
3176
  let providerBoundaryDiagnostic;
@@ -2480,34 +3285,75 @@ export async function startLocalServer(opts) {
2480
3285
  // Local to this exact answer invocation. Compound children each enter this
2481
3286
  // function separately, so no child can consume another child's capability.
2482
3287
  const agenticExecutionCapabilityGate = new AgenticExecutionCapabilityGate();
2483
- const prepareExploratorySqlExecution = async (sql) => {
3288
+ // The first proposal and its one permitted same-plan repair are distinct
3289
+ // one-shot authorities. Never recycle the first capability after the
3290
+ // connector has consumed it, and never admit a second repair.
3291
+ let exploratoryAuthorization;
3292
+ const prepareExploratorySqlExecution = async (sql, _artifact, authorizationAttempt) => {
2484
3293
  const cascade = routeDecision?.analyticalCascadeDecision;
2485
3294
  const selectedAttempt = selectedExploratoryAttempt;
3295
+ const selectedPlan = routeDecision?.resolvedAnalyticalPlan;
3296
+ const authorizationStateMismatch = (message) => Object.assign(analyticalError(message, {
3297
+ // This is an internal lifecycle invariant, not a connector, model,
3298
+ // or retryable network failure. The engine turns this typed code
3299
+ // into the durable SQL-authorize incident below.
3300
+ origin: 'host', stage: 'validation', code: 'exploratory_authorization_state_mismatch',
3301
+ }), { code: 'INTERNAL_EXPLORATORY_AUTHORIZATION_STATE_MISMATCH' });
2486
3302
  if (!cascade
2487
3303
  || cascade.selectedTier !== 'exploratory_sql'
2488
- || cascade.planFrozen
3304
+ || !cascade.planFrozen
2489
3305
  || !selectedAttempt
2490
3306
  || selectedAttempt.outcome !== 'executable'
2491
- || selectedAttempt.candidateIds.length === 0) {
2492
- throw analyticalError('The generated SQL no longer matches the router-selected exploratory path, so DQL did not authorize execution.', {
2493
- origin: 'governance_gate', stage: 'validation', code: 'unauthorized_sql',
2494
- });
3307
+ || !selectedAttempt.planFrozen
3308
+ || selectedAttempt.candidateIds.length === 0
3309
+ || !selectedPlan
3310
+ || selectedPlan.capability !== 'bounded_exploration') {
3311
+ throw authorizationStateMismatch('The frozen exploratory plan was not available for SQL authorization, so execution was not attempted.');
3312
+ }
3313
+ const proposedSqlFingerprint = executionFingerprint(sql);
3314
+ const isRepair = authorizationAttempt?.index === 1;
3315
+ if (authorizationAttempt && !isRepair) {
3316
+ throw authorizationStateMismatch('The exploratory SQL repair authorization did not carry the required bounded repair index.');
3317
+ }
3318
+ if (isRepair) {
3319
+ const initial = exploratoryAuthorization?.initial;
3320
+ if (!initial || !authorizationAttempt.parentSqlFingerprint
3321
+ || authorizationAttempt.parentSqlFingerprint !== initial.result.freeze.sqlFingerprint) {
3322
+ throw authorizationStateMismatch('The exploratory SQL repair did not bind to the initial frozen-plan SQL authorization, so execution was not attempted.');
3323
+ }
3324
+ const existingRepair = exploratoryAuthorization?.repair;
3325
+ if (existingRepair) {
3326
+ if (existingRepair.sqlFingerprint === proposedSqlFingerprint
3327
+ && existingRepair.parentSqlFingerprint === authorizationAttempt.parentSqlFingerprint) {
3328
+ return existingRepair.result;
3329
+ }
3330
+ throw authorizationStateMismatch('A second or mismatched exploratory SQL repair was submitted after the one permitted same-plan repair authorization.');
3331
+ }
3332
+ }
3333
+ else {
3334
+ const initial = exploratoryAuthorization?.initial;
3335
+ if (initial) {
3336
+ if (initial.sqlFingerprint === proposedSqlFingerprint)
3337
+ return initial.result;
3338
+ throw authorizationStateMismatch('A different SQL proposal was submitted after the exploratory plan had already been authorized.');
3339
+ }
3340
+ if (exploratoryAuthorization?.repair) {
3341
+ throw authorizationStateMismatch('The initial exploratory SQL authorization could not be replaced after a same-plan repair authorization.');
3342
+ }
2495
3343
  }
2496
3344
  if (!request.runId || !semanticConnection || !preparedContextPack || !preparedExploratoryContextPack) {
2497
- throw analyticalError('DQL could not bind the selected exploratory query to this run, target, and metadata snapshot, so it was not executed.', {
2498
- origin: 'governance_gate', stage: 'validation', code: 'unauthorized_sql',
2499
- });
3345
+ throw authorizationStateMismatch('The frozen exploratory plan could not be bound to this run, target, and metadata snapshot, so execution was not attempted.');
2500
3346
  }
2501
3347
  const retrievalSnapshotId = routeDecision?.retrievalEvidence?.snapshotId;
2502
3348
  const retrievalSourceFingerprint = routeDecision?.retrievalEvidence?.sourceFingerprint;
2503
3349
  const retrievalFreezeSnapshotId = retrievalSnapshotId ?? preparedContextPack.knowledgeLens.snapshotId;
2504
- if ((retrievalSnapshotId && retrievalSnapshotId !== preparedContextPack.knowledgeLens.snapshotId)
3350
+ if (selectedPlan.snapshotId !== retrievalFreezeSnapshotId
3351
+ ||
3352
+ (retrievalSnapshotId && retrievalSnapshotId !== preparedContextPack.knowledgeLens.snapshotId)
2505
3353
  || (retrievalSourceFingerprint
2506
3354
  && preparedContextPack.freshness.fingerprint
2507
3355
  && retrievalSourceFingerprint !== preparedContextPack.freshness.fingerprint)) {
2508
- throw analyticalError('The selected exploratory query no longer matches its retrieval snapshot or source fingerprint, so it was not executed.', {
2509
- origin: 'governance_gate', stage: 'validation', code: 'snapshot_drift',
2510
- });
3356
+ throw authorizationStateMismatch('The frozen exploratory plan no longer matches its retrieval snapshot or source fingerprint, so execution was not attempted.');
2511
3357
  }
2512
3358
  projectSnapshot();
2513
3359
  projectSnapshots.assertCurrent(runProjectSnapshot.snapshotId);
@@ -2518,6 +3364,20 @@ export async function startLocalServer(opts) {
2518
3364
  origin: 'governance_gate', stage: 'validation', code: 'unauthorized_sql',
2519
3365
  });
2520
3366
  }
3367
+ // The plan owns explicit outputs before SQL is generated. Do not allow a
3368
+ // generated query to execute a five-row "closest" table that omitted a
3369
+ // requested order/product identifier: review-required is not permission
3370
+ // to change the user-visible result tuple.
3371
+ const outputProjection = validateFrozenRequiredOutputProjection({
3372
+ plan: selectedPlan,
3373
+ sql,
3374
+ ...(semanticDriver ? { dialect: semanticDriver } : {}),
3375
+ });
3376
+ if (!outputProjection.ok) {
3377
+ throw analyticalError('The selected exploratory query did not prove every explicitly requested frozen output against its exact source column, so it was not executed.', {
3378
+ origin: 'governance_gate', stage: 'validation', code: 'unauthorized_sql',
3379
+ });
3380
+ }
2521
3381
  const validation = validateAuthorizedSqlReferences(sql, preparedExploratoryContextPack, {
2522
3382
  ...(semanticDriver ? { dialect: semanticDriver } : {}),
2523
3383
  runtimeSchema: preparedExploratoryQualifiedSchemaContext,
@@ -2570,23 +3430,19 @@ export async function startLocalServer(opts) {
2570
3430
  proofs.set(`${runtimeTable.relation}.${column}`, 'schema_tool');
2571
3431
  }
2572
3432
  const candidateIds = [...selectedAttempt.candidateIds];
2573
- const sqlFingerprint = executionFingerprint(sql);
2574
- const planFingerprint = executionFingerprint(stableExecutionValue({
2575
- version: 1,
2576
- tier: 'exploratory_sql',
2577
- snapshotId: retrievalFreezeSnapshotId,
2578
- executionSnapshotId: runProjectSnapshot.snapshotId,
2579
- sourceFingerprint: preparedContextPack.freshness.fingerprint,
2580
- targetFingerprint: target.identityFingerprint,
2581
- candidateIds,
2582
- sqlFingerprint,
2583
- }));
2584
- const planId = `exploratory-${planFingerprint.slice(0, 24)}`;
3433
+ const sqlFingerprint = proposedSqlFingerprint;
3434
+ // The immutable router plan is already frozen before SQL generation.
3435
+ // SQL authorization binds the generated bytes and live target *to* that
3436
+ // plan; it must not mint a replacement plan identity.
3437
+ const planFingerprint = selectedPlan.fingerprint;
3438
+ const planId = selectedPlan.planId;
2585
3439
  const capability = createAgenticSqlExecutionCapability({
2586
3440
  sql,
2587
3441
  proven: [...proofs.entries()].map(([identifier, evidence]) => ({ identifier, evidence })),
2588
3442
  runId: request.runId,
2589
- executionId: `${request.runId}:exploratory:${sqlFingerprint.slice(0, 16)}`,
3443
+ executionId: isRepair
3444
+ ? `${request.runId}:exploratory:repair:1:${sqlFingerprint.slice(0, 16)}`
3445
+ : `${request.runId}:exploratory:${sqlFingerprint.slice(0, 16)}`,
2590
3446
  snapshotId: runProjectSnapshot.snapshotId,
2591
3447
  planId,
2592
3448
  targetFingerprint: target.identityFingerprint,
@@ -2596,13 +3452,20 @@ export async function startLocalServer(opts) {
2596
3452
  // latter made a fully validated immutable proposal fail only after its
2597
3453
  // exploratory plan had frozen.
2598
3454
  bindings: { sqlParams: [], variables: {} },
3455
+ exploratoryAuthorizationAttempt: isRepair
3456
+ ? {
3457
+ version: 1,
3458
+ index: 1,
3459
+ parentSqlFingerprint: authorizationAttempt.parentSqlFingerprint,
3460
+ }
3461
+ : { version: 1, index: 0 },
2599
3462
  });
2600
3463
  if (!capability) {
2601
3464
  throw analyticalError('DQL could not mint a request-scoped exploratory execution capability, so it was not executed.', {
2602
3465
  origin: 'governance_gate', stage: 'validation', code: 'unauthorized_sql',
2603
3466
  });
2604
3467
  }
2605
- return {
3468
+ const result = {
2606
3469
  capability,
2607
3470
  freeze: {
2608
3471
  version: 1,
@@ -2614,11 +3477,33 @@ export async function startLocalServer(opts) {
2614
3477
  sqlFingerprint: capability.candidateSqlFingerprint,
2615
3478
  candidateIds,
2616
3479
  authorization: 'capability_minted',
3480
+ requiredOutputBindings: outputProjection.bindingProofs,
3481
+ authorizationAttempt: isRepair
3482
+ ? {
3483
+ version: 1,
3484
+ index: 1,
3485
+ parentSqlFingerprint: authorizationAttempt.parentSqlFingerprint,
3486
+ }
3487
+ : { version: 1, index: 0 },
2617
3488
  },
2618
3489
  };
3490
+ if (isRepair) {
3491
+ exploratoryAuthorization ??= {};
3492
+ exploratoryAuthorization.repair = {
3493
+ sqlFingerprint,
3494
+ parentSqlFingerprint: authorizationAttempt.parentSqlFingerprint,
3495
+ result,
3496
+ };
3497
+ }
3498
+ else {
3499
+ exploratoryAuthorization ??= {};
3500
+ exploratoryAuthorization.initial = { sqlFingerprint, result };
3501
+ }
3502
+ return result;
2619
3503
  };
2620
- await runner.run({
3504
+ await agentRunAskTraceContext.run(askTraceObserverForV1(request), async () => runner.run(attachAskTraceObserverV1({
2621
3505
  provider: resolvedProvider,
3506
+ ...(exactCertifiedProviderFreePlan ? { providerPreflightRequired: false } : {}),
2622
3507
  ...(agentRunProviderEvidenceContext.getStore()
2623
3508
  ? { providerDispatchEvidenceSink: agentRunProviderEvidenceContext.getStore() }
2624
3509
  : {}),
@@ -2641,9 +3526,14 @@ export async function startLocalServer(opts) {
2641
3526
  },
2642
3527
  reasoningEffort,
2643
3528
  ...(analysisDepth ? { analysisDepth } : {}),
2644
- orchestrationMode: route === 'research' ? 'research' : 'ask',
2645
- allowProviderSemanticMemberSelection: route === 'research',
2646
- researchResultRowsOptIn: route === 'research' && request.researchResultRowsOptIn === true,
3529
+ // A child remains a normal Ask cascade for routing and frozen-plan
3530
+ // authority, but every physical provider transport belongs to its
3531
+ // explicit Research root's ledger/trace budget. This prevents a
3532
+ // concurrent child from inheriting the ordinary Ask one-meaning cap.
3533
+ orchestrationMode: route === 'research' || isResearchChild ? 'research' : 'ask',
3534
+ allowProviderSemanticMemberSelection: route === 'research' || isResearchChild,
3535
+ researchResultRowsOptIn: (request.requestedMode === 'research' || isResearchChild)
3536
+ && request.researchResultRowsOptIn === true,
2647
3537
  projectRoot,
2648
3538
  // Keys the execution authorization, so the proofs the analyst loop
2649
3539
  // gathers can be checked against the statement this run executes.
@@ -2832,7 +3722,7 @@ export async function startLocalServer(opts) {
2832
3722
  // deliberately narrower than exploratory preflight: it only qualifies
2833
3723
  // an unambiguous FROM/JOIN leaf already present in the artifact and it
2834
3724
  // never supplies a missing table, join, or column.
2835
- executeCertifiedBlock: (node, invocation) => {
3725
+ executeCertifiedBlock: async (node, invocation) => {
2836
3726
  const frozenCertifiedPlan = routeDecision?.analyticalCascadeDecision?.planFrozen === true
2837
3727
  && routeDecision.analyticalCascadeDecision.selectedTier === 'certified'
2838
3728
  && routeDecision.resolvedAnalyticalPlan?.capability === 'certified_execution';
@@ -2865,9 +3755,23 @@ export async function startLocalServer(opts) {
2865
3755
  const certifiedSchemaContext = frozenCertifiedPlan
2866
3756
  ? buildFrozenCertifiedSchemaContext(preparedContextPack, runProjectSnapshot.manifest)
2867
3757
  : preparedQualifiedSchemaContext;
2868
- return executeCertifiedBlockForAgent(node, invocation, semanticConnection, semanticConnectionName, certifiedSchemaContext, frozenCertifiedPlan);
3758
+ try {
3759
+ return await executeCertifiedBlockForAgent(node, invocation, semanticConnection, semanticConnectionName, certifiedSchemaContext, frozenCertifiedPlan);
3760
+ }
3761
+ catch (error) {
3762
+ captureFrozenConnectionSetupFailure(error);
3763
+ throw error;
3764
+ }
3765
+ },
3766
+ executeGeneratedSql: async (sql, artifact) => {
3767
+ try {
3768
+ return await executeGeneratedArtifactForAgent(request.question, sql, artifact, semanticConnection, semanticConnectionName);
3769
+ }
3770
+ catch (error) {
3771
+ captureFrozenConnectionSetupFailure(error);
3772
+ throw error;
3773
+ }
2869
3774
  },
2870
- executeGeneratedSql: (sql, artifact) => executeGeneratedArtifactForAgent(request.question, sql, artifact, semanticConnection, semanticConnectionName),
2871
3775
  prepareExploratorySqlExecution,
2872
3776
  executeAgenticGeneratedSql: async (capability, sql, artifact) => {
2873
3777
  if (!agenticExecutionCapabilityGate.consume(capability)) {
@@ -2875,20 +3779,34 @@ export async function startLocalServer(opts) {
2875
3779
  origin: 'governance_gate', stage: 'execute', code: 'unauthorized_sql',
2876
3780
  });
2877
3781
  }
2878
- return executeGeneratedArtifactForAgent(request.question, sql, artifact, semanticConnection, semanticConnectionName, capability, {
2879
- runId: request.runId,
2880
- executionId: capability.executionId,
2881
- snapshotId: runProjectSnapshot.snapshotId,
2882
- planId: capability.planId,
2883
- targetFingerprint: capability.targetFingerprint,
2884
- });
3782
+ try {
3783
+ return await executeGeneratedArtifactForAgent(request.question, sql, artifact, semanticConnection, semanticConnectionName, capability, {
3784
+ runId: request.runId,
3785
+ executionId: capability.executionId,
3786
+ snapshotId: runProjectSnapshot.snapshotId,
3787
+ planId: capability.planId,
3788
+ targetFingerprint: capability.targetFingerprint,
3789
+ });
3790
+ }
3791
+ catch (error) {
3792
+ captureFrozenConnectionSetupFailure(error);
3793
+ throw error;
3794
+ }
3795
+ },
3796
+ executeDqlArtifact: async (artifact) => {
3797
+ try {
3798
+ return await executeArtifactReferenceForAgent(artifact, request.question, semanticConnection, semanticConnectionName);
3799
+ }
3800
+ catch (error) {
3801
+ captureFrozenConnectionSetupFailure(error);
3802
+ throw error;
3803
+ }
2885
3804
  },
2886
- executeDqlArtifact: (artifact) => executeArtifactReferenceForAgent(artifact, request.question, semanticConnection, semanticConnectionName),
2887
3805
  getSchemaContext: (question, preparedContextPack) => getSchemaContextForAgent(question, preparedContextPack, semanticConnection, request.executionTarget?.target === 'connection'
2888
3806
  ? request.executionTarget.connectionName
2889
3807
  : undefined),
2890
3808
  probeNamedRelations: (relations) => probeNamedRelationsForAgent(relations, semanticConnection),
2891
- }, (turn) => {
3809
+ }, askTraceObserverForV1(request)), (turn) => {
2892
3810
  if (turn.kind === 'thinking')
2893
3811
  onProgress?.(turn.text);
2894
3812
  if (turn.kind === 'tool_result' && turn.id === 'governed_answer') {
@@ -2899,13 +3817,22 @@ export async function startLocalServer(opts) {
2899
3817
  providerDispatchEvidence = turn.dispatchEvidence;
2900
3818
  providerBoundaryDiagnostic = turn.providerDiagnostic;
2901
3819
  }
2902
- }, runSignal);
3820
+ }, runSignal));
2903
3821
  if (!governedAnswer) {
2904
3822
  throw Object.assign(new Error(providerError ?? 'The AI provider did not return a governed answer.'), {
2905
3823
  ...(providerDispatchEvidence ? { providerDispatchEvidence } : {}),
2906
3824
  ...(providerBoundaryDiagnostic ? { providerDiagnostic: providerBoundaryDiagnostic } : {}),
2907
3825
  });
2908
3826
  }
3827
+ if (frozenExecutionSetupFailure && !governedAnswer.observabilityExecutionFailure) {
3828
+ // Preserve the fact captured at the actual pre-execution setup
3829
+ // boundary. The error itself remains redacted and the answer loop keeps
3830
+ // sole authority over the terminal answer/provenance.
3831
+ governedAnswer = {
3832
+ ...governedAnswer,
3833
+ observabilityExecutionFailure: frozenExecutionSetupFailure,
3834
+ };
3835
+ }
2909
3836
  return governedAnswer;
2910
3837
  }
2911
3838
  /**
@@ -3273,6 +4200,10 @@ export async function startLocalServer(opts) {
3273
4200
  resultFingerprint: answer.result.resultFingerprint ?? answer.result.executionReceipt?.resultFingerprint,
3274
4201
  executionReceipt: answer.result.executionReceipt,
3275
4202
  answerTier: answer.route?.tier ?? answer.sourceTier,
4203
+ trustState: canonicalPersistedTrustState({
4204
+ answer,
4205
+ answerTier: answer.route?.tier ?? answer.sourceTier,
4206
+ }),
3276
4207
  });
3277
4208
  answer.result = {
3278
4209
  ...answer.result,
@@ -3281,6 +4212,7 @@ export async function startLocalServer(opts) {
3281
4212
  rowCount: canonical.rowCount,
3282
4213
  resultFingerprint: canonical.resultFingerprint,
3283
4214
  ...(canonical.executionReceipt ? { executionReceipt: canonical.executionReceipt } : {}),
4215
+ ...(canonical.trustState ? { trustState: canonical.trustState } : {}),
3284
4216
  ...(canonical.answerTier ? { answerTier: canonical.answerTier } : {}),
3285
4217
  };
3286
4218
  }
@@ -3305,6 +4237,10 @@ export async function startLocalServer(opts) {
3305
4237
  resultFingerprint: parent.value.result.resultFingerprint ?? parent.value.result.executionReceipt?.resultFingerprint,
3306
4238
  executionReceipt: parent.value.result.executionReceipt,
3307
4239
  answerTier: parent.value.route?.tier ?? parent.value.sourceTier,
4240
+ trustState: canonicalPersistedTrustState({
4241
+ answer: parent.value,
4242
+ answerTier: parent.value.route?.tier ?? parent.value.sourceTier,
4243
+ }),
3308
4244
  })
3309
4245
  : undefined, parent?.task);
3310
4246
  },
@@ -3406,13 +4342,10 @@ export async function startLocalServer(opts) {
3406
4342
  ?? governedAnswer.result.executionReceipt?.resultFingerprint,
3407
4343
  executionReceipt: governedAnswer.result.executionReceipt,
3408
4344
  answerTier: governedAnswer.route?.tier ?? governedAnswer.sourceTier,
3409
- trustState: governedAnswer.certification === 'certified'
3410
- ? 'certified'
3411
- : governedAnswer.kind === 'no_answer'
3412
- ? 'blocked'
3413
- : governedAnswer.reviewStatus === 'analyst_review_required'
3414
- ? 'review_required'
3415
- : 'governed',
4345
+ trustState: canonicalPersistedTrustState({
4346
+ answer: governedAnswer,
4347
+ answerTier: governedAnswer.route?.tier ?? governedAnswer.sourceTier,
4348
+ }),
3416
4349
  });
3417
4350
  governedAnswer.result = {
3418
4351
  ...governedAnswer.result,
@@ -3426,6 +4359,7 @@ export async function startLocalServer(opts) {
3426
4359
  ...(canonical.executionTime !== undefined ? { executionTime: canonical.executionTime } : {}),
3427
4360
  ...(canonical.truncated ? { truncated: true } : {}),
3428
4361
  ...(canonical.executionReceipt ? { executionReceipt: canonical.executionReceipt } : {}),
4362
+ ...(canonical.trustState ? { trustState: canonical.trustState } : {}),
3429
4363
  ...(canonical.answerTier ? { answerTier: canonical.answerTier } : {}),
3430
4364
  };
3431
4365
  }
@@ -3736,26 +4670,28 @@ export async function startLocalServer(opts) {
3736
4670
  || (isExploratory && !governedAnswer.result)
3737
4671
  ? undefined
3738
4672
  : sql;
3739
- // Narrate any run that actually produced values. This used to be gated on
3740
- // `requestedMode === 'research'`, and because the UI sends ordinary Ask as
3741
- // `'auto'`, every normal question skipped synthesis and shipped the answer
3742
- // loop's deterministic fact-join as the primary answer the reported
3743
- // `column: value` dump. A model that never sees the result cannot describe
3744
- // it, so a bounded, redacted row sample goes with the request unless a
3745
- // project admin has switched row egress off.
4673
+ // Ordinary Ask stops after its bounded meaning call and the selected
4674
+ // deterministic analytical execution. It must not quietly make a second
4675
+ // narration dispatch or disclose result rows: that would make a governed
4676
+ // semantic receipt look like an opted-in Research run. Explicit Research
4677
+ // alone may ask a provider to narrate the settled result.
3746
4678
  let synthesizedAnswer;
3747
4679
  const providerEgressReceipts = [
3748
4680
  ...(governedAnswer.providerEgressReceipts ?? []),
3749
4681
  ];
3750
4682
  let narrationDurationMs = 0;
3751
- const researchRowsOptIn = route === 'research' && request.researchResultRowsOptIn === true;
4683
+ const researchRowsOptIn = request.requestedMode === 'research' && request.researchResultRowsOptIn === true;
3752
4684
  const rowEgress = resolveProviderResultRowEgressPolicy({
3753
4685
  projectSetting: projectConfig?.agent?.providerResultRowEgress,
4686
+ requestedMode: request.requestedMode,
3754
4687
  researchOptIn: researchRowsOptIn,
3755
4688
  });
3756
- // `createBlockStudioAssistProvider` returns null when no AI provider is
3757
- // configured; a narration failure must never take the governed answer down.
3758
- const narrationProvider = await createBlockStudioAssistProvider(projectRoot).catch(() => null);
4689
+ // Do not even initialize a narration transport for ordinary Ask. This
4690
+ // preserves the one-meaning-call contract and ensures the provider egress
4691
+ // ledger contains only physical dispatches that actually occurred.
4692
+ const narrationProvider = request.requestedMode === 'research'
4693
+ ? await createBlockStudioAssistProvider(projectRoot).catch(() => null)
4694
+ : null;
3759
4695
  const narrationPlan = planAgentRunNarration(governedAnswer, {
3760
4696
  requestedMode: request.requestedMode,
3761
4697
  providerAvailable: Boolean(narrationProvider),
@@ -3798,13 +4734,33 @@ export async function startLocalServer(opts) {
3798
4734
  if (narrationPlan.mode !== 'skip' && narrationProvider) {
3799
4735
  const narrationStartedAtMs = Date.now();
3800
4736
  const draft = governedAnswer.answer ?? governedAnswer.text;
3801
- const observeNarrationDispatch = (event) => agentRunProviderEvidenceContext.getStore().observe(event, {
3802
- purpose: 'answer_narration',
3803
- dispatchPhase: 'narration',
3804
- optIn: narrationMaxRows > 0,
3805
- serializedResultShape: {
3806
- resultRowCount: providerPreview?.rows.length ?? 0,
3807
- columnCount: providerPreview?.columns.length ?? 0,
4737
+ const narrationTrace = createProviderDispatchTrace({
4738
+ // Answer-loop narration runs after the inner runner's AsyncLocal trace
4739
+ // scope. The request carries the canonical stored observer for this
4740
+ // run, so use it directly instead of losing the physical send here.
4741
+ observer: askTraceObserverForV1(request),
4742
+ phase: 'narration',
4743
+ purpose: 'research_narration',
4744
+ admit: (event) => {
4745
+ const ledger = agentRunProviderEvidenceContext.getStore();
4746
+ if (ledger) {
4747
+ return ledger.observe(event, {
4748
+ purpose: 'research_narration',
4749
+ dispatchPhase: 'narration',
4750
+ optIn: researchRowsOptIn && narrationMaxRows > 0,
4751
+ serializedResultShape: {
4752
+ resultRowCount: providerPreview?.rows.length ?? 0,
4753
+ columnCount: providerPreview?.columns.length ?? 0,
4754
+ },
4755
+ });
4756
+ }
4757
+ const envelope = prepareProviderWireEnvelopeForDispatch(event.provider, event.envelope);
4758
+ assertProviderPayloadAllowed(envelope, {
4759
+ allowResultRows: false,
4760
+ maxResultRows: 0,
4761
+ purpose: 'research_narration',
4762
+ });
4763
+ return envelope;
3808
4764
  },
3809
4765
  });
3810
4766
  const narrationDispatchOptions = (factCount = 0) => ({
@@ -3819,9 +4775,7 @@ export async function startLocalServer(opts) {
3819
4775
  maxTokens: narrationMaxTokensForFacts(factCount),
3820
4776
  temperature: 0.3,
3821
4777
  maxProviderDispatches: 2,
3822
- ...(agentRunProviderEvidenceContext.getStore()
3823
- ? { onProviderDispatch: observeNarrationDispatch }
3824
- : {}),
4778
+ ...narrationTrace.options,
3825
4779
  });
3826
4780
  try {
3827
4781
  const frame = governedAnswer.resolvedAnalyticalPlan?.analyticalFrame;
@@ -3881,8 +4835,10 @@ export async function startLocalServer(opts) {
3881
4835
  validationFailures: [],
3882
4836
  };
3883
4837
  }
4838
+ narrationTrace.settle('ok');
3884
4839
  }
3885
- catch {
4840
+ catch (error) {
4841
+ narrationTrace.settle(request.signal?.aborted ? 'cancelled' : 'error', error);
3886
4842
  // Keep the governed draft on any narration failure.
3887
4843
  synthesizedAnswer = undefined;
3888
4844
  narrationIntegrityReceipt = {
@@ -3896,27 +4852,10 @@ export async function startLocalServer(opts) {
3896
4852
  narrationDurationMs = Date.now() - narrationStartedAtMs;
3897
4853
  }
3898
4854
  }
3899
- // Every run accounts for its narration egress, including the runs that did
3900
- // not narrate "no receipt" must never be ambiguous between "no rows left
3901
- // the host" and "nobody looked".
3902
- {
3903
- const narrated = narrationSource !== undefined;
3904
- const rowsSent = narrated ? providerPreview?.rows.length ?? 0 : 0;
3905
- providerEgressReceipts.push(createProviderEgressReceipt({
3906
- purpose: 'answer_narration',
3907
- provider: narrated ? narrationProvider?.name ?? 'none' : 'none',
3908
- permittedCategories: rowsSent > 0
3909
- ? ['instructions', 'question', 'governed_context', 'result_rows']
3910
- : ['instructions', 'question', 'governed_context'],
3911
- optIn: rowsSent > 0,
3912
- redactionPolicyId: rowEgress.policyId,
3913
- payload: narrated
3914
- ? { question: request.question, resultPreview: rowsSent > 0 ? providerPreview : undefined }
3915
- : { resultRows: 0, providerDisabled: !narrationProvider },
3916
- resultRowCount: rowsSent,
3917
- columnCount: narrated ? providerPreview?.columns.length ?? 0 : 0,
3918
- }));
3919
- }
4855
+ // Provider egress receipts are evidence of a physical provider dispatch,
4856
+ // not a completeness checklist. `RunScopedProviderDispatchEvidence.observe`
4857
+ // writes the receipt immediately before a real call; deliberately skipped
4858
+ // narration therefore has no synthetic `answer_narration` receipt.
3920
4859
  // A grounding/modeling gap is a REFUSAL. It was computed above but never
3921
4860
  // reached this ternary, so it fell through to `needs_review` — the same
3922
4861
  // status as a perfectly good uncertified answer. Every downstream trust
@@ -4149,6 +5088,9 @@ export async function startLocalServer(opts) {
4149
5088
  ...(governedAnswer.exploratoryExecutionFreeze
4150
5089
  ? { analyticalExecutionFreeze: governedAnswer.exploratoryExecutionFreeze }
4151
5090
  : {}),
5091
+ ...(governedAnswer.exploratoryRepairExecutionFreeze
5092
+ ? { analyticalExecutionRepairFreeze: governedAnswer.exploratoryRepairExecutionFreeze }
5093
+ : {}),
4152
5094
  };
4153
5095
  };
4154
5096
  /**
@@ -4209,18 +5151,44 @@ export async function startLocalServer(opts) {
4209
5151
  ...(request.history ?? []).slice(-6).map((message) => ({ role: message.role, content: message.text })),
4210
5152
  { role: 'user', content: request.question },
4211
5153
  ];
4212
- text = (await provider.generate(messages, {
4213
- maxTokens: 320,
4214
- temperature: 0.6,
4215
- maxProviderDispatches: 2,
4216
- ...(agentRunProviderEvidenceContext.getStore() ? {
4217
- onProviderDispatch: (event) => agentRunProviderEvidenceContext.getStore().observe(event, {
5154
+ // Conversation generation is a physical provider transport too.
5155
+ // It is outside the router's candidate-ID meaning call, so retain its
5156
+ // actual `generation` phase rather than relabelling it as meaning.
5157
+ const conversationTrace = createProviderDispatchTrace({
5158
+ observer: askTraceObserverForV1(request),
5159
+ phase: 'generation',
5160
+ purpose: 'answer_generation',
5161
+ admit: (event) => {
5162
+ const ledger = agentRunProviderEvidenceContext.getStore();
5163
+ if (ledger) {
5164
+ return ledger.observe(event, {
5165
+ purpose: 'answer_generation',
5166
+ dispatchPhase: 'generation',
5167
+ optIn: false,
5168
+ });
5169
+ }
5170
+ const envelope = prepareProviderWireEnvelopeForDispatch(event.provider, event.envelope);
5171
+ assertProviderPayloadAllowed(envelope, {
5172
+ allowResultRows: false,
5173
+ maxResultRows: 0,
4218
5174
  purpose: 'answer_generation',
4219
- dispatchPhase: 'generation',
4220
- optIn: false,
4221
- }),
4222
- } : {}),
4223
- })).trim();
5175
+ });
5176
+ return envelope;
5177
+ },
5178
+ });
5179
+ try {
5180
+ text = (await provider.generate(messages, {
5181
+ maxTokens: 320,
5182
+ temperature: 0.6,
5183
+ maxProviderDispatches: 2,
5184
+ ...conversationTrace.options,
5185
+ })).trim();
5186
+ conversationTrace.settle('ok');
5187
+ }
5188
+ catch (error) {
5189
+ conversationTrace.settle(request.signal?.aborted ? 'cancelled' : 'error', error);
5190
+ throw error;
5191
+ }
4224
5192
  // Perceived-latency: surface the reply as one delta for surfaces wired to stream.
4225
5193
  if (text)
4226
5194
  emitAnswerDelta?.(text);
@@ -4647,6 +5615,7 @@ export async function startLocalServer(opts) {
4647
5615
  skill_draft: skillAuthoringRunExecutor,
4648
5616
  research: async (researchContext) => {
4649
5617
  const { runId, request, routeDecision, emit } = researchContext;
5618
+ const trace = askTraceObserverForV1(request);
4650
5619
  const metrics = loadSemanticMetrics(projectRoot);
4651
5620
  let blocks = collectPlanBlocks(projectRoot, { certifiedOnly: true });
4652
5621
  const usedCertifiedOnly = blocks.length > 0;
@@ -4664,9 +5633,22 @@ export async function startLocalServer(opts) {
4664
5633
  // unreachable, `planResearch` keeps its deterministic template, so
4665
5634
  // research never depends on a model being available.
4666
5635
  const researchPlanner = resolveGovernedAnswerRunner(projectRoot);
4667
- const researchPlannerProvider = researchPlanner
5636
+ const rawResearchPlannerProvider = researchPlanner
4668
5637
  ? createGovernedTextProvider(researchPlanner.provider, projectRoot)
4669
5638
  : undefined;
5639
+ const researchPlannerProvider = rawResearchPlannerProvider
5640
+ ? createResearchHypothesisPlanningProvider({
5641
+ provider: rawResearchPlannerProvider,
5642
+ request,
5643
+ ledger: agentRunProviderEvidenceContext.getStore(),
5644
+ })
5645
+ : undefined;
5646
+ const researchPlanSpan = trace.startSpan({
5647
+ name: 'research.plan',
5648
+ stage: 'research',
5649
+ reasonCode: 'started',
5650
+ payload: { kind: 'research', branchCount: 0 },
5651
+ });
4670
5652
  const plan = await planResearch({
4671
5653
  question: request.question,
4672
5654
  metrics,
@@ -4678,14 +5660,30 @@ export async function startLocalServer(opts) {
4678
5660
  // When the user explicitly picked research, investigate — don't collapse to one step.
4679
5661
  forceInvestigate: request.requestedMode === 'research',
4680
5662
  rootPlan: routeDecision?.resolvedAnalyticalPlan,
5663
+ // Hypothesis planning is bounded by the same request deadline as
5664
+ // every child branch. The planner's internal 20s timer is only an
5665
+ // additional safety limit, never a replacement for cancellation.
5666
+ signal: request.signal,
4681
5667
  });
4682
5668
  // Direct governed answer ("the metric answers this directly"): don't wrap it in
4683
5669
  // a review-required research dossier — run the query through the answer loop and
4684
5670
  // return the executed result table + DQL artifact. A dossier is only for genuine
4685
5671
  // multi-step investigation (plan.done === false) or explicit research mode.
4686
5672
  if (plan.done && !plan.followUp && request.requestedMode !== 'research') {
5673
+ trace.finishSpan(researchPlanSpan, {
5674
+ outcome: 'ok',
5675
+ reasonCode: 'completed',
5676
+ payload: { kind: 'research', branchCount: plan.steps.length },
5677
+ });
4687
5678
  return answerRunExecutor(researchContext);
4688
5679
  }
5680
+ // Research may enter before ordinary Ask selected a root execution
5681
+ // route, but its child requirements must still begin from the reader's
5682
+ // own analytical tuple. Prefer the already-frozen/meaning seed when
5683
+ // one exists; otherwise build one deterministic host seed from the root
5684
+ // question. Planner prose and a prior root SQL are never inputs here.
5685
+ const researchRootRequirementSeed = routeDecision?.meaningResolution?.hostRequirementSeed
5686
+ ?? buildAnalyticalRequirementSeedV1({ question: request.question });
4689
5687
  // This is the executable, receipt-bound research plan. It carries the
4690
5688
  // branch hypothesis/expectation/validator kind into every child rather
4691
5689
  // than treating V2 as a presentation-only wrapper after the work ends.
@@ -4698,6 +5696,11 @@ export async function startLocalServer(opts) {
4698
5696
  validatorKind: inferResearchValidatorKind(step.thought, step.expectation),
4699
5697
  })),
4700
5698
  });
5699
+ trace.finishSpan(researchPlanSpan, {
5700
+ outcome: 'ok',
5701
+ reasonCode: 'completed',
5702
+ payload: { kind: 'research', branchCount: typedResearchPlan.hypotheses.length },
5703
+ });
4701
5704
  // The V2 contract is the executable branch authority: retain its stable
4702
5705
  // hypothesis ID, wording, expectation, target, and validator kind while
4703
5706
  // borrowing only the already-grounded action kind from the planner. This
@@ -4717,6 +5720,17 @@ export async function startLocalServer(opts) {
4717
5720
  action: { ...planned.action, target: hypothesis.targetId },
4718
5721
  }];
4719
5722
  });
5723
+ // `typedResearchPlan` can retain a hypothesis that no longer has a
5724
+ // matching, catalog-grounded executable action (for example after a
5725
+ // duplicate target is folded during planning). Scope must describe the
5726
+ // branch set that can actually be investigated, not the presentation
5727
+ // plan that happened to be typed before that admission check.
5728
+ //
5729
+ // Keep this count independent of execution success: a provider or local
5730
+ // execution failure is a failed branch, not evidence that a supported
5731
+ // branch was never available. The ledger will still make failed verdicts
5732
+ // explicit below.
5733
+ let groundableResearchBranchCount = executableResearchBranches.length;
4720
5734
  const typedHypothesesById = new Map(typedResearchPlan.hypotheses.map((hypothesis) => [hypothesis.id, hypothesis]));
4721
5735
  const needsClarification = Boolean(plan.followUp);
4722
5736
  const notebookPath = agentRunNotebookPath(request, runId);
@@ -4740,8 +5754,144 @@ export async function startLocalServer(opts) {
4740
5754
  };
4741
5755
  let researchRun;
4742
5756
  const researchRuns = [];
5757
+ const researchBranchSpans = new Map();
5758
+ // These outcomes are independent of the notebook storage status. A
5759
+ // skipped branch is persisted as a reviewable child record (storage has
5760
+ // no `skipped` state), while the immutable Ask artifact/trace retains the
5761
+ // precise bounded Research verdict and stop reason.
5762
+ const researchBranchOutcomes = new Map();
5763
+ const researchBranchReceipts = [];
4743
5764
  let researchWorkspaceError;
5765
+ // The notebook root is created before child execution. Keep its stable
5766
+ // identity outside the storage scope so a root deadline or user
5767
+ // cancellation can still persist a content-safe, restart-readable Ask
5768
+ // artifact after the child storage handle has closed.
5769
+ let researchRootRunId;
5770
+ let partialResearchArtifactPersisted = false;
5771
+ // The engine races every executor against the root cancellation signal.
5772
+ // Keep every admitted child in the current bounded wave available to a
5773
+ // synchronous root abort listener so the immutable partial artifact is
5774
+ // emitted before the outer engine resumes and persists the terminal run.
5775
+ const activeResearchBranches = new Map();
5776
+ let rootAbortReceiptPersisted = false;
5777
+ // Concurrent branches settle in wall-clock order. Receipts are product
5778
+ // evidence, so retain the planner order independently of timing and
5779
+ // never duplicate a child that the root abort listener already closed.
5780
+ const recordResearchBranchReceipt = (receipt) => {
5781
+ if (researchBranchReceipts.some((existing) => existing.childRunId === receipt.childRunId))
5782
+ return false;
5783
+ researchBranchReceipts.push(receipt);
5784
+ researchBranchReceipts.sort((left, right) => left.index - right.index || left.branchId.localeCompare(right.branchId));
5785
+ return true;
5786
+ };
5787
+ const partialResearchLedgerSnapshot = () => {
5788
+ const runsById = new Map(researchRuns.map((run) => [run.id, run]));
5789
+ const entries = researchBranchReceipts.slice(0, 6).map((receipt) => {
5790
+ const branch = runsById.get(receipt.childRunId);
5791
+ const branchContext = agentRunRecord(branch?.context)?.branch;
5792
+ const previewRecord = agentRunRecord(branch?.resultPreview);
5793
+ const executionReceipt = normalizeAnalyticalExecutionReceipt(previewRecord?.executionReceipt);
5794
+ const resultFingerprint = normalizeAnalyticalExecutionFingerprint(previewRecord?.resultFingerprint)
5795
+ ?? executionReceipt?.resultFingerprint;
5796
+ const observed = receipt.state === 'completed'
5797
+ && branch?.status === 'ready'
5798
+ && Boolean(resultFingerprint);
5799
+ const status = observed
5800
+ ? 'observed'
5801
+ : receipt.state === 'skipped'
5802
+ ? 'skipped'
5803
+ : 'failed';
5804
+ return {
5805
+ id: receipt.childRunId,
5806
+ branchId: receipt.branchId,
5807
+ question: branch?.question
5808
+ ?? typedHypothesesById.get(receipt.branchId)?.statement
5809
+ ?? `Research branch ${receipt.index}`,
5810
+ status,
5811
+ ...(previewRecord && Array.isArray(previewRecord.rows)
5812
+ ? { rowCount: previewRecord.rows.length }
5813
+ : {}),
5814
+ ...(resultFingerprint ? { resultFingerprint } : {}),
5815
+ ...(executionReceipt ? { executionReceipt } : {}),
5816
+ facts: [agentRunString(branchContext?.expectation)
5817
+ ?? typedHypothesesById.get(receipt.branchId)?.expectation
5818
+ ?? `Research branch ${receipt.index} did not complete before the root run stopped.`],
5819
+ receipts: observed && resultFingerprint ? [resultFingerprint] : [],
5820
+ ...(!observed ? {
5821
+ error: researchBranchOutcomes.get(receipt.branchId)?.error
5822
+ ?? branch?.error
5823
+ ?? (receipt.stopReason === 'run_deadline'
5824
+ ? 'Research root reached its deadline while this branch was active.'
5825
+ : receipt.stopReason === 'cancelled'
5826
+ ? 'Research was stopped by the user while this branch was active.'
5827
+ : 'Research branch did not produce an execution receipt or result fingerprint.'),
5828
+ } : {}),
5829
+ };
5830
+ });
5831
+ const researchLedger = buildResearchEvidenceLedger({
5832
+ rootQuestion: request.question,
5833
+ planId: plan.rootPlanId,
5834
+ snapshotId: routeDecision?.resolvedAnalyticalPlan?.snapshotId,
5835
+ entries,
5836
+ // The partial artifact is evidence for restart/review only; it is
5837
+ // never an accepted Research conclusion.
5838
+ stoppingReason: 'blocked',
5839
+ });
5840
+ return {
5841
+ researchLedger,
5842
+ researchLedgerV2: buildResearchEvidenceLedgerV2({
5843
+ rootQuestion: request.question,
5844
+ planId: plan.rootPlanId,
5845
+ snapshotId: routeDecision?.resolvedAnalyticalPlan?.snapshotId,
5846
+ groundableBranchCount: groundableResearchBranchCount,
5847
+ entries: researchLedger.entries.map((entry) => ({
5848
+ ...entry,
5849
+ hypothesis: typedHypothesesById.get(entry.branchId)?.statement,
5850
+ verdict: entry.status === 'observed'
5851
+ ? 'inconclusive'
5852
+ : entry.status === 'skipped'
5853
+ ? 'skipped'
5854
+ : 'failed',
5855
+ counterEvidenceFactIds: [],
5856
+ })),
5857
+ stoppingReason: researchLedger.stoppingReason,
5858
+ }),
5859
+ };
5860
+ };
5861
+ const persistPartialResearchArtifact = (terminalReason) => {
5862
+ if (partialResearchArtifactPersisted || !researchRootRunId)
5863
+ return;
5864
+ const { researchLedger, researchLedgerV2 } = partialResearchLedgerSnapshot();
5865
+ const childRunIds = [...new Set(researchBranchReceipts.map((receipt) => receipt.childRunId))];
5866
+ const branchTrace = researchBranchReceipts.map((receipt) => ({
5867
+ branchId: receipt.branchId,
5868
+ childRunId: receipt.childRunId,
5869
+ spanId: researchBranchSpans.get(receipt.branchId)?.spanId,
5870
+ kind: 'research_branch',
5871
+ }));
5872
+ const artifact = agentRunArtifact('research_run', 'Partial Research evidence', {
5873
+ version: 2,
5874
+ partial: true,
5875
+ terminalReason,
5876
+ rootResearchRunId: researchRootRunId,
5877
+ childRunIds,
5878
+ researchBranchReceipts: [...researchBranchReceipts],
5879
+ researchLedger,
5880
+ researchLedgerV2,
5881
+ traceReference: trace.reference(),
5882
+ researchTrace: { branchTrace },
5883
+ }, researchRootRunId, 'blocked');
5884
+ partialResearchArtifactPersisted = true;
5885
+ emit({
5886
+ type: 'artifact.created',
5887
+ message: 'Saved partial Research receipts before the root run stopped.',
5888
+ route: 'research',
5889
+ trustState: 'blocked',
5890
+ payload: artifact,
5891
+ });
5892
+ };
4744
5893
  if (!needsClarification) {
5894
+ let removeResearchRootAbortListener;
4745
5895
  try {
4746
5896
  const storage = openNotebookResearchStorage();
4747
5897
  try {
@@ -4762,6 +5912,70 @@ export async function startLocalServer(opts) {
4762
5912
  owner: agentRunWorkspaceValue(request, 'owner'),
4763
5913
  context: researchContextEnvelope,
4764
5914
  });
5915
+ researchRootRunId = created.id;
5916
+ const persistRootAbortBeforeEngineFinalizes = () => {
5917
+ if (rootAbortReceiptPersisted || !request.signal?.aborted)
5918
+ return;
5919
+ rootAbortReceiptPersisted = true;
5920
+ const userCancelled = isAgentRunUserCancellation(request.signal.reason);
5921
+ const terminalReason = userCancelled
5922
+ ? 'cancelled'
5923
+ : 'run_deadline';
5924
+ for (const active of [...activeResearchBranches.values()].sort((left, right) => left.index - right.index)) {
5925
+ if (researchBranchReceipts.some((receipt) => receipt.childRunId === active.childRunId))
5926
+ continue;
5927
+ const message = userCancelled
5928
+ ? 'Research was stopped by the user while this branch was active.'
5929
+ : 'Research root reached its deadline while this branch was active.';
5930
+ storage.updateRun(active.childRunId, {
5931
+ status: 'error',
5932
+ error: message,
5933
+ summary: 'Research branch stopped before producing a result.',
5934
+ reviewStatus: 'needs_review',
5935
+ });
5936
+ const stopped = storage.getRun(active.childRunId);
5937
+ if (stopped)
5938
+ researchRuns.push(withNotebookResearchChecklist(stopped));
5939
+ researchBranchOutcomes.set(active.branchId, {
5940
+ status: 'failed',
5941
+ stopReason: terminalReason,
5942
+ error: message,
5943
+ });
5944
+ recordResearchBranchReceipt({
5945
+ version: 1,
5946
+ branchId: active.branchId,
5947
+ childRunId: active.childRunId,
5948
+ index: active.index,
5949
+ state: 'failed',
5950
+ verdict: 'failed',
5951
+ stopReason: terminalReason,
5952
+ branchBudgetMs: active.branchBudgetMs,
5953
+ });
5954
+ trace.finishSpan(active.branchSpan, {
5955
+ outcome: userCancelled ? 'cancelled' : 'error',
5956
+ reasonCode: terminalReason,
5957
+ payload: {
5958
+ kind: 'research',
5959
+ branchId: active.branchId,
5960
+ hypothesisFingerprint: active.hypothesisFingerprint,
5961
+ verdict: 'failed',
5962
+ branchBudgetMs: active.branchBudgetMs,
5963
+ branchStopReason: terminalReason,
5964
+ },
5965
+ });
5966
+ }
5967
+ storage.updateRun(created.id, {
5968
+ status: 'error',
5969
+ error: userCancelled
5970
+ ? 'Research was stopped by the user while a branch was active.'
5971
+ : 'Research root reached its deadline while a branch was active.',
5972
+ summary: 'Partial Research receipts were saved before the root run stopped.',
5973
+ reviewStatus: 'needs_review',
5974
+ });
5975
+ persistPartialResearchArtifact(terminalReason);
5976
+ };
5977
+ request.signal?.addEventListener('abort', persistRootAbortBeforeEngineFinalizes, { once: true });
5978
+ removeResearchRootAbortListener = () => request.signal?.removeEventListener('abort', persistRootAbortBeforeEngineFinalizes);
4765
5979
  emit({
4766
5980
  type: 'artifact.created',
4767
5981
  message: 'Saved the immutable root research plan; executing bounded child branches.',
@@ -4788,6 +6002,15 @@ export async function startLocalServer(opts) {
4788
6002
  expectation: 'Whether the frozen context contains enough evidence for a bounded answer.',
4789
6003
  };
4790
6004
  const branches = capResearchBranches(executableResearchBranches.length > 0 ? executableResearchBranches : [fallbackBranch], 6);
6005
+ // An explicit Research request with no catalog-grounded planner
6006
+ // step still has one bounded frozen-context branch. It is visible
6007
+ // as limited scope rather than being padded with invented
6008
+ // hypotheses or silently reported as three successful checks.
6009
+ // That fallback is an observable attempt, not catalog-grounded
6010
+ // evidence, so it must not inflate the claimed groundable count.
6011
+ groundableResearchBranchCount = executableResearchBranches.length > 0
6012
+ ? branches.length
6013
+ : 0;
4791
6014
  // The replan edge. Each branch tests one hypothesis; folding its
4792
6015
  // outcome back into the state is what lets the investigation stop
4793
6016
  // when the question is settled instead of grinding through a plan
@@ -4799,24 +6022,54 @@ export async function startLocalServer(opts) {
4799
6022
  statement: branch.thought,
4800
6023
  priorConfidence: 1 - position / (branches.length + 1),
4801
6024
  })));
4802
- for (let index = 0; index < branches.length; index += 1) {
6025
+ const executeResearchBranch = async (index, branchBudget) => {
4803
6026
  const step = branches[index];
4804
- if (request.signal?.aborted)
4805
- rethrowIfCancelled(request.signal.reason, request.signal);
4806
- // A hypothesis an earlier finding already closed is not
4807
- // re-investigated, and an exhausted hop budget stops the run.
4808
- const stillOpen = nextHypothesis(researchState);
4809
- if (!stillOpen) {
4810
- emit({
4811
- type: 'executor.started',
4812
- message: `Stopping early: ${researchState.hopsUsed} of ${branches.length} branches settled what could be settled.`,
4813
- route: 'research',
4814
- });
4815
- break;
4816
- }
4817
6027
  const branchId = step.hypothesisId;
4818
- const branchQuestion = `${request.question}\nResearch branch ${index + 1} (${branchId}): ${step.expectation}`;
6028
+ // The planner asset is an evidence hint, not a client-selected
6029
+ // identifier. Put it in the child question so the shared Ask
6030
+ // retriever can bind it against the child snapshot, rather than
6031
+ // treating a human-facing planner label as a forged structured
6032
+ // selection. The child router still owns the qualified IDs,
6033
+ // relationship closure, and frozen RAP.
6034
+ // Each child is an ordinary, bounded analytical Ask. Do not
6035
+ // carry the root's "Research …" wording into its source
6036
+ // question: the router correctly interprets that as a request
6037
+ // to open another Research run instead of resolving and
6038
+ // freezing this hypothesis's own tuple. The root/child link is
6039
+ // retained in the trace and workspace context below; this text
6040
+ // is only the child analytical requirement seed.
6041
+ // The planner target is an evidence hint, never an executable
6042
+ // query. Project it with the root host requirements and the
6043
+ // typed action before a child Ask sees it. This keeps a time
6044
+ // comparison or breakdown tied to the root metric instead of
6045
+ // collapsing every branch to the target label/baseline metric.
6046
+ const branchProjection = buildResearchBranchRequirementProjection({
6047
+ action: step.action,
6048
+ rootRequirementSeed: researchRootRequirementSeed,
6049
+ rootPlan: routeDecision?.resolvedAnalyticalPlan,
6050
+ });
6051
+ const branchQuestion = branchProjection.question;
6052
+ const branchRequirementSeed = branchProjection.requirementSeed;
4819
6053
  const childId = `${created.id}:research:${index + 1}`;
6054
+ const hypothesisFingerprint = runtimeTraceFingerprint(`${step.thought}\u0000${step.expectation}`);
6055
+ const branchSpan = trace.startSpan({
6056
+ name: 'research.validate',
6057
+ stage: 'research',
6058
+ reasonCode: branchBudget.stopReason ?? 'started',
6059
+ payload: {
6060
+ kind: 'research',
6061
+ branchId,
6062
+ hypothesisFingerprint,
6063
+ ...(branchBudget.branchBudgetMs ? { branchBudgetMs: branchBudget.branchBudgetMs } : {}),
6064
+ ...(branchBudget.stopReason ? { branchStopReason: branchBudget.stopReason } : {}),
6065
+ },
6066
+ });
6067
+ researchBranchSpans.set(branchId, { spanId: branchSpan, hypothesisFingerprint });
6068
+ trace.recordLink({
6069
+ kind: 'research_branch',
6070
+ targetRunId: childId,
6071
+ hypothesisFingerprint,
6072
+ });
4820
6073
  const child = storage.createRun({
4821
6074
  id: childId,
4822
6075
  notebookPath,
@@ -4844,6 +6097,43 @@ export async function startLocalServer(opts) {
4844
6097
  },
4845
6098
  },
4846
6099
  });
6100
+ if (!branchBudget.branchBudgetMs) {
6101
+ const message = 'Research did not start this branch because the remaining run time is reserved for synthesis and persistence.';
6102
+ storage.updateRun(child.id, {
6103
+ status: 'error',
6104
+ error: message,
6105
+ summary: 'Research branch skipped because the bounded branch budget was exhausted.',
6106
+ reviewStatus: 'needs_review',
6107
+ });
6108
+ const skipped = storage.getRun(child.id);
6109
+ if (skipped)
6110
+ researchRuns.push(withNotebookResearchChecklist(skipped));
6111
+ researchBranchOutcomes.set(branchId, {
6112
+ status: 'skipped',
6113
+ stopReason: 'budget_exhausted',
6114
+ error: message,
6115
+ });
6116
+ recordResearchBranchReceipt({
6117
+ version: 1,
6118
+ branchId,
6119
+ childRunId: child.id,
6120
+ index: index + 1,
6121
+ state: 'skipped',
6122
+ verdict: 'skipped',
6123
+ stopReason: 'budget_exhausted',
6124
+ });
6125
+ trace.finishSpan(branchSpan, {
6126
+ outcome: 'skipped',
6127
+ reasonCode: 'budget_exhausted',
6128
+ payload: {
6129
+ kind: 'research',
6130
+ branchId,
6131
+ hypothesisFingerprint,
6132
+ branchStopReason: 'budget_exhausted',
6133
+ },
6134
+ });
6135
+ return { index, summary: message, strength: 0 };
6136
+ }
4847
6137
  emit({
4848
6138
  type: 'artifact.created',
4849
6139
  message: `Started research branch ${index + 1} of ${branches.length}.`,
@@ -4851,36 +6141,168 @@ export async function startLocalServer(opts) {
4851
6141
  trustState: 'review_required',
4852
6142
  payload: { researchRunId: child.id, parentResearchRunId: created.id, branchId },
4853
6143
  });
6144
+ const branchTimeout = AbortSignal.timeout(branchBudget.branchBudgetMs);
6145
+ const branchSignal = request.signal
6146
+ ? AbortSignal.any([request.signal, branchTimeout])
6147
+ : branchTimeout;
6148
+ activeResearchBranches.set(child.id, {
6149
+ branchId,
6150
+ childRunId: child.id,
6151
+ index: index + 1,
6152
+ branchBudgetMs: branchBudget.branchBudgetMs,
6153
+ branchSpan,
6154
+ hypothesisFingerprint,
6155
+ });
4854
6156
  try {
4855
- const executed = await runNotebookResearch(storage, child, {
4856
- domain: agentRunWorkspaceValue(request, 'domain'),
4857
- owner: agentRunWorkspaceValue(request, 'owner'),
4858
- sourceCellFingerprint,
4859
- question: branchQuestion,
4860
- intent: researchIntent,
4861
- context: {
4862
- ...researchContextEnvelope,
4863
- rootRunId: created.id,
4864
- rootPlanId: plan.rootPlanId,
4865
- branch: {
4866
- id: branchId,
4867
- hypothesisId: step.hypothesisId,
4868
- hypothesis: step.thought,
4869
- validatorKind: step.validatorKind,
6157
+ const executed = await awaitResearchBranchDeadline((async () => {
6158
+ /**
6159
+ * A Research branch is a child Ask run, not a notebook-SQL
6160
+ * shortcut. The root Research executor owns the one
6161
+ * run-scoped dispatch ledger, so preserving its AsyncLocal
6162
+ * context here makes planning, child meaning/generation,
6163
+ * bounded repair, and narration compete for the same
6164
+ * Research-12 physical-send budget. The child request has
6165
+ * no conversation/history or root SQL: each hypothesis must
6166
+ * retrieve, route, and freeze its own tuple/closure.
6167
+ */
6168
+ const branchRequest = attachAskTraceObserverV1({
6169
+ question: branchQuestion,
6170
+ ...(branchRequirementSeed ? { hostRequirementSeed: branchRequirementSeed } : {}),
6171
+ researchBranch: {
6172
+ rootRunId: created.id,
6173
+ childRunId: child.id,
6174
+ branchId,
4870
6175
  index: index + 1,
4871
- expectation: step.expectation,
4872
- action: step.action,
4873
6176
  },
4874
- },
4875
- executionConnection: researchExecutionConnection,
4876
- executionConnectionName: researchExecutionConnectionName,
4877
- signal: request.signal,
4878
- baselineSql: agentRunString(researchSource?.sql),
4879
- baselineDqlArtifact: researchSource?.dqlArtifact,
4880
- baselineRunId: agentRunString(researchSource?.runId),
4881
- });
6177
+ requestedMode: 'ask',
6178
+ runId: child.id,
6179
+ selectedObject: {
6180
+ kind: 'research',
6181
+ id: child.id,
6182
+ title: child.title,
6183
+ },
6184
+ executionTarget: request.executionTarget,
6185
+ audience: request.audience,
6186
+ reasoningEffort: request.reasoningEffort,
6187
+ analysisDepth: request.analysisDepth,
6188
+ thinkingMode: request.thinkingMode,
6189
+ signal: branchSignal,
6190
+ runBudget: request.runBudget,
6191
+ traceSurface: request.traceSurface,
6192
+ workspaceContext: {
6193
+ ...(request.workspaceContext ?? {}),
6194
+ researchBranch: {
6195
+ rootRunId: created.id,
6196
+ childRunId: child.id,
6197
+ branchId,
6198
+ index: index + 1,
6199
+ targetId: step.action.target,
6200
+ validatorKind: step.validatorKind,
6201
+ },
6202
+ },
6203
+ }, trace);
6204
+ const branchDecision = await agentRunRouter.decide(branchRequest);
6205
+ const branchRoute = selectRoute(branchRequest, branchDecision);
6206
+ const frozenBranchRoute = frozenAnalyticalRoute(branchDecision);
6207
+ const branchPlanFrozen = branchDecision.analyticalCascadeDecision?.planFrozen === true
6208
+ && branchDecision.resolvedAnalyticalPlan?.mode === 'authoritative';
6209
+ const executableBranchRoute = branchRoute === 'certified_answer'
6210
+ || branchRoute === 'semantic_answer'
6211
+ || branchRoute === 'generated_answer';
6212
+ const governedAnswer = branchPlanFrozen
6213
+ && frozenBranchRoute === branchRoute
6214
+ && executableBranchRoute
6215
+ ? await runGovernedAgentAnswerForRun(branchRequest, undefined, branchRoute, undefined, branchDecision)
6216
+ : {
6217
+ kind: 'no_answer',
6218
+ text: 'This Research branch did not freeze a safe, hypothesis-specific analytical plan. DQL did not reuse the root query or execute a legacy notebook SQL fallback.',
6219
+ answer: 'This Research branch did not freeze a safe, hypothesis-specific analytical plan. DQL did not reuse the root query or execute a legacy notebook SQL fallback.',
6220
+ refusalCode: branchDecision.requiresClarification ? 'ambiguous' : 'grounding_gap',
6221
+ intentDecision: branchDecision,
6222
+ resolvedAnalyticalPlan: branchDecision.resolvedAnalyticalPlan,
6223
+ citations: [],
6224
+ considered: [],
6225
+ };
6226
+ return runNotebookResearch(storage, child, {
6227
+ domain: agentRunWorkspaceValue(request, 'domain'),
6228
+ owner: agentRunWorkspaceValue(request, 'owner'),
6229
+ sourceCellFingerprint,
6230
+ question: branchQuestion,
6231
+ intent: researchIntent,
6232
+ context: {
6233
+ ...researchContextEnvelope,
6234
+ rootRunId: created.id,
6235
+ rootPlanId: plan.rootPlanId,
6236
+ branch: {
6237
+ id: branchId,
6238
+ hypothesisId: step.hypothesisId,
6239
+ hypothesis: step.thought,
6240
+ validatorKind: step.validatorKind,
6241
+ index: index + 1,
6242
+ expectation: step.expectation,
6243
+ action: step.action,
6244
+ },
6245
+ // Persist a compact, non-prompt branch authority
6246
+ // witness. It lets restart/trace review distinguish a
6247
+ // child that honestly could not freeze from one that
6248
+ // executed a frozen RAP, without granting Notebook
6249
+ // Research a second route authority.
6250
+ branchAuthority: {
6251
+ route: branchRoute,
6252
+ selectedTier: branchDecision.analyticalCascadeDecision?.selectedTier,
6253
+ planFrozen: branchPlanFrozen,
6254
+ planId: branchDecision.resolvedAnalyticalPlan?.planId,
6255
+ planFingerprint: branchDecision.resolvedAnalyticalPlan?.fingerprint,
6256
+ capability: branchDecision.resolvedAnalyticalPlan?.capability,
6257
+ executionId: branchDecision.resolvedAnalyticalPlan?.executionId,
6258
+ candidateIds: branchDecision.resolvedAnalyticalPlan?.selectedConceptIds,
6259
+ closureIds: branchDecision.resolvedAnalyticalPlan?.sourceRelationIds,
6260
+ },
6261
+ },
6262
+ executionConnection: researchExecutionConnection,
6263
+ executionConnectionName: researchExecutionConnectionName,
6264
+ signal: branchSignal,
6265
+ authoritativeBranch: {
6266
+ answer: governedAnswer,
6267
+ routeDecision: branchDecision,
6268
+ },
6269
+ });
6270
+ })(), branchSignal);
4882
6271
  const branchRun = withNotebookResearchChecklist(executed);
4883
6272
  researchRuns.push(branchRun);
6273
+ const branchFailed = branchRun.status === 'error';
6274
+ recordResearchBranchReceipt({
6275
+ version: 1,
6276
+ branchId,
6277
+ childRunId: child.id,
6278
+ index: index + 1,
6279
+ state: branchFailed ? 'failed' : 'completed',
6280
+ verdict: branchFailed ? 'failed' : 'inconclusive',
6281
+ stopReason: branchFailed ? 'execution_failed' : 'completed',
6282
+ branchBudgetMs: branchBudget.branchBudgetMs,
6283
+ });
6284
+ if (branchFailed) {
6285
+ const message = branchRun.error
6286
+ ?? branchRun.summary
6287
+ ?? 'Research branch stopped before producing a result.';
6288
+ researchBranchOutcomes.set(branchId, {
6289
+ status: 'failed',
6290
+ stopReason: 'execution_failed',
6291
+ error: message,
6292
+ });
6293
+ trace.finishSpan(branchSpan, {
6294
+ outcome: 'error',
6295
+ reasonCode: 'execution_failed',
6296
+ payload: {
6297
+ kind: 'research',
6298
+ branchId,
6299
+ hypothesisFingerprint,
6300
+ verdict: 'failed',
6301
+ branchBudgetMs: branchBudget.branchBudgetMs,
6302
+ branchStopReason: 'execution_failed',
6303
+ },
6304
+ });
6305
+ }
4884
6306
  // Observe, then decide. A branch that produced rows is evidence
4885
6307
  // for its hypothesis; one that did not is inconclusive, which is
4886
6308
  // a real outcome and not a failure.
@@ -4891,47 +6313,173 @@ export async function startLocalServer(opts) {
4891
6313
  // rows as `supports` would let the dossier report a driver the
4892
6314
  // evidence never established, which is the failure mode the
4893
6315
  // whole verified-fact chain exists to prevent.
4894
- researchState = applyFinding(researchState, {
4895
- id: `f${index + 1}`,
4896
- hypothesisId: `h${index + 1}`,
4897
- verdict: 'inconclusive',
6316
+ return {
6317
+ index,
4898
6318
  summary: branchRun.summary ?? '',
4899
6319
  strength: (branchRun.resultPreview?.rows?.length ?? 0) > 0
4900
6320
  ? 0.5
4901
6321
  : 0.1,
4902
- });
6322
+ };
4903
6323
  }
4904
6324
  catch (error) {
4905
- // A child is a real durable run even when cancellation stops the
4906
- // shared branch budget. Persist the truthful stop before
4907
- // propagating cancellation to the parent run.
4908
- const message = error instanceof Error ? error.message : String(error);
6325
+ // A child is a durable Research record even when its own
6326
+ // fair-share deadline expires. Only root/user cancellation
6327
+ // aborts the parent; a local child timeout becomes a typed
6328
+ // failed branch and the remaining branches receive their own
6329
+ // fair share (or explicit budget-exhausted receipts).
6330
+ const rootCancelled = Boolean(request.signal?.aborted);
6331
+ const userCancelled = rootCancelled && isAgentRunUserCancellation(request.signal?.reason);
6332
+ // The synchronous root abort listener has already recorded the
6333
+ // terminal child receipt and partial artifact. Do not race it
6334
+ // with a second child update after the engine has finalized.
6335
+ if (rootCancelled && rootAbortReceiptPersisted) {
6336
+ rethrowIfCancelled(error, request.signal);
6337
+ }
6338
+ const timedOut = branchTimeout.aborted && !rootCancelled;
6339
+ const stopReason = userCancelled
6340
+ ? 'cancelled'
6341
+ : rootCancelled
6342
+ ? 'run_deadline'
6343
+ : timedOut
6344
+ ? 'research_branch_timeout'
6345
+ : 'execution_failed';
6346
+ const message = timedOut
6347
+ ? 'Research branch reached its fair-share deadline before producing a result.'
6348
+ : error instanceof Error ? error.message : String(error);
4909
6349
  storage.updateRun(child.id, {
4910
6350
  status: 'error',
4911
6351
  error: message,
4912
- summary: 'Research branch stopped before producing a result.',
6352
+ summary: timedOut
6353
+ ? 'Research branch timed out within its bounded validation window.'
6354
+ : 'Research branch stopped before producing a result.',
4913
6355
  reviewStatus: 'needs_review',
4914
6356
  });
4915
6357
  const stopped = storage.getRun(child.id);
4916
6358
  if (stopped)
4917
6359
  researchRuns.push(withNotebookResearchChecklist(stopped));
6360
+ researchBranchOutcomes.set(branchId, {
6361
+ status: 'failed',
6362
+ stopReason,
6363
+ error: message,
6364
+ });
6365
+ recordResearchBranchReceipt({
6366
+ version: 1,
6367
+ branchId,
6368
+ childRunId: child.id,
6369
+ index: index + 1,
6370
+ state: timedOut ? 'timed_out' : 'failed',
6371
+ verdict: 'failed',
6372
+ stopReason,
6373
+ branchBudgetMs: branchBudget.branchBudgetMs,
6374
+ });
6375
+ trace.finishSpan(branchSpan, {
6376
+ outcome: userCancelled ? 'cancelled' : 'error',
6377
+ reasonCode: userCancelled
6378
+ ? 'cancelled'
6379
+ : rootCancelled
6380
+ ? 'run_deadline'
6381
+ : timedOut
6382
+ ? 'research_branch_timeout'
6383
+ : 'execution_failed',
6384
+ payload: {
6385
+ kind: 'research',
6386
+ branchId,
6387
+ hypothesisFingerprint,
6388
+ verdict: 'failed',
6389
+ branchBudgetMs: branchBudget.branchBudgetMs,
6390
+ branchStopReason: stopReason,
6391
+ },
6392
+ });
6393
+ if (rootCancelled) {
6394
+ storage.updateRun(created.id, {
6395
+ status: 'error',
6396
+ error: userCancelled
6397
+ ? 'Research was stopped by the user while a branch was active.'
6398
+ : 'Research root reached its deadline while a branch was active.',
6399
+ summary: 'Partial Research receipts were saved before the root run stopped.',
6400
+ reviewStatus: 'needs_review',
6401
+ });
6402
+ persistPartialResearchArtifact(userCancelled ? 'cancelled' : 'run_deadline');
6403
+ rethrowIfCancelled(error, request.signal);
6404
+ }
6405
+ return { index, summary: message, strength: 0 };
6406
+ }
6407
+ finally {
6408
+ activeResearchBranches.delete(child.id);
6409
+ }
6410
+ };
6411
+ let nextBranchIndex = 0;
6412
+ while (nextBranchIndex < branches.length) {
6413
+ if (request.signal?.aborted)
6414
+ rethrowIfCancelled(request.signal.reason, request.signal);
6415
+ // Fold an entire settled wave into the deterministic hypothesis
6416
+ // state before admitting the next one. No child completion order
6417
+ // is allowed to choose later work or alter the final dossier.
6418
+ const stillOpen = nextHypothesis(researchState);
6419
+ if (!stillOpen) {
6420
+ emit({
6421
+ type: 'executor.started',
6422
+ message: `Stopping early: ${researchState.hopsUsed} of ${branches.length} branches settled what could be settled.`,
6423
+ route: 'research',
6424
+ });
6425
+ break;
6426
+ }
6427
+ const remainingBranches = branches.length - nextBranchIndex;
6428
+ const branchBudget = allocateResearchBranchBudget({
6429
+ remainingMs: request.runBudget?.remainingMs() ?? 120_000,
6430
+ remainingBranches,
6431
+ maxConcurrentBranches: RESEARCH_MAX_CONCURRENT_BRANCHES,
6432
+ });
6433
+ const waveSize = Math.min(branchBudget.maxConcurrentBranches, remainingBranches);
6434
+ const waveIndexes = Array.from({ length: waveSize }, (_, offset) => nextBranchIndex + offset);
6435
+ emit({
6436
+ type: 'executor.started',
6437
+ message: branchBudget.branchBudgetMs
6438
+ ? `Starting Research wave ${Math.floor(nextBranchIndex / RESEARCH_MAX_CONCURRENT_BRANCHES) + 1} with ${waveIndexes.length} bounded branches.`
6439
+ : `Skipping ${waveIndexes.length} Research branches because the remaining root time is reserved for synthesis and persistence.`,
6440
+ route: 'research',
6441
+ });
6442
+ // Run at most three independent branches together. The shared
6443
+ // branch budget is derived from remaining waves, not from the
6444
+ // number of individual branches, so an early slow branch cannot
6445
+ // starve the entire investigation.
6446
+ const settled = await Promise.allSettled(waveIndexes.map((index) => executeResearchBranch(index, branchBudget)));
6447
+ const rejected = settled.find((entry) => entry.status === 'rejected');
6448
+ if (rejected)
6449
+ throw rejected.reason;
6450
+ for (const finding of settled
6451
+ .filter((entry) => entry.status === 'fulfilled')
6452
+ .map((entry) => entry.value)
6453
+ .sort((left, right) => left.index - right.index)) {
4918
6454
  researchState = applyFinding(researchState, {
4919
- id: `f${index + 1}`,
4920
- hypothesisId: `h${index + 1}`,
6455
+ id: `f${finding.index + 1}`,
6456
+ hypothesisId: `h${finding.index + 1}`,
4921
6457
  verdict: 'inconclusive',
4922
- summary: message,
4923
- strength: 0,
6458
+ summary: finding.summary,
6459
+ strength: finding.strength,
4924
6460
  });
4925
- rethrowIfCancelled(error, request.signal);
4926
6461
  }
6462
+ nextBranchIndex += waveIndexes.length;
4927
6463
  }
6464
+ const orderedResearchRuns = [...new Map(researchRuns.map((run) => [run.id, run])).values()].sort((left, right) => {
6465
+ const leftBranch = agentRunRecord(left.context)?.branch;
6466
+ const rightBranch = agentRunRecord(right.context)?.branch;
6467
+ const leftIndex = typeof leftBranch?.index === 'number' ? leftBranch.index : Number.MAX_SAFE_INTEGER;
6468
+ const rightIndex = typeof rightBranch?.index === 'number' ? rightBranch.index : Number.MAX_SAFE_INTEGER;
6469
+ return leftIndex - rightIndex || left.id.localeCompare(right.id);
6470
+ });
6471
+ researchRuns.splice(0, researchRuns.length, ...orderedResearchRuns);
4928
6472
  researchRun = researchRuns[0];
4929
6473
  }
4930
6474
  finally {
6475
+ removeResearchRootAbortListener?.();
4931
6476
  storage.close();
4932
6477
  }
4933
6478
  }
4934
6479
  catch (error) {
6480
+ if (request.signal?.aborted) {
6481
+ persistPartialResearchArtifact(isAgentRunUserCancellation(request.signal.reason) ? 'cancelled' : 'run_deadline');
6482
+ }
4935
6483
  rethrowIfCancelled(error, request.signal);
4936
6484
  researchWorkspaceError = formatNotebookResearchStorageError(error);
4937
6485
  }
@@ -4959,6 +6507,8 @@ export async function startLocalServer(opts) {
4959
6507
  snapshotId: routeDecision?.resolvedAnalyticalPlan?.snapshotId,
4960
6508
  entries: researchRuns.slice(0, 6).map((branch, index) => {
4961
6509
  const branchContext = agentRunRecord(branch.context)?.branch;
6510
+ const branchId = agentRunString(branchContext?.id) ?? `branch:${index + 1}`;
6511
+ const branchOutcome = researchBranchOutcomes.get(branchId);
4962
6512
  const branchPreviewRecord = agentRunRecord(branch.resultPreview);
4963
6513
  const branchPreview = coerceNarrateResultData(branchPreviewRecord);
4964
6514
  const previewRecord = branchPreviewRecord;
@@ -4970,12 +6520,14 @@ export async function startLocalServer(opts) {
4970
6520
  // (on the result or its execution receipt) can make a branch
4971
6521
  // observed (AGT-016/033).
4972
6522
  const executionProof = resultFingerprint;
4973
- const observed = branch.status === 'ready' && Boolean(executionProof);
6523
+ const observed = !branchOutcome && branch.status === 'ready' && Boolean(executionProof);
4974
6524
  return {
4975
6525
  id: branch.id,
4976
- branchId: agentRunString(branchContext?.id) ?? `branch:${index + 1}`,
6526
+ branchId,
4977
6527
  question: branch.question,
4978
- status: observed ? 'observed' : branch.status === 'error' ? 'failed' : 'skipped',
6528
+ status: observed
6529
+ ? 'observed'
6530
+ : branchOutcome?.status ?? (branch.status === 'error' ? 'failed' : 'skipped'),
4979
6531
  ...(branchPreviewRecord && Array.isArray(branchPreviewRecord.rows)
4980
6532
  ? { rowCount: branchPreviewRecord.rows.length }
4981
6533
  : {}),
@@ -4987,7 +6539,8 @@ export async function startLocalServer(opts) {
4987
6539
  // receipt/fingerprint (AGT-016/033).
4988
6540
  receipts: executionProof ? [executionProof] : [],
4989
6541
  ...(!observed ? {
4990
- error: branch.error
6542
+ error: branchOutcome?.error
6543
+ ?? branch.error
4991
6544
  ?? (branch.status === 'error'
4992
6545
  ? branch.summary
4993
6546
  : 'Research branch did not produce an execution receipt or result fingerprint.'),
@@ -4996,11 +6549,13 @@ export async function startLocalServer(opts) {
4996
6549
  }),
4997
6550
  stoppingReason: needsClarification
4998
6551
  ? 'not_started'
4999
- : researchRuns.some((run) => run.status === 'error')
5000
- ? 'insufficient_evidence'
5001
- : executableResearchBranches.length > 6
5002
- ? 'budget'
5003
- : 'completed',
6552
+ : researchBranchReceipts.some((receipt) => receipt.stopReason === 'budget_exhausted')
6553
+ ? 'budget'
6554
+ : researchRuns.some((run) => run.status === 'error')
6555
+ ? 'insufficient_evidence'
6556
+ : executableResearchBranches.length > 6
6557
+ ? 'budget'
6558
+ : 'completed',
5004
6559
  });
5005
6560
  // V2 carries a verdict per bounded hypothesis and makes a deliberately
5006
6561
  // small investigation visible to the caller. A returned row is still
@@ -5010,7 +6565,7 @@ export async function startLocalServer(opts) {
5010
6565
  rootQuestion: request.question,
5011
6566
  planId: plan.rootPlanId,
5012
6567
  snapshotId: routeDecision?.resolvedAnalyticalPlan?.snapshotId,
5013
- groundableBranchCount: typedResearchPlan.hypotheses.length,
6568
+ groundableBranchCount: groundableResearchBranchCount,
5014
6569
  entries: researchLedger.entries.map((entry, index) => ({
5015
6570
  ...entry,
5016
6571
  hypothesis: typedHypothesesById.get(entry.branchId)?.statement ?? plan.steps[index]?.thought,
@@ -5042,6 +6597,26 @@ export async function startLocalServer(opts) {
5042
6597
  })),
5043
6598
  stoppingReason: researchLedger.stoppingReason,
5044
6599
  });
6600
+ for (const entry of researchLedgerV2.entries) {
6601
+ const branch = researchBranchSpans.get(entry.branchId);
6602
+ if (!branch)
6603
+ continue;
6604
+ // Timeout/skipped spans were finalized at the physical branch boundary
6605
+ // with a typed reason. Do not overwrite that story with a generic
6606
+ // ledger projection during synthesis.
6607
+ if (researchBranchOutcomes.has(entry.branchId))
6608
+ continue;
6609
+ trace.finishSpan(branch.spanId, {
6610
+ outcome: entry.verdict === 'failed' ? 'error' : entry.verdict === 'skipped' ? 'skipped' : 'ok',
6611
+ reasonCode: entry.verdict === 'failed' ? 'unknown' : 'completed',
6612
+ payload: {
6613
+ kind: 'research',
6614
+ branchId: entry.branchId,
6615
+ hypothesisFingerprint: branch.hypothesisFingerprint,
6616
+ verdict: entry.verdict,
6617
+ },
6618
+ });
6619
+ }
5045
6620
  // A query that ran and matched 0 rows STILL executed — treat it as a clean,
5046
6621
  // grounded execution (not "no result"), so an empty answer is surfaced as
5047
6622
  // "0 rows matched" rather than silently downgraded to review-required.
@@ -5051,19 +6626,28 @@ export async function startLocalServer(opts) {
5051
6626
  && !researchWorkspaceError
5052
6627
  && researchRuns.length > 0
5053
6628
  && researchRuns.every((run) => run.status === 'ready');
5054
- const narration = !needsClarification && researchResultData
6629
+ // Finalization has a reserved slice. It can always construct the
6630
+ // deterministic receipt-bound story, but a fresh provider narration may
6631
+ // not begin after its soft cutoff.
6632
+ const narration = !needsClarification && researchResultData && (!request.runBudget || request.runBudget.mayStartNarration())
5055
6633
  ? await narrateForAgentRun({
5056
6634
  question: request.question,
5057
6635
  intent: request.intent,
5058
6636
  result: researchResultData,
5059
6637
  evidence: plan.sources,
5060
6638
  reviewRequired: true,
5061
- }, request.researchResultRowsOptIn === true)
6639
+ }, request.researchResultRowsOptIn === true, trace)
5062
6640
  : undefined;
5063
6641
  // The cross-branch story. Every branch tested a hypothesis and produced a
5064
6642
  // finding; narrating only the one result the executor happened to carry
5065
6643
  // reported a single fact and discarded the rest, which is the visible
5066
6644
  // half of "research answers one question instead of telling a story".
6645
+ const researchSynthesisSpan = trace.startSpan({
6646
+ name: 'research.synthesize',
6647
+ stage: 'research',
6648
+ reasonCode: 'started',
6649
+ payload: { kind: 'research', branchCount: researchLedgerV2.entries.length },
6650
+ });
5067
6651
  const researchStory = !needsClarification && researchLedgerV2.entries.length > 0
5068
6652
  ? synthesizeResearchNarrative({
5069
6653
  question: request.question,
@@ -5077,6 +6661,11 @@ export async function startLocalServer(opts) {
5077
6661
  })),
5078
6662
  })
5079
6663
  : undefined;
6664
+ trace.finishSpan(researchSynthesisSpan, {
6665
+ outcome: 'ok',
6666
+ reasonCode: 'completed',
6667
+ payload: { kind: 'research', branchCount: researchLedgerV2.entries.length },
6668
+ });
5080
6669
  const summary = needsClarification
5081
6670
  ? 'Needs clarification before running deeper research.'
5082
6671
  // The story leads; the verified-fact narration follows it, so the
@@ -5095,14 +6684,31 @@ export async function startLocalServer(opts) {
5095
6684
  : plan.done
5096
6685
  ? 'Prepared a direct grounded-answer plan.'
5097
6686
  : 'Prepared a grounded research plan over real DQL assets.');
5098
- const scopedSummary = !needsClarification && researchLedgerV2.limitedScope
5099
- ? `Limited research scope: fewer than three groundable branches were available. ${summary}`
5100
- : summary;
6687
+ const branchBudgetExhausted = researchBranchReceipts.some((receipt) => receipt.stopReason === 'budget_exhausted');
6688
+ const branchTimedOut = researchBranchReceipts.some((receipt) => receipt.stopReason === 'research_branch_timeout');
6689
+ const allResearchBranchesBoundedOut = researchBranchReceipts.length > 0
6690
+ && researchBranchReceipts.every((receipt) => receipt.stopReason === 'research_branch_timeout' || receipt.stopReason === 'budget_exhausted')
6691
+ && branchTimedOut;
6692
+ const scopePrefix = [
6693
+ !needsClarification && researchLedgerV2.limitedScope
6694
+ ? 'Limited research scope: fewer than three groundable branches were available.'
6695
+ : undefined,
6696
+ allResearchBranchesBoundedOut
6697
+ ? 'Limited Research: every admitted branch reached its bounded window before producing a receipt-backed finding. Inspect the branch failures and retry a narrower Research question.'
6698
+ : branchTimedOut
6699
+ ? 'One or more Research branches reached their fair-share deadline; the story uses only completed receipts and marks the remaining evidence as inconclusive or skipped.'
6700
+ : undefined,
6701
+ branchBudgetExhausted
6702
+ ? 'Remaining branches were skipped because time was reserved for synthesis and persistence.'
6703
+ : undefined,
6704
+ ].filter((value) => Boolean(value)).join(' ');
6705
+ const scopedSummary = scopePrefix ? `${scopePrefix} ${summary}` : summary;
5101
6706
  return {
5102
6707
  summary: scopedSummary,
5103
- answer: plan.followUp?.question ?? narration?.summary
5104
- ?? (researchZeroRows ? 'The query executed cleanly and matched 0 rows.' : undefined)
5105
- ?? researchRun?.summary,
6708
+ // Ask renders `answer` ahead of `summary`. Preserve the exact scoped
6709
+ // story there so a no-provider/no-target Research run cannot hide the
6710
+ // limited-scope warning behind a generic "no executed result" line.
6711
+ answer: plan.followUp?.question ?? scopedSummary,
5106
6712
  status: needsClarification ? 'needs_clarification' : 'needs_review',
5107
6713
  trustState: needsClarification ? 'not_applicable' : (researchExecutedCleanly ? 'grounded' : 'review_required'),
5108
6714
  stopReason: needsClarification ? 'needs_clarification' : 'human_review_required',
@@ -5113,6 +6719,14 @@ export async function startLocalServer(opts) {
5113
6719
  typedResearchPlan,
5114
6720
  researchLedger,
5115
6721
  researchLedgerV2,
6722
+ researchBranchReceipts,
6723
+ researchBudget: {
6724
+ version: 1,
6725
+ finalizationReserveMs: RESEARCH_BRANCH_FINALIZATION_RESERVE_MS,
6726
+ branchTimedOut,
6727
+ branchBudgetExhausted,
6728
+ allResearchBranchesBoundedOut,
6729
+ },
5116
6730
  researchRun,
5117
6731
  researchRuns,
5118
6732
  researchRunId: researchRun?.id,
@@ -5142,13 +6756,19 @@ export async function startLocalServer(opts) {
5142
6756
  : 'The query executed cleanly against real data and returned rows.')
5143
6757
  : 'No executed result was available; the output stays exploratory pending review.', { rowCount: Array.isArray(researchResultRecord?.rows) ? researchResultRecord.rows.length : 0 }),
5144
6758
  agentRunEvaluation('research-scope', 'Research scope', !researchLedgerV2.limitedScope, researchLedgerV2.limitedScope ? 'warning' : 'info', researchLedgerV2.limitedScope
5145
- ? `Limited research scope: ${researchLedgerV2.groundableBranchCount} of at least 3 branches produced groundable evidence.`
6759
+ ? `Limited research scope: ${researchLedgerV2.groundableBranchCount} of at least 3 evidence-supported branches were available for this investigation.`
5146
6760
  : `${researchLedgerV2.groundableBranchCount} groundable branches were retained with verdicts and counter-evidence slots.`, { groundableBranchCount: researchLedgerV2.groundableBranchCount, limitedScope: researchLedgerV2.limitedScope }),
5147
6761
  ],
5148
6762
  nextActions: needsClarification
5149
6763
  ? [{ id: 'answer-follow-up', label: 'Answer follow-up', route: 'research' }]
5150
6764
  : [
5151
6765
  ...(researchRun?.id ? [{ id: 'open-research', label: 'Open research dossier', artifactKind: 'research_run' }] : []),
6766
+ ...(allResearchBranchesBoundedOut
6767
+ ? [
6768
+ { id: 'inspect-research-failures', label: 'Inspect branch failures', route: 'research' },
6769
+ { id: 'retry-narrower-research', label: 'Retry narrower Research', route: 'research' },
6770
+ ]
6771
+ : []),
5152
6772
  { id: 'create-block', label: 'Review DQL draft', route: 'dql_block_draft', artifactKind: 'dql_block_draft' },
5153
6773
  ...(researchRuns.some((run) => run.generatedSql || run.reviewedSql) ? [{ id: 'insert-sql', label: 'Insert SQL preview', route: 'sql_cell', artifactKind: 'sql_cell' }] : []),
5154
6774
  ],
@@ -5450,22 +7070,15 @@ export async function startLocalServer(opts) {
5450
7070
  // lookup, and governed execution for the lifetime of a request. This removes
5451
7071
  // both positional catalog truncation and the previous duplicate retrieval pass.
5452
7072
  const preparedAgentContextPacks = new WeakMap();
5453
- /**
5454
- * Cross-encoder pass over the fused candidates, when a provider is available.
5455
- * Advisory throughout: it may only reorder ids retrieval returned, and any
5456
- * failure leaves retrieval's own ordering in place.
5457
- */
5458
- const agentRerankCandidates = (() => {
5459
- const governed = resolveGovernedAnswerRunner(projectRoot);
5460
- const provider = governed
5461
- ? createGovernedTextProvider(governed.provider, projectRoot)
5462
- : undefined;
5463
- if (!provider)
5464
- return undefined;
5465
- return (question, candidates) => rerankCandidates(provider, question, candidates, {
5466
- timeoutMs: Math.round(2_500 * deadlineScale()),
5467
- });
5468
- })();
7073
+ // The candidate-ID meaning call is the sole model-owned relevance decision
7074
+ // for an ordinary Ask. Do not attach the optional raw-provider cross
7075
+ // encoder here: it bypasses the request-scoped dispatch ledger and can turn
7076
+ // a meaning + generation + frozen-plan repair into four physical provider
7077
+ // sends. Deterministic retrieval already fuses exact, lexical, vector and
7078
+ // graph evidence before role-balanced admission; the canonical meaning
7079
+ // resolver may reorder only those admitted IDs under the normal Ask budget.
7080
+ // Research has its own explicitly traced planner/evidence paths and does
7081
+ // not borrow an unobserved rerank transport from this shared builder.
5469
7082
  const pendingAgentContextPacks = new WeakMap();
5470
7083
  const buildAgentRunContextPack = async (request) => {
5471
7084
  const prepared = preparedAgentContextPacks.get(request);
@@ -5481,6 +7094,10 @@ export async function startLocalServer(opts) {
5481
7094
  // this only inside the provider adapter allowed stale catalog matches to win
5482
7095
  // before "they" / "this amount" became customer-scoped context.
5483
7096
  const followUp = resolveAgentFollowUpContext(request.conversationContext, request.question);
7097
+ // Persist only the server-produced binding classification on the ephemeral
7098
+ // run request. The engine/trace can explain why prior state was (or was
7099
+ // not) admitted without receiving any prior rows or prompt content.
7100
+ request.conversationBinding = followUp?.binding ?? 'none';
5484
7101
  const serverSnapshot = agentRunRecord(request.conversationContext?.conversationEnvelope
5485
7102
  ?? request.conversationContext?.serverSnapshot);
5486
7103
  const topicRelation = agentRunString(serverSnapshot?.topicRelation);
@@ -5533,10 +7150,6 @@ export async function startLocalServer(opts) {
5533
7150
  },
5534
7151
  strictness: request.analysisDepth === 'deep' ? 'exploratory' : 'balanced',
5535
7152
  limit: request.analysisDepth === 'deep' ? 120 : 80,
5536
- // The runtime PRE-BUILDS this pack, so wiring the reranker only at the
5537
- // provider's own `buildLocalContextPack` left it unreachable on the
5538
- // common path — the prepared pack is used and that call never happens.
5539
- ...(agentRerankCandidates ? { rerankCandidates: agentRerankCandidates } : {}),
5540
7153
  domainContext: requestedDomain
5541
7154
  ? resolveUiDomainContext({
5542
7155
  manifest: snapshot.manifest,
@@ -5578,6 +7191,7 @@ export async function startLocalServer(opts) {
5578
7191
  knowledgeLens: pack.knowledgeLens,
5579
7192
  analyticalPolicies: pack.skills.flatMap((skill) => skill.analyticalPolicy ? [skill.analyticalPolicy] : []),
5580
7193
  contextObjects: pack.objects,
7194
+ retrievalLanes: pack.retrievalDiagnostics.lanes,
5581
7195
  durationMs: Date.now() - startedAt,
5582
7196
  truncated: pack.retrievalDiagnostics.topRejected.length > 0,
5583
7197
  });
@@ -5735,59 +7349,91 @@ export async function startLocalServer(opts) {
5735
7349
  // Provider-agnostic completion the planner injects. Reuses the configured provider
5736
7350
  // adapter; throwing here makes the planner fall back to its deterministic path.
5737
7351
  const agentRunPlanner = createLlmAgentRunPlanner({
5738
- complete: async ({ system, user, signal }) => {
7352
+ complete: async ({ system, user, request, signal }) => {
5739
7353
  const provider = await createBlockStudioAssistProvider(projectRoot);
5740
7354
  if (!provider)
5741
7355
  throw new Error('No AI provider configured for planning.');
5742
- return provider.generate([
5743
- { role: 'system', content: system },
5744
- { role: 'user', content: user },
5745
- ], {
5746
- maxTokens: 700,
5747
- temperature: 0.1,
5748
- signal,
5749
- maxProviderDispatches: 2,
5750
- ...(agentRunProviderEvidenceContext.getStore() ? {
5751
- onProviderDispatch: (event) => agentRunProviderEvidenceContext.getStore().observe(event, {
7356
+ const planningTrace = createProviderDispatchTrace({
7357
+ observer: askTraceObserverForV1(request),
7358
+ phase: 'planning',
7359
+ purpose: 'answer_generation',
7360
+ admit: (event) => {
7361
+ const ledger = agentRunProviderEvidenceContext.getStore();
7362
+ if (ledger) {
7363
+ return ledger.observe(event, {
7364
+ purpose: 'answer_generation',
7365
+ dispatchPhase: 'planning',
7366
+ optIn: false,
7367
+ });
7368
+ }
7369
+ const envelope = prepareProviderWireEnvelopeForDispatch(event.provider, event.envelope);
7370
+ assertProviderPayloadAllowed(envelope, {
7371
+ allowResultRows: false,
7372
+ maxResultRows: 0,
5752
7373
  purpose: 'answer_generation',
5753
- dispatchPhase: 'planning',
5754
- optIn: false,
5755
- }),
5756
- } : {}),
7374
+ });
7375
+ return envelope;
7376
+ },
5757
7377
  });
7378
+ try {
7379
+ const response = await provider.generate([
7380
+ { role: 'system', content: system },
7381
+ { role: 'user', content: user },
7382
+ ], {
7383
+ maxTokens: 700,
7384
+ temperature: 0.1,
7385
+ signal,
7386
+ // A planner is one bounded preparation transport, never a retry
7387
+ // lane. The run-scoped ledger also enforces the cross-phase cap.
7388
+ maxProviderDispatches: 1,
7389
+ ...planningTrace.options,
7390
+ });
7391
+ planningTrace.settle('ok');
7392
+ return response;
7393
+ }
7394
+ catch (error) {
7395
+ planningTrace.settle(signal?.aborted ? 'cancelled' : 'error', error);
7396
+ throw error;
7397
+ }
5758
7398
  },
5759
7399
  getCatalogContext: buildRankedAgentRunCatalogContext,
5760
7400
  });
5761
7401
  // Explicit selections and conversation-only turns remain deterministic;
5762
7402
  // each fresh natural-language analytical turn gets one bounded candidate-ID
5763
- // interpretation call by default. The rollback is host-owned and cannot be
5764
- // supplied by an HTTP/MCP client.
7403
+ // meaning call by default. The legacy/no-evidence category classifier is
7404
+ // separately labelled and cannot coexist with that analytical call. The
7405
+ // rollback is host-owned and cannot be supplied by an HTTP/MCP client.
5765
7406
  const agentRunRouter = createHybridRouter({
5766
7407
  requireMeaningCallForNaturalLanguage: opts.requireMeaningCallForNaturalLanguage ?? true,
5767
- complete: async ({ system, user, signal }) => {
7408
+ complete: async ({ system, user, signal, request, phase }) => {
5768
7409
  const provider = await createBlockStudioAssistProvider(projectRoot);
5769
7410
  if (!provider)
5770
7411
  throw new Error('No AI provider configured for meaning resolution.');
5771
- return provider.generate([
5772
- { role: 'system', content: system },
5773
- { role: 'user', content: user },
5774
- ], {
5775
- maxTokens: 600,
5776
- temperature: 0,
5777
- // AGT-009/PERF-002: ambiguity gets one bounded resolver call. If the
5778
- // provider stalls, the router falls back to its evidence-only decision
5779
- // instead of leaving the UI in "Generating and validating SQL" for a
5780
- // minute or more.
5781
- signal: boundedAgentMeaningSignal(signal),
5782
- maxProviderDispatches: 1,
5783
- ...(agentRunProviderEvidenceContext.getStore() ? {
5784
- onProviderDispatch: (event) => agentRunProviderEvidenceContext.getStore().observe(event, {
5785
- purpose: 'answer_generation',
5786
- dispatchPhase: 'meaning_resolution',
5787
- optIn: false,
5788
- }),
5789
- } : {}),
7412
+ const dispatchTrace = createRouterInterpretationProviderTrace({
7413
+ request,
7414
+ routerPhase: phase,
5790
7415
  });
7416
+ try {
7417
+ const response = await provider.generate([
7418
+ { role: 'system', content: system },
7419
+ { role: 'user', content: user },
7420
+ ], {
7421
+ maxTokens: 600,
7422
+ temperature: 0,
7423
+ // AGT-009/PERF-002: a fresh natural-language Ask gets one bounded
7424
+ // candidate-ID-only interpretation call. A certified exact route
7425
+ // never reaches this callback, preserving its zero-provider path.
7426
+ signal: boundedAgentMeaningSignal(signal),
7427
+ maxProviderDispatches: 1,
7428
+ ...(dispatchTrace?.options ?? {}),
7429
+ });
7430
+ dispatchTrace?.settle('ok');
7431
+ return response;
7432
+ }
7433
+ catch (error) {
7434
+ dispatchTrace?.settle(signal?.aborted ? 'cancelled' : 'error', error);
7435
+ throw error;
7436
+ }
5791
7437
  },
5792
7438
  getEvidence: buildAgentRunEvidence,
5793
7439
  getCatalogContext: buildRankedAgentRunCatalogContext,
@@ -5799,6 +7445,11 @@ export async function startLocalServer(opts) {
5799
7445
  path: defaultAgentRunSqlitePath(projectRoot),
5800
7446
  legacyJsonPath: defaultAgentRunStorePath(projectRoot),
5801
7447
  });
7448
+ // OBS-002: traces intentionally live outside agent-runs.sqlite. A corrupt,
7449
+ // oversized, or schema-newer trace store must not make Ask unavailable.
7450
+ const askTraceStore = new AskTraceSqliteStoreV1({
7451
+ path: defaultAskTraceSqlitePath(projectRoot),
7452
+ });
5802
7453
  const conversationStorePath = await prepareConversationPath(projectRoot);
5803
7454
  // A run may outlive its streaming browser connection, so cancellation is
5804
7455
  // server-owned and keyed by run id rather than relying on fetch abort alone.
@@ -5848,6 +7499,18 @@ export async function startLocalServer(opts) {
5848
7499
  gates: defaultAgentRunGates,
5849
7500
  planner: agentRunPlanner,
5850
7501
  router: agentRunRouter,
7502
+ traceObserverFactory: ({ runId, request, requestedMode }) => createAskTraceObserverV1({
7503
+ store: askTraceStore,
7504
+ runId,
7505
+ // This host-only value is attached after HTTP capability validation.
7506
+ // The public JSON parser never accepts a caller-supplied surface.
7507
+ surface: request.traceSurface ?? 'browser',
7508
+ mode: requestedMode === 'research' ? 'research' : 'ask',
7509
+ // The durable trace never receives the question text. This server-side
7510
+ // fingerprint is stable enough to correlate a reviewed reproduction.
7511
+ questionFingerprint: `sha256:${createHash('sha256').update(request.question).digest('hex')}`,
7512
+ ...(request.threadId ? { threadId: request.threadId } : {}),
7513
+ }),
5851
7514
  });
5852
7515
  const runNotebookForApp = async (appId, notebookPath) => {
5853
7516
  const absPath = safeJoin(projectRoot, notebookPath);
@@ -6046,25 +7709,31 @@ export async function startLocalServer(opts) {
6046
7709
  sqlParams: plan?.sqlParams,
6047
7710
  variables: { ...(plan?.variables ?? {}), ...invocation.values },
6048
7711
  executePrepared: async (preparation) => {
6049
- if (pinnedSemanticCompile) {
6050
- const semanticExecution = await executeTargetBoundSemanticQuery({
6051
- executor,
6052
- connection: activeConnection,
6053
- projectRoot,
6054
- plannedAdapter: pinnedSemanticCompile.engine,
6055
- metricFlow: pinnedSemanticCompile.engine === 'metricflow-cli'
6056
- ? resolveMetricFlowTargetMetadata(projectRoot, projectConfig)
6057
- : undefined,
6058
- compile: async () => pinnedSemanticCompile,
6059
- prepareSql: () => ({ sql: preparation.executedSql, connection: preparation.connection }),
6060
- rowBound: invocationInput?.rowLimit ?? pinnedSemanticCompile.effectiveRequest.limit,
6061
- });
6062
- if (semanticExecution) {
6063
- semanticExecutionHolder.value = semanticExecution;
6064
- return semanticExecution.result;
7712
+ return executePreparedArtifactTraceBoundary({
7713
+ preparedSql: preparation.executedSql,
7714
+ reviewRequired: metadata.reviewRequired ?? true,
7715
+ execute: async () => {
7716
+ if (pinnedSemanticCompile) {
7717
+ const semanticExecution = await executeTargetBoundSemanticQuery({
7718
+ executor,
7719
+ connection: activeConnection,
7720
+ projectRoot,
7721
+ plannedAdapter: pinnedSemanticCompile.engine,
7722
+ metricFlow: pinnedSemanticCompile.engine === 'metricflow-cli'
7723
+ ? resolveMetricFlowTargetMetadata(projectRoot, projectConfig)
7724
+ : undefined,
7725
+ compile: async () => pinnedSemanticCompile,
7726
+ prepareSql: () => ({ sql: preparation.executedSql, connection: preparation.connection }),
7727
+ rowBound: invocationInput?.rowLimit ?? pinnedSemanticCompile.effectiveRequest.limit,
7728
+ });
7729
+ if (semanticExecution) {
7730
+ semanticExecutionHolder.value = semanticExecution;
7731
+ return semanticExecution.result;
7732
+ }
7733
+ }
7734
+ return executor.executeQuery(preparation.executedSql, plan?.sqlParams ?? [], runtimeVariables({ ...(plan?.variables ?? {}), ...invocation.values }), preparation.connection);
6065
7735
  }
6066
- }
6067
- return executor.executeQuery(preparation.executedSql, plan?.sqlParams ?? [], runtimeVariables({ ...(plan?.variables ?? {}), ...invocation.values }), preparation.connection);
7736
+ });
6068
7737
  },
6069
7738
  });
6070
7739
  const semanticExecution = semanticExecutionHolder.value;
@@ -6137,6 +7806,7 @@ export async function startLocalServer(opts) {
6137
7806
  path: block.filePath,
6138
7807
  domain: block.domain,
6139
7808
  chartType: block.chartType,
7809
+ reviewRequired: false,
6140
7810
  ...(qualifiedSchemaContext?.length ? { qualifiedSchemaContext } : {}),
6141
7811
  }, invocationInput, executionConnection, executionConnectionName);
6142
7812
  return {
@@ -6181,6 +7851,11 @@ export async function startLocalServer(opts) {
6181
7851
  name: artifact.name,
6182
7852
  path: artifact.sourcePath,
6183
7853
  domain: artifact.source.match(/\bdomain\s*=\s*"([^"]+)"/i)?.[1],
7854
+ // Trust is owned by the route that froze this artifact. A governed
7855
+ // semantic artifact is not exploratory merely because it is not a
7856
+ // certified block; only the explicit review-required artifact lane
7857
+ // carries reviewRequired into the physical SQL span.
7858
+ reviewRequired: artifact.trustState === 'review_required',
6184
7859
  }, {
6185
7860
  question,
6186
7861
  parameters: invocation.values,
@@ -6502,6 +8177,32 @@ export async function startLocalServer(opts) {
6502
8177
  const repairs = boundedSql === preflight.sql
6503
8178
  ? [...preflight.repairs]
6504
8179
  : [...preflight.repairs, `Applied the requested overall top-${requestedTopN} bound before exploratory execution.`];
8180
+ // This is the only deterministic SQL rewrite point on the exploratory
8181
+ // execution path. Record it here, rather than inferring a successful SQL
8182
+ // repair later from a generic engine retry event.
8183
+ if (repairs.length > 0) {
8184
+ const trace = activeAskTraceObserver();
8185
+ const repairPayload = {
8186
+ kind: 'sql',
8187
+ execution: {
8188
+ version: 1,
8189
+ sqlFingerprint: runtimeTraceFingerprint(boundedSql),
8190
+ planFingerprint: runtimeTraceFingerprint(normalizedCandidateSql),
8191
+ reviewRequired: true,
8192
+ },
8193
+ };
8194
+ const repairSpan = trace?.startSpan({
8195
+ name: 'sql.repair',
8196
+ stage: 'sql',
8197
+ reasonCode: 'repair_attempted',
8198
+ payload: repairPayload,
8199
+ });
8200
+ trace?.finishSpan(repairSpan, {
8201
+ outcome: 'ok',
8202
+ reasonCode: 'repair_attempted',
8203
+ payload: repairPayload,
8204
+ });
8205
+ }
6505
8206
  if (preflight.blockedReason) {
6506
8207
  return failed(preflight.blockedReason, boundedSql, repairs, [], 'exploratory_preflight_blocked');
6507
8208
  }
@@ -6852,33 +8553,43 @@ export async function startLocalServer(opts) {
6852
8553
  maxResultRows: 0,
6853
8554
  purpose: 'repair_sql',
6854
8555
  });
8556
+ const repairDispatchTrace = createProviderDispatchTrace({
8557
+ observer: activeAskTraceObserver(),
8558
+ phase: 'repair',
8559
+ purpose: 'repair_sql',
8560
+ admit: (event) => {
8561
+ const envelope = prepareProviderWireEnvelopeForDispatch(provider.name, event.envelope);
8562
+ assertProviderPayloadAllowed(envelope, {
8563
+ allowResultRows: false,
8564
+ maxResultRows: 0,
8565
+ purpose: 'repair_sql',
8566
+ });
8567
+ providerRoundTrips += 1;
8568
+ providerEgressReceipts.push(createProviderDispatchEgressReceipt({
8569
+ purpose: 'repair_sql',
8570
+ dispatchPhase: 'repair',
8571
+ provider: provider.name,
8572
+ permittedCategories: ['instructions', 'question', 'schema_metadata', 'governed_context'],
8573
+ optIn: false,
8574
+ envelope,
8575
+ }));
8576
+ return envelope;
8577
+ },
8578
+ });
6855
8579
  let raw;
6856
8580
  try {
6857
8581
  raw = await provider.generate(repairEnvelope.messages, {
6858
8582
  maxTokens: 1200,
6859
8583
  temperature: 0,
6860
- maxProviderDispatches: 2,
6861
- onProviderDispatch: (event) => {
6862
- const envelope = prepareProviderWireEnvelopeForDispatch(provider.name, event.envelope);
6863
- assertProviderPayloadAllowed(envelope, {
6864
- allowResultRows: false,
6865
- maxResultRows: 0,
6866
- purpose: 'repair_sql',
6867
- });
6868
- providerRoundTrips += 1;
6869
- providerEgressReceipts.push(createProviderDispatchEgressReceipt({
6870
- purpose: 'repair_sql',
6871
- dispatchPhase: 'repair',
6872
- provider: provider.name,
6873
- permittedCategories: ['instructions', 'question', 'schema_metadata', 'governed_context'],
6874
- optIn: false,
6875
- envelope,
6876
- }));
6877
- return envelope;
6878
- },
8584
+ // This is the one parent-bound repair transport. A provider retry
8585
+ // would be a second repair attempt, so the host never authorizes it.
8586
+ maxProviderDispatches: 1,
8587
+ ...repairDispatchTrace.options,
6879
8588
  });
8589
+ repairDispatchTrace.settle('ok');
6880
8590
  }
6881
- catch {
8591
+ catch (error) {
8592
+ repairDispatchTrace.settle('error', error);
6882
8593
  throw Object.assign(boundedRepairError('The AI provider could not complete this repair. Check Provider Settings and retry.', 502), {
6883
8594
  providerDispatchEvidence: {
6884
8595
  providerEgressReceipts: [...providerEgressReceipts],
@@ -7443,6 +9154,15 @@ export async function startLocalServer(opts) {
7443
9154
  warnings: [notebookResearchStorageUnavailableMessage],
7444
9155
  });
7445
9156
  const runNotebookResearch = async (storage, run, input = {}) => {
9157
+ // A fair-share branch signal is separate from the root run signal. Check it
9158
+ // at each durable boundary so an overrun cannot later overwrite the
9159
+ // timeout/skipped receipt the parent has already recorded.
9160
+ const throwIfResearchSignalAborted = () => {
9161
+ if (!input.signal?.aborted)
9162
+ return;
9163
+ throw input.signal.reason ?? new DOMException('The Research branch deadline elapsed.', 'TimeoutError');
9164
+ };
9165
+ throwIfResearchSignalAborted();
7446
9166
  const question = notebookResearchString(input.question) || run.question;
7447
9167
  const domain = notebookResearchString(input.domain) ?? run.domain;
7448
9168
  const owner = notebookResearchString(input.owner) ?? run.owner;
@@ -7471,33 +9191,50 @@ export async function startLocalServer(opts) {
7471
9191
  error: '',
7472
9192
  });
7473
9193
  try {
7474
- const contextPack = await buildLocalContextPack(projectRoot, {
7475
- question,
7476
- mode: 'question',
7477
- surface: 'notebook',
7478
- selectedContext: {
7479
- ...notebookResearchSelectedContext(run, context),
7480
- domain,
7481
- owner,
7482
- intent,
7483
- researchPattern: notebookResearchIntentPattern(intent),
7484
- },
7485
- strictness: 'exploratory',
7486
- limit: 160,
7487
- }).catch((error) => {
7488
- const message = error instanceof Error ? error.message : String(error);
7489
- return {
9194
+ throwIfResearchSignalAborted();
9195
+ // An explicit Research child has already retrieved, interpreted, and
9196
+ // frozen its own authoritative Ask plan before it reaches this durable
9197
+ // Notebook record. Rebuilding a broad Notebook context pack here is
9198
+ // both duplicate work and a fairness bug: it can consume the child's
9199
+ // entire branch window *after* a safe SQL result already exists. Keep a
9200
+ // deliberately small receipt projection instead. It is reporting-only;
9201
+ // execution remains bound to the child RAP supplied above.
9202
+ const contextPack = input.authoritativeBranch
9203
+ ? {
7490
9204
  id: '',
7491
- routeDecision: undefined,
9205
+ routeDecision: input.authoritativeBranch.routeDecision,
7492
9206
  evidenceRoles: [],
7493
- warnings: [`Context pack failed: ${message}`],
9207
+ warnings: [],
7494
9208
  retrievalDiagnostics: { selectedEvidence: [] },
7495
- };
7496
- });
7497
- let governedAnswer;
9209
+ }
9210
+ : await buildLocalContextPack(projectRoot, {
9211
+ question,
9212
+ mode: 'question',
9213
+ surface: 'notebook',
9214
+ selectedContext: {
9215
+ ...notebookResearchSelectedContext(run, context),
9216
+ domain,
9217
+ owner,
9218
+ intent,
9219
+ researchPattern: notebookResearchIntentPattern(intent),
9220
+ },
9221
+ strictness: 'exploratory',
9222
+ limit: 160,
9223
+ }).catch((error) => {
9224
+ const message = error instanceof Error ? error.message : String(error);
9225
+ return {
9226
+ id: '',
9227
+ routeDecision: undefined,
9228
+ evidenceRoles: [],
9229
+ warnings: [`Context pack failed: ${message}`],
9230
+ retrievalDiagnostics: { selectedEvidence: [] },
9231
+ };
9232
+ });
9233
+ throwIfResearchSignalAborted();
9234
+ let governedAnswer = input.authoritativeBranch?.answer;
7498
9235
  let providerError;
7499
9236
  const generationWarnings = [];
7500
- if (!generatedSql && !reviewedSql) {
9237
+ if (!generatedSql && !reviewedSql && !input.authoritativeBranch) {
7501
9238
  const governedResearch = resolveGovernedAnswerRunner(projectRoot);
7502
9239
  const resolvedProvider = governedResearch?.provider ?? null;
7503
9240
  const runner = governedResearch?.runner ?? null;
@@ -7507,6 +9244,7 @@ export async function startLocalServer(opts) {
7507
9244
  else {
7508
9245
  const researchSignal = input.signal ?? new AbortController().signal;
7509
9246
  try {
9247
+ throwIfResearchSignalAborted();
7510
9248
  await runner.run({
7511
9249
  provider: resolvedProvider,
7512
9250
  messages: [{ role: 'user', content: notebookResearchAgentPrompt(question, intent) }],
@@ -7537,6 +9275,7 @@ export async function startLocalServer(opts) {
7537
9275
  if (turn.kind === 'error')
7538
9276
  providerError = turn.message;
7539
9277
  }, researchSignal);
9278
+ throwIfResearchSignalAborted();
7540
9279
  }
7541
9280
  catch (error) {
7542
9281
  rethrowIfCancelled(error, input.signal);
@@ -7559,7 +9298,11 @@ export async function startLocalServer(opts) {
7559
9298
  || agentAnswerHasExecutionFailure(governedAnswer)
7560
9299
  || Boolean(governedAnswer.analyticalFailure)
7561
9300
  || (!governedAnswer.result && !generatedSql && !dqlArtifact);
7562
- if (!reviewedSql && baselineSql && deeperCompositionFailed) {
9301
+ // A root baseline is an execution receipt for its own analytical tuple,
9302
+ // not a reusable SQL authority for every Research hypothesis. Explicit
9303
+ // Research children always carry their own router-frozen RAP, so they
9304
+ // must never borrow this generic Notebook fallback.
9305
+ if (!input.authoritativeBranch && !reviewedSql && baselineSql && deeperCompositionFailed) {
7563
9306
  const failure = governedAnswer?.executionError
7564
9307
  ?? governedAnswer?.analyticalFailure?.message
7565
9308
  ?? governedAnswer?.answer
@@ -7582,13 +9325,16 @@ export async function startLocalServer(opts) {
7582
9325
  let resultPreview;
7583
9326
  let previewError;
7584
9327
  if (!usedBaselineFallback && governedAnswer?.result?.rows && !reviewedSql) {
9328
+ throwIfResearchSignalAborted();
7585
9329
  resultPreview = normalizeNotebookAgentResult(governedAnswer.result);
7586
9330
  }
7587
- else if (sqlForPreview) {
9331
+ else if (sqlForPreview && !input.authoritativeBranch) {
7588
9332
  try {
9333
+ throwIfResearchSignalAborted();
7589
9334
  const previewSql = buildAgentPreviewSql(sqlForPreview);
7590
9335
  const previewStart = Date.now();
7591
9336
  resultPreview = await executeLocalSqlForStoredResult(previewSql, input.executionConnection);
9337
+ throwIfResearchSignalAborted();
7592
9338
  recordNotebookQueryRun(projectRoot, {
7593
9339
  notebookPath: run.notebookPath,
7594
9340
  cellId: run.sourceCellId,
@@ -7617,7 +9363,8 @@ export async function startLocalServer(opts) {
7617
9363
  });
7618
9364
  }
7619
9365
  }
7620
- const routeDecision = notebookResearchRouteDecisionForRun(run, contextPack.routeDecision, sqlForPreview);
9366
+ const routeDecision = input.authoritativeBranch?.routeDecision
9367
+ ?? notebookResearchRouteDecisionForRun(run, contextPack.routeDecision, sqlForPreview);
7621
9368
  const display = resultPreview
7622
9369
  ? recommendVisualization(projectRoot, {
7623
9370
  prompt: question,
@@ -7665,11 +9412,6 @@ export async function startLocalServer(opts) {
7665
9412
  ...(governedAnswer?.citations ?? []),
7666
9413
  ].slice(0, 40),
7667
9414
  };
7668
- const summary = usedBaselineFallback && resultPreview && !previewError
7669
- ? 'Research retained the successful Ask baseline and revalidated it on the same data target. No unverified replacement query was accepted; use the dossier to add the next breakdown or comparison.'
7670
- : notebookResearchString(governedAnswer?.answer)
7671
- ?? notebookResearchString(governedAnswer?.text)
7672
- ?? notebookResearchSummary(question, resultPreview, previewError);
7673
9415
  const previewRecord = agentRunRecord(resultPreview);
7674
9416
  const executionReceipt = normalizeAnalyticalExecutionReceipt(previewRecord?.executionReceipt);
7675
9417
  // Do not treat a child run ID as execution evidence. The canonical
@@ -7683,6 +9425,29 @@ export async function startLocalServer(opts) {
7683
9425
  ?? (executionUnavailable
7684
9426
  ? 'Research did not produce an executed result or execution receipt; the branch remains review-required.'
7685
9427
  : undefined);
9428
+ // A Research child reaches this point only after its own Ask cascade
9429
+ // selected and executed a frozen plan. Once that execution has a
9430
+ // canonical receipt, preserve it immediately with a deterministic,
9431
+ // fact-bound branch narrative. In particular, do not route the result
9432
+ // back through ordinary Ask narration (or another provider transport)
9433
+ // while the branch's fair-share deadline is running. The root Research
9434
+ // synthesis consumes this persisted observation later.
9435
+ const authoritativeExecutedBranch = Boolean(input.authoritativeBranch
9436
+ && resultPreview
9437
+ && !previewError
9438
+ && executionProof);
9439
+ const summary = authoritativeExecutedBranch
9440
+ ? deterministicResearchBranchSummary({
9441
+ question,
9442
+ result: resultPreview,
9443
+ executionReceipt,
9444
+ analyticalNarrative: governedAnswer?.analyticalNarrative,
9445
+ })
9446
+ : usedBaselineFallback && resultPreview && !previewError
9447
+ ? 'Research retained the successful Ask baseline and revalidated it on the same data target. No unverified replacement query was accepted; use the dossier to add the next breakdown or comparison.'
9448
+ : notebookResearchString(governedAnswer?.answer)
9449
+ ?? notebookResearchString(governedAnswer?.text)
9450
+ ?? notebookResearchSummary(question, resultPreview, previewError);
7686
9451
  const recommendation = previewError
7687
9452
  ? 'Review the SQL, selected metadata, and connection context before rerunning.'
7688
9453
  : dqlArtifact && !reviewedSql
@@ -7700,6 +9465,7 @@ export async function startLocalServer(opts) {
7700
9465
  dqlArtifact,
7701
9466
  routeDecision,
7702
9467
  });
9468
+ throwIfResearchSignalAborted();
7703
9469
  return storage.updateRun(run.id, {
7704
9470
  domain,
7705
9471
  owner,
@@ -8497,7 +10263,7 @@ export async function startLocalServer(opts) {
8497
10263
  res.setHeader('Access-Control-Allow-Origin', requestOrigin);
8498
10264
  res.setHeader('Vary', 'Origin');
8499
10265
  res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
8500
- res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, Idempotency-Key');
10266
+ res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, Idempotency-Key, X-DQL-Ask-Trace-Capability');
8501
10267
  if (!originAllowed && path.startsWith('/api/')) {
8502
10268
  res.writeHead(403, { 'Content-Type': 'application/json; charset=utf-8' });
8503
10269
  res.end(serializeJSON({ error: 'Origin is not allowed.' }));
@@ -8516,6 +10282,21 @@ export async function startLocalServer(opts) {
8516
10282
  return;
8517
10283
  }
8518
10284
  }
10285
+ // Existing `dql notebook` runtimes cannot hand their in-memory capability
10286
+ // to a later CLI process. A short-lived challenge is therefore issued only
10287
+ // when BOTH the server binding and requesting socket are loopback. Remote
10288
+ // and browser-origin requests retain their normal `browser` attribution.
10289
+ if (req.method === 'GET' && path === '/api/ask-traces/cli-capability') {
10290
+ if (!loopback || !isLoopbackRemoteAddress(req.socket.remoteAddress)) {
10291
+ res.writeHead(403, { 'Content-Type': 'application/json; charset=utf-8' });
10292
+ res.end(serializeJSON({ code: 'TRACE_CLI_CAPABILITY_FORBIDDEN', error: 'CLI trace attribution is available only to a loopback local runtime.' }));
10293
+ return;
10294
+ }
10295
+ const capability = cliAskTraceCapabilities.issue();
10296
+ res.writeHead(201, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store' });
10297
+ res.end(serializeJSON(capability));
10298
+ return;
10299
+ }
8519
10300
  if (req.method === 'GET' && path === '/api/health') {
8520
10301
  // REL-002: expose runtime/build identity so a server started before a
8521
10302
  // rebuild or running a different version than the project pin is visibly
@@ -9692,6 +11473,169 @@ export async function startLocalServer(opts) {
9692
11473
  res.end(serializeJSON({ runs, total: stored.length, limit }));
9693
11474
  return;
9694
11475
  }
11476
+ // OBS-005: local-only Ask trace APIs. These endpoints return the compact
11477
+ // trace envelope or its typed, redacted detail; they never read a prompt,
11478
+ // SQL string, result row, provider response, or live network exporter.
11479
+ const writeTraceStoreUnavailable = (traceStatus) => {
11480
+ const schemaUnsupported = traceStatus.reason === 'unsupported_schema';
11481
+ res.writeHead(schemaUnsupported ? 409 : 503, { 'Content-Type': 'application/json; charset=utf-8' });
11482
+ res.end(serializeJSON({
11483
+ code: schemaUnsupported ? 'TRACE_SCHEMA_UNSUPPORTED' : 'TRACE_STORE_UNAVAILABLE',
11484
+ error: schemaUnsupported
11485
+ ? 'The local Ask trace schema is newer than this DQL runtime can read.'
11486
+ : 'Local Ask trace storage is unavailable.',
11487
+ status: traceStatus,
11488
+ }));
11489
+ };
11490
+ if (req.method === 'GET' && path === '/api/ask-traces/status') {
11491
+ res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
11492
+ res.end(serializeJSON({ status: askTraceStore.status() }));
11493
+ return;
11494
+ }
11495
+ if (req.method === 'GET' && path === '/api/ask-traces') {
11496
+ const traceStatus = askTraceStore.status();
11497
+ if (!traceStatus.available) {
11498
+ writeTraceStoreUnavailable(traceStatus);
11499
+ return;
11500
+ }
11501
+ const rawLimit = Number(url.searchParams.get('limit'));
11502
+ const limit = Number.isFinite(rawLimit) && rawLimit > 0 ? Math.min(100, Math.floor(rawLimit)) : 50;
11503
+ // Trace-store filters are all allowlisted scalar receipt fields. The
11504
+ // question preview is deliberately NOT a trace-store filter: prompts do
11505
+ // not live in ask-observability.sqlite and list pagination remains
11506
+ // snapshot/receipt based rather than text-search based.
11507
+ const traceListInput = {
11508
+ limit,
11509
+ ...(url.searchParams.get('cursor') ? { cursor: url.searchParams.get('cursor') } : {}),
11510
+ ...(url.searchParams.get('status') ? { status: url.searchParams.get('status') } : {}),
11511
+ ...(url.searchParams.get('mode') ? { mode: url.searchParams.get('mode') } : {}),
11512
+ ...(url.searchParams.get('trustState') ? { trustState: url.searchParams.get('trustState') } : {}),
11513
+ ...(url.searchParams.get('selectedTier') ? { selectedTier: url.searchParams.get('selectedTier') } : {}),
11514
+ ...(url.searchParams.get('surface') ? { surface: url.searchParams.get('surface') } : {}),
11515
+ ...(url.searchParams.get('recordingStatus') ? { recordingStatus: url.searchParams.get('recordingStatus') } : {}),
11516
+ };
11517
+ const page = askTraceStore.list(traceListInput);
11518
+ const traces = await Promise.all(page.traces.map(async (trace) => {
11519
+ const run = await agentRunStore.get(trace.runId);
11520
+ if (!run)
11521
+ return trace;
11522
+ const questionPreview = askTraceQuestionPreview(run.question);
11523
+ return {
11524
+ ...trace,
11525
+ ...(questionPreview ? { questionPreview } : {}),
11526
+ scenarioLabel: askTraceScenarioLabel(run),
11527
+ };
11528
+ }));
11529
+ res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
11530
+ res.end(serializeJSON({ ...page, traces }));
11531
+ return;
11532
+ }
11533
+ if (req.method === 'GET' && /^\/api\/ask-traces\/by-run\/[^/]+$/.test(path)) {
11534
+ const traceStatus = askTraceStore.status();
11535
+ if (!traceStatus.available) {
11536
+ writeTraceStoreUnavailable(traceStatus);
11537
+ return;
11538
+ }
11539
+ const runId = decodeURIComponent(path.slice('/api/ask-traces/by-run/'.length));
11540
+ const trace = askTraceStore.getByRun(runId);
11541
+ if (!trace) {
11542
+ res.writeHead(404, { 'Content-Type': 'application/json; charset=utf-8' });
11543
+ res.end(serializeJSON({ code: 'TRACE_NOT_FOUND', error: 'No local trace was found for this Ask run.' }));
11544
+ return;
11545
+ }
11546
+ if (trace.envelope.recordingStatus === 'detail_expired') {
11547
+ res.writeHead(410, { 'Content-Type': 'application/json; charset=utf-8' });
11548
+ res.end(serializeJSON({ code: 'TRACE_DETAIL_EXPIRED', error: 'Detailed trace evidence has expired; the run summary remains available.', envelope: trace.envelope }));
11549
+ return;
11550
+ }
11551
+ const run = await agentRunStore.get(runId);
11552
+ res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
11553
+ // The canonical Ask story is persisted on the AgentRun, not rebuilt from
11554
+ // spans. Join it only at this local API boundary so trace storage never
11555
+ // becomes prompt/result retention and old runs explicitly omit it.
11556
+ res.end(serializeJSON({
11557
+ ...trace,
11558
+ ...(run?.diagnosticReceiptV4?.summary ? { decisionSummary: run.diagnosticReceiptV4.summary } : {}),
11559
+ }));
11560
+ return;
11561
+ }
11562
+ if (req.method === 'GET' && /^\/api\/ask-traces\/[0-9a-f]{32}\/export$/.test(path)) {
11563
+ const traceStatus = askTraceStore.status();
11564
+ if (!traceStatus.available) {
11565
+ writeTraceStoreUnavailable(traceStatus);
11566
+ return;
11567
+ }
11568
+ const traceId = path.split('/')[3] ?? '';
11569
+ const trace = askTraceStore.get(traceId);
11570
+ if (!trace) {
11571
+ res.writeHead(404, { 'Content-Type': 'application/json; charset=utf-8' });
11572
+ res.end(serializeJSON({ code: 'TRACE_NOT_FOUND', error: 'No local trace was found.' }));
11573
+ return;
11574
+ }
11575
+ if (trace.envelope.recordingStatus === 'detail_expired') {
11576
+ res.writeHead(410, { 'Content-Type': 'application/json; charset=utf-8' });
11577
+ res.end(serializeJSON({ code: 'TRACE_DETAIL_EXPIRED', error: 'Detailed trace evidence has expired; it cannot be exported.' }));
11578
+ return;
11579
+ }
11580
+ if ((url.searchParams.get('profile') ?? 'strict') !== 'strict') {
11581
+ res.writeHead(422, { 'Content-Type': 'application/json; charset=utf-8' });
11582
+ res.end(serializeJSON({ code: 'TRACE_EXPORT_REDACTION_FAILED', error: 'The runtime export endpoint only serves strict redacted bundles.' }));
11583
+ return;
11584
+ }
11585
+ try {
11586
+ const run = await agentRunStore.get(trace.envelope.runId);
11587
+ const bundle = createAskTracePortableBundleV1(trace, {
11588
+ profile: 'strict',
11589
+ runReceipt: run,
11590
+ provenance: 'recorded',
11591
+ });
11592
+ askTraceStore.recordExportReceipt(trace.envelope.traceId, {
11593
+ version: 1,
11594
+ profile: 'strict',
11595
+ bundleFingerprint: bundle.manifest.bundleFingerprint,
11596
+ exportedAt: bundle.manifest.createdAt,
11597
+ checksums: {
11598
+ 'manifest.json': `sha256:${createHash('sha256').update(serializeJSON(bundle.manifest)).digest('hex')}`,
11599
+ ...bundle.manifest.checksums,
11600
+ },
11601
+ canaryPassed: true,
11602
+ ...(bundle.trace.envelope.traceFingerprint ? { traceFingerprint: bundle.trace.envelope.traceFingerprint } : {}),
11603
+ });
11604
+ res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8', 'Content-Disposition': `attachment; filename="ask-trace-${traceId}.json"` });
11605
+ res.end(serializeJSON(bundle));
11606
+ }
11607
+ catch {
11608
+ res.writeHead(422, { 'Content-Type': 'application/json; charset=utf-8' });
11609
+ res.end(serializeJSON({ code: 'TRACE_EXPORT_REDACTION_FAILED', error: 'The local trace could not be exported safely.' }));
11610
+ }
11611
+ return;
11612
+ }
11613
+ if (req.method === 'GET' && /^\/api\/ask-traces\/[0-9a-f]{32}$/.test(path)) {
11614
+ const traceStatus = askTraceStore.status();
11615
+ if (!traceStatus.available) {
11616
+ writeTraceStoreUnavailable(traceStatus);
11617
+ return;
11618
+ }
11619
+ const traceId = path.split('/').at(-1) ?? '';
11620
+ const trace = askTraceStore.get(traceId);
11621
+ if (!trace) {
11622
+ res.writeHead(404, { 'Content-Type': 'application/json; charset=utf-8' });
11623
+ res.end(serializeJSON({ code: 'TRACE_NOT_FOUND', error: 'No local trace was found.' }));
11624
+ return;
11625
+ }
11626
+ if (trace.envelope.recordingStatus === 'detail_expired') {
11627
+ res.writeHead(410, { 'Content-Type': 'application/json; charset=utf-8' });
11628
+ res.end(serializeJSON({ code: 'TRACE_DETAIL_EXPIRED', error: 'Detailed trace evidence has expired; the run summary remains available.', envelope: trace.envelope }));
11629
+ return;
11630
+ }
11631
+ const run = await agentRunStore.get(trace.envelope.runId);
11632
+ res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
11633
+ res.end(serializeJSON({
11634
+ ...trace,
11635
+ ...(run?.diagnosticReceiptV4?.summary ? { decisionSummary: run.diagnosticReceiptV4.summary } : {}),
11636
+ }));
11637
+ return;
11638
+ }
9695
11639
  /**
9696
11640
  * One-click bounded execution repair.
9697
11641
  *
@@ -9837,6 +11781,41 @@ export async function startLocalServer(opts) {
9837
11781
  const repairStartedAt = new Date().toISOString();
9838
11782
  const repairStartedAtMs = Date.now();
9839
11783
  const sourceFailureForReservation = analyticalFailedRunFromAgentRun(sourceRun)?.failure;
11784
+ // A one-click repair is a derived Ask run, not an invisible side effect
11785
+ // of the original failure. Give it its own addressable local trace and
11786
+ // keep the source run/trace plus immutable plan fingerprint as typed
11787
+ // linkage. No prompt, SQL, result row, or raw failure text enters it.
11788
+ const repairTrace = createAskTraceObserverV1({
11789
+ store: askTraceStore,
11790
+ runId: derivedRunId,
11791
+ surface: 'browser',
11792
+ mode: sourceRun.requestedMode === 'research' ? 'research' : 'ask',
11793
+ questionFingerprint: runtimeTraceFingerprint(sourceRun.question),
11794
+ ...(sourceRun.traceReference?.traceId ? { parentTraceId: sourceRun.traceReference.traceId } : {}),
11795
+ parentRunId: sourceRun.id,
11796
+ });
11797
+ repairTrace.recordLink({
11798
+ kind: 'derived_repair',
11799
+ targetRunId: sourceRun.id,
11800
+ ...(sourceRun.traceReference?.traceId ? { targetTraceId: sourceRun.traceReference.traceId } : {}),
11801
+ });
11802
+ const repairTracePayload = {
11803
+ kind: 'sql',
11804
+ execution: {
11805
+ version: 1,
11806
+ tier: 'exploratory_sql',
11807
+ planFingerprint: capability.planFingerprint,
11808
+ sqlFingerprint: capability.sqlFingerprint,
11809
+ targetFingerprint: capability.targetFingerprint,
11810
+ reviewRequired: true,
11811
+ },
11812
+ };
11813
+ const repairTraceSpan = repairTrace.startSpan({
11814
+ name: 'sql.repair',
11815
+ stage: 'sql',
11816
+ reasonCode: 'repair_attempted',
11817
+ payload: repairTracePayload,
11818
+ });
9840
11819
  const reservationEvent = {
9841
11820
  id: `${derivedRunId}:repair`,
9842
11821
  runId: derivedRunId,
@@ -9893,119 +11872,137 @@ export async function startLocalServer(opts) {
9893
11872
  }
9894
11873
  let repaired;
9895
11874
  try {
9896
- // Compile the immutable original wrapper first. A provider receives only
9897
- // this embedded SQL, never the DQL wrapper or its surrounding fields.
9898
- const invocation = prepareBlockInvocation({
9899
- source: sourceDqlArtifact.source,
9900
- parameters: sourceDqlArtifact.parameterValues,
9901
- question: sourceRun.question,
9902
- surface: 'ask_ai',
9903
- });
9904
- if (invocation.errors.length || invocation.unresolvedParameters.length) {
9905
- throw boundedRepairError(invocation.errors.join(' ') || `Provide required parameters before retrying: ${invocation.unresolvedParameters.join(', ')}.`, 409);
9906
- }
9907
- const originalPlan = buildExecutionPlan({
9908
- id: 'ask-execution-repair-source',
9909
- type: 'dql',
9910
- source: sourceDqlArtifact.source,
9911
- title: sourceDqlArtifact.name,
9912
- }, {
9913
- semanticLayer,
9914
- driver: targetConnection.driver,
9915
- parameters: invocation.values,
9916
- });
9917
- if (!originalPlan?.sql?.trim())
9918
- throw boundedRepairError('The original DQL wrapper did not compile to SQL.', 409);
9919
- if (capability.sqlFingerprint !== capabilityHash(originalPlan.sql.trim())) {
9920
- throw boundedRepairError('The original compiled SQL no longer matches its immutable repair capability.', 409);
9921
- }
9922
- const sqlRepair = await executeBoundedSqlRepair({
9923
- question: sourceRun.question,
9924
- sourceSql: originalPlan.sql,
9925
- targetConnection,
9926
- targetConnectionName,
9927
- sqlParams: originalPlan.sqlParams,
9928
- variables: { ...originalPlan.variables, ...invocation.values },
9929
- });
9930
- const embeddedSql = restoreNotebookDqlParameterInterpolations(sqlRepair.repairedSql, originalPlan.sqlParams);
9931
- const repairedSource = replaceNotebookDqlQueryForRepair(sourceDqlArtifact.source, embeddedSql);
9932
- if (!repairedSource)
9933
- throw boundedRepairError('DQL could not reinsert the repaired SQL into the immutable wrapper.', 422);
9934
- const repairedInvocation = prepareBlockInvocation({
9935
- source: repairedSource,
9936
- parameters: sourceDqlArtifact.parameterValues,
9937
- question: sourceRun.question,
9938
- surface: 'ask_ai',
9939
- });
9940
- if (repairedInvocation.errors.length || repairedInvocation.unresolvedParameters.length) {
9941
- throw boundedRepairError('The SQL-only repair did not preserve a compilable DQL wrapper.', 422);
9942
- }
9943
- const repairedPlan = buildExecutionPlan({
9944
- id: 'ask-execution-repair-derived',
9945
- type: 'dql',
9946
- source: repairedSource,
9947
- title: sourceDqlArtifact.name,
9948
- }, {
9949
- semanticLayer,
9950
- driver: targetConnection.driver,
9951
- parameters: repairedInvocation.values,
9952
- });
9953
- if (!repairedPlan?.sql?.trim() || normalizeSqlForComparison(repairedPlan.sql) !== normalizeSqlForComparison(sqlRepair.repairedSql)) {
9954
- throw boundedRepairError('The repaired wrapper recompiled to different SQL, so DQL stopped before publishing it.', 422);
9955
- }
9956
- const repairedExecutionReceipt = createDqlArtifactExecutionReceipt(repairedSource, sqlRepair.result.sql ?? repairedPlan.sql, repairedInvocation.values, sqlRepair.result);
9957
- const repairedExecutableArtifact = sqlRepair.result.executableArtifact
9958
- ? {
9959
- ...sqlRepair.result.executableArtifact,
9960
- kind: 'sql_block',
9961
- dqlFingerprint: executionFingerprint(repairedSource),
9962
- sourceFingerprint: executionFingerprint(stableExecutionValue({
9963
- name: sourceDqlArtifact.name ?? null,
9964
- path: sourceDqlArtifact.sourcePath ?? null,
9965
- })),
9966
- compiledSqlFingerprint: executionFingerprint(repairedPlan.sql),
9967
- normalizedSqlFingerprint: executionFingerprint(sqlRepair.repairedSql),
9968
- parameterFingerprint: repairedExecutionReceipt.parameterFingerprint,
9969
- provenanceFingerprint: executionFingerprint(stableExecutionValue(repairedInvocation.resolvedParameters.map((parameter) => ({
9970
- name: parameter.name,
9971
- source: parameter.source,
9972
- })))),
9973
- planFingerprint: executionFingerprint(stableExecutionValue({
11875
+ repaired = await agentRunAskTraceContext.run(repairTrace, async () => {
11876
+ // Compile the immutable original wrapper first. A provider receives only
11877
+ // this embedded SQL, never the DQL wrapper or its surrounding fields.
11878
+ const invocation = prepareBlockInvocation({
11879
+ source: sourceDqlArtifact.source,
11880
+ parameters: sourceDqlArtifact.parameterValues,
11881
+ question: sourceRun.question,
11882
+ surface: 'ask_ai',
11883
+ });
11884
+ if (invocation.errors.length || invocation.unresolvedParameters.length) {
11885
+ throw boundedRepairError(invocation.errors.join(' ') || `Provide required parameters before retrying: ${invocation.unresolvedParameters.join(', ')}.`, 409);
11886
+ }
11887
+ const originalPlan = buildExecutionPlan({
11888
+ id: 'ask-execution-repair-source',
11889
+ type: 'dql',
11890
+ source: sourceDqlArtifact.source,
11891
+ title: sourceDqlArtifact.name,
11892
+ }, {
11893
+ semanticLayer,
11894
+ driver: targetConnection.driver,
11895
+ parameters: invocation.values,
11896
+ });
11897
+ if (!originalPlan?.sql?.trim())
11898
+ throw boundedRepairError('The original DQL wrapper did not compile to SQL.', 409);
11899
+ if (capability.sqlFingerprint !== capabilityHash(originalPlan.sql.trim())) {
11900
+ throw boundedRepairError('The original compiled SQL no longer matches its immutable repair capability.', 409);
11901
+ }
11902
+ const sqlRepair = await executeBoundedSqlRepair({
11903
+ question: sourceRun.question,
11904
+ sourceSql: originalPlan.sql,
11905
+ targetConnection,
11906
+ targetConnectionName,
11907
+ sqlParams: originalPlan.sqlParams,
11908
+ variables: { ...originalPlan.variables, ...invocation.values },
11909
+ });
11910
+ const embeddedSql = restoreNotebookDqlParameterInterpolations(sqlRepair.repairedSql, originalPlan.sqlParams);
11911
+ const repairedSource = replaceNotebookDqlQueryForRepair(sourceDqlArtifact.source, embeddedSql);
11912
+ if (!repairedSource)
11913
+ throw boundedRepairError('DQL could not reinsert the repaired SQL into the immutable wrapper.', 422);
11914
+ const repairedInvocation = prepareBlockInvocation({
11915
+ source: repairedSource,
11916
+ parameters: sourceDqlArtifact.parameterValues,
11917
+ question: sourceRun.question,
11918
+ surface: 'ask_ai',
11919
+ });
11920
+ if (repairedInvocation.errors.length || repairedInvocation.unresolvedParameters.length) {
11921
+ throw boundedRepairError('The SQL-only repair did not preserve a compilable DQL wrapper.', 422);
11922
+ }
11923
+ const repairedPlan = buildExecutionPlan({
11924
+ id: 'ask-execution-repair-derived',
11925
+ type: 'dql',
11926
+ source: repairedSource,
11927
+ title: sourceDqlArtifact.name,
11928
+ }, {
11929
+ semanticLayer,
11930
+ driver: targetConnection.driver,
11931
+ parameters: repairedInvocation.values,
11932
+ });
11933
+ if (!repairedPlan?.sql?.trim() || normalizeSqlForComparison(repairedPlan.sql) !== normalizeSqlForComparison(sqlRepair.repairedSql)) {
11934
+ throw boundedRepairError('The repaired wrapper recompiled to different SQL, so DQL stopped before publishing it.', 422);
11935
+ }
11936
+ const repairedExecutionReceipt = createDqlArtifactExecutionReceipt(repairedSource, sqlRepair.result.sql ?? repairedPlan.sql, repairedInvocation.values, sqlRepair.result);
11937
+ const repairedExecutableArtifact = sqlRepair.result.executableArtifact
11938
+ ? {
11939
+ ...sqlRepair.result.executableArtifact,
11940
+ kind: 'sql_block',
11941
+ dqlFingerprint: executionFingerprint(repairedSource),
11942
+ sourceFingerprint: executionFingerprint(stableExecutionValue({
11943
+ name: sourceDqlArtifact.name ?? null,
11944
+ path: sourceDqlArtifact.sourcePath ?? null,
11945
+ })),
9974
11946
  compiledSqlFingerprint: executionFingerprint(repairedPlan.sql),
9975
- parameterCount: repairedPlan.sqlParams?.length ?? 0,
9976
- variableNames: Object.keys(repairedPlan.variables ?? {}).sort(),
9977
- })),
11947
+ normalizedSqlFingerprint: executionFingerprint(sqlRepair.repairedSql),
11948
+ parameterFingerprint: repairedExecutionReceipt.parameterFingerprint,
11949
+ provenanceFingerprint: executionFingerprint(stableExecutionValue(repairedInvocation.resolvedParameters.map((parameter) => ({
11950
+ name: parameter.name,
11951
+ source: parameter.source,
11952
+ })))),
11953
+ planFingerprint: executionFingerprint(stableExecutionValue({
11954
+ compiledSqlFingerprint: executionFingerprint(repairedPlan.sql),
11955
+ parameterCount: repairedPlan.sqlParams?.length ?? 0,
11956
+ variableNames: Object.keys(repairedPlan.variables ?? {}).sort(),
11957
+ })),
11958
+ trustState: 'review_required',
11959
+ receipt: repairedExecutionReceipt,
11960
+ }
11961
+ : undefined;
11962
+ const repairedArtifact = {
11963
+ ...sourceDqlArtifact,
11964
+ source: repairedSource,
11965
+ compiledSql: repairedPlan.sql,
9978
11966
  trustState: 'review_required',
9979
- receipt: repairedExecutionReceipt,
9980
- }
9981
- : undefined;
9982
- const repairedArtifact = {
9983
- ...sourceDqlArtifact,
9984
- source: repairedSource,
9985
- compiledSql: repairedPlan.sql,
9986
- trustState: 'review_required',
9987
- persistence: 'transient',
9988
- executionReceipt: repairedExecutionReceipt,
9989
- ...(repairedExecutableArtifact ? { executableArtifact: repairedExecutableArtifact } : {}),
9990
- ...(repairedInvocation.parameters.length ? { parameters: repairedInvocation.parameters } : {}),
9991
- ...(Object.keys(repairedInvocation.values).length ? { parameterValues: repairedInvocation.values } : {}),
9992
- };
9993
- repaired = {
9994
- ...sqlRepair,
9995
- result: {
9996
- ...sqlRepair.result,
11967
+ persistence: 'transient',
9997
11968
  executionReceipt: repairedExecutionReceipt,
9998
11969
  ...(repairedExecutableArtifact ? { executableArtifact: repairedExecutableArtifact } : {}),
9999
- dqlArtifact: repairedArtifact,
10000
- },
10001
- repairedSql: repairedPlan.sql,
10002
- repairedSource,
10003
- sqlParams: repairedPlan.sqlParams,
10004
- variables: { ...repairedPlan.variables, ...repairedInvocation.values },
10005
- };
11970
+ ...(repairedInvocation.parameters.length ? { parameters: repairedInvocation.parameters } : {}),
11971
+ ...(Object.keys(repairedInvocation.values).length ? { parameterValues: repairedInvocation.values } : {}),
11972
+ };
11973
+ return {
11974
+ ...sqlRepair,
11975
+ result: {
11976
+ ...sqlRepair.result,
11977
+ executionReceipt: repairedExecutionReceipt,
11978
+ ...(repairedExecutableArtifact ? { executableArtifact: repairedExecutableArtifact } : {}),
11979
+ dqlArtifact: repairedArtifact,
11980
+ },
11981
+ repairedSql: repairedPlan.sql,
11982
+ repairedSource,
11983
+ sqlParams: repairedPlan.sqlParams,
11984
+ variables: { ...repairedPlan.variables, ...repairedInvocation.values },
11985
+ };
11986
+ });
11987
+ repairTrace.finishSpan(repairTraceSpan, {
11988
+ outcome: 'ok',
11989
+ reasonCode: 'repair_attempted',
11990
+ payload: repairTracePayload,
11991
+ });
10006
11992
  }
10007
11993
  catch (error) {
10008
11994
  const repairError = error;
11995
+ repairTrace.finishSpan(repairTraceSpan, {
11996
+ outcome: 'error',
11997
+ reasonCode: 'sql_failure',
11998
+ payload: repairTracePayload,
11999
+ });
12000
+ const repairTraceReference = repairTrace.finalize({
12001
+ status: 'blocked',
12002
+ terminalOutcome: 'blocked',
12003
+ trustState: 'blocked',
12004
+ selectedTier: 'exploratory_sql',
12005
+ });
10009
12006
  const dispatchEvidence = providerDispatchTerminalEvidence(error);
10010
12007
  const failedAt = new Date().toISOString();
10011
12008
  const failedTelemetry = {
@@ -10053,6 +12050,7 @@ export async function startLocalServer(opts) {
10053
12050
  providerEgressReceiptFingerprints: failedReceipts.map((receipt) => executionFingerprint(stableExecutionValue(receipt))),
10054
12051
  repairCapabilityFingerprint: executionFingerprint(stableExecutionValue(capability)),
10055
12052
  },
12053
+ ...(repairTraceReference ? { traceReference: repairTraceReference } : {}),
10056
12054
  });
10057
12055
  res.writeHead(repairError.status ?? 500, { 'Content-Type': 'application/json; charset=utf-8' });
10058
12056
  res.end(serializeJSON({
@@ -10069,6 +12067,21 @@ export async function startLocalServer(opts) {
10069
12067
  const sourceFailure = analyticalFailedRunFromAgentRun(sourceRun)?.failure;
10070
12068
  const presentationContext = repairPresentationContextFromAgentRun(sourceRun);
10071
12069
  const executionResultFingerprint = result.executionReceipt?.resultFingerprint;
12070
+ const resultTraceSpan = repairTrace.startSpan({
12071
+ name: 'result.normalize',
12072
+ stage: 'result',
12073
+ reasonCode: 'result_accepted',
12074
+ payload: {
12075
+ kind: 'result',
12076
+ ...(executionResultFingerprint ? { resultFingerprint: executionResultFingerprint } : {}),
12077
+ rowCount: result.rowCount,
12078
+ trustState: 'review_required',
12079
+ },
12080
+ });
12081
+ repairTrace.finishSpan(resultTraceSpan, {
12082
+ outcome: 'ok',
12083
+ reasonCode: 'result_accepted',
12084
+ });
10072
12085
  const repairTotalDurationMs = Date.now() - repairStartedAtMs;
10073
12086
  const repairTelemetry = {
10074
12087
  version: 1,
@@ -10212,6 +12225,14 @@ export async function startLocalServer(opts) {
10212
12225
  attempt: 1,
10213
12226
  },
10214
12227
  };
12228
+ const repairTraceReference = repairTrace.finalize({
12229
+ status: 'completed',
12230
+ terminalOutcome: 'needs_review',
12231
+ trustState: 'review_required',
12232
+ selectedTier: 'exploratory_sql',
12233
+ });
12234
+ if (repairTraceReference)
12235
+ derivedRun.traceReference = repairTraceReference;
10215
12236
  await agentRunStore.save(derivedRun);
10216
12237
  recordConversationTurn(threadId ? getConversationStore() : null, threadId, derivedRun);
10217
12238
  res.writeHead(201, { 'Content-Type': 'application/json; charset=utf-8' });
@@ -10291,6 +12312,17 @@ export async function startLocalServer(opts) {
10291
12312
  res.end(serializeJSON({ error: parsed.error ?? 'Invalid agent run request.' }));
10292
12313
  return;
10293
12314
  }
12315
+ // A trace surface is host-controlled metadata. Do not parse it from
12316
+ // the JSON body: only the unguessable per-runtime capability passed
12317
+ // from `dql agent ask` may mark this local request as CLI.
12318
+ const traceSurface = cliAskTraceCapabilities.consume({
12319
+ capability: req.headers['x-dql-ask-trace-capability'],
12320
+ scope: 'agent-runs',
12321
+ loopbackServer: loopback,
12322
+ remoteAddress: req.socket.remoteAddress,
12323
+ });
12324
+ if (traceSurface)
12325
+ parsed.request.traceSurface = traceSurface;
10294
12326
  // The answer-loop cascade now proves certified/semantic/generated tiers
10295
12327
  // after retrieval and execution. Avoid pre-routing ordinary Ask runs from
10296
12328
  // token-overlap signals; explicit callers may still provide signals.
@@ -10355,14 +12387,9 @@ export async function startLocalServer(opts) {
10355
12387
  inheritedSignal: ingressSignal,
10356
12388
  });
10357
12389
  parsed.request.signal = parsed.request.runBudget.hardSignal;
10358
- // Ordinary Ask needs room to think, write, and recover: meaning (1)
10359
- // + planning/generation (3) + narration (2) + one repair. The old
10360
- // `total: 2` left nothing for narration once generation had run,
10361
- // which is why the answer arrived as a deterministic fact-join.
10362
- const runProviderEvidence = new RunScopedProviderDispatchEvidence(parsed.request.requestedMode === 'research'
10363
- ? { total: 14, meaningResolution: 1, generationGroup: 11, narration: 2, repair: 1 }
10364
- : { total: 6, meaningResolution: 1, generationGroup: 3, narration: 2, repair: 1 }, parsed.request.runBudget, resolveProviderResultRowEgressPolicy({
12390
+ const runProviderEvidence = new RunScopedProviderDispatchEvidence(agentRunProviderDispatchBudgetForMode(parsed.request.requestedMode), parsed.request.runBudget, resolveProviderResultRowEgressPolicy({
10365
12391
  projectSetting: projectConfig?.agent?.providerResultRowEgress,
12392
+ requestedMode: parsed.request.requestedMode,
10366
12393
  researchOptIn: parsed.request.requestedMode === 'research'
10367
12394
  && parsed.request.researchResultRowsOptIn === true,
10368
12395
  }));
@@ -11568,7 +13595,12 @@ export async function startLocalServer(opts) {
11568
13595
  const limit = Number(url.searchParams.get('limit') ?? '50');
11569
13596
  const includeArchived = url.searchParams.get('archived') === '1';
11570
13597
  res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
11571
- res.end(serializeJSON({ threads: store.listThreads({ limit: Number.isFinite(limit) ? limit : 50, includeArchived }) }));
13598
+ res.end(serializeJSON({
13599
+ threads: store.listThreads({ limit: Number.isFinite(limit) ? limit : 50, includeArchived }),
13600
+ // Browser cache identity only. It is an opaque one-way fingerprint
13601
+ // and intentionally does not become conversation/trace evidence.
13602
+ projectIdentity: conversationProjectIdentity,
13603
+ }));
11572
13604
  return;
11573
13605
  }
11574
13606
  if (req.method === 'POST' && path === '/api/agent/threads') {
@@ -18725,7 +20757,12 @@ table: ${table}${tagList}
18725
20757
  res.end('Method not allowed');
18726
20758
  return;
18727
20759
  }
18728
- const requestedPath = path === '/' ? '/index.html' : path;
20760
+ // The notebook owns these explicit client routes. Keep the fallback
20761
+ // deliberately narrow: arbitrary missing paths remain real 404s.
20762
+ const requestedPath = path === '/'
20763
+ || isAskClientRoutePath(path)
20764
+ ? '/index.html'
20765
+ : path;
18729
20766
  const filePath = safeJoin(rootDir, requestedPath);
18730
20767
  if (!filePath || !existsSync(filePath) || statSync(filePath).isDirectory()) {
18731
20768
  res.writeHead(404, { 'Content-Type': 'text/html; charset=utf-8' });
@@ -18773,6 +20810,7 @@ table: ${table}${tagList}
18773
20810
  projectWatchers.length = 0;
18774
20811
  unsubscribeOperationEvents();
18775
20812
  projectRefreshCoordinator.close();
20813
+ askTraceStore.close();
18776
20814
  for (const client of operationSseClients) {
18777
20815
  try {
18778
20816
  client.end();
@@ -18815,7 +20853,10 @@ function providerDispatchTerminalEvidence(value) {
18815
20853
  || (evidence.providerRoundTrips ?? -1) < 0)
18816
20854
  return undefined;
18817
20855
  return {
18818
- providerEgressReceipts: evidence.providerEgressReceipts,
20856
+ providerEgressReceipts: evidence.providerEgressReceipts.flatMap((receipt) => {
20857
+ const normalized = normalizeProviderEgressReceiptV1(receipt);
20858
+ return normalized ? [normalized] : [];
20859
+ }),
18819
20860
  providerRoundTrips: evidence.providerRoundTrips,
18820
20861
  toolCalls: Number.isInteger(evidence.toolCalls) && (evidence.toolCalls ?? -1) >= 0 ? evidence.toolCalls : 0,
18821
20862
  sqlExecutions: Number.isInteger(evidence.sqlExecutions) && (evidence.sqlExecutions ?? -1) >= 0 ? evidence.sqlExecutions : 0,
@@ -19121,7 +21162,10 @@ function normalizeQueryResult(result, semanticRefs) {
19121
21162
  truncated: result?.truncated,
19122
21163
  resultFingerprint: result?.resultFingerprint,
19123
21164
  executionReceipt: result?.executionReceipt,
19124
- trustState: result?.trustState,
21165
+ trustState: canonicalPersistedTrustState({
21166
+ trustState: result?.trustState,
21167
+ answerTier: result?.answerTier,
21168
+ }),
19125
21169
  answerTier: result?.answerTier,
19126
21170
  });
19127
21171
  const rawRows = Array.isArray(result?.rows) ? result.rows : [];
@@ -19174,7 +21218,20 @@ export function resolveDefaultLLMProvider(projectRoot) {
19174
21218
  * answer-loop runner — never the MCP `claudeCodeRunner`, which doesn't emit a governed
19175
21219
  * answer envelope. Everything else uses the Settings-resolved default runner.
19176
21220
  */
19177
- export function resolveGovernedAnswerRunner(projectRoot) {
21221
+ export function resolveGovernedAnswerRunner(projectRoot, requestedProvider) {
21222
+ // This is intentionally before configuration discovery. It lets the
21223
+ // canonical AgentRun preflight distinguish an explicit unknown provider from
21224
+ // an absent configuration, while a known-but-unavailable provider still
21225
+ // reaches its real adapter readiness check (for example Ollama network
21226
+ // failure rather than a fabricated authentication error).
21227
+ if (requestedProvider !== undefined) {
21228
+ if (!isGovernedAnswerProviderId(requestedProvider))
21229
+ return null;
21230
+ return {
21231
+ provider: requestedProvider,
21232
+ runner: createDqlAgentProviderRunner(requestedProvider),
21233
+ };
21234
+ }
19178
21235
  // Runtime eval cassettes are an explicit, offline provider source. Resolve
19179
21236
  // them before Settings because the CI fixture intentionally has no user
19180
21237
  // provider configuration; otherwise Ask exits at this earlier gate and never
@@ -19214,6 +21271,21 @@ function isGovernedAnswerProviderId(value) {
19214
21271
  || value === 'claude-code'
19215
21272
  || value === 'codex';
19216
21273
  }
21274
+ /**
21275
+ * The no-runner outcome is still an authoritative provider preflight result.
21276
+ * Preserve an explicit invalid selection as `MODEL_NOT_FOUND` instead of
21277
+ * collapsing it into the historical generic authentication error.
21278
+ */
21279
+ export function governedProviderPreflightError(requestedProvider) {
21280
+ const invalidRequestedProvider = Boolean(requestedProvider)
21281
+ && !isGovernedAnswerProviderId(requestedProvider);
21282
+ return Object.assign(new Error(invalidRequestedProvider
21283
+ ? 'The selected AI provider is not available in this local runtime. Choose a configured provider in Settings and retry.'
21284
+ : 'No AI provider is configured. Configure a subscription (Claude Code / Codex), OpenAI, Gemini, Ollama, or a custom OpenAI-compatible endpoint in Settings.'), {
21285
+ code: invalidRequestedProvider ? 'MODEL_NOT_FOUND' : 'AUTHENTICATION_FAILED',
21286
+ providerPhase: 'preflight',
21287
+ });
21288
+ }
19217
21289
  /** Map a runner provider id to the settings id whose reasoning ceiling applies. */
19218
21290
  function reasoningSettingsIdFor(provider) {
19219
21291
  return provider === 'claude-agent-sdk' ? 'anthropic' : provider;
@@ -21567,6 +23639,24 @@ function agentRunTelemetryForAnswer(answer, egressReceipts, totalDurationMs, nar
21567
23639
  function executionFingerprint(value) {
21568
23640
  return createHash('sha256').update(value).digest('hex');
21569
23641
  }
23642
+ /**
23643
+ * Opaque browser-cache identity for local Ask conversations. A browser origin
23644
+ * is not a project boundary: a user can stop one `dql notebook` process and
23645
+ * start a different project on the same port. The client therefore receives
23646
+ * only this one-way, server-owned value and never a local path. It is not a
23647
+ * secret: it is intentionally linkable only within the local browser/runtime
23648
+ * across restarts so stale cache can be rejected; never log or export it.
23649
+ */
23650
+ export function askConversationProjectIdentity(projectRoot) {
23651
+ let canonicalRoot;
23652
+ try {
23653
+ canonicalRoot = realpathSync(projectRoot);
23654
+ }
23655
+ catch {
23656
+ canonicalRoot = resolve(projectRoot);
23657
+ }
23658
+ return `sha256:${executionFingerprint(`dql.ask.conversation-cache.v1\0${canonicalRoot}`)}`;
23659
+ }
21570
23660
  function buildSemanticAggregationCompilerReceipt(input) {
21571
23661
  const plan = input.plan;
21572
23662
  const metricId = plan?.analyticalFrame?.metricConceptIds[0];
@@ -29909,6 +31999,252 @@ export function predictDispatchMs(observed, assumedMs = ASSUMED_PROVIDER_DISPATC
29909
31999
  }
29910
32000
  const AGENT_MEANING_TIMEOUT_BASE_MS = 10_000;
29911
32001
  const AGENT_MEANING_TIMEOUT_MS = AGENT_MEANING_TIMEOUT_BASE_MS * deadlineScale();
32002
+ /**
32003
+ * A Research root owns a fixed 120-second deadline. Reserve the last slice
32004
+ * for durable branch receipts, deterministic synthesis, and run persistence;
32005
+ * otherwise the first slow child can consume the entire investigation and
32006
+ * leave the user with neither an answer nor an explanation of the failure.
32007
+ *
32008
+ * This is deliberately a local runtime scheduling policy, not a second
32009
+ * product deadline. The root AgentRunBudget remains the one hard authority.
32010
+ */
32011
+ export const RESEARCH_BRANCH_FINALIZATION_RESERVE_MS = 15_000;
32012
+ export const RESEARCH_MIN_BRANCH_EXECUTION_MS = 1_000;
32013
+ /**
32014
+ * Research branches are independent bounded investigations. Run a small
32015
+ * deterministic wave so a five-hypothesis plan does not divide the root
32016
+ * budget into five unusably short serial windows. This is deliberately below
32017
+ * the provider cap and keeps cancellation/finalization responsive.
32018
+ */
32019
+ export const RESEARCH_MAX_CONCURRENT_BRANCHES = 3;
32020
+ /**
32021
+ * Fair-share one child deadline from the remaining root time. The formula is
32022
+ * intentionally deterministic so a trace can explain why a branch stopped:
32023
+ * after finalization is reserved, divide the usable window by the number of
32024
+ * remaining bounded waves. Every branch in the current wave receives the
32025
+ * same window. A too-small share is a skipped branch, not a late
32026
+ * provider/warehouse admission.
32027
+ */
32028
+ export function allocateResearchBranchBudget(input) {
32029
+ const remainingMs = Math.max(0, Math.trunc(input.remainingMs));
32030
+ const remainingBranches = Math.max(1, Math.trunc(input.remainingBranches));
32031
+ const finalizationReserveMs = Math.max(0, Math.trunc(input.finalizationReserveMs ?? RESEARCH_BRANCH_FINALIZATION_RESERVE_MS));
32032
+ const minExecutionMs = Math.max(1, Math.trunc(input.minExecutionMs ?? RESEARCH_MIN_BRANCH_EXECUTION_MS));
32033
+ const maxConcurrentBranches = Math.max(1, Math.min(remainingBranches, Math.trunc(input.maxConcurrentBranches ?? RESEARCH_MAX_CONCURRENT_BRANCHES)));
32034
+ const remainingWaves = Math.ceil(remainingBranches / maxConcurrentBranches);
32035
+ const usableMs = Math.max(0, remainingMs - finalizationReserveMs);
32036
+ const branchBudgetMs = Math.floor(usableMs / remainingWaves);
32037
+ if (branchBudgetMs < minExecutionMs) {
32038
+ return {
32039
+ version: 1,
32040
+ remainingMs,
32041
+ finalizationReserveMs,
32042
+ maxConcurrentBranches,
32043
+ remainingWaves,
32044
+ stopReason: 'budget_exhausted',
32045
+ };
32046
+ }
32047
+ return {
32048
+ version: 1,
32049
+ remainingMs,
32050
+ finalizationReserveMs,
32051
+ maxConcurrentBranches,
32052
+ remainingWaves,
32053
+ branchBudgetMs,
32054
+ };
32055
+ }
32056
+ /**
32057
+ * Convert one typed Research action into an ordinary Ask requirement seed.
32058
+ *
32059
+ * The older branch runner reduced every action to the final token of its
32060
+ * target. That meant `compare_time` and `breakdown` silently discarded the
32061
+ * root metric (and, for time, its required role/grain), so distinct research
32062
+ * branches could all execute the same baseline. This projection is host-only:
32063
+ * it preserves the root tuple and adds the action's one requested operation,
32064
+ * but never turns a planner label into a trusted candidate or SQL authority.
32065
+ */
32066
+ export function buildResearchBranchRequirementProjection(input) {
32067
+ const targetHint = researchBranchTargetHint(input.action.target);
32068
+ const rootRequirements = input.rootRequirementSeed.requirements;
32069
+ const rootPlan = input.rootPlan;
32070
+ const resolvedPlanTerms = (bindings) => bindings
32071
+ .filter((binding) => binding.status === 'resolved')
32072
+ .map((binding) => binding.requested);
32073
+ const rootMeasures = uniqueResearchBranchTerms(rootRequirements.measures, rootPlan ? resolvedPlanTerms(rootPlan.query.measures) : []);
32074
+ const rootDimensions = uniqueResearchBranchTerms(rootRequirements.dimensions, rootPlan ? resolvedPlanTerms(rootPlan.query.dimensions) : []);
32075
+ const rootEntityTerms = uniqueResearchBranchTerms(rootRequirements.entityTerms);
32076
+ const rootEntityDisplayTerms = uniqueResearchBranchTerms(rootRequirements.entityDisplayTerms);
32077
+ const rootMemberTerms = uniqueResearchBranchTerms(rootRequirements.memberTerms);
32078
+ const rootOutputTerms = uniqueResearchBranchTerms(rootRequirements.outputTerms ?? []);
32079
+ const rootTimeGrain = researchBranchTimeGrain(rootRequirements.time?.grain
32080
+ ?? rootPlan?.query.timeGrain
32081
+ ?? rootPlan?.analyticalFrame?.timeContext?.grain);
32082
+ const rootTime = rootRequirements.time
32083
+ ? { ...rootRequirements.time }
32084
+ : rootTimeGrain
32085
+ ? {
32086
+ role: 'time_axis',
32087
+ grain: rootTimeGrain,
32088
+ requiresDeclaredFiscalCalendar: false,
32089
+ }
32090
+ : undefined;
32091
+ const baseRequirements = (inputOverrides) => ({
32092
+ version: 1,
32093
+ measures: rootMeasures,
32094
+ dimensions: rootDimensions,
32095
+ entityTerms: rootEntityTerms,
32096
+ entityDisplayTerms: rootEntityDisplayTerms,
32097
+ memberTerms: rootMemberTerms,
32098
+ ...(rootOutputTerms.length > 0 ? { outputTerms: rootOutputTerms } : {}),
32099
+ ...(rootRequirements.grain ? { grain: rootRequirements.grain } : {}),
32100
+ ...(rootRequirements.ranking ? { ranking: { ...rootRequirements.ranking } } : {}),
32101
+ ...(rootTime ? { time: rootTime } : {}),
32102
+ ...inputOverrides,
32103
+ });
32104
+ const rootTimeRange = input.rootRequirementSeed.queryIntent.timeRange
32105
+ ?? rootPlan?.query.timeRange;
32106
+ const questionWithRootTimeRange = (question) => rootTimeRange
32107
+ ? `${question} for ${rootTimeRange}`
32108
+ : question;
32109
+ /**
32110
+ * A child request has one host-owned tuple. The planner's target can
32111
+ * improve retrieval, but it must not erase a root filter, output, rank,
32112
+ * fiscal binding, or the root measure just because this particular branch
32113
+ * happens to look up a metric. Rebuild the seed for the child wording, then
32114
+ * restore the immutable root query intent and add only an action-owned
32115
+ * dimension where the action actually requires one.
32116
+ */
32117
+ const projectedSeed = (question, requirements) => {
32118
+ const seed = buildAnalyticalRequirementSeedV1({ question, requirements });
32119
+ const rootIntent = input.rootRequirementSeed.queryIntent;
32120
+ const queryIntentDimensions = uniqueResearchBranchTerms(rootIntent.dimensions, requirements.dimensions, requirements.entityDisplayTerms);
32121
+ const queryIntentMeasures = uniqueResearchBranchTerms(rootIntent.measures, requirements.measures);
32122
+ return {
32123
+ ...seed,
32124
+ queryIntent: {
32125
+ measures: queryIntentMeasures,
32126
+ dimensions: queryIntentDimensions,
32127
+ // Filter/member interpretation is host-owned on the root request. A
32128
+ // branch may bind it to a child snapshot, but may not discard it.
32129
+ filters: rootIntent.filters.map((filter) => ({ ...filter })),
32130
+ ...(rootIntent.timeRange ? { timeRange: rootIntent.timeRange } : {}),
32131
+ ...(rootIntent.timeGrain
32132
+ ? { timeGrain: rootIntent.timeGrain }
32133
+ : requirements.time?.grain ? { timeGrain: requirements.time.grain } : {}),
32134
+ ...(rootIntent.order ? { order: rootIntent.order } : {}),
32135
+ ...(rootIntent.limit !== undefined ? { limit: rootIntent.limit } : {}),
32136
+ ...(rootIntent.fiscalCalendarId ? { fiscalCalendarId: rootIntent.fiscalCalendarId } : {}),
32137
+ ...(rootIntent.fiscalDateRoleId ? { fiscalDateRoleId: rootIntent.fiscalDateRoleId } : {}),
32138
+ },
32139
+ };
32140
+ };
32141
+ if (input.action.kind === 'lookup_metric') {
32142
+ // The target remains a retrieval phrase only. Keeping the root tuple here
32143
+ // matters for multi-metric, ranked, fiscal, filtered research: a lookup
32144
+ // branch cannot silently become a query for just the planner's metric.
32145
+ const question = `Focus on ${targetHint} while answering: ${researchBranchRootQuestion(input.rootRequirementSeed.sourceQuestion)}`;
32146
+ return {
32147
+ version: 1,
32148
+ action: input.action.kind,
32149
+ question,
32150
+ requirementSeed: projectedSeed(question, baseRequirements({})),
32151
+ };
32152
+ }
32153
+ if (input.action.kind === 'breakdown' && rootMeasures.length > 0) {
32154
+ const question = questionWithRootTimeRange(`Show ${rootMeasures.join(' and ')} by ${targetHint}`);
32155
+ return {
32156
+ version: 1,
32157
+ action: input.action.kind,
32158
+ question,
32159
+ requirementSeed: projectedSeed(question, baseRequirements({
32160
+ dimensions: uniqueResearchBranchTerms(rootDimensions, [targetHint]),
32161
+ })),
32162
+ };
32163
+ }
32164
+ if (input.action.kind === 'compare_time' && rootMeasures.length > 0) {
32165
+ const question = questionWithRootTimeRange(`Compare ${rootMeasures.join(' and ')} over time by ${targetHint}`);
32166
+ return {
32167
+ version: 1,
32168
+ action: input.action.kind,
32169
+ question,
32170
+ requirementSeed: projectedSeed(question, baseRequirements({
32171
+ // Keep the time target in the role-balanced requirement package as
32172
+ // a retrieval term. The child router still verifies its exact
32173
+ // time-axis role against its own candidate capability before plan
32174
+ // freeze; a planner label alone cannot authorize a time field.
32175
+ dimensions: uniqueResearchBranchTerms(rootDimensions, [targetHint]),
32176
+ time: {
32177
+ role: 'time_axis',
32178
+ ...(rootTimeGrain ? { grain: rootTimeGrain } : {}),
32179
+ ...(rootRequirements.time?.fiscalPeriod
32180
+ ? { fiscalPeriod: rootRequirements.time.fiscalPeriod }
32181
+ : {}),
32182
+ requiresDeclaredFiscalCalendar: rootRequirements.time?.requiresDeclaredFiscalCalendar ?? false,
32183
+ },
32184
+ })),
32185
+ };
32186
+ }
32187
+ // Blocks, lineage, and app composition do not invent an analytical tuple.
32188
+ // Their target remains a bounded retrieval phrase, and the child cascade
32189
+ // decides whether it can freeze a compatible route.
32190
+ return {
32191
+ version: 1,
32192
+ action: input.action.kind,
32193
+ question: targetHint,
32194
+ };
32195
+ }
32196
+ function researchBranchTargetHint(target) {
32197
+ return target
32198
+ .trim()
32199
+ .split(/[/:]/)
32200
+ .at(-1)
32201
+ ?.split('.')
32202
+ .at(-1)
32203
+ ?.replace(/[_-]+/g, ' ')
32204
+ .trim() || 'the planned analytical target';
32205
+ }
32206
+ function researchBranchRootQuestion(sourceQuestion) {
32207
+ const withoutResearchPrefix = sourceQuestion
32208
+ .replace(/^\s*(?:deep\s+)?research\b\s*(?:about|on|into|for)?\s*/i, '')
32209
+ .trim();
32210
+ return withoutResearchPrefix || 'the root analytical question';
32211
+ }
32212
+ function uniqueResearchBranchTerms(...groups) {
32213
+ return [...new Set(groups
32214
+ .flat()
32215
+ .map((term) => term.trim())
32216
+ .filter(Boolean))];
32217
+ }
32218
+ function researchBranchTimeGrain(value) {
32219
+ return value === 'day' || value === 'week' || value === 'month' || value === 'quarter' || value === 'year'
32220
+ ? value
32221
+ : undefined;
32222
+ }
32223
+ /**
32224
+ * Race a child against its own signal while consuming an eventual late
32225
+ * rejection. `runNotebookResearch` also receives that signal and checks it at
32226
+ * persistence boundaries, so a slow provider/query cannot overwrite the
32227
+ * already-recorded timeout receipt after this promise rejects.
32228
+ */
32229
+ export function awaitResearchBranchDeadline(work, signal) {
32230
+ if (signal.aborted) {
32231
+ void work.catch(() => undefined);
32232
+ return Promise.reject(signal.reason ?? new DOMException('The Research branch deadline elapsed.', 'TimeoutError'));
32233
+ }
32234
+ return new Promise((resolve, reject) => {
32235
+ let settled = false;
32236
+ const finish = (callback) => {
32237
+ if (settled)
32238
+ return;
32239
+ settled = true;
32240
+ signal.removeEventListener('abort', onAbort);
32241
+ callback();
32242
+ };
32243
+ const onAbort = () => finish(() => reject(signal.reason ?? new DOMException('The Research branch deadline elapsed.', 'TimeoutError')));
32244
+ signal.addEventListener('abort', onAbort, { once: true });
32245
+ work.then((value) => finish(() => resolve(value)), (error) => finish(() => reject(error)));
32246
+ });
32247
+ }
29912
32248
  export function boundedAgentMeaningSignal(signal, timeoutMs = AGENT_MEANING_TIMEOUT_MS) {
29913
32249
  const timeout = AbortSignal.timeout(Math.max(1, timeoutMs));
29914
32250
  return signal ? AbortSignal.any([signal, timeout]) : timeout;
@@ -30566,7 +32902,10 @@ function normalizeNotebookAgentResult(result) {
30566
32902
  executionTime: result.executionTime,
30567
32903
  resultFingerprint: result.resultFingerprint,
30568
32904
  executionReceipt: result.executionReceipt,
30569
- trustState: result.executableArtifact?.trustState,
32905
+ trustState: canonicalPersistedTrustState({
32906
+ trustState: result.executableArtifact?.trustState,
32907
+ answerTier: result.answerTier,
32908
+ }),
30570
32909
  answerTier: result.answerTier,
30571
32910
  });
30572
32911
  return {
@@ -30577,6 +32916,7 @@ function normalizeNotebookAgentResult(result) {
30577
32916
  executionTime: canonical.executionTime ?? 0,
30578
32917
  ...(canonical.truncated ? { truncated: true } : {}),
30579
32918
  ...(canonical.executionReceipt ? { executionReceipt: canonical.executionReceipt } : {}),
32919
+ ...(canonical.trustState ? { trustState: canonical.trustState } : {}),
30580
32920
  ...(canonical.answerTier ? { answerTier: canonical.answerTier } : {}),
30581
32921
  };
30582
32922
  }
@@ -30589,6 +32929,20 @@ function notebookResearchSummary(question, result, error) {
30589
32929
  }
30590
32930
  return `Research plan created for "${question}". Add or generate SQL, then run a bounded preview.`;
30591
32931
  }
32932
+ /**
32933
+ * Persisted Research children are deliberately narrated without another model
32934
+ * call after a frozen Ask plan has returned rows. This keeps the branch inside
32935
+ * its fair-share deadline and makes the stored finding a direct consequence of
32936
+ * its execution receipt rather than an unbounded second interpretation pass.
32937
+ */
32938
+ function deterministicResearchBranchSummary(input) {
32939
+ const factNarrative = notebookResearchString(input.analyticalNarrative?.text);
32940
+ const fallback = notebookResearchSummary(input.question, input.result, undefined);
32941
+ const receiptKind = input.executionReceipt?.resultFingerprint
32942
+ ? 'receipt-bound'
32943
+ : 'execution-fingerprint-bound';
32944
+ return `${factNarrative ?? fallback} This ${receiptKind} Research branch was retained for synthesis without a follow-up provider narration.`;
32945
+ }
30592
32946
  function recordNotebookQueryRun(projectRoot, input) {
30593
32947
  try {
30594
32948
  recordQueryRun(projectRoot, {