@duckcodeailabs/dql-cli 1.14.0 → 1.14.2

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 (96) hide show
  1. package/dist/args.d.ts +15 -0
  2. package/dist/args.d.ts.map +1 -1
  3. package/dist/args.js +25 -0
  4. package/dist/args.js.map +1 -1
  5. package/dist/assets/dql-notebook/assets/{AgentLogPage-Ch7VK20X.js → AgentLogPage-DKbGpRQS.js} +1 -1
  6. package/dist/assets/dql-notebook/assets/{AiBuildDialog-DBr5TmyM.js → AiBuildDialog-DPSu0Mly.js} +1 -1
  7. package/dist/assets/dql-notebook/assets/{AiBuildResult-jLPzQO7O.js → AiBuildResult-1uaGnpi1.js} +1 -1
  8. package/dist/assets/dql-notebook/assets/{AiSidePanel-CdlGsiVC.js → AiSidePanel-BwMREwa7.js} +1 -1
  9. package/dist/assets/dql-notebook/assets/{AnalyticsHome-C0DXbOwY.js → AnalyticsHome-BGfey_ve.js} +1 -1
  10. package/dist/assets/dql-notebook/assets/{AppsView-CcpwjApv.js → AppsView-CM1tPywy.js} +4 -4
  11. package/dist/assets/dql-notebook/assets/{BlockStudio-CFYxafw-.js → BlockStudio--S6WFVO4.js} +1 -1
  12. package/dist/assets/dql-notebook/assets/{BusinessArtifactView-C0kLYg2p.js → BusinessArtifactView-BEAJ-yNW.js} +1 -1
  13. package/dist/assets/dql-notebook/assets/{DbtFirstModelingPage-CMwElXD_.js → DbtFirstModelingPage-CNyU5MBX.js} +1 -1
  14. package/dist/assets/dql-notebook/assets/{GitPage-JjhRDeWY.js → GitPage-IcydAai3.js} +1 -1
  15. package/dist/assets/dql-notebook/assets/{GlobalAiRail-DakE4NdR.js → GlobalAiRail-CSKW-5eD.js} +1 -1
  16. package/dist/assets/dql-notebook/assets/{GovernedContextPage-BokDqG6a.js → GovernedContextPage-CFen0eFT.js} +1 -1
  17. package/dist/assets/dql-notebook/assets/{HelpDocsPage-CjOv6_gz.js → HelpDocsPage-D0D9UCIz.js} +1 -1
  18. package/dist/assets/dql-notebook/assets/{HomePage-nGaNcwdw.js → HomePage-CmSxapR3.js} +1 -1
  19. package/dist/assets/dql-notebook/assets/{LineageDAG-CO6CFJRg.js → LineageDAG-BGUcIt1B.js} +1 -1
  20. package/dist/assets/dql-notebook/assets/{LineageDetailView-BnGb7OF7.js → LineageDetailView-Dx48Zfzb.js} +1 -1
  21. package/dist/assets/dql-notebook/assets/{LineageDrawer-C5Y0Ht0b.js → LineageDrawer-zaBfSLZO.js} +1 -1
  22. package/dist/assets/dql-notebook/assets/{LineagePathBreadcrumb-Cgh3GR3F.js → LineagePathBreadcrumb-CTZJp4_r.js} +1 -1
  23. package/dist/assets/dql-notebook/assets/{MiniLineageGraph-Kla9PYuj.js → MiniLineageGraph-CdivNR1S.js} +1 -1
  24. package/dist/assets/dql-notebook/assets/{NewBlockModal-BR2SnmPT.js → NewBlockModal-DMBFB7nE.js} +1 -1
  25. package/dist/assets/dql-notebook/assets/{NewNotebookModal-BaCHMYeB.js → NewNotebookModal-DsC9CWlW.js} +1 -1
  26. package/dist/assets/dql-notebook/assets/{NotebookEditor-CBLqY8cE.js → NotebookEditor-hs-kw8v9.js} +1 -1
  27. package/dist/assets/dql-notebook/assets/{ReadinessPage-CiN0IWSS.js → ReadinessPage-DJ83cMik.js} +1 -1
  28. package/dist/assets/dql-notebook/assets/{SetupOnboarding-BhiCsYF-.js → SetupOnboarding-B1Pu_Bvv.js} +1 -1
  29. package/dist/assets/dql-notebook/assets/{SkillsPage-CCSf8VMm.js → SkillsPage-BHKDay8n.js} +1 -1
  30. package/dist/assets/dql-notebook/assets/{TrustBadge-BgQmFe_x.js → TrustBadge-BkyGgob2.js} +1 -1
  31. package/dist/assets/dql-notebook/assets/UnifiedAgentRunPanel-BYdrTaEW.js +89 -0
  32. package/dist/assets/dql-notebook/assets/{answer-to-notebook-AeDYUDla.js → answer-to-notebook-DmNiuLQA.js} +1 -1
  33. package/dist/assets/dql-notebook/assets/{arrow-left-C55x2hq_.js → arrow-left--1rsrxm8.js} +1 -1
  34. package/dist/assets/dql-notebook/assets/{arrow-right-C1cJrhOm.js → arrow-right-D5TdqY1G.js} +1 -1
  35. package/dist/assets/dql-notebook/assets/{book-open-text-CQf_sdv2.js → book-open-text-Bw7nHbzg.js} +1 -1
  36. package/dist/assets/dql-notebook/assets/{circle-x-X8-Z2yLY.js → circle-x-DLe6NNM4.js} +1 -1
  37. package/dist/assets/dql-notebook/assets/{dagre.esm-BjjNYKyY.js → dagre.esm-CW5QZdBt.js} +1 -1
  38. package/dist/assets/dql-notebook/assets/{external-link-C2zrz5DH.js → external-link-C9Q97sA3.js} +1 -1
  39. package/dist/assets/dql-notebook/assets/{grip-vertical-qGV_PYGU.js → grip-vertical-CztvkIgo.js} +1 -1
  40. package/dist/assets/dql-notebook/assets/{index-ByTDPDaH.js → index-zHHzDn6l.js} +127 -127
  41. package/dist/assets/dql-notebook/assets/{link-2-VpyOxQXG.js → link-2-CiKAvumL.js} +1 -1
  42. package/dist/assets/dql-notebook/assets/{list-tree-CH2Jhwms.js → list-tree-BtnP2nQ5.js} +1 -1
  43. package/dist/assets/dql-notebook/assets/{minimize-2-B2TZJ8BT.js → minimize-2-TSFGxcCP.js} +1 -1
  44. package/dist/assets/dql-notebook/assets/{panel-right-open-BunF88lt.js → panel-right-open-BfXIUWy0.js} +1 -1
  45. package/dist/assets/dql-notebook/assets/{play-DAFVF4_G.js → play-DVbSFJHD.js} +1 -1
  46. package/dist/assets/dql-notebook/assets/{rotate-ccw-DGCrrqtY.js → rotate-ccw-D_cesDcX.js} +1 -1
  47. package/dist/assets/dql-notebook/assets/{semantic-fields-Ci-9QL9F.js → semantic-fields-CoVStdYB.js} +1 -1
  48. package/dist/assets/dql-notebook/assets/{sliders-horizontal-BOAlXXbn.js → sliders-horizontal-l7xV9K5A.js} +1 -1
  49. package/dist/assets/dql-notebook/assets/{star-CkksZXHt.js → star-CSBS0H3b.js} +1 -1
  50. package/dist/assets/dql-notebook/assets/{triangle-alert-D3mjyJZE.js → triangle-alert-BTrnyY4q.js} +1 -1
  51. package/dist/assets/dql-notebook/assets/{upload-CTNOAVEO.js → upload-sySLq9zb.js} +1 -1
  52. package/dist/assets/dql-notebook/assets/{usePersistedAgentThreadId-DiQjc7x-.js → usePersistedAgentThreadId-CzwgGdus.js} +1 -1
  53. package/dist/assets/dql-notebook/assets/{user-round-BWd5tQRg.js → user-round-ChlgXi9j.js} +1 -1
  54. package/dist/assets/dql-notebook/assets/{wand-sparkles-CqsAv8P-.js → wand-sparkles-CGw0ytyT.js} +1 -1
  55. package/dist/assets/dql-notebook/assets/{workflow-CSqsj-sC.js → workflow-C_RltiK5.js} +1 -1
  56. package/dist/assets/dql-notebook/assets/{wrench-DWqzqlX8.js → wrench-D-wLfeu0.js} +1 -1
  57. package/dist/assets/dql-notebook/assets/{x-65M5rLCE.js → x-B28hIJIC.js} +1 -1
  58. package/dist/assets/dql-notebook/index.html +1 -1
  59. package/dist/commands/agent-eval-cassette.d.ts +164 -0
  60. package/dist/commands/agent-eval-cassette.d.ts.map +1 -0
  61. package/dist/commands/agent-eval-cassette.js +313 -0
  62. package/dist/commands/agent-eval-cassette.js.map +1 -0
  63. package/dist/commands/agent-eval-runtime.d.ts +109 -0
  64. package/dist/commands/agent-eval-runtime.d.ts.map +1 -0
  65. package/dist/commands/agent-eval-runtime.js +165 -0
  66. package/dist/commands/agent-eval-runtime.js.map +1 -0
  67. package/dist/commands/agent.d.ts +124 -4
  68. package/dist/commands/agent.d.ts.map +1 -1
  69. package/dist/commands/agent.js +410 -106
  70. package/dist/commands/agent.js.map +1 -1
  71. package/dist/commands/compile.d.ts +13 -1
  72. package/dist/commands/compile.d.ts.map +1 -1
  73. package/dist/commands/compile.js +36 -4
  74. package/dist/commands/compile.js.map +1 -1
  75. package/dist/commands/sync.d.ts.map +1 -1
  76. package/dist/commands/sync.js +11 -3
  77. package/dist/commands/sync.js.map +1 -1
  78. package/dist/index.js +4 -0
  79. package/dist/index.js.map +1 -1
  80. package/dist/llm/analyst-loop-tools.d.ts +19 -0
  81. package/dist/llm/analyst-loop-tools.d.ts.map +1 -0
  82. package/dist/llm/analyst-loop-tools.js +56 -0
  83. package/dist/llm/analyst-loop-tools.js.map +1 -0
  84. package/dist/llm/providers/dql-agent-provider.d.ts +51 -1
  85. package/dist/llm/providers/dql-agent-provider.d.ts.map +1 -1
  86. package/dist/llm/providers/dql-agent-provider.js +563 -14
  87. package/dist/llm/providers/dql-agent-provider.js.map +1 -1
  88. package/dist/llm/types.d.ts +47 -1
  89. package/dist/llm/types.d.ts.map +1 -1
  90. package/dist/local-runtime.d.ts +123 -1
  91. package/dist/local-runtime.d.ts.map +1 -1
  92. package/dist/local-runtime.js +1437 -163
  93. package/dist/local-runtime.js.map +1 -1
  94. package/dist/package.json +10 -10
  95. package/package.json +10 -10
  96. package/dist/assets/dql-notebook/assets/UnifiedAgentRunPanel-Bw5AXNDB.js +0 -88
@@ -1,6 +1,6 @@
1
1
  import { AsyncLocalStorage } from 'node:async_hooks';
2
2
  import { execFileSync, execSync } from "node:child_process";
3
- import { createHash } from "node:crypto";
3
+ import { createHash, randomUUID } from "node:crypto";
4
4
  import { gzip } from "node:zlib";
5
5
  import { createServer } from "node:http";
6
6
  import { existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, renameSync, rmSync, statSync, watch, writeFileSync, } from "node:fs";
@@ -23,9 +23,10 @@ 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 { createDqlAgentProviderRunner, resolveAgentFollowUpContext } from './llm/providers/dql-agent-provider.js';
26
+ import { applyFinding, createResearchState, narrationMaxTokensForFacts, nextHypothesis, rerankCandidates, synthesizeResearchNarrative, AgenticExecutionCapabilityGate, createAgenticSqlExecutionCapability, mintFinalSqlAuthorization, verifyAgenticSqlExecutionCapability, qualifyAuthorizationReferences, scopeContextPackToExploratoryCandidateClosure, validateSqlAgainstLocalContext as validateAuthorizedSqlReferences, verifyFinalSql, } from '@duckcodeailabs/dql-agent';
27
+ import { applyEvalCassette, createDqlAgentProviderRunner, createEvalCassetteReplayProvider, createGovernedTextProvider, resolveAgentFollowUpContext } from './llm/providers/dql-agent-provider.js';
27
28
  import { listRemoteMcpSettings, saveRemoteMcpSettings } from './llm/mcp-config.js';
28
- import { ClaudeProvider, ConversationStore, advanceThreadState, buildConversationSnapshot, conversationHistoryFromContext, recallRelevantTurns, renderConversationEnvelopeForPrompt, GeminiProvider, MemoryStore, OllamaProvider, OpenAIProvider, buildBlockBusinessFingerprint, buildBlockSqlFingerprints, buildAnalysisQuestionPlan, composeSemanticQueryForQuestion, aggregationIntegrityIssuesForSql, buildAggregationSafetyProof, buildLocalContextPack, applyContextPackCompatibility, toAgentRetrievalEvidence, prepareConversationPath, defaultMemoryPath, ensureDefaultMemoryFiles, ensureAgentProjectReady, isAgentProjectIndexReady, currentMetadataFingerprint, ensureMetadataCatalogFresh, readIndexedDomainKnowledge, readIndexedKnowledge360, compactSemanticRuntimeFailure, classifyAnalyticalFailure, normalizeWarehouseSqlFailure, parseProposal, propose, proposePlan, recordGovernedCorrection, HintStore, defaultHintIndexPath, ensureHintIndexFresh, listHintsFromGit, getHintEvaluationFromGit, getCorrectionTraceFromGit, inspectGovernedHint, editGovernedHintCandidate, reopenGovernedHint, retireHint, supersedeHint, hintsConflict, mineJoinPatterns, reviewGovernedHint, AgentRunEngine, SqliteAgentRunStore, defaultAgentRunGates, createLlmAgentRunPlanner, createHybridRouter, computeResultStats, buildDeterministicDashboardStory, synthesizeAnswer, streamOrGenerate, narrateResult, buildProposePreview, buildFromPrompt, internalRelationIdsInSql, defaultAgentRunStorePath, defaultAgentRunSqlitePath, resolveLocalOwner, resolveProposeConfig, recordQueryRun, recordRuntimeSchemaSnapshot, latestRuntimeSchemaSnapshotForProject, loadSkills, migrateLegacySkills, configuredSkillsPath, skillsDir, draftDomainSkillBootstrap, buildDomainSkillBootstrapPrompt, mergeDomainSkillBootstrapEnrichment, writeSkill, previewSkillChange, buildContextAuthoringProposal, contextAuthoringDependencyClosure, FileContextAuthoringProposalStore, deleteSkill, deriveGeneratedDraftSlug, deriveAnalyticalRepair, reindexProject, invalidateAgentProjectState, recordAgentRuntimeVersion, resolveDomainContextEnvelope, projectEmbeddingProvider, isHashedEmbeddingProvider, clearProjectEmbeddingCache, upgradeVectorIndexForProject, openMetadataCatalog, defaultKgPath, planAppFromPrompt, KGStore, planResearch, loadSemanticMetrics, cascadeTraceToEvidenceRouteSteps, createCascadeAnswerResult, createCascadeTrace, routeReasoningEffort, createAgentRunBudget, routeForCascadeAnswerTier, clampReasoningEffort, bumpReasoningEffort, resolveThinkingMode, coerceThinkingMode, upsertGeneratedDqlArtifactDraft, loadAgentSemanticLayer, isTrustedConversationTurn, resolveInternalRelationIds, analyticalError, tagAnalyticalError, withAnalyticalErrorOrigin, withAnalyticalErrorOriginSync, assertProviderPayloadAllowed, createProviderDispatchEgressReceipt, prepareProviderWireEnvelopeForDispatch, markProviderMetadataArray, createProviderEgressReceipt, redactProviderResultRows, composeVerifiedAnalyticalNarrative, buildCoverageGap, capResearchBranches, buildResearchEvidenceLedger, buildAnalyticalTurnPlan, DEFAULT_ASK_ROW_EGRESS_POLICY, ZERO_ROW_EGRESS_POLICY, resolveProviderResultRowEgressPolicy, normalizeCanonicalQueryResult, normalizeAnalyticalExecutionFingerprint, normalizeAnalyticalExecutionReceipt, createAgentRunCancellationError, } from '@duckcodeailabs/dql-agent';
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
30
  import { addSqlResultFilter, dashboardFilterableResultColumns, filterableResultColumns, replaceBlockStudioSql } from './sql-result-filter.js';
30
31
  import { gatherProposeEnrichment } from './propose-enrich.js';
31
32
  import { handleAppsApi, proposeAppAiBuild, recommendVisualization, } from './apps-api.js';
@@ -313,10 +314,14 @@ export function parseAgentRunRequestBody(body) {
313
314
  selectedObject,
314
315
  executionTarget,
315
316
  workspaceContext,
316
- conversationContext: sanitizeClientConversationContext(agentRunRecord(record.conversationContext)),
317
+ conversationContext: sanitizeClientConversationContext(agentRunRecord(record.conversationContext), {
318
+ stripStructuredSelectionEnvelope: Boolean(agentRunString(record.selectedEvidenceId)),
319
+ }),
317
320
  history: parseAgentRunHistory(record.history),
318
321
  threadId: agentRunString(record.threadId),
319
- runId: agentRunString(record.runId),
322
+ // `runId` is deliberately absent at public ingress. It scopes the
323
+ // controller, persisted run, SSE operation, and one-shot SQL capability,
324
+ // so a browser-supplied value must never become execution authority.
320
325
  reasoningEffort: parseAgentRunReasoningEffort(record.reasoningEffort),
321
326
  analysisDepth: parseAgentRunAnalysisDepth(record.analysisDepth) ?? parseAgentRunAnalysisDepth(record.depth),
322
327
  thinkingMode: coerceThinkingMode(record.thinkingMode),
@@ -328,13 +333,26 @@ const CLIENT_PLAN_AUTHORITY_KEYS = new Set([
328
333
  'priorResolvedAnalyticalPlan',
329
334
  'resolvedAnalyticalPlan',
330
335
  'analyticalFrame',
336
+ // Only the local compound executor may inject this after it has derived a
337
+ // canonical parent result binding. A browser-provided lookalike cannot become
338
+ // a child filter or skip ordinary member validation.
339
+ 'analyticalTaskDependencyBinding',
340
+ ]);
341
+ // A no-thread embedding may retain ordinary conversation context, but a
342
+ // selectedEvidenceId is a structured server continuation—not a client plan
343
+ // hint. Strip this state only for that selection path, while always stripping
344
+ // the host-only authority marker below.
345
+ const CLIENT_STRUCTURED_SELECTION_AUTHORITY_KEYS = new Set([
346
+ 'conversationEnvelope',
347
+ 'serverSnapshot',
348
+ 'serverIssuedClarificationSelection',
331
349
  ]);
332
350
  /**
333
351
  * Browser/embedding context is useful retrieval and history input, but it is
334
352
  * not a plan-authority channel. Remove plan-shaped fields recursively at HTTP
335
353
  * ingress; server-retained thread context is added afterwards from local state.
336
354
  */
337
- function sanitizeClientConversationContext(context) {
355
+ function sanitizeClientConversationContext(context, options = {}) {
338
356
  if (!context)
339
357
  return undefined;
340
358
  const sanitize = (value) => {
@@ -343,7 +361,14 @@ function sanitizeClientConversationContext(context) {
343
361
  const record = agentRunRecord(value);
344
362
  if (!record)
345
363
  return value;
346
- return Object.fromEntries(Object.entries(record).flatMap(([key, nested]) => CLIENT_PLAN_AUTHORITY_KEYS.has(key) ? [] : [[key, sanitize(nested)]]));
364
+ return Object.fromEntries(Object.entries(record).flatMap(([key, nested]) => {
365
+ const isHostOnlySelectionAuthority = key === 'serverIssuedClarificationSelection';
366
+ const isUntrustedSelectionEnvelope = options.stripStructuredSelectionEnvelope
367
+ && CLIENT_STRUCTURED_SELECTION_AUTHORITY_KEYS.has(key);
368
+ return CLIENT_PLAN_AUTHORITY_KEYS.has(key) || isHostOnlySelectionAuthority || isUntrustedSelectionEnvelope
369
+ ? []
370
+ : [[key, sanitize(nested)]];
371
+ }));
347
372
  };
348
373
  return sanitize(context);
349
374
  }
@@ -378,6 +403,52 @@ export function agentRunDeadlineMs(request, env = process.env, activeProviderId)
378
403
  ? AGENT_RESEARCH_DEADLINE_MS
379
404
  : AGENT_LOOKUP_DEADLINE_MS;
380
405
  }
406
+ /**
407
+ * Run ready independent compound clauses concurrently, but wait for a typed
408
+ * parent result before executing a declared dependent clause. The scheduler
409
+ * itself has no authority to query or filter; callers supply both execution and
410
+ * a dependency resolver so immutable-plan and SQL guards remain unchanged.
411
+ */
412
+ export async function scheduleCompoundAnalyticalTasks(input) {
413
+ const pending = [...input.tasks];
414
+ const settled = new Map();
415
+ while (pending.length > 0) {
416
+ const ready = pending.filter((task) => task.dependencies.every((dependencyId) => settled.has(dependencyId)));
417
+ if (ready.length === 0) {
418
+ for (const task of pending.splice(0)) {
419
+ settled.set(task.id, {
420
+ task,
421
+ error: 'The compound task dependency graph could not be resolved.',
422
+ dependencyError: {
423
+ ok: false,
424
+ code: 'RESULT_CONTRACT_MISMATCH',
425
+ message: 'The dependent task could not run because its parent dependency was unresolved.',
426
+ },
427
+ });
428
+ }
429
+ break;
430
+ }
431
+ for (const task of ready)
432
+ pending.splice(pending.indexOf(task), 1);
433
+ const batch = await Promise.all(ready.map(async (task) => {
434
+ if (!task.dependency || task.dependency.kind !== 'top_ranked_region')
435
+ return input.runTask(task);
436
+ const parent = settled.get(task.dependency.sourceTaskId);
437
+ const resolution = input.resolveDependency(task, parent);
438
+ if (!resolution.ok) {
439
+ const dependencyError = resolution;
440
+ return { task, error: dependencyError.message, dependencyError };
441
+ }
442
+ return input.runTask(task, resolution.binding);
443
+ }));
444
+ for (const result of batch)
445
+ settled.set(result.task.id, result);
446
+ }
447
+ return input.tasks.map((task) => settled.get(task.id) ?? {
448
+ task,
449
+ error: 'The compound task did not produce an outcome.',
450
+ });
451
+ }
381
452
  /**
382
453
  * Decide how a settled answer gets its business-facing prose.
383
454
  *
@@ -420,11 +491,64 @@ export function shouldSynthesizeAgentRunAnswer(governedAnswer, requestedMode = '
420
491
  rowEgress: DEFAULT_ASK_ROW_EGRESS_POLICY,
421
492
  }).mode !== 'skip';
422
493
  }
494
+ /**
495
+ * A receipt may be rendered in the local inspector and exported into evaluation
496
+ * output. Keep only stable validation codes there; provider error messages can
497
+ * contain a prompt excerpt, result value, or connector detail and must not
498
+ * become user-visible durable data.
499
+ */
500
+ function narrationIntegrityFailureCodes(failures) {
501
+ return [...new Set(failures.map((failure) => {
502
+ const match = failure.trim().match(/^([A-Z][A-Z0-9_]{1,80})/);
503
+ return match?.[1] ?? 'NARRATION_VALIDATION_FAILED';
504
+ }).filter(Boolean))].slice(0, 8);
505
+ }
423
506
  /**
424
507
  * AGT-010 — the semantic route label is descriptive, while the exact
425
508
  * route-specific aggregation proof is authoritative for governed trust.
426
509
  * Missing proof remains blocked for legacy or malformed results.
427
510
  */
511
+ /**
512
+ * Trust for ONE answer, by the same rule the single-answer path uses: a route
513
+ * label is not authority, and a semantic route earns `governed` only when its
514
+ * aggregation proof actually passed.
515
+ */
516
+ export function trustStateForAgentAnswer(answer) {
517
+ if (answer.certification === 'certified' || answer.kind === 'certified')
518
+ return 'certified';
519
+ return semanticAnswerHasPassedAggregationProof(answer) ? 'governed' : 'review_required';
520
+ }
521
+ const TRUST_RANK = {
522
+ certified: 3,
523
+ governed: 2,
524
+ grounded: 1,
525
+ review_required: 0,
526
+ };
527
+ /**
528
+ * A compound answer is exactly as trustworthy as its WEAKEST successful child.
529
+ *
530
+ * The previous rule was `every child completed ? 'governed' : 'review_required'`,
531
+ * which stamped `governed` on a parent whose children were review-required
532
+ * generated SQL — completion is not proof. That is a governance violation and
533
+ * the worst possible failure for this product: the reader is told a number
534
+ * carries governed authority when nothing proved it.
535
+ *
536
+ * `certified` is deliberately NOT reachable here. Certified trust is granted
537
+ * only by executing the exact certified artifact; a parent that merely
538
+ * assembled certified children did not execute one, so it caps at `governed`.
539
+ */
540
+ export function compoundTrustState(childTrust) {
541
+ if (childTrust.length === 0)
542
+ return 'review_required';
543
+ const weakest = childTrust.reduce((low, current) => (TRUST_RANK[current] ?? 0) < (TRUST_RANK[low] ?? 0) ? current : low);
544
+ return weakest === 'certified' ? 'governed' : weakest;
545
+ }
546
+ /** Neutral parent outcome: governed only when every child completed governed. */
547
+ export function compoundStopReason(completedCount, childCount, trustState) {
548
+ return completedCount === childCount && childCount > 0 && trustState === 'governed'
549
+ ? 'governed_compound_answer'
550
+ : 'human_review_required';
551
+ }
428
552
  export function semanticAnswerHasPassedAggregationProof(governedAnswer) {
429
553
  return governedAnswer.route?.tier === 'semantic_metric'
430
554
  && governedAnswer.aggregationSafetyProof?.status === 'safe';
@@ -433,6 +557,18 @@ export function agentAnswerHasExecutionFailure(governedAnswer) {
433
557
  return typeof governedAnswer.executionError === 'string'
434
558
  && governedAnswer.executionError.trim().length > 0;
435
559
  }
560
+ /**
561
+ * Return only a router/producer-issued analytical gap witness.
562
+ *
563
+ * `terminalOutcome.kind === 'modeling_gap'` is deliberately insufficient to
564
+ * claim a relationship problem. Older receipts and generic tuple failures
565
+ * return `undefined`; callers render generic coverage guidance for those.
566
+ */
567
+ export function persistedAnalyticalGapWitness(routeDecision) {
568
+ return routeDecision?.terminalOutcome?.kind === 'modeling_gap'
569
+ ? routeDecision.terminalOutcome.gap
570
+ : undefined;
571
+ }
436
572
  /** Rebuild the immutable failed-run input from the artifact retained by API-007. */
437
573
  export function analyticalFailedRunFromAgentRun(run) {
438
574
  for (const artifact of run.artifacts) {
@@ -784,7 +920,7 @@ function businessNarrativeGaps(warnings) {
784
920
  // When a run carries a threadId, the persisted thread is the authoritative
785
921
  // source of prior turns (survives refresh); the client-built context remains
786
922
  // the fallback for embedders that never send a threadId.
787
- async function conversationContextFromThread(store, threadId, clientContext, question) {
923
+ async function conversationContextFromThread(store, threadId, clientContext, question, preservePendingClarification = false) {
788
924
  // Token-budget backstop: six verbatim turns with per-field caps. Older turns
789
925
  // remain reachable through the rolling summary + semantic recall below —
790
926
  // carrying more raw prose mostly slows every provider call on follow-ups.
@@ -832,7 +968,10 @@ async function conversationContextFromThread(store, threadId, clientContext, que
832
968
  const thread = store.getThread(threadId);
833
969
  // Bounded structured snapshot (working state + rolling summary + topic relation)
834
970
  // for the answer loop's conversation-state prompt section.
835
- const serverSnapshot = buildConversationSnapshot(store, threadId, { question });
971
+ const serverSnapshot = buildConversationSnapshot(store, threadId, {
972
+ question,
973
+ preservePendingClarification,
974
+ });
836
975
  if (serverSnapshot && question) {
837
976
  // Semantic recall over OLDER turns (the recent window is already verbatim).
838
977
  serverSnapshot.recalledTurns = await recallRelevantTurns(store, threadId, question, {
@@ -840,12 +979,26 @@ async function conversationContextFromThread(store, threadId, clientContext, que
840
979
  excludeTurnIds: serverSnapshot.recentTurns.map((turn) => turn.id),
841
980
  });
842
981
  }
982
+ const pendingSelection = serverSnapshot?.pendingClarification?.selection;
983
+ const pendingSourceTurnId = serverSnapshot?.pendingClarification?.sourceTurnId;
984
+ // This value never crosses the HTTP boundary from a client. It is rebuilt
985
+ // only after the local conversation store has resolved the requested thread,
986
+ // and the router requires it for every selectedEvidenceId continuation.
987
+ const serverIssuedClarificationSelection = pendingSelection?.snapshotId && pendingSourceTurnId
988
+ ? {
989
+ version: 1,
990
+ threadId,
991
+ sourceTurnId: pendingSourceTurnId,
992
+ snapshotId: pendingSelection.snapshotId,
993
+ }
994
+ : undefined;
843
995
  return {
844
996
  ...(sanitizeClientConversationContext(clientContext) ?? {}),
845
997
  conversationStateVersion: 1,
846
998
  threadId,
847
999
  ...(serverSnapshot ? { conversationEnvelope: serverSnapshot } : {}),
848
1000
  ...(serverSnapshot ? { serverSnapshot } : {}),
1001
+ ...(serverIssuedClarificationSelection ? { serverIssuedClarificationSelection } : {}),
849
1002
  ...(thread?.rollingSummary ? { conversationSummary: thread.rollingSummary } : {}),
850
1003
  // `activeTurnId` is the FOLLOW-UP ANCHOR: the turn whose filters, prior DQL
851
1004
  // artifact, and source SQL the next question builds on. Anchoring it to the
@@ -1015,6 +1168,40 @@ export function conversationTurnInputFromRun(run) {
1015
1168
  const contextPack = agentRunRecord(payload?.contextPack);
1016
1169
  const questionPlan = agentRunRecord(contextPack?.questionPlan);
1017
1170
  const requestedShape = agentRunRecord(questionPlan?.requestedShape);
1171
+ // A structured clarification choice must survive reload/restart with the
1172
+ // exact option IDs and typed requirements that rendered it. This is stored
1173
+ // inside the existing JSON contract envelope so older conversation rows stay
1174
+ // readable; the router treats it as reject-only continuity evidence and
1175
+ // always rechecks the current snapshot before it can freeze a plan.
1176
+ // Most analytical clarifications retain the router-owned cascade requirement
1177
+ // set. Evidence-only ambiguity deliberately has no frozen cascade, however,
1178
+ // and its first rendered options still need a typed, reload-safe contract.
1179
+ // Derive that narrow fallback from the immutable original question and the
1180
+ // already-resolved intent—not from a later click or client context—so the
1181
+ // first valid structured selection can be revalidated without weakening the
1182
+ // server-issued-envelope requirement.
1183
+ const clarificationRequirements = run.diagnosticReceiptV3?.cascade?.requirements
1184
+ ?? run.routeDecision?.analyticalCascadeDecision?.requirements
1185
+ ?? (run.status === 'needs_clarification' && (run.clarificationOptions?.length ?? 0) > 0
1186
+ ? buildAnalyticalRequirementSet({
1187
+ question: run.question,
1188
+ parsedIntent: run.routeDecision?.meaningResolution?.queryIntent,
1189
+ })
1190
+ : undefined);
1191
+ const clarificationSelection = run.status === 'needs_clarification'
1192
+ && (run.clarificationOptions?.length ?? 0) > 0
1193
+ ? {
1194
+ version: 1,
1195
+ optionIds: [...new Set(run.clarificationOptions.map((option) => option.id).filter(Boolean))].slice(0, 16),
1196
+ ambiguityCandidateIds: [...new Set(run.clarificationOptions.map((option) => option.id).filter(Boolean))].slice(0, 16),
1197
+ ...(clarificationRequirements
1198
+ ? { requirements: clarificationRequirements }
1199
+ : {}),
1200
+ ...(run.routeDecision?.retrievalEvidence?.snapshotId
1201
+ ? { snapshotId: run.routeDecision.retrievalEvidence.snapshotId }
1202
+ : {}),
1203
+ }
1204
+ : undefined;
1018
1205
  const rowCountRaw = result?.rowCount;
1019
1206
  const measureColumns = conversationMeasureColumns(columns, requestedShape, rows);
1020
1207
  return {
@@ -1023,7 +1210,13 @@ export function conversationTurnInputFromRun(run) {
1023
1210
  answerSummary: run.answer ?? run.summary,
1024
1211
  answerText: run.answer,
1025
1212
  route: run.route,
1026
- trustLabel: agentRunString(payload?.trustLabel) ?? run.trustState,
1213
+ // A route/context label is presentation metadata, not the terminal trust
1214
+ // authority. In particular, a certified run can retain a `mixed` context
1215
+ // label from candidates considered before the certified tuple froze. Using
1216
+ // that label here made the persisted conversation contradict the immutable
1217
+ // run. Persist the canonical run state unless the answer itself has
1218
+ // multiple materially different answer sections.
1219
+ trustLabel: conversationTrustLabelFromRun(run, payload),
1027
1220
  runStatus: run.status,
1028
1221
  stopReason: run.stopReason,
1029
1222
  // Persisted so a later turn can tell a refusal from an answer. `runStatus`
@@ -1039,6 +1232,7 @@ export function conversationTurnInputFromRun(run) {
1039
1232
  sql: agentRunString(payload?.proposedSql) ?? agentRunString(payload?.sql),
1040
1233
  dqlArtifact: agentRunRecord(payload?.dqlArtifact),
1041
1234
  cascade: agentRunRecord(payload?.cascade),
1235
+ narrationIntegrityReceipt: run.narrationIntegrityReceipt,
1042
1236
  result: columns.length > 0 || rows.length > 0
1043
1237
  ? {
1044
1238
  columns,
@@ -1048,9 +1242,41 @@ export function conversationTurnInputFromRun(run) {
1048
1242
  rowCount: typeof rowCountRaw === 'number' ? rowCountRaw : rows.length || undefined,
1049
1243
  }
1050
1244
  : undefined,
1051
- contract: requestedShape,
1245
+ contract: {
1246
+ ...(requestedShape ?? {}),
1247
+ ...(clarificationSelection ? { clarificationSelection } : {}),
1248
+ },
1052
1249
  };
1053
1250
  }
1251
+ function conversationTrustLabelFromRun(run, payload) {
1252
+ if (!isCanonicalConversationTrustState(run.trustState)) {
1253
+ return agentRunString(payload?.trustLabel);
1254
+ }
1255
+ return hasMixedAnswerSectionTrust(run.artifacts) ? 'mixed' : run.trustState;
1256
+ }
1257
+ function isCanonicalConversationTrustState(value) {
1258
+ return value === 'certified'
1259
+ || value === 'governed'
1260
+ || value === 'grounded'
1261
+ || value === 'review_required'
1262
+ || value === 'blocked'
1263
+ || value === 'not_applicable';
1264
+ }
1265
+ /**
1266
+ * `mixed` is meaningful only when the durable answer contains sections with
1267
+ * materially different trust states. Supporting artifacts such as a DQL draft
1268
+ * or a diagnostics receipt do not turn one certified answer into mixed trust.
1269
+ */
1270
+ function hasMixedAnswerSectionTrust(artifacts) {
1271
+ const states = new Set(artifacts
1272
+ .filter((artifact) => artifact.kind === 'answer' || artifact.kind === 'research_run')
1273
+ .map((artifact) => artifact.trustState)
1274
+ .filter((state) => state === 'certified'
1275
+ || state === 'governed'
1276
+ || state === 'grounded'
1277
+ || state === 'review_required'));
1278
+ return states.size > 1;
1279
+ }
1054
1280
  function conversationResultColumns(value) {
1055
1281
  if (!Array.isArray(value))
1056
1282
  return [];
@@ -1062,10 +1288,24 @@ function conversationResultColumns(value) {
1062
1288
  .slice(0, 24);
1063
1289
  }
1064
1290
  function conversationMeasureColumns(columns, requestedShape, rows) {
1291
+ // A requested phrase is not evidence that the execution returned that
1292
+ // measure. In particular, an incomplete certified block used to persist
1293
+ // `revenue` beside its actual `lifetime_spend` output merely because the
1294
+ // request said revenue. Retain an exact requested column only when it is
1295
+ // actually present in the result contract.
1065
1296
  const requested = conversationStringArray(requestedShape?.measures) ?? [];
1297
+ const canonical = (value) => value.toLowerCase()
1298
+ .replace(/[_./:-]+/g, ' ')
1299
+ .replace(/[^a-z0-9 ]+/g, ' ')
1300
+ .replace(/\s+/g, ' ')
1301
+ .trim();
1302
+ const actualRequestedColumns = columns.filter((column) => {
1303
+ const identity = canonical(column);
1304
+ return identity.length > 0 && requested.some((measure) => canonical(measure) === identity);
1305
+ });
1066
1306
  const numericColumns = columns.filter((column) => rows.some((row) => typeof row[column] === 'number' && Number.isFinite(row[column])));
1067
1307
  const metricNamedColumns = columns.filter((column) => /\b(revenue|sales|amount|total|count|average|avg|sum|spend|cost|margin|profit|value|points?|score|quantity|units?|rate|volume)\b/i.test(column.replace(/_/g, ' ')));
1068
- const unique = Array.from(new Set([...requested, ...numericColumns, ...metricNamedColumns]
1308
+ const unique = Array.from(new Set([...actualRequestedColumns, ...numericColumns, ...metricNamedColumns]
1069
1309
  .map((value) => value.trim())
1070
1310
  .filter(Boolean)));
1071
1311
  return unique.length > 0 ? unique.slice(0, 24) : undefined;
@@ -1166,12 +1406,7 @@ export class RunScopedProviderDispatchEvidence {
1166
1406
  * from what it already has.
1167
1407
  */
1168
1408
  expectedDispatchMs() {
1169
- if (this.observedDispatchDurations.length === 0)
1170
- return ASSUMED_PROVIDER_DISPATCH_MS;
1171
- const sorted = [...this.observedDispatchDurations].sort((left, right) => left - right);
1172
- // The slowest observed call is the honest predictor: an optimistic median
1173
- // still admits a dispatch that the deadline then kills.
1174
- return sorted[sorted.length - 1];
1409
+ return predictDispatchMs(this.observedDispatchDurations);
1175
1410
  }
1176
1411
  /** True when the remaining wall clock cannot fit another provider call. */
1177
1412
  cannotFitAnotherDispatch() {
@@ -1364,6 +1599,50 @@ function mergeRunScopedProviderDispatchEvidence(run, evidence) {
1364
1599
  diagnosticReceiptV2,
1365
1600
  };
1366
1601
  }
1602
+ /**
1603
+ * Final physical generated-SQL boundary.
1604
+ *
1605
+ * This receives the exact prepared statement immediately before the connector
1606
+ * callback. It intentionally validates before invoking `execute`: a bad
1607
+ * capability or unproven prepared reference must result in zero warehouse
1608
+ * calls, not a post-execution warning. It is module-exported only for the
1609
+ * local-runtime boundary harness; it is never an HTTP API or durable artifact.
1610
+ *
1611
+ * @internal
1612
+ */
1613
+ export async function executePreparedAgenticSqlBoundary(input) {
1614
+ const capability = input.capability;
1615
+ if (capability) {
1616
+ const authorization = mintFinalSqlAuthorization({
1617
+ sql: input.preparedSql,
1618
+ proven: capability.provenIdentifiers.map((identifier) => ({
1619
+ identifier,
1620
+ evidence: capability.evidence[identifier] ?? 'catalog',
1621
+ })),
1622
+ runId: capability.runId,
1623
+ executionId: capability.executionId,
1624
+ snapshotId: capability.snapshotId,
1625
+ planId: capability.planId,
1626
+ targetFingerprint: capability.targetFingerprint,
1627
+ bindings: input.bindings,
1628
+ });
1629
+ const validation = validateAuthorizedSqlReferences(input.preparedSql, undefined);
1630
+ const verdict = verifyFinalSql(authorization, input.preparedSql, qualifyAuthorizationReferences(input.preparedSql, {
1631
+ relations: validation.referencedRelations ?? [],
1632
+ columns: validation.referencedColumns ?? [],
1633
+ }), {
1634
+ ...input.scope,
1635
+ bindings: input.bindings,
1636
+ });
1637
+ if (process.env.DQL_ORCHESTRATOR_TRACE) {
1638
+ console.warn(`[dql] execution authorization: ${verdict.ok ? 'admitted' : 'REFUSED'} proven=${authorization.provenIdentifiers.length}${verdict.ok ? '' : ` reason=${verdict.reason}`}`);
1639
+ }
1640
+ if (!verdict.ok) {
1641
+ throw analyticalError(verdict.reason ?? 'The statement was not authorized for execution.', { origin: 'governance_gate', stage: 'execute', code: 'unauthorized_sql' });
1642
+ }
1643
+ }
1644
+ return input.execute();
1645
+ }
1367
1646
  export async function startLocalServer(opts) {
1368
1647
  const { rootDir, executor, connection: rawConnection, preferredPort, projectRoot = process.cwd() } = opts;
1369
1648
  const bindHost = opts.host ?? process.env.DQL_HOST ?? '127.0.0.1';
@@ -2061,6 +2340,9 @@ export async function startLocalServer(opts) {
2061
2340
  });
2062
2341
  };
2063
2342
  async function runGovernedAgentAnswerForRun(request, repair, route = 'generated_answer', onProgress, routeDecision) {
2343
+ return runGovernedAgentAnswerForRunInner(request, repair, route, onProgress, routeDecision);
2344
+ }
2345
+ async function runGovernedAgentAnswerForRunInner(request, repair, route = 'generated_answer', onProgress, routeDecision) {
2064
2346
  const governed = resolveGovernedAnswerRunner(projectRoot);
2065
2347
  let resolvedProvider = governed?.provider ?? null;
2066
2348
  let runner = governed?.runner ?? null;
@@ -2081,11 +2363,12 @@ export async function startLocalServer(opts) {
2081
2363
  runner = createDqlAgentProviderRunner('ollama', deterministicProvider);
2082
2364
  }
2083
2365
  if (!resolvedProvider || !runner) {
2084
- throw new Error('No AI provider is configured. Configure a subscription (Claude Code / Codex), OpenAI, Gemini, Ollama, or a custom OpenAI-compatible endpoint in Settings.');
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' });
2085
2367
  }
2086
2368
  let governedAnswer;
2087
2369
  let providerError;
2088
2370
  let providerDispatchEvidence;
2371
+ let providerBoundaryDiagnostic;
2089
2372
  const isRepair = (repair?.attempt ?? 0) > 0 && Boolean(repair?.repairHint);
2090
2373
  // The chat composer sends a `thinkingMode` (auto/low/medium/high); resolve it
2091
2374
  // into the effort+depth bundle it stands for. An explicit `reasoningEffort` /
@@ -2152,6 +2435,37 @@ export async function startLocalServer(opts) {
2152
2435
  const requestedPurpose = agentRunWorkspaceValue(request, 'purpose');
2153
2436
  const requestedModelAreaId = agentRunWorkspaceValue(request, 'modelAreaId');
2154
2437
  const runProjectSnapshot = projectSnapshot();
2438
+ // Keep the execution callback on the exact ranked context pack that the
2439
+ // router used. The engine can invoke the executor after the router's
2440
+ // asynchronous evidence phase, so looking the pack up lazily inside the
2441
+ // callback occasionally observed an empty WeakMap entry and sent an
2442
+ // authored leaf relation straight to the connector. That bypassed the
2443
+ // same-snapshot qualified relation binding despite retrieval having proved
2444
+ // (for example) `jaffle_shop.dev.dim_customers`.
2445
+ //
2446
+ // Building only when this request has no prepared entry retains the normal
2447
+ // single-retrieval path. A frozen certified plan below rejects a pack whose
2448
+ // snapshot/fingerprint does not match the router decision rather than
2449
+ // borrowing a newer catalog to make an old plan executable.
2450
+ const preparedContextPack = preparedAgentContextPacks.get(request)
2451
+ ?? await buildAgentRunContextPack(request).catch(() => undefined);
2452
+ const preparedQualifiedSchemaContext = preparedContextPack
2453
+ ? buildAgentSchemaContextFromContextPack(request.question, preparedContextPack)
2454
+ : [];
2455
+ const selectedExploratoryAttempt = routeDecision?.analyticalCascadeDecision?.attempts.find((attempt) => attempt.tier === 'exploratory_sql');
2456
+ // The exploratory candidate set belongs to the router's immutable
2457
+ // same-snapshot cascade decision. Build its physical prompt/execution
2458
+ // closure once here; the broad retrieved pack remains receipt-only and is
2459
+ // never handed to SQL validation for this selected tier.
2460
+ const exploratoryCandidateIds = routeDecision?.analyticalCascadeDecision?.selectedTier === 'exploratory_sql'
2461
+ ? selectedExploratoryAttempt?.candidateIds ?? []
2462
+ : [];
2463
+ const preparedExploratoryContextPack = exploratoryCandidateIds.length > 0
2464
+ ? scopeContextPackToExploratoryCandidateClosure(preparedContextPack, exploratoryCandidateIds)
2465
+ : undefined;
2466
+ const preparedExploratoryQualifiedSchemaContext = preparedExploratoryContextPack
2467
+ ? buildAgentSchemaContextFromContextPack(request.question, preparedExploratoryContextPack, { includeUnscored: true, limit: 80 })
2468
+ : [];
2155
2469
  const domainContext = requestedDomain
2156
2470
  ? resolveUiDomainContext({
2157
2471
  manifest: runProjectSnapshot.manifest,
@@ -2163,6 +2477,146 @@ export async function startLocalServer(opts) {
2163
2477
  snapshotId: runProjectSnapshot.snapshotId,
2164
2478
  })
2165
2479
  : undefined;
2480
+ // Local to this exact answer invocation. Compound children each enter this
2481
+ // function separately, so no child can consume another child's capability.
2482
+ const agenticExecutionCapabilityGate = new AgenticExecutionCapabilityGate();
2483
+ const prepareExploratorySqlExecution = async (sql) => {
2484
+ const cascade = routeDecision?.analyticalCascadeDecision;
2485
+ const selectedAttempt = selectedExploratoryAttempt;
2486
+ if (!cascade
2487
+ || cascade.selectedTier !== 'exploratory_sql'
2488
+ || cascade.planFrozen
2489
+ || !selectedAttempt
2490
+ || 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
+ });
2495
+ }
2496
+ 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
+ });
2500
+ }
2501
+ const retrievalSnapshotId = routeDecision?.retrievalEvidence?.snapshotId;
2502
+ const retrievalSourceFingerprint = routeDecision?.retrievalEvidence?.sourceFingerprint;
2503
+ const retrievalFreezeSnapshotId = retrievalSnapshotId ?? preparedContextPack.knowledgeLens.snapshotId;
2504
+ if ((retrievalSnapshotId && retrievalSnapshotId !== preparedContextPack.knowledgeLens.snapshotId)
2505
+ || (retrievalSourceFingerprint
2506
+ && preparedContextPack.freshness.fingerprint
2507
+ && 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
+ });
2511
+ }
2512
+ projectSnapshot();
2513
+ projectSnapshots.assertCurrent(runProjectSnapshot.snapshotId);
2514
+ const target = await observeWarehouseTargetIdentity(executor, semanticConnection);
2515
+ if (generatedProposalTargetIdentity?.identityFingerprint
2516
+ && target.identityFingerprint !== generatedProposalTargetIdentity.identityFingerprint) {
2517
+ throw analyticalError('The selected exploratory query no longer matches the observed execution target, so it was not executed.', {
2518
+ origin: 'governance_gate', stage: 'validation', code: 'unauthorized_sql',
2519
+ });
2520
+ }
2521
+ const validation = validateAuthorizedSqlReferences(sql, preparedExploratoryContextPack, {
2522
+ ...(semanticDriver ? { dialect: semanticDriver } : {}),
2523
+ runtimeSchema: preparedExploratoryQualifiedSchemaContext,
2524
+ });
2525
+ if (!validation.ok) {
2526
+ throw analyticalError('The selected exploratory query did not pass the host SQL/context validation, so it was not executed.', {
2527
+ origin: 'governance_gate', stage: 'validation', code: 'unauthorized_sql',
2528
+ });
2529
+ }
2530
+ const normalizeIdentifier = (value) => value
2531
+ .trim()
2532
+ .split('.')
2533
+ .map((part) => part.trim().replace(/^["`\[]|["`\]]$/g, '').toLowerCase())
2534
+ .filter(Boolean)
2535
+ .join('.');
2536
+ const allowedExploratoryRelations = new Set(preparedExploratoryQualifiedSchemaContext.map((table) => normalizeIdentifier(table.relation)));
2537
+ const outsideClosure = validation.referencedRelations.filter((relation) => !allowedExploratoryRelations.has(normalizeIdentifier(relation)));
2538
+ if (outsideClosure.length > 0) {
2539
+ throw analyticalError('The generated SQL references a relation outside the router-selected physical closure, so it was not executed.', {
2540
+ origin: 'governance_gate', stage: 'validation', code: 'unauthorized_sql',
2541
+ });
2542
+ }
2543
+ const runtimeByRelation = new Map(preparedExploratoryQualifiedSchemaContext.map((table) => [normalizeIdentifier(table.relation), table]));
2544
+ const qualifiedReferences = qualifyAuthorizationReferences(sql, {
2545
+ relations: validation.referencedRelations,
2546
+ columns: validation.referencedColumns,
2547
+ });
2548
+ const proofs = new Map();
2549
+ for (const relation of validation.referencedRelations) {
2550
+ const table = runtimeByRelation.get(normalizeIdentifier(relation));
2551
+ if (!table) {
2552
+ throw analyticalError('The selected exploratory query references a relation that was not proven by the live runtime schema, so it was not executed.', {
2553
+ origin: 'governance_gate', stage: 'validation', code: 'unauthorized_sql',
2554
+ });
2555
+ }
2556
+ proofs.set(table.relation, 'schema_tool');
2557
+ }
2558
+ for (const reference of qualifiedReferences) {
2559
+ const normalizedReference = normalizeIdentifier(reference);
2560
+ const table = [...runtimeByRelation.entries()].find(([relation]) => normalizedReference.startsWith(`${relation}.`));
2561
+ if (!table)
2562
+ continue;
2563
+ const [, runtimeTable] = table;
2564
+ const column = normalizedReference.slice(`${table[0]}.`.length);
2565
+ if (!column || !runtimeTable.columns.some((candidate) => normalizeIdentifier(candidate.name) === column)) {
2566
+ throw analyticalError('The selected exploratory query references a column that was not proven by the live runtime schema, so it was not executed.', {
2567
+ origin: 'governance_gate', stage: 'validation', code: 'unauthorized_sql',
2568
+ });
2569
+ }
2570
+ proofs.set(`${runtimeTable.relation}.${column}`, 'schema_tool');
2571
+ }
2572
+ 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)}`;
2585
+ const capability = createAgenticSqlExecutionCapability({
2586
+ sql,
2587
+ proven: [...proofs.entries()].map(([identifier, evidence]) => ({ identifier, evidence })),
2588
+ runId: request.runId,
2589
+ executionId: `${request.runId}:exploratory:${sqlFingerprint.slice(0, 16)}`,
2590
+ snapshotId: runProjectSnapshot.snapshotId,
2591
+ planId,
2592
+ targetFingerprint: target.identityFingerprint,
2593
+ // Keep the capability binding shape identical to the direct execution
2594
+ // boundary below. An omitted generated-artifact binding is represented
2595
+ // there as `{ sqlParams: [], variables: {} }`, not `{}`; minting the
2596
+ // latter made a fully validated immutable proposal fail only after its
2597
+ // exploratory plan had frozen.
2598
+ bindings: { sqlParams: [], variables: {} },
2599
+ });
2600
+ if (!capability) {
2601
+ throw analyticalError('DQL could not mint a request-scoped exploratory execution capability, so it was not executed.', {
2602
+ origin: 'governance_gate', stage: 'validation', code: 'unauthorized_sql',
2603
+ });
2604
+ }
2605
+ return {
2606
+ capability,
2607
+ freeze: {
2608
+ version: 1,
2609
+ selectedTier: 'exploratory_sql',
2610
+ planId,
2611
+ planFingerprint,
2612
+ snapshotId: retrievalFreezeSnapshotId,
2613
+ targetFingerprint: target.identityFingerprint,
2614
+ sqlFingerprint: capability.candidateSqlFingerprint,
2615
+ candidateIds,
2616
+ authorization: 'capability_minted',
2617
+ },
2618
+ };
2619
+ };
2166
2620
  await runner.run({
2167
2621
  provider: resolvedProvider,
2168
2622
  ...(agentRunProviderEvidenceContext.getStore()
@@ -2187,10 +2641,21 @@ export async function startLocalServer(opts) {
2187
2641
  },
2188
2642
  reasoningEffort,
2189
2643
  ...(analysisDepth ? { analysisDepth } : {}),
2644
+ orchestrationMode: route === 'research' ? 'research' : 'ask',
2190
2645
  allowProviderSemanticMemberSelection: route === 'research',
2191
2646
  researchResultRowsOptIn: route === 'research' && request.researchResultRowsOptIn === true,
2192
2647
  projectRoot,
2193
- preparedContextPack: preparedAgentContextPacks.get(request),
2648
+ // Keys the execution authorization, so the proofs the analyst loop
2649
+ // gathers can be checked against the statement this run executes.
2650
+ ...(request.runId ? { agentRunId: request.runId } : {}),
2651
+ preparedContextPack,
2652
+ // This closure is derived only from the router-owned cascade attempt
2653
+ // above. It is intentionally not sourced from the HTTP request: a
2654
+ // client or provider cannot widen the relations the exploratory prompt
2655
+ // or capability may use.
2656
+ ...(preparedExploratoryContextPack
2657
+ ? { preparedExploratoryContextPack }
2658
+ : {}),
2194
2659
  domainContext,
2195
2660
  projectSnapshot: { snapshotId: runProjectSnapshot.snapshotId, manifest: runProjectSnapshot.manifest },
2196
2661
  assertProjectSnapshot: (snapshotId) => {
@@ -2351,12 +2816,73 @@ export async function startLocalServer(opts) {
2351
2816
  ...(routeDecision?.resolvedAnalyticalPlan
2352
2817
  ? { resolvedAnalyticalPlan: routeDecision.resolvedAnalyticalPlan }
2353
2818
  : {}),
2819
+ ...(routeDecision?.analyticalCascadeDecision?.selectedTier
2820
+ ? { selectedCascadeTier: routeDecision.analyticalCascadeDecision.selectedTier }
2821
+ : {}),
2822
+ ...(exploratoryCandidateIds.length > 0
2823
+ ? { exploratoryCandidateIds }
2824
+ : {}),
2354
2825
  ...(generatedProposalTargetIdentity?.identityFingerprint
2355
2826
  ? { generatedProposalTargetFingerprint: generatedProposalTargetIdentity.identityFingerprint }
2356
2827
  : {}),
2357
2828
  analyticalReferenceInstant: new Date().toISOString(),
2358
- executeCertifiedBlock: (node, invocation) => executeCertifiedBlockForAgent(node, invocation, semanticConnection, semanticConnectionName),
2829
+ // A frozen certified artifact may use the human-friendly leaf relation
2830
+ // authored in its DQL. Its execution must still bind to the exact
2831
+ // same-snapshot physical relation that retrieval inspected. This is
2832
+ // deliberately narrower than exploratory preflight: it only qualifies
2833
+ // an unambiguous FROM/JOIN leaf already present in the artifact and it
2834
+ // never supplies a missing table, join, or column.
2835
+ executeCertifiedBlock: (node, invocation) => {
2836
+ const frozenCertifiedPlan = routeDecision?.analyticalCascadeDecision?.planFrozen === true
2837
+ && routeDecision.analyticalCascadeDecision.selectedTier === 'certified'
2838
+ && routeDecision.resolvedAnalyticalPlan?.capability === 'certified_execution';
2839
+ if (frozenCertifiedPlan) {
2840
+ const plan = routeDecision.resolvedAnalyticalPlan;
2841
+ const retrievedSourceFingerprint = routeDecision.retrievalEvidence?.sourceFingerprint;
2842
+ const retrievedSnapshotId = routeDecision.retrievalEvidence?.snapshotId;
2843
+ // A certified stamp is valid only for the exact router snapshot
2844
+ // and retrieval source that selected this block. The runner's
2845
+ // standard guard separately confirms the current host snapshot
2846
+ // immediately before this callback. Do not re-fingerprint the
2847
+ // mutable local cache here: cache refresh is not a source edit.
2848
+ if (retrievedSnapshotId && plan.snapshotId !== retrievedSnapshotId) {
2849
+ throw analyticalError('The selected certified plan no longer matches the retrieval snapshot that froze it. Refresh the answer before retrying.', { origin: 'governance_gate', stage: 'validation', code: 'snapshot_drift' });
2850
+ }
2851
+ if (plan.sourceFingerprint && retrievedSourceFingerprint
2852
+ && plan.sourceFingerprint !== retrievedSourceFingerprint) {
2853
+ throw analyticalError('The selected certified plan no longer matches the retrieval source that froze it. Refresh the answer before retrying.', { origin: 'governance_gate', stage: 'validation', code: 'snapshot_drift' });
2854
+ }
2855
+ if (!preparedContextPack
2856
+ || plan.snapshotId !== preparedContextPack.knowledgeLens.snapshotId) {
2857
+ throw analyticalError('The selected certified plan no longer matches the retrieved metadata snapshot. Refresh the answer before retrying.', { origin: 'governance_gate', stage: 'validation', code: 'snapshot_drift' });
2858
+ }
2859
+ const preparedSourceFingerprint = preparedContextPack.freshness.fingerprint;
2860
+ if (plan.sourceFingerprint && preparedSourceFingerprint
2861
+ && plan.sourceFingerprint !== preparedSourceFingerprint) {
2862
+ throw analyticalError('The selected certified plan no longer matches the retrieved metadata source. Refresh the answer before retrying.', { origin: 'governance_gate', stage: 'validation', code: 'snapshot_drift' });
2863
+ }
2864
+ }
2865
+ const certifiedSchemaContext = frozenCertifiedPlan
2866
+ ? buildFrozenCertifiedSchemaContext(preparedContextPack, runProjectSnapshot.manifest)
2867
+ : preparedQualifiedSchemaContext;
2868
+ return executeCertifiedBlockForAgent(node, invocation, semanticConnection, semanticConnectionName, certifiedSchemaContext, frozenCertifiedPlan);
2869
+ },
2359
2870
  executeGeneratedSql: (sql, artifact) => executeGeneratedArtifactForAgent(request.question, sql, artifact, semanticConnection, semanticConnectionName),
2871
+ prepareExploratorySqlExecution,
2872
+ executeAgenticGeneratedSql: async (capability, sql, artifact) => {
2873
+ if (!agenticExecutionCapabilityGate.consume(capability)) {
2874
+ throw analyticalError('This analyst execution capability was already consumed; DQL did not retry it with stale proof.', {
2875
+ origin: 'governance_gate', stage: 'execute', code: 'unauthorized_sql',
2876
+ });
2877
+ }
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
+ });
2885
+ },
2360
2886
  executeDqlArtifact: (artifact) => executeArtifactReferenceForAgent(artifact, request.question, semanticConnection, semanticConnectionName),
2361
2887
  getSchemaContext: (question, preparedContextPack) => getSchemaContextForAgent(question, preparedContextPack, semanticConnection, request.executionTarget?.target === 'connection'
2362
2888
  ? request.executionTarget.connectionName
@@ -2371,10 +2897,14 @@ export async function startLocalServer(opts) {
2371
2897
  if (turn.kind === 'error') {
2372
2898
  providerError = turn.message;
2373
2899
  providerDispatchEvidence = turn.dispatchEvidence;
2900
+ providerBoundaryDiagnostic = turn.providerDiagnostic;
2374
2901
  }
2375
2902
  }, runSignal);
2376
2903
  if (!governedAnswer) {
2377
- throw Object.assign(new Error(providerError ?? 'The AI provider did not return a governed answer.'), ...(providerDispatchEvidence ? [{ providerDispatchEvidence }] : []));
2904
+ throw Object.assign(new Error(providerError ?? 'The AI provider did not return a governed answer.'), {
2905
+ ...(providerDispatchEvidence ? { providerDispatchEvidence } : {}),
2906
+ ...(providerBoundaryDiagnostic ? { providerDiagnostic: providerBoundaryDiagnostic } : {}),
2907
+ });
2378
2908
  }
2379
2909
  return governedAnswer;
2380
2910
  }
@@ -2412,6 +2942,68 @@ export async function startLocalServer(opts) {
2412
2942
  function resolvedRunRouteFromAnswer(governedAnswer) {
2413
2943
  return routeForCascadeAnswerTier(governedAnswer.route?.tier);
2414
2944
  }
2945
+ /**
2946
+ * Return the route the router already froze. This is intentionally derived
2947
+ * from the typed cascade/plan, never from an answer-loop route label.
2948
+ */
2949
+ function frozenAnalyticalRoute(routeDecision) {
2950
+ if (!routeDecision)
2951
+ return undefined;
2952
+ const cascade = routeDecision.analyticalCascadeDecision;
2953
+ if (cascade?.planFrozen) {
2954
+ switch (cascade.selectedTier) {
2955
+ case 'certified': return 'certified_answer';
2956
+ case 'semantic': return 'semantic_answer';
2957
+ case 'governed_relational':
2958
+ case 'exploratory_sql': return 'generated_answer';
2959
+ default: return undefined;
2960
+ }
2961
+ }
2962
+ const plan = routeDecision.resolvedAnalyticalPlan;
2963
+ if (plan?.mode !== 'authoritative')
2964
+ return undefined;
2965
+ switch (plan.capability) {
2966
+ case 'certified_execution': return 'certified_answer';
2967
+ case 'semantic_execution': return 'semantic_answer';
2968
+ case 'governed_relational':
2969
+ case 'bounded_exploration': return 'generated_answer';
2970
+ default: return undefined;
2971
+ }
2972
+ }
2973
+ /**
2974
+ * A frozen tier may fail, but cannot silently substitute a result selected by
2975
+ * a different answer-loop lane. The engine repeats this guard so host-injected
2976
+ * executors receive the same protection.
2977
+ */
2978
+ function frozenPlanRouteFailure(route, routeDecision, reportedRoute) {
2979
+ const message = `The frozen ${route.replaceAll('_', ' ')} plan could not execute as selected. DQL did not substitute another analytical tier.`;
2980
+ return {
2981
+ resolvedRoute: route,
2982
+ status: 'blocked',
2983
+ trustState: 'blocked',
2984
+ stopReason: 'blocked',
2985
+ // Keep the public refusal vocabulary stable; the artifact/evaluation
2986
+ // below retains the precise route-integrity diagnostic.
2987
+ answerRefusalCode: 'grounding_gap',
2988
+ summary: message,
2989
+ answer: message,
2990
+ artifacts: [agentRunArtifact('answer', 'Frozen analytical plan could not execute', {
2991
+ kind: 'no_answer',
2992
+ refusalCode: 'grounding_gap',
2993
+ frozenPlanFailureCode: 'frozen_plan_route_mismatch',
2994
+ text: message,
2995
+ selectedTier: routeDecision?.analyticalCascadeDecision?.selectedTier,
2996
+ selectedConceptIds: routeDecision?.meaningResolution?.selectedConceptIds ?? [],
2997
+ ...(reportedRoute ? { reportedRoute } : {}),
2998
+ }, undefined, 'blocked')],
2999
+ evaluations: [agentRunEvaluation('frozen-plan-route-mismatch', 'Frozen analytical route', false, 'blocking', `The router froze ${route.replaceAll('_', ' ')}, but the answer executor reported ${reportedRoute?.replaceAll('_', ' ') ?? 'no matching route'}.`, {
3000
+ selectedRoute: route,
3001
+ reportedRoute,
3002
+ selectedTier: routeDecision?.analyticalCascadeDecision?.selectedTier,
3003
+ planId: routeDecision?.resolvedAnalyticalPlan?.planId,
3004
+ })],
3005
+ };
3006
+ }
2415
3007
  function groundingGapRepairHint(governedAnswer) {
2416
3008
  const details = governedAnswer.refusalDetails;
2417
3009
  if (!details)
@@ -2625,10 +3217,20 @@ export async function startLocalServer(opts) {
2625
3217
  if (governedAnswer.result)
2626
3218
  delete governedAnswer.result.dqlArtifact;
2627
3219
  }
2628
- const answerRunExecutor = async ({ request, route, routeDecision, attempt, repairHint, emit }) => {
3220
+ const answerRunExecutor = async ({ runId, request, route, routeDecision, attempt, repairHint, emit }) => {
3221
+ // `AgentRunEngine` owns the canonical run ID when HTTP callers omit one.
3222
+ // Bind that server-issued value to the same request object before the
3223
+ // provider/SQL handoff: the prepared context pack is keyed by this object,
3224
+ // while the exploratory capability must be scoped to the persisted run.
3225
+ // Cloning here would lose the exact retrieval pack; accepting a missing ID
3226
+ // would mint an unbound capability. This is server-side bookkeeping only,
3227
+ // never client-provided execution authority.
3228
+ if (!request.runId)
3229
+ request.runId = runId;
2629
3230
  const runStartedAtMs = Date.now();
2630
3231
  const turnPlan = buildAnalyticalTurnPlan({
2631
3232
  question: request.question,
3233
+ mode: route === 'research' ? 'research' : 'ask',
2632
3234
  turnId: request.runId,
2633
3235
  candidateIds: routeDecision?.retrievalEvidence?.candidateIds ?? [],
2634
3236
  frozen: routeDecision?.resolvedAnalyticalPlan?.mode === 'authoritative',
@@ -2641,18 +3243,27 @@ export async function startLocalServer(opts) {
2641
3243
  // is copied into several labels. Independent children share the parent's
2642
3244
  // signal/deadline and return truthful partial success.
2643
3245
  if (turnPlan.tasks.length > 1 && !childTurn && (attempt ?? 0) === 0) {
2644
- const childResults = await Promise.all(turnPlan.tasks.slice(0, 6).map(async (task) => {
3246
+ const runChildTask = async (task, dependencyBinding) => {
2645
3247
  if (request.signal?.aborted)
2646
3248
  rethrowIfCancelled(request.signal.reason, request.signal);
2647
3249
  try {
2648
3250
  const childRequest = {
2649
3251
  ...request,
2650
3252
  question: task.question,
3253
+ ...(dependencyBinding ? {
3254
+ conversationContext: {
3255
+ ...(request.conversationContext ?? {}),
3256
+ // This is the complete parent-to-child data boundary: no
3257
+ // parent prose, SQL, or rows cross into a dependent clause.
3258
+ analyticalTaskDependencyBinding: dependencyBinding,
3259
+ },
3260
+ } : {}),
2651
3261
  workspaceContext: {
2652
3262
  ...(request.workspaceContext && typeof request.workspaceContext === 'object' ? request.workspaceContext : {}),
2653
3263
  analyticalTaskChild: true,
2654
3264
  analyticalParentRunId: request.runId,
2655
3265
  analyticalTaskId: task.id,
3266
+ ...(dependencyBinding ? { analyticalTaskDependencyBinding: dependencyBinding } : {}),
2656
3267
  },
2657
3268
  };
2658
3269
  const answer = await runGovernedAgentAnswerForRun(childRequest, { attempt: 0, repairHint }, route, (message) => emit({ type: 'executor.started', message: `Task ${task.id}: ${message}`, route }), undefined);
@@ -2679,6 +3290,42 @@ export async function startLocalServer(opts) {
2679
3290
  rethrowIfCancelled(error, request.signal);
2680
3291
  return { task, error: error instanceof Error ? error.message : String(error) };
2681
3292
  }
3293
+ };
3294
+ const scheduledChildren = await scheduleCompoundAnalyticalTasks({
3295
+ tasks: turnPlan.tasks.slice(0, 6),
3296
+ runTask: async (task, binding) => {
3297
+ const child = await runChildTask(task, binding);
3298
+ return { task, value: child.answer, error: child.error };
3299
+ },
3300
+ resolveDependency: (task, parent) => {
3301
+ const sourceTaskId = task.dependency?.sourceTaskId ?? '';
3302
+ return resolveTopRankedRegionDependency(sourceTaskId, parent?.value?.result
3303
+ ? normalizeCanonicalQueryResult({
3304
+ ...parent.value.result,
3305
+ resultFingerprint: parent.value.result.resultFingerprint ?? parent.value.result.executionReceipt?.resultFingerprint,
3306
+ executionReceipt: parent.value.result.executionReceipt,
3307
+ answerTier: parent.value.route?.tier ?? parent.value.sourceTier,
3308
+ })
3309
+ : undefined, parent?.task);
3310
+ },
3311
+ });
3312
+ const childResults = scheduledChildren.map(({ task, value, error, dependencyError }) => ({
3313
+ task,
3314
+ answer: value,
3315
+ error,
3316
+ ...(dependencyError ? {
3317
+ dependencyGap: buildCoverageGap({
3318
+ code: dependencyError.code,
3319
+ phase: 'planning',
3320
+ message: dependencyError.message,
3321
+ searchedSources: routeDecision?.retrievalEvidence?.candidateIds ?? [],
3322
+ attemptedRoutes: ['certified', 'semantic', 'governed_relational', 'generated'],
3323
+ missing: ['unambiguous_top_region'],
3324
+ recoverable: true,
3325
+ planFrozen: turnPlan.frozen,
3326
+ nextActions: ['Ask for a single top region or review the parent result before retrying the customer task.'],
3327
+ }),
3328
+ } : {}),
2682
3329
  }));
2683
3330
  const outcomes = childResults.map(({ task, answer, error }) => ({
2684
3331
  version: 1,
@@ -2687,7 +3334,7 @@ export async function startLocalServer(opts) {
2687
3334
  ...(answer?.answer || answer?.text ? { summary: answer.answer ?? answer.text } : {}),
2688
3335
  ...(answer?.result?.resultFingerprint ? { resultFingerprint: answer.result.resultFingerprint } : {}),
2689
3336
  ...(error || answer?.kind === 'no_answer' ? {
2690
- gap: buildCoverageGap({
3337
+ gap: childResults.find((candidate) => candidate.task.id === task.id)?.dependencyGap ?? buildCoverageGap({
2691
3338
  code: answer?.refusalCode === 'ambiguous' ? 'AMBIGUOUS_MEANING' : 'EXECUTION_FAILED',
2692
3339
  phase: answer?.executionError ? 'execution' : 'meaning',
2693
3340
  message: error ?? answer?.answer ?? answer?.text ?? 'The task did not produce an accepted analytical result.',
@@ -2706,14 +3353,23 @@ export async function startLocalServer(opts) {
2706
3353
  status: outcomes.find((outcome) => outcome.taskId === task.id)?.status === 'completed' ? 'completed' : 'gap',
2707
3354
  }));
2708
3355
  const answerText = childResults.map(({ task, answer, error }) => `${task.question}: ${error ?? answer?.answer ?? answer?.text ?? 'No accepted result was produced.'}`).join('\n\n');
3356
+ const compoundTrust = compoundTrustState(childResults
3357
+ .filter(({ answer, error }) => !error && answer && answer.kind !== 'no_answer')
3358
+ .map(({ answer }) => trustStateForAgentAnswer(answer)));
2709
3359
  return {
2710
3360
  summary: completedCount === outcomes.length
2711
- ? `Answered ${completedCount} independent analytical clauses.`
3361
+ ? `Answered ${completedCount} analytical clauses.`
2712
3362
  : `Answered ${completedCount} of ${outcomes.length} analytical clauses; the remaining clauses need review.`,
2713
3363
  answer: answerText,
2714
3364
  status: completedCount === outcomes.length ? 'completed' : completedCount > 0 ? 'needs_review' : 'needs_clarification',
2715
- trustState: completedCount === outcomes.length ? 'governed' : 'review_required',
2716
- stopReason: completedCount === outcomes.length ? 'governed_semantic_answer' : 'human_review_required',
3365
+ // The parent is only as trustworthy as its weakest SUCCESSFUL child.
3366
+ // Completion is not proof: the previous rule stamped `governed` on a
3367
+ // parent assembled from review-required generated SQL.
3368
+ trustState: compoundTrust,
3369
+ // The stop reason has to agree with the trust it reports. Claiming a
3370
+ // governed semantic answer over generated/certified children
3371
+ // misrepresents provenance as much as the trust label does.
3372
+ stopReason: compoundStopReason(completedCount, outcomes.length, compoundTrust),
2717
3373
  artifacts: childResults.map(({ task, answer, error }) => agentRunArtifact('answer', `Task: ${task.question}`, {
2718
3374
  taskId: task.id,
2719
3375
  question: task.question,
@@ -2729,6 +3385,15 @@ export async function startLocalServer(opts) {
2729
3385
  let governedAnswer;
2730
3386
  try {
2731
3387
  governedAnswer = await runGovernedAgentAnswerForRun(request, { attempt, repairHint }, route, (message) => emit({ type: 'executor.started', message, route }), routeDecision);
3388
+ const frozenRoute = frozenAnalyticalRoute(routeDecision);
3389
+ const reportedRoute = resolvedRunRouteFromAnswer(governedAnswer);
3390
+ // Do not let the legacy answer loop reselect meaning and overwrite the
3391
+ // router's immutable tier through `resolvedRunRouteFromAnswer`. A frozen
3392
+ // plan either executes on its selected route or returns a same-tier,
3393
+ // inspectable terminal failure; it never downgrades to generated SQL.
3394
+ if (frozenRoute && (frozenRoute !== route || (reportedRoute && reportedRoute !== frozenRoute))) {
3395
+ return frozenPlanRouteFailure(frozenRoute, routeDecision, reportedRoute);
3396
+ }
2732
3397
  // Keep the canonical result contract on the answer itself, not only on
2733
3398
  // the narration preview. Conversation persistence, follow-up member
2734
3399
  // resolution, Apply, and the notebook table all read this payload; if a
@@ -2903,6 +3568,33 @@ export async function startLocalServer(opts) {
2903
3568
  const message = dispatchBudgetExhausted
2904
3569
  ? 'Ask reached its internal provider-dispatch limit before it froze one executable analytical plan. Nothing was run. Narrow the metric or dimension and retry; this is an orchestration-budget diagnostic, not a provider outage.'
2905
3570
  : formatAgentRunInfrastructureError(error, 'AI answer provider');
3571
+ const providerCode = error && typeof error === 'object'
3572
+ ? String(error.code ?? '')
3573
+ : '';
3574
+ const boundaryProviderDiagnostic = error && typeof error === 'object'
3575
+ ? error.providerDiagnostic
3576
+ : undefined;
3577
+ const providerDiagnostic = boundaryProviderDiagnostic
3578
+ && typeof boundaryProviderDiagnostic === 'object'
3579
+ && boundaryProviderDiagnostic.version === 1
3580
+ ? boundaryProviderDiagnostic
3581
+ : classifyProviderFailure({
3582
+ code: dispatchBudgetExhausted ? 'PROVIDER_DISPATCH_BUDGET' : providerCode,
3583
+ // Classification happens at the provider boundary before this error is
3584
+ // coalesced into the friendly headline. Persist the classifier output,
3585
+ // never this possibly sensitive raw message.
3586
+ message: error instanceof Error ? error.message : String(error ?? ''),
3587
+ phase: /no ai provider configured|not configured or reachable/i.test(message) ? 'preflight' : 'generation',
3588
+ providerFingerprint: agentRunWorkspaceValue(request, 'provider')
3589
+ ? `sha256:${createHash('sha256').update(agentRunWorkspaceValue(request, 'provider')).digest('hex')}`
3590
+ : undefined,
3591
+ modelFingerprint: agentRunWorkspaceValue(request, 'model')
3592
+ ? `sha256:${createHash('sha256').update(agentRunWorkspaceValue(request, 'model')).digest('hex')}`
3593
+ : undefined,
3594
+ baseOriginFingerprint: agentRunWorkspaceValue(request, 'providerBaseOrigin')
3595
+ ? `sha256:${createHash('sha256').update(agentRunWorkspaceValue(request, 'providerBaseOrigin')).digest('hex')}`
3596
+ : undefined,
3597
+ });
2906
3598
  return {
2907
3599
  summary: message,
2908
3600
  status: 'blocked',
@@ -2920,11 +3612,12 @@ export async function startLocalServer(opts) {
2920
3612
  code: dispatchBudgetExhausted ? 'orchestration_budget_exhausted' : 'AI_PROVIDER_FAILURE',
2921
3613
  message,
2922
3614
  recoverable: !dispatchBudgetExhausted,
3615
+ diagnostic: providerDiagnostic,
2923
3616
  },
2924
3617
  }, undefined, 'blocked')],
2925
3618
  evaluations: [
2926
3619
  agentRunEvaluation('route-decision', 'Route decision', true, 'info', routeDecision?.reason ?? 'Routed request to governed answer.'),
2927
- agentRunEvaluation(dispatchBudgetExhausted ? 'orchestration-budget' : 'ai-provider', dispatchBudgetExhausted ? 'Internal orchestration budget' : 'AI provider', false, 'blocking', message, { originalErrorType: error instanceof Error ? error.name : typeof error }),
3620
+ agentRunEvaluation(dispatchBudgetExhausted ? 'orchestration-budget' : 'ai-provider', dispatchBudgetExhausted ? 'Internal orchestration budget' : 'AI provider', false, 'blocking', message, { originalErrorType: error instanceof Error ? error.name : typeof error, providerDiagnostic }),
2928
3621
  ],
2929
3622
  nextActions: [
2930
3623
  ...(dispatchBudgetExhausted
@@ -2951,7 +3644,10 @@ export async function startLocalServer(opts) {
2951
3644
  },
2952
3645
  };
2953
3646
  }
2954
- const answeredRoute = resolvedRunRouteFromAnswer(governedAnswer) ?? route;
3647
+ // For an unfrozen legacy route, the answer loop can still describe which
3648
+ // tier actually produced the answer. Once the router froze a cascade tier,
3649
+ // that immutable route is the only durable provenance authority.
3650
+ const answeredRoute = frozenAnalyticalRoute(routeDecision) ?? resolvedRunRouteFromAnswer(governedAnswer) ?? route;
2955
3651
  const isCertified = governedAnswer.certification === 'certified' || governedAnswer.kind === 'certified';
2956
3652
  const semanticRouteClaimed = governedAnswer.route?.tier === 'semantic_metric';
2957
3653
  const semanticAggregationProofPassed = semanticAnswerHasPassedAggregationProof(governedAnswer);
@@ -2990,9 +3686,14 @@ export async function startLocalServer(opts) {
2990
3686
  && route === 'generated_answer'
2991
3687
  && (request.requestedMode === undefined || request.requestedMode === 'auto' || request.requestedMode === 'ask')
2992
3688
  && routeDecision?.resolvedAnalyticalPlan?.mode !== 'authoritative';
3689
+ // A generic `modeling_gap` says only that the complete analytical tuple did
3690
+ // not prove out. Relationship-specific repair language is allowed solely
3691
+ // when the router retained a structured coverage witness.
3692
+ const persistedGapWitness = persistedAnalyticalGapWitness(routeDecision);
3693
+ const relationshipSpecificGap = persistedGapWitness?.code === 'MISSING_RELATIONSHIP';
2993
3694
  const typedCoverageGap = (isGroundingGap || isModelDeclined)
2994
3695
  ? buildCoverageGap({
2995
- code: governedAnswer.refusalCode === 'modeling_gap' ? 'MISSING_RELATIONSHIP' : 'MISSING_RUNTIME_CAPABILITY',
3696
+ code: persistedGapWitness?.code ?? 'MISSING_RUNTIME_CAPABILITY',
2996
3697
  phase: 'planning',
2997
3698
  message: governedAnswer.refusalDetails?.message
2998
3699
  ?? (isModelDeclined
@@ -3000,17 +3701,21 @@ export async function startLocalServer(opts) {
3000
3701
  : 'The retrieved context did not prove the metadata required for this analytical question.'),
3001
3702
  searchedSources: ['certified_blocks', 'semantic_metrics', 'dbt_manifest', 'relationship_graph', 'warehouse_metadata'],
3002
3703
  attemptedRoutes: ['certified', 'semantic', 'governed_relational', 'generated'],
3003
- missing: governedAnswer.refusalDetails?.offending
3004
- ? [
3005
- governedAnswer.refusalDetails.offending.relation,
3006
- governedAnswer.refusalDetails.offending.column,
3007
- ].filter((value) => Boolean(value))
3008
- : ['an executable metric/dimension/relationship tuple'],
3704
+ missing: persistedGapWitness?.missing.length
3705
+ ? persistedGapWitness.missing
3706
+ : governedAnswer.refusalDetails?.offending
3707
+ ? [
3708
+ governedAnswer.refusalDetails.offending.relation,
3709
+ governedAnswer.refusalDetails.offending.column,
3710
+ ].filter((value) => Boolean(value))
3711
+ : ['the complete requested analytical tuple'],
3009
3712
  recoverable: canRecoverPreFreezeGap,
3010
3713
  planFrozen: routeDecision?.resolvedAnalyticalPlan?.mode === 'authoritative',
3011
3714
  nextActions: canRecoverPreFreezeGap
3012
3715
  ? ['continue through DBT-grounded relational context', 'run review-required generated SQL', 'start bounded Research if coverage remains incomplete']
3013
- : ['review the retained metadata gap', 'select a governed metric or dimension', 'repair modeling if the relationship is missing'],
3716
+ : relationshipSpecificGap
3717
+ ? ['review the retained relationship proof', 'repair the certified relationship in Modeling', 'retry after relationship validation']
3718
+ : ['review the retained metadata gap', 'select a governed metric or dimension', 'repair the missing metric, dimension, or output contract'],
3014
3719
  })
3015
3720
  : undefined;
3016
3721
  // Only a genuinely AMBIGUOUS question is surfaced as "needs clarification".
@@ -3062,7 +3767,34 @@ export async function startLocalServer(opts) {
3062
3767
  ? { ...preview, rows: redactProviderResultRows(preview.rows, narrationMaxRows) }
3063
3768
  : undefined;
3064
3769
  let narrationSource;
3065
- let narrationValidationFailures = [];
3770
+ // The receipt is the durable source of truth for evaluation and inspector
3771
+ // display. Do not reconstruct this later from rows or the reader-facing
3772
+ // fallback sentence: both are presentation artifacts, not evidence that a
3773
+ // fact-grounded narrator actually ran.
3774
+ const verifiedFactNarration = narrationPlan.mode === 'verified_facts'
3775
+ && Boolean(governedAnswer.analyticalFacts && governedAnswer.resolvedAnalyticalPlan?.analyticalFrame);
3776
+ let narrationIntegrityReceipt = narrationPlan.mode === 'skip'
3777
+ ? {
3778
+ version: 1,
3779
+ mode: 'skip',
3780
+ outcome: 'skipped',
3781
+ attempted: false,
3782
+ factCount: 0,
3783
+ maxRows: 0,
3784
+ validationFailures: [],
3785
+ skipReason: narrationPlan.reason,
3786
+ }
3787
+ : {
3788
+ version: 1,
3789
+ mode: verifiedFactNarration ? 'verified_facts' : 'preview_grounded',
3790
+ // This is deliberately pessimistic until a narration outcome is
3791
+ // observed, so an exception cannot be persisted as a silent skip.
3792
+ outcome: 'error',
3793
+ attempted: true,
3794
+ factCount: verifiedFactNarration ? governedAnswer.analyticalFacts?.facts.length ?? 0 : 0,
3795
+ maxRows: narrationPlan.maxRows,
3796
+ validationFailures: [],
3797
+ };
3066
3798
  if (narrationPlan.mode !== 'skip' && narrationProvider) {
3067
3799
  const narrationStartedAtMs = Date.now();
3068
3800
  const draft = governedAnswer.answer ?? governedAnswer.text;
@@ -3075,8 +3807,16 @@ export async function startLocalServer(opts) {
3075
3807
  columnCount: providerPreview?.columns.length ?? 0,
3076
3808
  },
3077
3809
  });
3078
- const narrationDispatchOptions = () => ({
3079
- maxTokens: 350,
3810
+ const narrationDispatchOptions = (factCount = 0) => ({
3811
+ // The ceiling has to grow with the result. Every claim must echo the
3812
+ // fact ids it rests on, and a fact id is a long hex string that
3813
+ // tokenizes badly — ten of them consume most of the budget before a
3814
+ // word of prose is written. At a flat 350 a ten-row answer was
3815
+ // truncated mid-sentence ("...Elizabeth Shea (875), and Dyl"), so the
3816
+ // JSON never closed, BOTH attempts failed as UNPARSEABLE_CLAIMS, and
3817
+ // the reader got "Verified narration was unavailable" above a robot
3818
+ // dump of the very rows the model had just described correctly.
3819
+ maxTokens: narrationMaxTokensForFacts(factCount),
3080
3820
  temperature: 0.3,
3081
3821
  maxProviderDispatches: 2,
3082
3822
  ...(agentRunProviderEvidenceContext.getStore()
@@ -3093,10 +3833,19 @@ export async function startLocalServer(opts) {
3093
3833
  factSet: governedAnswer.analyticalFacts,
3094
3834
  question: request.question,
3095
3835
  maxRows: narrationPlan.maxRows,
3096
- complete: async ({ system, user }) => streamOrGenerate(narrationProvider, [{ role: 'system', content: system }, { role: 'user', content: user }], narrationDispatchOptions(), () => { }),
3836
+ complete: async ({ system, user }) => streamOrGenerate(narrationProvider, [{ role: 'system', content: system }, { role: 'user', content: user }], narrationDispatchOptions(governedAnswer.analyticalFacts?.facts.length ?? 0), () => { }),
3097
3837
  });
3098
3838
  narrationSource = composed.source;
3099
- narrationValidationFailures = composed.validationFailures;
3839
+ narrationIntegrityReceipt = {
3840
+ ...narrationIntegrityReceipt,
3841
+ outcome: composed.source === 'llm' ? 'success' : 'deterministic_fallback',
3842
+ validationFailures: narrationIntegrityFailureCodes(composed.validationFailures),
3843
+ };
3844
+ if (process.env.DQL_ORCHESTRATOR_TRACE) {
3845
+ console.warn(`[dql] narration: source=${composed.source}${narrationIntegrityReceipt.validationFailures.length > 0
3846
+ ? ` rejected=${narrationIntegrityReceipt.validationFailures.join(',')}`
3847
+ : ''}`);
3848
+ }
3100
3849
  synthesizedAnswer = composed.source === 'llm'
3101
3850
  ? composed.narrative.text
3102
3851
  // A verification failure is not silent: the deterministic join is a
@@ -3126,11 +3875,22 @@ export async function startLocalServer(opts) {
3126
3875
  narrationSource = result.source;
3127
3876
  if (result.text)
3128
3877
  synthesizedAnswer = result.text;
3878
+ narrationIntegrityReceipt = {
3879
+ ...narrationIntegrityReceipt,
3880
+ outcome: result.source === 'llm' ? 'success' : 'deterministic_fallback',
3881
+ validationFailures: [],
3882
+ };
3129
3883
  }
3130
3884
  }
3131
3885
  catch {
3132
3886
  // Keep the governed draft on any narration failure.
3133
3887
  synthesizedAnswer = undefined;
3888
+ narrationIntegrityReceipt = {
3889
+ ...narrationIntegrityReceipt,
3890
+ outcome: 'error',
3891
+ validationFailures: [],
3892
+ errorCode: 'narration_error',
3893
+ };
3134
3894
  }
3135
3895
  finally {
3136
3896
  narrationDurationMs = Date.now() - narrationStartedAtMs;
@@ -3223,12 +3983,15 @@ export async function startLocalServer(opts) {
3223
3983
  : isTerminalFailure
3224
3984
  ? terminalFailureActions
3225
3985
  : isGroundingGap
3226
- ? governedAnswer.refusalCode === 'modeling_gap'
3986
+ ? relationshipSpecificGap
3227
3987
  ? [
3228
- { id: 'fix-modeling-gap', label: 'Fix with Modeling AI', route: 'modeling_draft', artifactKind: 'modeling_change_proposal' },
3988
+ { id: 'repair-relationship-proof', label: 'Repair relationship proof in Modeling', route: 'modeling_draft', artifactKind: 'modeling_change_proposal' },
3989
+ { id: 'research-gap', label: 'Research missing metadata coverage', route: 'research', artifactKind: 'research_run' },
3990
+ ]
3991
+ : [
3992
+ { id: 'review-metadata-gap', label: 'Review missing metadata coverage', route: 'blocked' },
3229
3993
  { id: 'research-gap', label: 'Research missing metadata coverage', route: 'research', artifactKind: 'research_run' },
3230
3994
  ]
3231
- : [{ id: 'research-gap', label: 'Research missing metadata coverage', route: 'research', artifactKind: 'research_run' }]
3232
3995
  : [
3233
3996
  { id: 'create-block', label: governedAnswer.dqlArtifact ? 'Review DQL draft' : 'Create DQL draft', route: 'dql_block_draft', artifactKind: 'dql_block_draft' },
3234
3997
  { id: 'research-gap', label: 'Research deeper', route: 'research' },
@@ -3270,7 +4033,11 @@ export async function startLocalServer(opts) {
3270
4033
  trustState,
3271
4034
  stopReason,
3272
4035
  artifacts: isTerminalFailure
3273
- ? [agentRunArtifact('answer', 'Failed governed analytical run', governedAnswer, governedAnswer.sourceCertifiedBlock ?? governedAnswer.block?.name, 'blocked')]
4036
+ ? [agentRunArtifact('answer',
4037
+ // Was 'Failed governed analytical run' — internal orchestration state
4038
+ // used as the card heading a user reads. It names our pipeline, not
4039
+ // what happened to their question.
4040
+ terminalFailureTitle(governedAnswer), governedAnswer, governedAnswer.sourceCertifiedBlock ?? governedAnswer.block?.name, 'blocked')]
3274
4041
  : governedAnswer.kind === 'no_answer'
3275
4042
  // A refusal still keeps the DQL draft the answer loop produced (when any),
3276
4043
  // so the "Review DQL draft" next-action isn't a dead link and the user can
@@ -3367,20 +4134,41 @@ export async function startLocalServer(opts) {
3367
4134
  ...(governedAnswer.executionError ? [
3368
4135
  agentRunEvaluation('execution-error', 'Execution error', false, 'warning', governedAnswer.executionError),
3369
4136
  ] : []),
3370
- // A rejected narration must say WHY it was rejected. The failures were
3371
- // computed and then dropped, so a reader saw "Verified narration was
3372
- // unavailable" with no way to tell whether the model invented a number,
3373
- // cited a fact id that does not exist, or the provider simply failed.
3374
- // The fallback itself stays: this only makes its reason inspectable.
3375
- ...(narrationSource === 'deterministic' && narrationValidationFailures.length > 0 ? [
3376
- agentRunEvaluation('narration-verification', 'Narration verification', false, 'warning', `The drafted narration was rejected against the result fact set, so the deterministic record was shown instead: ${narrationValidationFailures.join('; ')}`, { narrationSource, validationFailures: narrationValidationFailures }),
4137
+ // Keep only the content-free receipt codes in the durable inspection
4138
+ // record. Raw verifier prose can contain a result value, prompt excerpt,
4139
+ // or provider error and is not safe evidence to surface or persist.
4140
+ ...(narrationIntegrityReceipt.outcome === 'deterministic_fallback'
4141
+ && narrationIntegrityReceipt.validationFailures.length > 0 ? [
4142
+ agentRunEvaluation('narration-verification', 'Narration verification', false, 'warning', `The drafted narration was rejected against the result fact set, so the deterministic record was shown instead: ${narrationIntegrityReceipt.validationFailures.join(', ')}.`, { narrationSource, validationFailures: narrationIntegrityReceipt.validationFailures }),
3377
4143
  ] : []),
3378
4144
  ],
3379
4145
  nextActions,
3380
4146
  providerEgressReceipts: finalProviderEgressReceipts,
3381
4147
  telemetry: finalTelemetry,
4148
+ narrationIntegrityReceipt,
4149
+ ...(governedAnswer.exploratoryExecutionFreeze
4150
+ ? { analyticalExecutionFreeze: governedAnswer.exploratoryExecutionFreeze }
4151
+ : {}),
3382
4152
  };
3383
4153
  };
4154
+ /**
4155
+ * A heading for a run that ended without an answer, in the user's terms.
4156
+ *
4157
+ * Says WHICH stage stopped, because "it failed" and "it was stopped before
4158
+ * running" call for different next moves: one is worth retrying, the other
4159
+ * needs the question or the model changed.
4160
+ */
4161
+ const terminalFailureTitle = (answer) => {
4162
+ switch (answer.refusalCode) {
4163
+ case 'policy_blocked': return 'Blocked by a governance policy';
4164
+ case 'modeling_gap': return 'Not modeled yet';
4165
+ case 'grounding_gap': return 'Not enough context to answer safely';
4166
+ case 'model_declined': return 'The assistant declined to answer';
4167
+ case 'provider_error': return 'The AI provider did not respond';
4168
+ case 'ambiguous': return 'Needs one detail before running';
4169
+ default: return 'No answer was produced';
4170
+ }
4171
+ };
3384
4172
  const conversationRunExecutor = async ({ request, routeDecision, emitAnswerDelta }) => {
3385
4173
  const kind = routeDecision?.conversationalKind ?? 'smalltalk';
3386
4174
  const isGeneralKnowledge = routeDecision?.category === 'general_knowledge';
@@ -3397,6 +4185,15 @@ export async function startLocalServer(opts) {
3397
4185
  let text = kind === 'answer_explanation'
3398
4186
  ? buildPriorAnswerExplanation(request.question, request.conversationContext)
3399
4187
  : undefined;
4188
+ // A definitional question that NAMES a governed artifact is answerable from
4189
+ // the catalog: the description, domain, and dimensions are already recorded.
4190
+ // Reaching for a provider to paraphrase facts we hold can only add drift, and
4191
+ // the generic conversational reply this replaces used none of them.
4192
+ //
4193
+ // Returns undefined unless the question names something real, so a turn that
4194
+ // does not match keeps today's behaviour exactly.
4195
+ if (!text)
4196
+ text = buildGovernedObjectExplanation(request.question);
3400
4197
  if (text) {
3401
4198
  emitAnswerDelta?.(text);
3402
4199
  }
@@ -3863,10 +4660,18 @@ export async function startLocalServer(opts) {
3863
4660
  const conversationHistory = request.history?.length
3864
4661
  ? request.history
3865
4662
  : conversationHistoryFromContext(request.conversationContext);
4663
+ // The provider that will plan the investigation as hypotheses. Absent or
4664
+ // unreachable, `planResearch` keeps its deterministic template, so
4665
+ // research never depends on a model being available.
4666
+ const researchPlanner = resolveGovernedAnswerRunner(projectRoot);
4667
+ const researchPlannerProvider = researchPlanner
4668
+ ? createGovernedTextProvider(researchPlanner.provider, projectRoot)
4669
+ : undefined;
3866
4670
  const plan = await planResearch({
3867
4671
  question: request.question,
3868
4672
  metrics,
3869
4673
  blocks,
4674
+ ...(researchPlannerProvider ? { provider: researchPlannerProvider } : {}),
3870
4675
  intent: request.intent,
3871
4676
  isFollowUp: conversationHistory.length > 0,
3872
4677
  history: conversationHistory,
@@ -3881,6 +4686,38 @@ export async function startLocalServer(opts) {
3881
4686
  if (plan.done && !plan.followUp && request.requestedMode !== 'research') {
3882
4687
  return answerRunExecutor(researchContext);
3883
4688
  }
4689
+ // This is the executable, receipt-bound research plan. It carries the
4690
+ // branch hypothesis/expectation/validator kind into every child rather
4691
+ // than treating V2 as a presentation-only wrapper after the work ends.
4692
+ const typedResearchPlan = buildResearchHypothesisPlanV2({
4693
+ hypotheses: plan.steps.map((step, index) => ({
4694
+ id: `h${index + 1}`,
4695
+ statement: step.thought,
4696
+ expectation: step.expectation,
4697
+ targetId: step.action.target,
4698
+ validatorKind: inferResearchValidatorKind(step.thought, step.expectation),
4699
+ })),
4700
+ });
4701
+ // The V2 contract is the executable branch authority: retain its stable
4702
+ // hypothesis ID, wording, expectation, target, and validator kind while
4703
+ // borrowing only the already-grounded action kind from the planner. This
4704
+ // prevents a presentation-only V2 ledger from drifting away from the
4705
+ // child runs that actually produced the receipts.
4706
+ const executableResearchBranches = typedResearchPlan.hypotheses.flatMap((hypothesis) => {
4707
+ const planned = plan.steps.find((step) => step.thought.trim() === hypothesis.statement
4708
+ && step.action.target === hypothesis.targetId)
4709
+ ?? plan.steps.find((step) => step.action.target === hypothesis.targetId);
4710
+ if (!planned)
4711
+ return [];
4712
+ return [{
4713
+ hypothesisId: hypothesis.id,
4714
+ validatorKind: hypothesis.validatorKind,
4715
+ thought: hypothesis.statement,
4716
+ expectation: hypothesis.expectation,
4717
+ action: { ...planned.action, target: hypothesis.targetId },
4718
+ }];
4719
+ });
4720
+ const typedHypothesesById = new Map(typedResearchPlan.hypotheses.map((hypothesis) => [hypothesis.id, hypothesis]));
3884
4721
  const needsClarification = Boolean(plan.followUp);
3885
4722
  const notebookPath = agentRunNotebookPath(request, runId);
3886
4723
  const researchIntent = agentRunResearchIntent(request);
@@ -3941,6 +4778,8 @@ export async function startLocalServer(opts) {
3941
4778
  // attempt, not a fabricated successful finding; its durable status
3942
4779
  // and receipt determine the ledger entry.
3943
4780
  const fallbackBranch = {
4781
+ hypothesisId: 'fallback:context',
4782
+ validatorKind: 'counter_evidence',
3944
4783
  thought: 'Inspect the requested analytical question against the frozen root context.',
3945
4784
  action: {
3946
4785
  kind: 'lookup_metric',
@@ -3948,12 +4787,34 @@ export async function startLocalServer(opts) {
3948
4787
  },
3949
4788
  expectation: 'Whether the frozen context contains enough evidence for a bounded answer.',
3950
4789
  };
3951
- const branches = capResearchBranches(plan.steps.length > 0 ? plan.steps : [fallbackBranch], 6);
4790
+ const branches = capResearchBranches(executableResearchBranches.length > 0 ? executableResearchBranches : [fallbackBranch], 6);
4791
+ // The replan edge. Each branch tests one hypothesis; folding its
4792
+ // outcome back into the state is what lets the investigation stop
4793
+ // when the question is settled instead of grinding through a plan
4794
+ // frozen before any observation. `nextHypothesis` returning
4795
+ // undefined is how the loop learns to stop — it enforces the hop
4796
+ // budget and reports when nothing is open.
4797
+ let researchState = createResearchState(request.question, branches.map((branch, position) => ({
4798
+ id: `h${position + 1}`,
4799
+ statement: branch.thought,
4800
+ priorConfidence: 1 - position / (branches.length + 1),
4801
+ })));
3952
4802
  for (let index = 0; index < branches.length; index += 1) {
3953
4803
  const step = branches[index];
3954
4804
  if (request.signal?.aborted)
3955
4805
  rethrowIfCancelled(request.signal.reason, request.signal);
3956
- const branchId = `${step.action.kind}:${step.action.target}`;
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
+ const branchId = step.hypothesisId;
3957
4818
  const branchQuestion = `${request.question}\nResearch branch ${index + 1} (${branchId}): ${step.expectation}`;
3958
4819
  const childId = `${created.id}:research:${index + 1}`;
3959
4820
  const child = storage.createRun({
@@ -3974,6 +4835,9 @@ export async function startLocalServer(opts) {
3974
4835
  rootPlanId: plan.rootPlanId,
3975
4836
  branch: {
3976
4837
  id: branchId,
4838
+ hypothesisId: step.hypothesisId,
4839
+ hypothesis: step.thought,
4840
+ validatorKind: step.validatorKind,
3977
4841
  index: index + 1,
3978
4842
  expectation: step.expectation,
3979
4843
  action: step.action,
@@ -3998,7 +4862,15 @@ export async function startLocalServer(opts) {
3998
4862
  ...researchContextEnvelope,
3999
4863
  rootRunId: created.id,
4000
4864
  rootPlanId: plan.rootPlanId,
4001
- branch: { id: branchId, index: index + 1, expectation: step.expectation, action: step.action },
4865
+ branch: {
4866
+ id: branchId,
4867
+ hypothesisId: step.hypothesisId,
4868
+ hypothesis: step.thought,
4869
+ validatorKind: step.validatorKind,
4870
+ index: index + 1,
4871
+ expectation: step.expectation,
4872
+ action: step.action,
4873
+ },
4002
4874
  },
4003
4875
  executionConnection: researchExecutionConnection,
4004
4876
  executionConnectionName: researchExecutionConnectionName,
@@ -4007,7 +4879,27 @@ export async function startLocalServer(opts) {
4007
4879
  baselineDqlArtifact: researchSource?.dqlArtifact,
4008
4880
  baselineRunId: agentRunString(researchSource?.runId),
4009
4881
  });
4010
- researchRuns.push(withNotebookResearchChecklist(executed));
4882
+ const branchRun = withNotebookResearchChecklist(executed);
4883
+ researchRuns.push(branchRun);
4884
+ // Observe, then decide. A branch that produced rows is evidence
4885
+ // for its hypothesis; one that did not is inconclusive, which is
4886
+ // a real outcome and not a failure.
4887
+ // Rows are not support. A branch that returned data has been
4888
+ // OBSERVED, not confirmed — deciding whether the observation
4889
+ // matches what the hypothesis predicted needs the expectation,
4890
+ // and nothing available at this layer can judge it. Recording
4891
+ // rows as `supports` would let the dossier report a driver the
4892
+ // evidence never established, which is the failure mode the
4893
+ // whole verified-fact chain exists to prevent.
4894
+ researchState = applyFinding(researchState, {
4895
+ id: `f${index + 1}`,
4896
+ hypothesisId: `h${index + 1}`,
4897
+ verdict: 'inconclusive',
4898
+ summary: branchRun.summary ?? '',
4899
+ strength: (branchRun.resultPreview?.rows?.length ?? 0) > 0
4900
+ ? 0.5
4901
+ : 0.1,
4902
+ });
4011
4903
  }
4012
4904
  catch (error) {
4013
4905
  // A child is a real durable run even when cancellation stops the
@@ -4023,6 +4915,13 @@ export async function startLocalServer(opts) {
4023
4915
  const stopped = storage.getRun(child.id);
4024
4916
  if (stopped)
4025
4917
  researchRuns.push(withNotebookResearchChecklist(stopped));
4918
+ researchState = applyFinding(researchState, {
4919
+ id: `f${index + 1}`,
4920
+ hypothesisId: `h${index + 1}`,
4921
+ verdict: 'inconclusive',
4922
+ summary: message,
4923
+ strength: 0,
4924
+ });
4026
4925
  rethrowIfCancelled(error, request.signal);
4027
4926
  }
4028
4927
  }
@@ -4099,10 +4998,50 @@ export async function startLocalServer(opts) {
4099
4998
  ? 'not_started'
4100
4999
  : researchRuns.some((run) => run.status === 'error')
4101
5000
  ? 'insufficient_evidence'
4102
- : plan.steps.length > 6
5001
+ : executableResearchBranches.length > 6
4103
5002
  ? 'budget'
4104
5003
  : 'completed',
4105
5004
  });
5005
+ // V2 carries a verdict per bounded hypothesis and makes a deliberately
5006
+ // small investigation visible to the caller. A returned row is still
5007
+ // only an observation: without a deterministic expectation validator it
5008
+ // remains inconclusive rather than being promoted to causal support.
5009
+ const researchLedgerV2 = buildResearchEvidenceLedgerV2({
5010
+ rootQuestion: request.question,
5011
+ planId: plan.rootPlanId,
5012
+ snapshotId: routeDecision?.resolvedAnalyticalPlan?.snapshotId,
5013
+ groundableBranchCount: typedResearchPlan.hypotheses.length,
5014
+ entries: researchLedger.entries.map((entry, index) => ({
5015
+ ...entry,
5016
+ hypothesis: typedHypothesesById.get(entry.branchId)?.statement ?? plan.steps[index]?.thought,
5017
+ verdict: entry.status === 'failed'
5018
+ ? 'failed'
5019
+ : entry.status === 'skipped'
5020
+ ? 'skipped'
5021
+ : entry.rowCount === 0
5022
+ ? 'contradicted'
5023
+ : 'inconclusive',
5024
+ ...(entry.status === 'observed' && entry.resultFingerprint
5025
+ ? {
5026
+ validator: {
5027
+ version: 1,
5028
+ kind: typedHypothesesById.get(entry.branchId)?.validatorKind
5029
+ ?? inferResearchValidatorKind(plan.steps[index]?.thought ?? '', plan.steps[index]?.expectation ?? ''),
5030
+ // This proves that the branch completed the deterministic
5031
+ // receipt-bound observation. It deliberately does not claim
5032
+ // the hypothesis was true: rows/correlation alone remain
5033
+ // inconclusive until a stronger domain-specific predicate is
5034
+ // supplied by a future validator.
5035
+ evaluated: true,
5036
+ ...(entry.rowCount === 0 ? { outcome: 'contradicts_observation' } : {}),
5037
+ receiptFingerprints: [entry.resultFingerprint],
5038
+ },
5039
+ }
5040
+ : {}),
5041
+ counterEvidenceFactIds: entry.rowCount === 0 ? entry.facts.slice(0, 1) : [],
5042
+ })),
5043
+ stoppingReason: researchLedger.stoppingReason,
5044
+ });
4106
5045
  // A query that ran and matched 0 rows STILL executed — treat it as a clean,
4107
5046
  // grounded execution (not "no result"), so an empty answer is surfaced as
4108
5047
  // "0 rows matched" rather than silently downgraded to review-required.
@@ -4121,22 +5060,46 @@ export async function startLocalServer(opts) {
4121
5060
  reviewRequired: true,
4122
5061
  }, request.researchResultRowsOptIn === true)
4123
5062
  : undefined;
5063
+ // The cross-branch story. Every branch tested a hypothesis and produced a
5064
+ // finding; narrating only the one result the executor happened to carry
5065
+ // reported a single fact and discarded the rest, which is the visible
5066
+ // half of "research answers one question instead of telling a story".
5067
+ const researchStory = !needsClarification && researchLedgerV2.entries.length > 0
5068
+ ? synthesizeResearchNarrative({
5069
+ question: request.question,
5070
+ branches: researchLedgerV2.entries.map((entry) => ({
5071
+ statement: entry.hypothesis ?? entry.question,
5072
+ produced: entry.status === 'observed',
5073
+ verdict: entry.verdict,
5074
+ counterEvidenceFactIds: entry.counterEvidenceFactIds,
5075
+ ...(entry.error ? { summary: entry.error } : {}),
5076
+ status: entry.status,
5077
+ })),
5078
+ })
5079
+ : undefined;
4124
5080
  const summary = needsClarification
4125
5081
  ? 'Needs clarification before running deeper research.'
4126
- : narration?.summary
4127
- ?? (researchZeroRows
4128
- ? 'The query executed cleanly against real data and matched 0 rows.'
4129
- : researchRun?.status === 'ready'
4130
- ? 'Saved a grounded research dossier with context evidence and next review actions.'
4131
- : researchRun?.status === 'error'
4132
- ? 'Saved a research dossier, but the preview needs review before promotion.'
4133
- : researchWorkspaceError
4134
- ? 'Prepared a grounded research plan; durable research storage is unavailable in this runtime.'
4135
- : plan.done
4136
- ? 'Prepared a direct grounded-answer plan.'
4137
- : 'Prepared a grounded research plan over real DQL assets.');
5082
+ // The story leads; the verified-fact narration follows it, so the
5083
+ // numbers still come from the narrator that checks them.
5084
+ : researchStory
5085
+ ? `${researchStory}${narration?.summary ? `\n\n${narration.summary}` : ''}`
5086
+ : narration?.summary
5087
+ ?? (researchZeroRows
5088
+ ? 'The query executed cleanly against real data and matched 0 rows.'
5089
+ : researchRun?.status === 'ready'
5090
+ ? 'Saved a grounded research dossier with context evidence and next review actions.'
5091
+ : researchRun?.status === 'error'
5092
+ ? 'Saved a research dossier, but the preview needs review before promotion.'
5093
+ : researchWorkspaceError
5094
+ ? 'Prepared a grounded research plan; durable research storage is unavailable in this runtime.'
5095
+ : plan.done
5096
+ ? 'Prepared a direct grounded-answer plan.'
5097
+ : '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;
4138
5101
  return {
4139
- summary,
5102
+ summary: scopedSummary,
4140
5103
  answer: plan.followUp?.question ?? narration?.summary
4141
5104
  ?? (researchZeroRows ? 'The query executed cleanly and matched 0 rows.' : undefined)
4142
5105
  ?? researchRun?.summary,
@@ -4147,7 +5110,9 @@ export async function startLocalServer(opts) {
4147
5110
  ? []
4148
5111
  : [agentRunArtifact('research_run', 'Research plan', {
4149
5112
  plan,
5113
+ typedResearchPlan,
4150
5114
  researchLedger,
5115
+ researchLedgerV2,
4151
5116
  researchRun,
4152
5117
  researchRuns,
4153
5118
  researchRunId: researchRun?.id,
@@ -4176,6 +5141,9 @@ export async function startLocalServer(opts) {
4176
5141
  ? 'The query executed cleanly against real data and matched 0 rows.'
4177
5142
  : 'The query executed cleanly against real data and returned rows.')
4178
5143
  : 'No executed result was available; the output stays exploratory pending review.', { rowCount: Array.isArray(researchResultRecord?.rows) ? researchResultRecord.rows.length : 0 }),
5144
+ 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.`
5146
+ : `${researchLedgerV2.groundableBranchCount} groundable branches were retained with verdicts and counter-evidence slots.`, { groundableBranchCount: researchLedgerV2.groundableBranchCount, limitedScope: researchLedgerV2.limitedScope }),
4179
5147
  ],
4180
5148
  nextActions: needsClarification
4181
5149
  ? [{ id: 'answer-follow-up', label: 'Answer follow-up', route: 'research' }]
@@ -4482,6 +5450,22 @@ export async function startLocalServer(opts) {
4482
5450
  // lookup, and governed execution for the lifetime of a request. This removes
4483
5451
  // both positional catalog truncation and the previous duplicate retrieval pass.
4484
5452
  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
+ })();
4485
5469
  const pendingAgentContextPacks = new WeakMap();
4486
5470
  const buildAgentRunContextPack = async (request) => {
4487
5471
  const prepared = preparedAgentContextPacks.get(request);
@@ -4549,6 +5533,10 @@ export async function startLocalServer(opts) {
4549
5533
  },
4550
5534
  strictness: request.analysisDepth === 'deep' ? 'exploratory' : 'balanced',
4551
5535
  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 } : {}),
4552
5540
  domainContext: requestedDomain
4553
5541
  ? resolveUiDomainContext({
4554
5542
  manifest: snapshot.manifest,
@@ -4593,7 +5581,74 @@ export async function startLocalServer(opts) {
4593
5581
  durationMs: Date.now() - startedAt,
4594
5582
  truncated: pack.retrievalDiagnostics.topRejected.length > 0,
4595
5583
  });
4596
- return applyContextPackCompatibility(evidence, pack, request.selectedEvidenceId);
5584
+ // Preserve real snapshot/lane outcomes for the router receipt. These are
5585
+ // not reconstructed later from candidate IDs: a source with no selected
5586
+ // card can be empty, stale, errored, or intentionally skipped.
5587
+ const sourceCoverage = [];
5588
+ const fusionLanes = pack.retrievalDiagnostics.fusion?.lanes;
5589
+ const retrievalLaneStates = Object.values(fusionLanes ?? {});
5590
+ const retrievalErrored = retrievalLaneStates.length > 0
5591
+ && retrievalLaneStates.every((item) => item.status === 'error');
5592
+ const retrievalSkipped = retrievalLaneStates.length > 0
5593
+ && retrievalLaneStates.every((item) => item.status === 'skipped');
5594
+ const snapshotStale = /\bstale\b|out[- ]of[- ]date/i.test(pack.warnings.join(' '));
5595
+ const coverageStatus = (hasCandidate) => {
5596
+ if (snapshotStale)
5597
+ return 'stale';
5598
+ if (hasCandidate)
5599
+ return 'available';
5600
+ if (retrievalErrored)
5601
+ return 'errored';
5602
+ if (retrievalSkipped)
5603
+ return 'skipped';
5604
+ return 'empty';
5605
+ };
5606
+ const sourceDescriptors = [
5607
+ { source: 'certified', matches: (candidate) => candidate.kind === 'certified_block' },
5608
+ { source: 'semantic', matches: (candidate) => candidate.kind === 'semantic_metric' || candidate.kind === 'semantic_member' || candidate.trustTier === 'semantic' },
5609
+ { source: 'governed_relational', matches: (candidate) => candidate.kind === 'dql_modeling' || (candidate.relationshipEvidence?.length ?? 0) > 0 },
5610
+ { source: 'exploratory', matches: (candidate) => candidate.kind === 'dbt_model' || candidate.kind === 'dbt_source' || candidate.kind === 'sql_table' || candidate.kind === 'sql_column' },
5611
+ { source: 'dbt_manifest', matches: (candidate) => candidate.kind === 'dbt_model' || candidate.kind === 'dbt_source' },
5612
+ { source: 'runtime_schema', matches: (candidate) => candidate.kind === 'sql_table' || candidate.kind === 'sql_column' },
5613
+ ];
5614
+ // `compatible` is declared immediately below. Build IDs from the adapter
5615
+ // output first, then retain them unchanged through compatibility filtering.
5616
+ const compatible = applyContextPackCompatibility(evidence, pack, request.selectedEvidenceId);
5617
+ for (const descriptor of sourceDescriptors) {
5618
+ const ids = compatible.candidates
5619
+ .filter(descriptor.matches)
5620
+ .map((candidate) => candidate.qualifiedId ?? candidate.id)
5621
+ .slice(0, 32);
5622
+ sourceCoverage.push({
5623
+ version: 1,
5624
+ source: descriptor.source,
5625
+ status: coverageStatus(ids.length > 0),
5626
+ candidateIds: ids,
5627
+ ...(snapshotStale ? { reason: 'The snapshot freshness warning marked this source stale.' } : {}),
5628
+ });
5629
+ }
5630
+ const lane = pack.retrievalDiagnostics.fusion?.lanes?.vector;
5631
+ if (lane) {
5632
+ sourceCoverage.push({
5633
+ version: 1,
5634
+ source: 'vector',
5635
+ status: lane.status === 'ok' ? 'available' : lane.status === 'error' ? 'errored' : lane.status === 'empty' ? 'empty' : 'skipped',
5636
+ candidateIds: [],
5637
+ ...(lane.error ? { reason: lane.error } : lane.skippedReason ? { reason: lane.skippedReason } : {}),
5638
+ });
5639
+ }
5640
+ const hasConversation = Boolean(request.conversationContext && Object.keys(request.conversationContext).length > 0);
5641
+ sourceCoverage.push({
5642
+ version: 1,
5643
+ source: 'conversation',
5644
+ status: hasConversation ? 'available' : 'skipped',
5645
+ candidateIds: [],
5646
+ reason: hasConversation ? 'Persisted conversation context was supplied for this turn.' : 'No persisted conversation context was supplied for this turn.',
5647
+ });
5648
+ return {
5649
+ ...compatible,
5650
+ diagnostics: { ...compatible.diagnostics, sourceCoverage },
5651
+ };
4597
5652
  };
4598
5653
  const buildRankedAgentRunCatalogContext = async (request) => {
4599
5654
  const evidence = await buildAgentRunEvidence(request);
@@ -4604,6 +5659,56 @@ export async function startLocalServer(opts) {
4604
5659
  };
4605
5660
  // Compact fallback used only for plain conversational replies. Analytical
4606
5661
  // turns use the structured, question-ranked evidence path above.
5662
+ /**
5663
+ * Explain a governed artifact the question names, from catalog metadata alone.
5664
+ *
5665
+ * Certified blocks are offered first: when a concept exists both as a
5666
+ * certified block and a raw model, the certified one is the authored
5667
+ * definition and the other is an implementation detail.
5668
+ */
5669
+ const buildGovernedObjectExplanation = (question) => {
5670
+ try {
5671
+ const blocks = collectPlanBlocks(projectRoot, { certifiedOnly: true });
5672
+ const certifiedNames = new Set(blocks.map((block) => block.name));
5673
+ const all = [
5674
+ ...blocks.map((block) => ({ block, status: 'certified' })),
5675
+ ...collectPlanBlocks(projectRoot, { certifiedOnly: false })
5676
+ .filter((block) => !certifiedNames.has(block.name))
5677
+ .map((block) => ({ block, status: 'draft' })),
5678
+ ];
5679
+ // Metrics as well as blocks. "How is revenue defined here?" names a
5680
+ // semantic metric, not a block, and answering it from the metric's own
5681
+ // description is the whole point of holding one.
5682
+ const metricObjects = loadSemanticMetrics(projectRoot).map((metric) => ({
5683
+ objectKey: `semantic:metric:${metric.name}`,
5684
+ objectType: 'semantic_metric',
5685
+ name: metric.name,
5686
+ ...(metric.description ? { description: metric.description } : {}),
5687
+ ...(metric.domain ? { domain: metric.domain } : {}),
5688
+ status: 'governed',
5689
+ payload: {},
5690
+ }));
5691
+ const explanation = composeBusinessExplanation(question, [
5692
+ ...all.map(({ block, status }) => ({
5693
+ objectKey: `dql:block:${block.name}`,
5694
+ objectType: 'dql_block',
5695
+ name: block.name,
5696
+ ...(block.description ? { description: block.description } : {}),
5697
+ ...(block.domain ? { domain: block.domain } : {}),
5698
+ status,
5699
+ payload: {
5700
+ ...(block.dimensions?.length ? { dimensions: block.dimensions } : {}),
5701
+ },
5702
+ })),
5703
+ ...metricObjects,
5704
+ ]);
5705
+ return explanation?.text;
5706
+ }
5707
+ catch {
5708
+ // Never let an explanation attempt break a conversational turn.
5709
+ return undefined;
5710
+ }
5711
+ };
4607
5712
  const buildAgentRunCatalogContext = () => {
4608
5713
  try {
4609
5714
  const blocks = collectPlanBlocks(projectRoot, { certifiedOnly: true });
@@ -4912,6 +6017,16 @@ export async function startLocalServer(opts) {
4912
6017
  const semanticError = semanticCompose?.diagnostics.find((diagnostic) => diagnostic.severity === 'error')?.message;
4913
6018
  throw analyticalError(semanticError ?? `DQL artifact "${metadata.name ?? 'draft'}" produced no executable SQL.`, { origin: 'dql_compilation', stage: 'compile' });
4914
6019
  }
6020
+ // A certified block is authored as DQL rather than warehouse-specific SQL.
6021
+ // When its source uses an unqualified leaf relation, bind it only if the
6022
+ // immutable context snapshot proves one unique physical relation. Do not
6023
+ // reuse broad exploratory repairs here: certified execution may not alter
6024
+ // joins, aggregation, aliases, or any other frozen artifact semantics.
6025
+ const compiledSql = semanticCompose?.sql ?? plan.sql;
6026
+ const qualificationRepairs = [];
6027
+ const executableSql = !semanticCompose?.sql && metadata.qualifiedSchemaContext?.length
6028
+ ? qualifyUnambiguousSqlRelationsFromSchema(compiledSql, metadata.qualifiedSchemaContext, qualificationRepairs, activeConnection.driver)
6029
+ : compiledSql;
4915
6030
  const app = loadRuntimeApp(projectRoot, activePersonaAppId());
4916
6031
  const sourceDomain = metadata.domain ?? source.match(/\bdomain\s*=\s*"([^"]+)"/i)?.[1];
4917
6032
  assertAppAccess({ app, domain: sourceDomain ?? app?.domain, level: 'execute' });
@@ -4920,7 +6035,7 @@ export async function startLocalServer(opts) {
4920
6035
  : undefined;
4921
6036
  const semanticExecutionHolder = { value: null };
4922
6037
  const execution = await analyticalExecutionService.execute({
4923
- sql: semanticCompose?.sql ?? plan.sql,
6038
+ sql: executableSql,
4924
6039
  subject: 'DQL artifact query',
4925
6040
  connection: activeConnection,
4926
6041
  // Preserve the existing contract: previewed Ask artifacts are explicitly
@@ -4965,7 +6080,7 @@ export async function startLocalServer(opts) {
4965
6080
  path: metadata.path ?? null,
4966
6081
  domain: sourceDomain ?? null,
4967
6082
  })),
4968
- compiledSqlFingerprint: executionFingerprint(semanticCompose?.sql ?? plan?.sql ?? preparation.sourceSql),
6083
+ compiledSqlFingerprint: executionFingerprint(executableSql ?? preparation.sourceSql),
4969
6084
  normalizedSqlFingerprint: executionFingerprint(preparation.decodedSql),
4970
6085
  parameterFingerprint: executionReceipt.parameterFingerprint,
4971
6086
  provenanceFingerprint: executionFingerprint(stableExecutionValue(invocation.resolvedParameters.map((parameter) => ({
@@ -4975,7 +6090,7 @@ export async function startLocalServer(opts) {
4975
6090
  targetFingerprint: targetGenerationFingerprint(activeConnection, executionConnectionName),
4976
6091
  snapshotFingerprint: executionFingerprint(projectSnapshot().snapshotId),
4977
6092
  planFingerprint: executionFingerprint(stableExecutionValue({
4978
- sqlFingerprint: executionFingerprint(semanticCompose?.sql ?? plan?.sql ?? preparation.sourceSql),
6093
+ sqlFingerprint: executionFingerprint(executableSql ?? preparation.sourceSql),
4979
6094
  parameterCount: plan?.sqlParams?.length ?? 0,
4980
6095
  variableNames: Object.keys(plan?.variables ?? {}).sort(),
4981
6096
  chartConfig: plan?.chartConfig ?? null,
@@ -5007,7 +6122,7 @@ export async function startLocalServer(opts) {
5007
6122
  } : {}),
5008
6123
  };
5009
6124
  };
5010
- const executeCertifiedBlockByNameForAgent = async (blockName, invocationInput, requireCertified = false, executionConnection, executionConnectionName) => {
6125
+ const executeCertifiedBlockByNameForAgent = async (blockName, invocationInput, requireCertified = false, executionConnection, executionConnectionName, qualifiedSchemaContext) => {
5011
6126
  const manifest = buildManifest({ projectRoot });
5012
6127
  const block = manifest.blocks[blockName];
5013
6128
  if (!block) {
@@ -5022,6 +6137,7 @@ export async function startLocalServer(opts) {
5022
6137
  path: block.filePath,
5023
6138
  domain: block.domain,
5024
6139
  chartType: block.chartType,
6140
+ ...(qualifiedSchemaContext?.length ? { qualifiedSchemaContext } : {}),
5025
6141
  }, invocationInput, executionConnection, executionConnectionName);
5026
6142
  return {
5027
6143
  ...result,
@@ -5044,11 +6160,11 @@ export async function startLocalServer(opts) {
5044
6160
  },
5045
6161
  };
5046
6162
  };
5047
- const executeCertifiedBlockForAgent = async (node, invocationInput, executionConnection, executionConnectionName) => {
6163
+ const executeCertifiedBlockForAgent = async (node, invocationInput, executionConnection, executionConnectionName, qualifiedSchemaContext, requireCertified = false) => {
5048
6164
  if (node.kind !== 'block') {
5049
6165
  throw new Error(`Certified ${node.kind} "${node.name}" is a navigation artifact and cannot be executed as a block.`);
5050
6166
  }
5051
- return executeCertifiedBlockByNameForAgent(node.name || node.nodeId.replace(/^block:/, ''), invocationInput, false, executionConnection, executionConnectionName);
6167
+ return executeCertifiedBlockByNameForAgent(node.name || node.nodeId.replace(/^block:/, ''), invocationInput, requireCertified, executionConnection, executionConnectionName, qualifiedSchemaContext);
5052
6168
  };
5053
6169
  // Secondary agent surfaces use this compatibility callback. Delegate to the
5054
6170
  // same artifact-first path as Ask so research/app/notebook execution cannot
@@ -5155,12 +6271,43 @@ export async function startLocalServer(opts) {
5155
6271
  * string, so the only remaining reasons to differ are the connection and the
5156
6272
  * governance gates, both of which report themselves honestly.
5157
6273
  */
5158
- const executeGeneratedSqlDirect = async (question, sql, seed, executionConnection, bindings, executionConnectionName) => {
6274
+ const executeGeneratedSqlDirect = async (question, sql, seed, executionConnection, bindings, executionConnectionName, agenticCapability, agenticScope) => {
5159
6275
  const activeConnection = requireActiveConnection(executionConnection);
5160
6276
  const rowBound = clampAnalyticalRowBound(seed?.limit ?? 200);
5161
6277
  const trimmed = sql.trim().replace(/;\s*$/, '').trim();
5162
6278
  if (!trimmed)
5163
6279
  throw analyticalError('The generated SQL was empty.', { origin: 'host', stage: 'execute' });
6280
+ const bindingValue = {
6281
+ sqlParams: bindings?.sqlParams ?? [],
6282
+ variables: bindings?.variables ?? {},
6283
+ };
6284
+ if (agenticCapability) {
6285
+ // The proposal itself is immutable. A changed literal, comment, or
6286
+ // whitespace is drift here rather than a benign formatting change.
6287
+ const currentSnapshotId = projectSnapshot().snapshotId;
6288
+ let targetFingerprint;
6289
+ if (agenticCapability.targetFingerprint) {
6290
+ try {
6291
+ targetFingerprint = (await observeWarehouseTargetIdentity(executor, activeConnection)).identityFingerprint;
6292
+ }
6293
+ catch {
6294
+ throw analyticalError('DQL could not re-confirm the selected execution target, so the analyst-approved query was not run.', {
6295
+ origin: 'governance_gate', stage: 'execute', code: 'unauthorized_sql',
6296
+ });
6297
+ }
6298
+ }
6299
+ const capabilityVerdict = verifyAgenticSqlExecutionCapability(agenticCapability, sql, {
6300
+ ...agenticScope,
6301
+ bindings: bindingValue,
6302
+ snapshotId: currentSnapshotId,
6303
+ ...(targetFingerprint ? { targetFingerprint } : {}),
6304
+ });
6305
+ if (!capabilityVerdict.ok) {
6306
+ throw analyticalError(capabilityVerdict.reason ?? 'The analyst-approved SQL no longer matches this execution.', {
6307
+ origin: 'governance_gate', stage: 'execute', code: 'unauthorized_sql',
6308
+ });
6309
+ }
6310
+ }
5164
6311
  // RESOLVE FIRST, VALIDATE SECOND. Executing verbatim means executing the
5165
6312
  // statement the notebook would execute — and the notebook resolves
5166
6313
  // `@metric()` / `@dim()` refs and dbt macros before it runs anything.
@@ -5185,8 +6332,6 @@ export async function startLocalServer(opts) {
5185
6332
  // release denied; a governance boundary must not move as a side effect.
5186
6333
  const sourceDomain = seed?.source?.match(/\bdomain\s*=\s*"([^"]+)"/i)?.[1] ?? 'uncategorized';
5187
6334
  assertAppAccess({ app, domain: sourceDomain, level: 'execute' });
5188
- // A semantic query the loop already compiled (MetricFlow / dbt Cloud) must
5189
- // execute through its pinned target binding, not as loose SQL.
5190
6335
  const semanticExecutionHolder = { value: null };
5191
6336
  const execution = await analyticalExecutionService.execute({
5192
6337
  sql: semantic.sql,
@@ -5198,27 +6343,46 @@ export async function startLocalServer(opts) {
5198
6343
  variables: bindings?.variables,
5199
6344
  semanticRefs: semantic.semanticRefs,
5200
6345
  executePrepared: async (preparation) => {
5201
- const pinnedSemanticCompile = compiledSemanticQueries.get(executionFingerprint(preparation.preparedSql))
5202
- ?? compiledSemanticQueries.get(executionFingerprint(trimmed));
5203
- if (pinnedSemanticCompile) {
5204
- const semanticExecution = await executeTargetBoundSemanticQuery({
5205
- executor,
5206
- connection: activeConnection,
5207
- projectRoot,
5208
- plannedAdapter: pinnedSemanticCompile.engine,
5209
- metricFlow: pinnedSemanticCompile.engine === 'metricflow-cli'
5210
- ? resolveMetricFlowTargetMetadata(projectRoot, projectConfig)
5211
- : undefined,
5212
- compile: async () => pinnedSemanticCompile,
5213
- prepareSql: () => ({ sql: preparation.executedSql, connection: preparation.connection }),
5214
- rowBound,
5215
- });
5216
- if (semanticExecution) {
5217
- semanticExecutionHolder.value = semanticExecution;
5218
- return semanticExecution.result;
6346
+ // Both the native executor and the target-bound semantic adapter can
6347
+ // reach the warehouse from this callback. Admit the exact prepared
6348
+ // bytes before either path so a matching cached semantic compile cannot
6349
+ // bypass the generated proposal's capability.
6350
+ return executePreparedAgenticSqlBoundary({
6351
+ capability: agenticCapability,
6352
+ preparedSql: preparation.executedSql,
6353
+ bindings: bindingValue,
6354
+ scope: {
6355
+ ...agenticScope,
6356
+ snapshotId: projectSnapshot().snapshotId,
6357
+ ...(agenticCapability ? { targetFingerprint: agenticCapability.targetFingerprint } : {}),
6358
+ },
6359
+ execute: async () => {
6360
+ // A semantic query the loop already compiled (MetricFlow / dbt
6361
+ // Cloud) must execute through its pinned target binding, not as
6362
+ // loose SQL.
6363
+ const pinnedSemanticCompile = compiledSemanticQueries.get(executionFingerprint(preparation.preparedSql))
6364
+ ?? compiledSemanticQueries.get(executionFingerprint(trimmed));
6365
+ if (pinnedSemanticCompile) {
6366
+ const semanticExecution = await executeTargetBoundSemanticQuery({
6367
+ executor,
6368
+ connection: activeConnection,
6369
+ projectRoot,
6370
+ plannedAdapter: pinnedSemanticCompile.engine,
6371
+ metricFlow: pinnedSemanticCompile.engine === 'metricflow-cli'
6372
+ ? resolveMetricFlowTargetMetadata(projectRoot, projectConfig)
6373
+ : undefined,
6374
+ compile: async () => pinnedSemanticCompile,
6375
+ prepareSql: () => ({ sql: preparation.executedSql, connection: preparation.connection }),
6376
+ rowBound,
6377
+ });
6378
+ if (semanticExecution) {
6379
+ semanticExecutionHolder.value = semanticExecution;
6380
+ return semanticExecution.result;
6381
+ }
6382
+ }
6383
+ return executor.executeQuery(preparation.executedSql, bindings?.sqlParams ?? [], runtimeVariables(bindings?.variables ?? {}), preparation.connection);
5219
6384
  }
5220
- }
5221
- return executor.executeQuery(preparation.executedSql, bindings?.sqlParams ?? [], runtimeVariables(bindings?.variables ?? {}), preparation.connection);
6385
+ });
5222
6386
  },
5223
6387
  });
5224
6388
  const semanticExecution = semanticExecutionHolder.value;
@@ -5276,14 +6440,19 @@ export async function startLocalServer(opts) {
5276
6440
  executableArtifact,
5277
6441
  };
5278
6442
  };
5279
- const executeGeneratedArtifactForAgent = async (question, sql, seed, executionConnection, executionConnectionName) => {
6443
+ const executeGeneratedArtifactForAgent = async (question, sql, seed, executionConnection, executionConnectionName, agenticCapability, agenticScope) => {
5280
6444
  // A seed that is already a certified/saved artifact keeps the DQL-first
5281
6445
  // path: there the `.dql` source IS the contract, and its parameters and
5282
6446
  // semantic refs must be compiled, not bypassed.
5283
6447
  if (seed && seed.kind !== 'sql_block') {
6448
+ if (agenticCapability) {
6449
+ throw analyticalError('The analyst-approved SQL cannot be redirected through a saved artifact.', {
6450
+ origin: 'governance_gate', stage: 'execute', code: 'unauthorized_sql',
6451
+ });
6452
+ }
5284
6453
  return executeArtifactReferenceForAgent({ ...seed, limit: seed.limit ?? 200 }, question, executionConnection, executionConnectionName);
5285
6454
  }
5286
- return executeGeneratedSqlDirect(question, sql, seed, executionConnection, undefined, executionConnectionName);
6455
+ return executeGeneratedSqlDirect(question, sql, seed, executionConnection, undefined, executionConnectionName, agenticCapability, agenticScope);
5287
6456
  };
5288
6457
  /**
5289
6458
  * EXP-001: execution host for the deliberately narrow non-governed lane.
@@ -5891,7 +7060,10 @@ export async function startLocalServer(opts) {
5891
7060
  let invocation;
5892
7061
  let plan;
5893
7062
  try {
5894
- const program = new Parser(repairedSource, '<bounded-dql-repair>').parse();
7063
+ // The source label is echoed into parse errors, which reach the user.
7064
+ // `<bounded-dql-repair>` is an internal artifact id and tells them nothing;
7065
+ // it appeared verbatim in a reported failure card.
7066
+ const program = new Parser(repairedSource, 'repaired query').parse();
5895
7067
  const blocks = program.statements.filter((statement) => statement.kind === NodeKind.BlockDecl);
5896
7068
  if (blocks.length !== 1 || program.statements.length !== 1) {
5897
7069
  throw new Error('The repaired source must contain exactly one DQL block.');
@@ -9126,7 +10298,7 @@ export async function startLocalServer(opts) {
9126
10298
  // state wins; the client-built context stays the no-threadId fallback).
9127
10299
  const conversationStore = parsed.request.threadId ? getConversationStore() : null;
9128
10300
  if (conversationStore && parsed.request.threadId && conversationStore.getThread(parsed.request.threadId)) {
9129
- parsed.request.conversationContext = await conversationContextFromThread(conversationStore, parsed.request.threadId, parsed.request.conversationContext, parsed.request.question);
10301
+ parsed.request.conversationContext = await conversationContextFromThread(conversationStore, parsed.request.threadId, parsed.request.conversationContext, parsed.request.question, Boolean(parsed.request.selectedEvidenceId));
9130
10302
  // The persisted thread is authoritative for prior turns. Raw client
9131
10303
  // history would duplicate the same conversation into the prompt a
9132
10304
  // second time — dropping it keeps follow-up prompts bounded.
@@ -9134,10 +10306,13 @@ export async function startLocalServer(opts) {
9134
10306
  parsed.request.history = [];
9135
10307
  }
9136
10308
  const wantsStream = url.searchParams.get('stream') === '1' || url.searchParams.get('stream') === 'true';
9137
- const runId = parsed.request.runId;
10309
+ // The public body may carry UI correlation data, but the run identity
10310
+ // is server-owned. Mint it before every controller/SSE/engine handoff
10311
+ // so no client-provided string can bind a SQL capability or operation.
10312
+ const runId = randomUUID();
10313
+ parsed.request.runId = runId;
9138
10314
  const runController = new AbortController();
9139
- if (runId)
9140
- activeAgentRunControllers.set(runId, runController);
10315
+ activeAgentRunControllers.set(runId, runController);
9141
10316
  let streamConnected = true;
9142
10317
  res.on('close', () => { streamConnected = false; });
9143
10318
  const writeStream = (event, data) => {
@@ -9158,13 +10333,13 @@ export async function startLocalServer(opts) {
9158
10333
  'X-Accel-Buffering': 'no',
9159
10334
  });
9160
10335
  }
9161
- const operation = runId ? operationCoordinator.create({
10336
+ const operation = operationCoordinator.create({
9162
10337
  type: 'agent_run',
9163
10338
  scope: `agent-run:${runId}`,
9164
10339
  resourceRevision: parsed.request.threadId,
9165
10340
  message: 'AI request accepted. You can change pages while it runs.',
9166
10341
  cancellable: true,
9167
- }) : null;
10342
+ });
9168
10343
  if (wantsStream)
9169
10344
  writeStream('agent-run-accepted', { runId, operationId: operation?.id });
9170
10345
  let completedRun;
@@ -9256,8 +10431,7 @@ export async function startLocalServer(opts) {
9256
10431
  res.end(serializeJSON({ run: slimAgentRunForTransport(completedRun) }));
9257
10432
  }
9258
10433
  finally {
9259
- if (runId)
9260
- activeAgentRunControllers.delete(runId);
10434
+ activeAgentRunControllers.delete(runId);
9261
10435
  }
9262
10436
  }
9263
10437
  catch (error) {
@@ -18001,6 +19175,16 @@ export function resolveDefaultLLMProvider(projectRoot) {
18001
19175
  * answer envelope. Everything else uses the Settings-resolved default runner.
18002
19176
  */
18003
19177
  export function resolveGovernedAnswerRunner(projectRoot) {
19178
+ // Runtime eval cassettes are an explicit, offline provider source. Resolve
19179
+ // them before Settings because the CI fixture intentionally has no user
19180
+ // provider configuration; otherwise Ask exits at this earlier gate and never
19181
+ // reaches the cassette-wrapped provider used by the answer loop.
19182
+ const cassetteProvider = createEvalCassetteReplayProvider(projectRoot);
19183
+ if (cassetteProvider) {
19184
+ const provider = governedRunnerProviderForCassette(cassetteProvider.name);
19185
+ if (provider)
19186
+ return { provider, runner: createDqlAgentProviderRunner(provider, cassetteProvider) };
19187
+ }
18004
19188
  const active = getActiveProvider(projectRoot);
18005
19189
  if (isGovernedAnswerProviderId(active)) {
18006
19190
  return { provider: active, runner: createDqlAgentProviderRunner(active) };
@@ -18013,6 +19197,14 @@ export function resolveGovernedAnswerRunner(projectRoot) {
18013
19197
  }
18014
19198
  return null;
18015
19199
  }
19200
+ function governedRunnerProviderForCassette(name) {
19201
+ switch (name) {
19202
+ case 'claude': return 'anthropic';
19203
+ case 'openai': return 'openai';
19204
+ case 'gemini': return 'gemini';
19205
+ case 'ollama': return 'ollama';
19206
+ }
19207
+ }
18016
19208
  function isGovernedAnswerProviderId(value) {
18017
19209
  return value === 'anthropic'
18018
19210
  || value === 'openai'
@@ -21580,7 +22772,7 @@ export function buildAgentPreviewSql(sql, rowLimit = 200) {
21580
22772
  */
21581
22773
  export function repairExploratorySqlBeforeExecution(sql, schemaContext, question = '', dialect = 'duckdb') {
21582
22774
  const repairs = [];
21583
- let repairedSql = qualifyExploratoryRelationsFromSchema(sql, schemaContext, repairs, dialect);
22775
+ let repairedSql = qualifyUnambiguousSqlRelationsFromSchema(sql, schemaContext, repairs, dialect);
21584
22776
  repairedSql = repairExploratoryRelationQualifiers(repairedSql, repairs, dialect);
21585
22777
  repairedSql = repairExploratoryLifetimeMeasureSelection(repairedSql, schemaContext, question, repairs, dialect);
21586
22778
  repairedSql = repairExploratoryMisleadingPercentAliases(repairedSql, question, repairs);
@@ -21639,7 +22831,13 @@ export function applyRequestedTopNToExploratorySql(sql, requestedTopN) {
21639
22831
  }
21640
22832
  return `${withoutTerminator}\nLIMIT ${requestedTopN}`;
21641
22833
  }
21642
- function qualifyExploratoryRelationsFromSchema(sql, schemaContext, repairs, dialect = 'duckdb') {
22834
+ /**
22835
+ * Bind an already-authored unqualified relation to a unique inspected physical
22836
+ * relation. The caller owns whether that binding is permitted (exploratory
22837
+ * preflight or a frozen certified artifact); this helper itself never adds a
22838
+ * relation, key, join, predicate, or column.
22839
+ */
22840
+ export function qualifyUnambiguousSqlRelationsFromSchema(sql, schemaContext, repairs, dialect = 'duckdb') {
21643
22841
  const analysis = analyzeSqlReferences(sql, dialect);
21644
22842
  if (!analysis.parsed)
21645
22843
  return sql;
@@ -25778,6 +26976,14 @@ async function buildBlockStudioAiAssistSummary(projectRoot, action, candidate, v
25778
26976
  }
25779
26977
  }
25780
26978
  async function createBlockStudioAssistProvider(projectRoot, requestedProvider) {
26979
+ // Runtime-driven evals intentionally start from a clean fixture with no user
26980
+ // provider settings. When replay cassettes are explicitly enabled, their
26981
+ // recorded identity is the authoritative provider and never performs network
26982
+ // readiness checks. Normal product startup has no cassette env and follows
26983
+ // the unchanged configured-provider path below.
26984
+ const cassetteProvider = createEvalCassetteReplayProvider(projectRoot);
26985
+ if (cassetteProvider)
26986
+ return cassetteProvider;
25781
26987
  const settings = listProviderSettings(projectRoot);
25782
26988
  const activeProvider = getActiveProvider(projectRoot);
25783
26989
  // Subscription CLI providers (Claude Code / Codex) carry no API key — they're
@@ -25821,7 +27027,13 @@ async function createBlockStudioAssistProvider(projectRoot, requestedProvider) {
25821
27027
  default:
25822
27028
  return null;
25823
27029
  }
25824
- return await provider.available() ? provider : null;
27030
+ // Route through the eval cassette too. This constructor serves the MEANING
27031
+ // call and narration — the two dispatches that decide routing and wording —
27032
+ // so leaving it unwrapped meant a recorded suite still hit a live model for
27033
+ // exactly the calls whose non-determinism it was recorded to remove. A local
27034
+ // baseline reproduced that: the same question blocked on one run and answered
27035
+ // on the next, and zero cassettes were written.
27036
+ return await provider.available() ? applyEvalCassette(provider, projectRoot) : null;
25825
27037
  }
25826
27038
  /** Convert a governed answer's result payload into a bounded synthesis preview. */
25827
27039
  function agentResultToSynthesisPreview(result) {
@@ -28658,7 +29870,45 @@ async function buildAgentSchemaContextFromCatalog(projectRoot, question, prepare
28658
29870
  const RUNTIME_SNAPSHOT_MAX_AGE_MS = 60 * 60 * 1000; // 1 hour
28659
29871
  // A resolver compares at most 12 compact cards and never performs tool calls;
28660
29872
  // ten seconds is the full allowance, not the start of another planning loop.
28661
- const AGENT_MEANING_TIMEOUT_MS = 10_000;
29873
+ /**
29874
+ * Ceiling on the one bounded meaning-resolution call.
29875
+ *
29876
+ * 10s assumes a hosted model. A local Ollama model needs ~7s for a ONE-WORD
29877
+ * reply, so a 600-token resolution over a dozen candidates never lands: it
29878
+ * aborts, the router falls back to its evidence-only decision, and
29879
+ * `mayAssumeInterpretation` goes false — which sends every ambiguous question to
29880
+ * the clarification gate (AGT-017). The effect is that a local model cannot
29881
+ * answer anything ambiguous, in a product whose whole positioning is local-first.
29882
+ *
29883
+ * Scaled by the same `DQL_AGENT_DEADLINE_SCALE` as the run budget, so one
29884
+ * setting moves the provider's whole time envelope together rather than leaving
29885
+ * an inner bound to silently cap an outer one.
29886
+ */
29887
+ /**
29888
+ * Predict how long the next provider call will take, for admission control.
29889
+ *
29890
+ * With fewer than three samples the MAX is the only honest predictor: there is
29891
+ * no distribution yet, and admitting a call the deadline then kills wastes the
29892
+ * whole remaining budget.
29893
+ *
29894
+ * With a real sample, p75 rather than the max. One slow response — a cold model
29895
+ * load, a retried connection — otherwise poisons admission control for the rest
29896
+ * of the run: every later call is refused against a worst case that already
29897
+ * passed. A recorded run tripped RUN_DEADLINE_INSUFFICIENT 6.4s into a 45s
29898
+ * budget for exactly that reason. p75 still errs slow, so a genuinely slow
29899
+ * provider is still respected.
29900
+ */
29901
+ export function predictDispatchMs(observed, assumedMs = ASSUMED_PROVIDER_DISPATCH_MS) {
29902
+ if (observed.length === 0)
29903
+ return assumedMs;
29904
+ const sorted = [...observed].sort((left, right) => left - right);
29905
+ if (sorted.length < 3)
29906
+ return sorted[sorted.length - 1];
29907
+ const index = Math.max(0, Math.min(sorted.length - 1, Math.ceil(sorted.length * 0.75) - 1));
29908
+ return sorted[index];
29909
+ }
29910
+ const AGENT_MEANING_TIMEOUT_BASE_MS = 10_000;
29911
+ const AGENT_MEANING_TIMEOUT_MS = AGENT_MEANING_TIMEOUT_BASE_MS * deadlineScale();
28662
29912
  export function boundedAgentMeaningSignal(signal, timeoutMs = AGENT_MEANING_TIMEOUT_MS) {
28663
29913
  const timeout = AbortSignal.timeout(Math.max(1, timeoutMs));
28664
29914
  return signal ? AbortSignal.any([signal, timeout]) : timeout;
@@ -29410,7 +30660,7 @@ function compactSqlForRunHistory(sql) {
29410
30660
  const clean = sql.replace(/\s+/g, ' ').trim();
29411
30661
  return clean.length > 1200 ? `${clean.slice(0, 1197)}...` : clean;
29412
30662
  }
29413
- function buildAgentSchemaContextFromContextPack(question, contextPack) {
30663
+ function buildAgentSchemaContextFromContextPack(question, contextPack, options = {}) {
29414
30664
  const byRelation = new Map();
29415
30665
  const objectsByKey = new Map(contextPack.objects.map((object) => [object.objectKey, object]));
29416
30666
  const upsert = (table) => {
@@ -29464,11 +30714,75 @@ function buildAgentSchemaContextFromContextPack(question, contextPack) {
29464
30714
  table,
29465
30715
  score: scoreAgentSchemaTable(table, tokens) + (shouldProbeValues ? scoreAgentValueProbeTable(table) : 0),
29466
30716
  }))
29467
- .filter((entry) => entry.table.columns.length > 0 && entry.score > 0)
30717
+ .filter((entry) => entry.table.columns.length > 0 && (options.includeUnscored || entry.score > 0))
29468
30718
  .sort((a, b) => b.score - a.score || a.table.relation.localeCompare(b.table.relation))
29469
- .slice(0, 12)
30719
+ .slice(0, Math.max(1, options.limit ?? 12))
29470
30720
  .map((entry) => entry.table);
29471
30721
  }
30722
+ /**
30723
+ * Exact frozen certified execution needs the physical relation closure from
30724
+ * the same source snapshot, not only the twelve tables that happened to rank
30725
+ * for the natural-language prompt. An artifact may project `category` while
30726
+ * its authored SQL says `FROM order_items`, so prompt relevance alone is not
30727
+ * a safe authority to remove that relation from the execution handoff.
30728
+ *
30729
+ * This remains deliberately narrower than an exploratory repair: it exposes
30730
+ * only snapshot-indexed dbt models plus the already retrieved local objects.
30731
+ * `qualifyUnambiguousSqlRelationsFromSchema` still changes a leaf only when
30732
+ * exactly one physical relation in that closure owns it. Duplicate leaves
30733
+ * therefore remain a same-tier certified failure rather than a guessed bind.
30734
+ */
30735
+ function buildFrozenCertifiedSchemaContext(contextPack, manifest) {
30736
+ const byRelation = new Map();
30737
+ const upsert = (table) => {
30738
+ if (!table.relation || !table.name)
30739
+ return;
30740
+ const key = table.relation.toLowerCase();
30741
+ const existing = byRelation.get(key);
30742
+ if (!existing) {
30743
+ byRelation.set(key, {
30744
+ ...table,
30745
+ columns: dedupeAgentSchemaColumns(table.columns).slice(0, 80),
30746
+ });
30747
+ return;
30748
+ }
30749
+ byRelation.set(key, {
30750
+ ...existing,
30751
+ description: existing.description ?? table.description,
30752
+ source: existing.source === table.source ? existing.source : 'local metadata catalog',
30753
+ columns: dedupeAgentSchemaColumns([...existing.columns, ...table.columns]).slice(0, 80),
30754
+ });
30755
+ };
30756
+ if (contextPack) {
30757
+ for (const object of contextPack.objects) {
30758
+ const table = metadataObjectToAgentSchemaTable(object);
30759
+ if (table)
30760
+ upsert(table);
30761
+ }
30762
+ }
30763
+ for (const model of manifest.dbtImport?.dbtDag?.models ?? []) {
30764
+ const relation = [model.database, model.schema, model.name].filter(Boolean).join('.');
30765
+ // A bare dbt identity does not prove a physical catalog/schema and must
30766
+ // not be promoted into a qualifier. It remains harmless context only.
30767
+ if (!relation || relation === model.name)
30768
+ continue;
30769
+ upsert({
30770
+ relation,
30771
+ schema: model.schema,
30772
+ name: model.name,
30773
+ description: model.description,
30774
+ columns: (model.columns ?? []).map((column) => ({
30775
+ name: column.name,
30776
+ type: column.type,
30777
+ description: column.description,
30778
+ })),
30779
+ source: 'local metadata catalog',
30780
+ });
30781
+ }
30782
+ return Array.from(byRelation.values())
30783
+ .filter((table) => table.columns.length > 0)
30784
+ .sort((left, right) => left.relation.localeCompare(right.relation));
30785
+ }
29472
30786
  function metadataObjectToAgentSchemaTable(object) {
29473
30787
  if (object.objectType === 'dbt_column' || object.objectType === 'runtime_column') {
29474
30788
  const relation = metadataPayloadString(object, 'relation');
@@ -30043,49 +31357,9 @@ function scoreAgentValueProbeColumn(table, column) {
30043
31357
  return score;
30044
31358
  }
30045
31359
  export function isAgentValueProbeColumn(column) {
30046
- const name = column.name.toLowerCase();
30047
- // Tokenize underscore/camel names before applying the hard deny-list. This is
30048
- // intentionally independent of an allowlist: secrets and free-text payloads
30049
- // can never be probed through automatic grounding.
30050
- const normalizedName = column.name
30051
- .replace(/([a-z0-9])([A-Z])/g, '$1 $2')
30052
- .replace(/[_-]+/g, ' ')
30053
- .toLowerCase();
30054
- if (/\b(password|secret|token|credential|hash|salt|notes?|comments?|description|message|body|payload|content)\b/.test(normalizedName))
30055
- return false;
30056
- if (/\bemail\b/.test(normalizedName))
30057
- return false;
30058
- if (!hasAgentSchemaToken(name, [
30059
- 'account',
30060
- 'category',
30061
- 'channel',
30062
- 'city',
30063
- 'code',
30064
- 'country',
30065
- 'customer',
30066
- 'email',
30067
- 'full',
30068
- 'id',
30069
- 'key',
30070
- 'member',
30071
- 'name',
30072
- 'number',
30073
- 'product',
30074
- 'region',
30075
- 'segment',
30076
- 'sku',
30077
- 'state',
30078
- 'status',
30079
- 'subscriber',
30080
- 'type',
30081
- 'user',
30082
- ])) {
30083
- return false;
30084
- }
30085
- const type = column.type?.toLowerCase() ?? '';
30086
- if (!type)
30087
- return true;
30088
- return /\b(char|character|clob|email|string|text|uuid|varchar)\b/.test(type);
31360
+ // Delegates to the canonical predicate in dql-agent. Two copies of a security
31361
+ // rule drift, and the one that drifts is the one nobody is looking at.
31362
+ return isProbeSafeColumn({ name: column.name, ...(column.type ? { type: column.type } : {}) });
30089
31363
  }
30090
31364
  export function buildAgentValueProbeSql(table, column, searchTerms, connection) {
30091
31365
  const relation = quoteAgentRelation(table.relation, connection);