@duckcodeailabs/dql-cli 1.14.1 → 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 (86) hide show
  1. package/dist/assets/dql-notebook/assets/{AgentLogPage-BPz-UWFh.js → AgentLogPage-DKbGpRQS.js} +1 -1
  2. package/dist/assets/dql-notebook/assets/{AiBuildDialog-BEl53WA_.js → AiBuildDialog-DPSu0Mly.js} +1 -1
  3. package/dist/assets/dql-notebook/assets/{AiBuildResult-B4yfGTTZ.js → AiBuildResult-1uaGnpi1.js} +1 -1
  4. package/dist/assets/dql-notebook/assets/{AiSidePanel-CSZAAvuD.js → AiSidePanel-BwMREwa7.js} +1 -1
  5. package/dist/assets/dql-notebook/assets/{AnalyticsHome-D5P6Ujwi.js → AnalyticsHome-BGfey_ve.js} +1 -1
  6. package/dist/assets/dql-notebook/assets/{AppsView-DQOwU9Cg.js → AppsView-CM1tPywy.js} +4 -4
  7. package/dist/assets/dql-notebook/assets/{BlockStudio-B4ap0GdY.js → BlockStudio--S6WFVO4.js} +1 -1
  8. package/dist/assets/dql-notebook/assets/{BusinessArtifactView-BIfNI-0S.js → BusinessArtifactView-BEAJ-yNW.js} +1 -1
  9. package/dist/assets/dql-notebook/assets/{DbtFirstModelingPage-D72byb2g.js → DbtFirstModelingPage-CNyU5MBX.js} +1 -1
  10. package/dist/assets/dql-notebook/assets/{GitPage-lQb1uXH2.js → GitPage-IcydAai3.js} +1 -1
  11. package/dist/assets/dql-notebook/assets/{GlobalAiRail-CVECf6Xj.js → GlobalAiRail-CSKW-5eD.js} +1 -1
  12. package/dist/assets/dql-notebook/assets/{GovernedContextPage-trOyMCY6.js → GovernedContextPage-CFen0eFT.js} +1 -1
  13. package/dist/assets/dql-notebook/assets/{HelpDocsPage-D8hLS5lE.js → HelpDocsPage-D0D9UCIz.js} +1 -1
  14. package/dist/assets/dql-notebook/assets/{HomePage-eIkfBIep.js → HomePage-CmSxapR3.js} +1 -1
  15. package/dist/assets/dql-notebook/assets/{LineageDAG-CSqcbDrE.js → LineageDAG-BGUcIt1B.js} +1 -1
  16. package/dist/assets/dql-notebook/assets/{LineageDetailView-DJZZjZu-.js → LineageDetailView-Dx48Zfzb.js} +1 -1
  17. package/dist/assets/dql-notebook/assets/{LineageDrawer-BcIipIc3.js → LineageDrawer-zaBfSLZO.js} +1 -1
  18. package/dist/assets/dql-notebook/assets/{LineagePathBreadcrumb-CviIf8PN.js → LineagePathBreadcrumb-CTZJp4_r.js} +1 -1
  19. package/dist/assets/dql-notebook/assets/{MiniLineageGraph-vH_MY_Ju.js → MiniLineageGraph-CdivNR1S.js} +1 -1
  20. package/dist/assets/dql-notebook/assets/{NewBlockModal-DbCQg-pj.js → NewBlockModal-DMBFB7nE.js} +1 -1
  21. package/dist/assets/dql-notebook/assets/{NewNotebookModal-5Vp6XiuK.js → NewNotebookModal-DsC9CWlW.js} +1 -1
  22. package/dist/assets/dql-notebook/assets/{NotebookEditor-DjqJS44s.js → NotebookEditor-hs-kw8v9.js} +1 -1
  23. package/dist/assets/dql-notebook/assets/{ReadinessPage-BHGrC5ho.js → ReadinessPage-DJ83cMik.js} +1 -1
  24. package/dist/assets/dql-notebook/assets/{SetupOnboarding-BfS9Tdx-.js → SetupOnboarding-B1Pu_Bvv.js} +1 -1
  25. package/dist/assets/dql-notebook/assets/{SkillsPage-BFH21wSj.js → SkillsPage-BHKDay8n.js} +1 -1
  26. package/dist/assets/dql-notebook/assets/{TrustBadge-zm6g_SxZ.js → TrustBadge-BkyGgob2.js} +1 -1
  27. package/dist/assets/dql-notebook/assets/UnifiedAgentRunPanel-BYdrTaEW.js +89 -0
  28. package/dist/assets/dql-notebook/assets/{answer-to-notebook-DPhxIEzF.js → answer-to-notebook-DmNiuLQA.js} +1 -1
  29. package/dist/assets/dql-notebook/assets/{arrow-left-DNEb86Xc.js → arrow-left--1rsrxm8.js} +1 -1
  30. package/dist/assets/dql-notebook/assets/{arrow-right-DpPWbwaD.js → arrow-right-D5TdqY1G.js} +1 -1
  31. package/dist/assets/dql-notebook/assets/{book-open-text-D7s5jo4X.js → book-open-text-Bw7nHbzg.js} +1 -1
  32. package/dist/assets/dql-notebook/assets/{circle-x-Db3dXKg7.js → circle-x-DLe6NNM4.js} +1 -1
  33. package/dist/assets/dql-notebook/assets/{dagre.esm-C7pppQ1a.js → dagre.esm-CW5QZdBt.js} +1 -1
  34. package/dist/assets/dql-notebook/assets/{external-link-BxwXitO_.js → external-link-C9Q97sA3.js} +1 -1
  35. package/dist/assets/dql-notebook/assets/{grip-vertical-Dt4lkWRi.js → grip-vertical-CztvkIgo.js} +1 -1
  36. package/dist/assets/dql-notebook/assets/{index-DKo-bwNw.js → index-zHHzDn6l.js} +127 -127
  37. package/dist/assets/dql-notebook/assets/{link-2-Dfo2P6wi.js → link-2-CiKAvumL.js} +1 -1
  38. package/dist/assets/dql-notebook/assets/{list-tree-DiTmIWAL.js → list-tree-BtnP2nQ5.js} +1 -1
  39. package/dist/assets/dql-notebook/assets/{minimize-2-CMTAkPzL.js → minimize-2-TSFGxcCP.js} +1 -1
  40. package/dist/assets/dql-notebook/assets/{panel-right-open-DwYr7FW4.js → panel-right-open-BfXIUWy0.js} +1 -1
  41. package/dist/assets/dql-notebook/assets/{play-BXhHYQ4x.js → play-DVbSFJHD.js} +1 -1
  42. package/dist/assets/dql-notebook/assets/{rotate-ccw-BNi6F8pl.js → rotate-ccw-D_cesDcX.js} +1 -1
  43. package/dist/assets/dql-notebook/assets/{semantic-fields-CNOGysAy.js → semantic-fields-CoVStdYB.js} +1 -1
  44. package/dist/assets/dql-notebook/assets/{sliders-horizontal-Ec5MUMUW.js → sliders-horizontal-l7xV9K5A.js} +1 -1
  45. package/dist/assets/dql-notebook/assets/{star-B9leDkp_.js → star-CSBS0H3b.js} +1 -1
  46. package/dist/assets/dql-notebook/assets/{triangle-alert-BefTYCzx.js → triangle-alert-BTrnyY4q.js} +1 -1
  47. package/dist/assets/dql-notebook/assets/{upload-SPiOM2tQ.js → upload-sySLq9zb.js} +1 -1
  48. package/dist/assets/dql-notebook/assets/{usePersistedAgentThreadId-C4foXeiQ.js → usePersistedAgentThreadId-CzwgGdus.js} +1 -1
  49. package/dist/assets/dql-notebook/assets/{user-round-comGmyw-.js → user-round-ChlgXi9j.js} +1 -1
  50. package/dist/assets/dql-notebook/assets/{wand-sparkles-BffR4dF8.js → wand-sparkles-CGw0ytyT.js} +1 -1
  51. package/dist/assets/dql-notebook/assets/{workflow-ChmPTEzH.js → workflow-C_RltiK5.js} +1 -1
  52. package/dist/assets/dql-notebook/assets/{wrench-DovaG_ze.js → wrench-D-wLfeu0.js} +1 -1
  53. package/dist/assets/dql-notebook/assets/{x-nRx91AgW.js → x-B28hIJIC.js} +1 -1
  54. package/dist/assets/dql-notebook/index.html +1 -1
  55. package/dist/commands/agent-eval-cassette.d.ts +97 -6
  56. package/dist/commands/agent-eval-cassette.d.ts.map +1 -1
  57. package/dist/commands/agent-eval-cassette.js +165 -22
  58. package/dist/commands/agent-eval-cassette.js.map +1 -1
  59. package/dist/commands/agent-eval-runtime.d.ts +14 -2
  60. package/dist/commands/agent-eval-runtime.d.ts.map +1 -1
  61. package/dist/commands/agent-eval-runtime.js +12 -2
  62. package/dist/commands/agent-eval-runtime.js.map +1 -1
  63. package/dist/commands/agent.d.ts +9 -3
  64. package/dist/commands/agent.d.ts.map +1 -1
  65. package/dist/commands/agent.js +126 -49
  66. package/dist/commands/agent.js.map +1 -1
  67. package/dist/commands/compile.d.ts +13 -1
  68. package/dist/commands/compile.d.ts.map +1 -1
  69. package/dist/commands/compile.js +36 -4
  70. package/dist/commands/compile.js.map +1 -1
  71. package/dist/commands/sync.d.ts.map +1 -1
  72. package/dist/commands/sync.js +11 -3
  73. package/dist/commands/sync.js.map +1 -1
  74. package/dist/llm/providers/dql-agent-provider.d.ts +28 -1
  75. package/dist/llm/providers/dql-agent-provider.d.ts.map +1 -1
  76. package/dist/llm/providers/dql-agent-provider.js +272 -22
  77. package/dist/llm/providers/dql-agent-provider.js.map +1 -1
  78. package/dist/llm/types.d.ts +29 -1
  79. package/dist/llm/types.d.ts.map +1 -1
  80. package/dist/local-runtime.d.ts +16 -1
  81. package/dist/local-runtime.d.ts.map +1 -1
  82. package/dist/local-runtime.js +824 -66
  83. package/dist/local-runtime.js.map +1 -1
  84. package/dist/package.json +10 -10
  85. package/package.json +10 -10
  86. package/dist/assets/dql-notebook/assets/UnifiedAgentRunPanel--oxmjlgr.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,10 +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 { applyFinding, createResearchState, narrationMaxTokensForFacts, nextHypothesis, rerankCandidates, synthesizeResearchNarrative, AgenticExecutionCapabilityGate, mintFinalSqlAuthorization, verifyAgenticSqlExecutionCapability, qualifyAuthorizationReferences, validateSqlAgainstLocalContext as validateAuthorizedSqlReferences, verifyFinalSql, } from '@duckcodeailabs/dql-agent';
27
- import { applyEvalCassette, createDqlAgentProviderRunner, createGovernedTextProvider, 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';
28
28
  import { listRemoteMcpSettings, saveRemoteMcpSettings } from './llm/mcp-config.js';
29
- import { composeBusinessExplanation, ClaudeProvider, ConversationStore, advanceThreadState, buildConversationSnapshot, conversationHistoryFromContext, recallRelevantTurns, renderConversationEnvelopeForPrompt, GeminiProvider, MemoryStore, OllamaProvider, OpenAIProvider, buildBlockBusinessFingerprint, buildBlockSqlFingerprints, buildAnalysisQuestionPlan, composeSemanticQueryForQuestion, aggregationIntegrityIssuesForSql, buildAggregationSafetyProof, buildLocalContextPack, applyContextPackCompatibility, toAgentRetrievalEvidence, prepareConversationPath, defaultMemoryPath, ensureDefaultMemoryFiles, ensureAgentProjectReady, isAgentProjectIndexReady, currentMetadataFingerprint, ensureMetadataCatalogFresh, readIndexedDomainKnowledge, readIndexedKnowledge360, compactSemanticRuntimeFailure, classifyAnalyticalFailure, normalizeWarehouseSqlFailure, parseProposal, propose, proposePlan, recordGovernedCorrection, HintStore, defaultHintIndexPath, ensureHintIndexFresh, listHintsFromGit, getHintEvaluationFromGit, getCorrectionTraceFromGit, inspectGovernedHint, editGovernedHintCandidate, reopenGovernedHint, retireHint, supersedeHint, hintsConflict, mineJoinPatterns, reviewGovernedHint, AgentRunEngine, SqliteAgentRunStore, defaultAgentRunGates, createLlmAgentRunPlanner, createHybridRouter, computeResultStats, buildDeterministicDashboardStory, synthesizeAnswer, streamOrGenerate, narrateResult, buildProposePreview, buildFromPrompt, internalRelationIdsInSql, defaultAgentRunStorePath, defaultAgentRunSqlitePath, resolveLocalOwner, resolveProposeConfig, recordQueryRun, recordRuntimeSchemaSnapshot, latestRuntimeSchemaSnapshotForProject, loadSkills, migrateLegacySkills, configuredSkillsPath, skillsDir, draftDomainSkillBootstrap, buildDomainSkillBootstrapPrompt, mergeDomainSkillBootstrapEnrichment, writeSkill, previewSkillChange, buildContextAuthoringProposal, contextAuthoringDependencyClosure, FileContextAuthoringProposalStore, deleteSkill, deriveGeneratedDraftSlug, deriveAnalyticalRepair, reindexProject, invalidateAgentProjectState, recordAgentRuntimeVersion, resolveDomainContextEnvelope, projectEmbeddingProvider, isHashedEmbeddingProvider, clearProjectEmbeddingCache, upgradeVectorIndexForProject, openMetadataCatalog, defaultKgPath, planAppFromPrompt, KGStore, planResearch, loadSemanticMetrics, cascadeTraceToEvidenceRouteSteps, createCascadeAnswerResult, createCascadeTrace, routeReasoningEffort, createAgentRunBudget, isProbeSafeColumn, deadlineScale, routeForCascadeAnswerTier, clampReasoningEffort, bumpReasoningEffort, resolveThinkingMode, coerceThinkingMode, upsertGeneratedDqlArtifactDraft, loadAgentSemanticLayer, isTrustedConversationTurn, resolveInternalRelationIds, analyticalError, tagAnalyticalError, withAnalyticalErrorOrigin, withAnalyticalErrorOriginSync, assertProviderPayloadAllowed, createProviderDispatchEgressReceipt, prepareProviderWireEnvelopeForDispatch, markProviderMetadataArray, createProviderEgressReceipt, redactProviderResultRows, composeVerifiedAnalyticalNarrative, buildCoverageGap, capResearchBranches, buildResearchEvidenceLedger, buildAnalyticalTurnPlan, resolveTopRankedRegionDependency, DEFAULT_ASK_ROW_EGRESS_POLICY, ZERO_ROW_EGRESS_POLICY, resolveProviderResultRowEgressPolicy, normalizeCanonicalQueryResult, normalizeAnalyticalExecutionFingerprint, normalizeAnalyticalExecutionReceipt, createAgentRunCancellationError, } from '@duckcodeailabs/dql-agent';
29
+ import { composeBusinessExplanation, ClaudeProvider, ConversationStore, advanceThreadState, buildConversationSnapshot, conversationHistoryFromContext, recallRelevantTurns, renderConversationEnvelopeForPrompt, GeminiProvider, MemoryStore, OllamaProvider, OpenAIProvider, buildBlockBusinessFingerprint, buildBlockSqlFingerprints, buildAnalysisQuestionPlan, composeSemanticQueryForQuestion, aggregationIntegrityIssuesForSql, buildAggregationSafetyProof, buildLocalContextPack, applyContextPackCompatibility, toAgentRetrievalEvidence, prepareConversationPath, defaultMemoryPath, ensureDefaultMemoryFiles, ensureAgentProjectReady, isAgentProjectIndexReady, currentMetadataFingerprint, ensureMetadataCatalogFresh, readIndexedDomainKnowledge, readIndexedKnowledge360, compactSemanticRuntimeFailure, classifyAnalyticalFailure, normalizeWarehouseSqlFailure, parseProposal, propose, proposePlan, recordGovernedCorrection, HintStore, defaultHintIndexPath, ensureHintIndexFresh, listHintsFromGit, getHintEvaluationFromGit, getCorrectionTraceFromGit, inspectGovernedHint, editGovernedHintCandidate, reopenGovernedHint, retireHint, supersedeHint, hintsConflict, mineJoinPatterns, reviewGovernedHint, AgentRunEngine, SqliteAgentRunStore, defaultAgentRunGates, createLlmAgentRunPlanner, createHybridRouter, computeResultStats, buildDeterministicDashboardStory, synthesizeAnswer, streamOrGenerate, narrateResult, buildProposePreview, buildFromPrompt, internalRelationIdsInSql, defaultAgentRunStorePath, defaultAgentRunSqlitePath, 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';
30
30
  import { addSqlResultFilter, dashboardFilterableResultColumns, filterableResultColumns, replaceBlockStudioSql } from './sql-result-filter.js';
31
31
  import { gatherProposeEnrichment } from './propose-enrich.js';
32
32
  import { handleAppsApi, proposeAppAiBuild, recommendVisualization, } from './apps-api.js';
@@ -314,10 +314,14 @@ export function parseAgentRunRequestBody(body) {
314
314
  selectedObject,
315
315
  executionTarget,
316
316
  workspaceContext,
317
- conversationContext: sanitizeClientConversationContext(agentRunRecord(record.conversationContext)),
317
+ conversationContext: sanitizeClientConversationContext(agentRunRecord(record.conversationContext), {
318
+ stripStructuredSelectionEnvelope: Boolean(agentRunString(record.selectedEvidenceId)),
319
+ }),
318
320
  history: parseAgentRunHistory(record.history),
319
321
  threadId: agentRunString(record.threadId),
320
- 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.
321
325
  reasoningEffort: parseAgentRunReasoningEffort(record.reasoningEffort),
322
326
  analysisDepth: parseAgentRunAnalysisDepth(record.analysisDepth) ?? parseAgentRunAnalysisDepth(record.depth),
323
327
  thinkingMode: coerceThinkingMode(record.thinkingMode),
@@ -334,12 +338,21 @@ const CLIENT_PLAN_AUTHORITY_KEYS = new Set([
334
338
  // a child filter or skip ordinary member validation.
335
339
  'analyticalTaskDependencyBinding',
336
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',
349
+ ]);
337
350
  /**
338
351
  * Browser/embedding context is useful retrieval and history input, but it is
339
352
  * not a plan-authority channel. Remove plan-shaped fields recursively at HTTP
340
353
  * ingress; server-retained thread context is added afterwards from local state.
341
354
  */
342
- function sanitizeClientConversationContext(context) {
355
+ function sanitizeClientConversationContext(context, options = {}) {
343
356
  if (!context)
344
357
  return undefined;
345
358
  const sanitize = (value) => {
@@ -348,7 +361,14 @@ function sanitizeClientConversationContext(context) {
348
361
  const record = agentRunRecord(value);
349
362
  if (!record)
350
363
  return value;
351
- 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
+ }));
352
372
  };
353
373
  return sanitize(context);
354
374
  }
@@ -537,6 +557,18 @@ export function agentAnswerHasExecutionFailure(governedAnswer) {
537
557
  return typeof governedAnswer.executionError === 'string'
538
558
  && governedAnswer.executionError.trim().length > 0;
539
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
+ }
540
572
  /** Rebuild the immutable failed-run input from the artifact retained by API-007. */
541
573
  export function analyticalFailedRunFromAgentRun(run) {
542
574
  for (const artifact of run.artifacts) {
@@ -888,7 +920,7 @@ function businessNarrativeGaps(warnings) {
888
920
  // When a run carries a threadId, the persisted thread is the authoritative
889
921
  // source of prior turns (survives refresh); the client-built context remains
890
922
  // the fallback for embedders that never send a threadId.
891
- async function conversationContextFromThread(store, threadId, clientContext, question) {
923
+ async function conversationContextFromThread(store, threadId, clientContext, question, preservePendingClarification = false) {
892
924
  // Token-budget backstop: six verbatim turns with per-field caps. Older turns
893
925
  // remain reachable through the rolling summary + semantic recall below —
894
926
  // carrying more raw prose mostly slows every provider call on follow-ups.
@@ -936,7 +968,10 @@ async function conversationContextFromThread(store, threadId, clientContext, que
936
968
  const thread = store.getThread(threadId);
937
969
  // Bounded structured snapshot (working state + rolling summary + topic relation)
938
970
  // for the answer loop's conversation-state prompt section.
939
- const serverSnapshot = buildConversationSnapshot(store, threadId, { question });
971
+ const serverSnapshot = buildConversationSnapshot(store, threadId, {
972
+ question,
973
+ preservePendingClarification,
974
+ });
940
975
  if (serverSnapshot && question) {
941
976
  // Semantic recall over OLDER turns (the recent window is already verbatim).
942
977
  serverSnapshot.recalledTurns = await recallRelevantTurns(store, threadId, question, {
@@ -944,12 +979,26 @@ async function conversationContextFromThread(store, threadId, clientContext, que
944
979
  excludeTurnIds: serverSnapshot.recentTurns.map((turn) => turn.id),
945
980
  });
946
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;
947
995
  return {
948
996
  ...(sanitizeClientConversationContext(clientContext) ?? {}),
949
997
  conversationStateVersion: 1,
950
998
  threadId,
951
999
  ...(serverSnapshot ? { conversationEnvelope: serverSnapshot } : {}),
952
1000
  ...(serverSnapshot ? { serverSnapshot } : {}),
1001
+ ...(serverIssuedClarificationSelection ? { serverIssuedClarificationSelection } : {}),
953
1002
  ...(thread?.rollingSummary ? { conversationSummary: thread.rollingSummary } : {}),
954
1003
  // `activeTurnId` is the FOLLOW-UP ANCHOR: the turn whose filters, prior DQL
955
1004
  // artifact, and source SQL the next question builds on. Anchoring it to the
@@ -1119,6 +1168,40 @@ export function conversationTurnInputFromRun(run) {
1119
1168
  const contextPack = agentRunRecord(payload?.contextPack);
1120
1169
  const questionPlan = agentRunRecord(contextPack?.questionPlan);
1121
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;
1122
1205
  const rowCountRaw = result?.rowCount;
1123
1206
  const measureColumns = conversationMeasureColumns(columns, requestedShape, rows);
1124
1207
  return {
@@ -1127,7 +1210,13 @@ export function conversationTurnInputFromRun(run) {
1127
1210
  answerSummary: run.answer ?? run.summary,
1128
1211
  answerText: run.answer,
1129
1212
  route: run.route,
1130
- 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),
1131
1220
  runStatus: run.status,
1132
1221
  stopReason: run.stopReason,
1133
1222
  // Persisted so a later turn can tell a refusal from an answer. `runStatus`
@@ -1153,9 +1242,41 @@ export function conversationTurnInputFromRun(run) {
1153
1242
  rowCount: typeof rowCountRaw === 'number' ? rowCountRaw : rows.length || undefined,
1154
1243
  }
1155
1244
  : undefined,
1156
- contract: requestedShape,
1245
+ contract: {
1246
+ ...(requestedShape ?? {}),
1247
+ ...(clarificationSelection ? { clarificationSelection } : {}),
1248
+ },
1157
1249
  };
1158
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
+ }
1159
1280
  function conversationResultColumns(value) {
1160
1281
  if (!Array.isArray(value))
1161
1282
  return [];
@@ -1167,10 +1288,24 @@ function conversationResultColumns(value) {
1167
1288
  .slice(0, 24);
1168
1289
  }
1169
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.
1170
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
+ });
1171
1306
  const numericColumns = columns.filter((column) => rows.some((row) => typeof row[column] === 'number' && Number.isFinite(row[column])));
1172
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, ' ')));
1173
- const unique = Array.from(new Set([...requested, ...numericColumns, ...metricNamedColumns]
1308
+ const unique = Array.from(new Set([...actualRequestedColumns, ...numericColumns, ...metricNamedColumns]
1174
1309
  .map((value) => value.trim())
1175
1310
  .filter(Boolean)));
1176
1311
  return unique.length > 0 ? unique.slice(0, 24) : undefined;
@@ -2228,11 +2363,12 @@ export async function startLocalServer(opts) {
2228
2363
  runner = createDqlAgentProviderRunner('ollama', deterministicProvider);
2229
2364
  }
2230
2365
  if (!resolvedProvider || !runner) {
2231
- 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' });
2232
2367
  }
2233
2368
  let governedAnswer;
2234
2369
  let providerError;
2235
2370
  let providerDispatchEvidence;
2371
+ let providerBoundaryDiagnostic;
2236
2372
  const isRepair = (repair?.attempt ?? 0) > 0 && Boolean(repair?.repairHint);
2237
2373
  // The chat composer sends a `thinkingMode` (auto/low/medium/high); resolve it
2238
2374
  // into the effort+depth bundle it stands for. An explicit `reasoningEffort` /
@@ -2299,6 +2435,37 @@ export async function startLocalServer(opts) {
2299
2435
  const requestedPurpose = agentRunWorkspaceValue(request, 'purpose');
2300
2436
  const requestedModelAreaId = agentRunWorkspaceValue(request, 'modelAreaId');
2301
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
+ : [];
2302
2469
  const domainContext = requestedDomain
2303
2470
  ? resolveUiDomainContext({
2304
2471
  manifest: runProjectSnapshot.manifest,
@@ -2313,6 +2480,143 @@ export async function startLocalServer(opts) {
2313
2480
  // Local to this exact answer invocation. Compound children each enter this
2314
2481
  // function separately, so no child can consume another child's capability.
2315
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
+ };
2316
2620
  await runner.run({
2317
2621
  provider: resolvedProvider,
2318
2622
  ...(agentRunProviderEvidenceContext.getStore()
@@ -2344,7 +2648,14 @@ export async function startLocalServer(opts) {
2344
2648
  // Keys the execution authorization, so the proofs the analyst loop
2345
2649
  // gathers can be checked against the statement this run executes.
2346
2650
  ...(request.runId ? { agentRunId: request.runId } : {}),
2347
- preparedContextPack: preparedAgentContextPacks.get(request),
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
+ : {}),
2348
2659
  domainContext,
2349
2660
  projectSnapshot: { snapshotId: runProjectSnapshot.snapshotId, manifest: runProjectSnapshot.manifest },
2350
2661
  assertProjectSnapshot: (snapshotId) => {
@@ -2505,12 +2816,59 @@ export async function startLocalServer(opts) {
2505
2816
  ...(routeDecision?.resolvedAnalyticalPlan
2506
2817
  ? { resolvedAnalyticalPlan: routeDecision.resolvedAnalyticalPlan }
2507
2818
  : {}),
2819
+ ...(routeDecision?.analyticalCascadeDecision?.selectedTier
2820
+ ? { selectedCascadeTier: routeDecision.analyticalCascadeDecision.selectedTier }
2821
+ : {}),
2822
+ ...(exploratoryCandidateIds.length > 0
2823
+ ? { exploratoryCandidateIds }
2824
+ : {}),
2508
2825
  ...(generatedProposalTargetIdentity?.identityFingerprint
2509
2826
  ? { generatedProposalTargetFingerprint: generatedProposalTargetIdentity.identityFingerprint }
2510
2827
  : {}),
2511
2828
  analyticalReferenceInstant: new Date().toISOString(),
2512
- 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
+ },
2513
2870
  executeGeneratedSql: (sql, artifact) => executeGeneratedArtifactForAgent(request.question, sql, artifact, semanticConnection, semanticConnectionName),
2871
+ prepareExploratorySqlExecution,
2514
2872
  executeAgenticGeneratedSql: async (capability, sql, artifact) => {
2515
2873
  if (!agenticExecutionCapabilityGate.consume(capability)) {
2516
2874
  throw analyticalError('This analyst execution capability was already consumed; DQL did not retry it with stale proof.', {
@@ -2521,8 +2879,8 @@ export async function startLocalServer(opts) {
2521
2879
  runId: request.runId,
2522
2880
  executionId: capability.executionId,
2523
2881
  snapshotId: runProjectSnapshot.snapshotId,
2524
- planId: routeDecision?.resolvedAnalyticalPlan?.planId,
2525
- targetFingerprint: generatedProposalTargetIdentity?.identityFingerprint,
2882
+ planId: capability.planId,
2883
+ targetFingerprint: capability.targetFingerprint,
2526
2884
  });
2527
2885
  },
2528
2886
  executeDqlArtifact: (artifact) => executeArtifactReferenceForAgent(artifact, request.question, semanticConnection, semanticConnectionName),
@@ -2539,10 +2897,14 @@ export async function startLocalServer(opts) {
2539
2897
  if (turn.kind === 'error') {
2540
2898
  providerError = turn.message;
2541
2899
  providerDispatchEvidence = turn.dispatchEvidence;
2900
+ providerBoundaryDiagnostic = turn.providerDiagnostic;
2542
2901
  }
2543
2902
  }, runSignal);
2544
2903
  if (!governedAnswer) {
2545
- 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
+ });
2546
2908
  }
2547
2909
  return governedAnswer;
2548
2910
  }
@@ -2580,6 +2942,68 @@ export async function startLocalServer(opts) {
2580
2942
  function resolvedRunRouteFromAnswer(governedAnswer) {
2581
2943
  return routeForCascadeAnswerTier(governedAnswer.route?.tier);
2582
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
+ }
2583
3007
  function groundingGapRepairHint(governedAnswer) {
2584
3008
  const details = governedAnswer.refusalDetails;
2585
3009
  if (!details)
@@ -2793,7 +3217,16 @@ export async function startLocalServer(opts) {
2793
3217
  if (governedAnswer.result)
2794
3218
  delete governedAnswer.result.dqlArtifact;
2795
3219
  }
2796
- 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;
2797
3230
  const runStartedAtMs = Date.now();
2798
3231
  const turnPlan = buildAnalyticalTurnPlan({
2799
3232
  question: request.question,
@@ -2952,6 +3385,15 @@ export async function startLocalServer(opts) {
2952
3385
  let governedAnswer;
2953
3386
  try {
2954
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
+ }
2955
3397
  // Keep the canonical result contract on the answer itself, not only on
2956
3398
  // the narration preview. Conversation persistence, follow-up member
2957
3399
  // resolution, Apply, and the notebook table all read this payload; if a
@@ -3126,6 +3568,33 @@ export async function startLocalServer(opts) {
3126
3568
  const message = dispatchBudgetExhausted
3127
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.'
3128
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
+ });
3129
3598
  return {
3130
3599
  summary: message,
3131
3600
  status: 'blocked',
@@ -3143,11 +3612,12 @@ export async function startLocalServer(opts) {
3143
3612
  code: dispatchBudgetExhausted ? 'orchestration_budget_exhausted' : 'AI_PROVIDER_FAILURE',
3144
3613
  message,
3145
3614
  recoverable: !dispatchBudgetExhausted,
3615
+ diagnostic: providerDiagnostic,
3146
3616
  },
3147
3617
  }, undefined, 'blocked')],
3148
3618
  evaluations: [
3149
3619
  agentRunEvaluation('route-decision', 'Route decision', true, 'info', routeDecision?.reason ?? 'Routed request to governed answer.'),
3150
- 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 }),
3151
3621
  ],
3152
3622
  nextActions: [
3153
3623
  ...(dispatchBudgetExhausted
@@ -3174,7 +3644,10 @@ export async function startLocalServer(opts) {
3174
3644
  },
3175
3645
  };
3176
3646
  }
3177
- 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;
3178
3651
  const isCertified = governedAnswer.certification === 'certified' || governedAnswer.kind === 'certified';
3179
3652
  const semanticRouteClaimed = governedAnswer.route?.tier === 'semantic_metric';
3180
3653
  const semanticAggregationProofPassed = semanticAnswerHasPassedAggregationProof(governedAnswer);
@@ -3213,9 +3686,14 @@ export async function startLocalServer(opts) {
3213
3686
  && route === 'generated_answer'
3214
3687
  && (request.requestedMode === undefined || request.requestedMode === 'auto' || request.requestedMode === 'ask')
3215
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';
3216
3694
  const typedCoverageGap = (isGroundingGap || isModelDeclined)
3217
3695
  ? buildCoverageGap({
3218
- code: governedAnswer.refusalCode === 'modeling_gap' ? 'MISSING_RELATIONSHIP' : 'MISSING_RUNTIME_CAPABILITY',
3696
+ code: persistedGapWitness?.code ?? 'MISSING_RUNTIME_CAPABILITY',
3219
3697
  phase: 'planning',
3220
3698
  message: governedAnswer.refusalDetails?.message
3221
3699
  ?? (isModelDeclined
@@ -3223,17 +3701,21 @@ export async function startLocalServer(opts) {
3223
3701
  : 'The retrieved context did not prove the metadata required for this analytical question.'),
3224
3702
  searchedSources: ['certified_blocks', 'semantic_metrics', 'dbt_manifest', 'relationship_graph', 'warehouse_metadata'],
3225
3703
  attemptedRoutes: ['certified', 'semantic', 'governed_relational', 'generated'],
3226
- missing: governedAnswer.refusalDetails?.offending
3227
- ? [
3228
- governedAnswer.refusalDetails.offending.relation,
3229
- governedAnswer.refusalDetails.offending.column,
3230
- ].filter((value) => Boolean(value))
3231
- : ['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'],
3232
3712
  recoverable: canRecoverPreFreezeGap,
3233
3713
  planFrozen: routeDecision?.resolvedAnalyticalPlan?.mode === 'authoritative',
3234
3714
  nextActions: canRecoverPreFreezeGap
3235
3715
  ? ['continue through DBT-grounded relational context', 'run review-required generated SQL', 'start bounded Research if coverage remains incomplete']
3236
- : ['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'],
3237
3719
  })
3238
3720
  : undefined;
3239
3721
  // Only a genuinely AMBIGUOUS question is surfaced as "needs clarification".
@@ -3501,12 +3983,15 @@ export async function startLocalServer(opts) {
3501
3983
  : isTerminalFailure
3502
3984
  ? terminalFailureActions
3503
3985
  : isGroundingGap
3504
- ? governedAnswer.refusalCode === 'modeling_gap'
3986
+ ? relationshipSpecificGap
3505
3987
  ? [
3506
- { 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' },
3507
3993
  { id: 'research-gap', label: 'Research missing metadata coverage', route: 'research', artifactKind: 'research_run' },
3508
3994
  ]
3509
- : [{ id: 'research-gap', label: 'Research missing metadata coverage', route: 'research', artifactKind: 'research_run' }]
3510
3995
  : [
3511
3996
  { id: 'create-block', label: governedAnswer.dqlArtifact ? 'Review DQL draft' : 'Create DQL draft', route: 'dql_block_draft', artifactKind: 'dql_block_draft' },
3512
3997
  { id: 'research-gap', label: 'Research deeper', route: 'research' },
@@ -3661,6 +4146,9 @@ export async function startLocalServer(opts) {
3661
4146
  providerEgressReceipts: finalProviderEgressReceipts,
3662
4147
  telemetry: finalTelemetry,
3663
4148
  narrationIntegrityReceipt,
4149
+ ...(governedAnswer.exploratoryExecutionFreeze
4150
+ ? { analyticalExecutionFreeze: governedAnswer.exploratoryExecutionFreeze }
4151
+ : {}),
3664
4152
  };
3665
4153
  };
3666
4154
  /**
@@ -4198,6 +4686,38 @@ export async function startLocalServer(opts) {
4198
4686
  if (plan.done && !plan.followUp && request.requestedMode !== 'research') {
4199
4687
  return answerRunExecutor(researchContext);
4200
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]));
4201
4721
  const needsClarification = Boolean(plan.followUp);
4202
4722
  const notebookPath = agentRunNotebookPath(request, runId);
4203
4723
  const researchIntent = agentRunResearchIntent(request);
@@ -4258,6 +4778,8 @@ export async function startLocalServer(opts) {
4258
4778
  // attempt, not a fabricated successful finding; its durable status
4259
4779
  // and receipt determine the ledger entry.
4260
4780
  const fallbackBranch = {
4781
+ hypothesisId: 'fallback:context',
4782
+ validatorKind: 'counter_evidence',
4261
4783
  thought: 'Inspect the requested analytical question against the frozen root context.',
4262
4784
  action: {
4263
4785
  kind: 'lookup_metric',
@@ -4265,7 +4787,7 @@ export async function startLocalServer(opts) {
4265
4787
  },
4266
4788
  expectation: 'Whether the frozen context contains enough evidence for a bounded answer.',
4267
4789
  };
4268
- const branches = capResearchBranches(plan.steps.length > 0 ? plan.steps : [fallbackBranch], 6);
4790
+ const branches = capResearchBranches(executableResearchBranches.length > 0 ? executableResearchBranches : [fallbackBranch], 6);
4269
4791
  // The replan edge. Each branch tests one hypothesis; folding its
4270
4792
  // outcome back into the state is what lets the investigation stop
4271
4793
  // when the question is settled instead of grinding through a plan
@@ -4292,7 +4814,7 @@ export async function startLocalServer(opts) {
4292
4814
  });
4293
4815
  break;
4294
4816
  }
4295
- const branchId = `${step.action.kind}:${step.action.target}`;
4817
+ const branchId = step.hypothesisId;
4296
4818
  const branchQuestion = `${request.question}\nResearch branch ${index + 1} (${branchId}): ${step.expectation}`;
4297
4819
  const childId = `${created.id}:research:${index + 1}`;
4298
4820
  const child = storage.createRun({
@@ -4313,6 +4835,9 @@ export async function startLocalServer(opts) {
4313
4835
  rootPlanId: plan.rootPlanId,
4314
4836
  branch: {
4315
4837
  id: branchId,
4838
+ hypothesisId: step.hypothesisId,
4839
+ hypothesis: step.thought,
4840
+ validatorKind: step.validatorKind,
4316
4841
  index: index + 1,
4317
4842
  expectation: step.expectation,
4318
4843
  action: step.action,
@@ -4337,7 +4862,15 @@ export async function startLocalServer(opts) {
4337
4862
  ...researchContextEnvelope,
4338
4863
  rootRunId: created.id,
4339
4864
  rootPlanId: plan.rootPlanId,
4340
- 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
+ },
4341
4874
  },
4342
4875
  executionConnection: researchExecutionConnection,
4343
4876
  executionConnectionName: researchExecutionConnectionName,
@@ -4465,10 +4998,50 @@ export async function startLocalServer(opts) {
4465
4998
  ? 'not_started'
4466
4999
  : researchRuns.some((run) => run.status === 'error')
4467
5000
  ? 'insufficient_evidence'
4468
- : plan.steps.length > 6
5001
+ : executableResearchBranches.length > 6
4469
5002
  ? 'budget'
4470
5003
  : 'completed',
4471
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
+ });
4472
5045
  // A query that ran and matched 0 rows STILL executed — treat it as a clean,
4473
5046
  // grounded execution (not "no result"), so an empty answer is surfaced as
4474
5047
  // "0 rows matched" rather than silently downgraded to review-required.
@@ -4491,15 +5064,16 @@ export async function startLocalServer(opts) {
4491
5064
  // finding; narrating only the one result the executor happened to carry
4492
5065
  // reported a single fact and discarded the rest, which is the visible
4493
5066
  // half of "research answers one question instead of telling a story".
4494
- const researchStory = !needsClarification && plan.steps.length > 0
5067
+ const researchStory = !needsClarification && researchLedgerV2.entries.length > 0
4495
5068
  ? synthesizeResearchNarrative({
4496
5069
  question: request.question,
4497
- branches: researchRuns.map((branch, index) => ({
4498
- statement: plan.steps[index]?.thought ?? branch.question ?? '',
4499
- produced: branch.status === 'ready'
4500
- && (branch.resultPreview?.rows?.length ?? 0) > 0,
4501
- ...(branch.summary ? { summary: branch.summary } : {}),
4502
- ...(branch.status ? { status: branch.status } : {}),
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,
4503
5077
  })),
4504
5078
  })
4505
5079
  : undefined;
@@ -4521,8 +5095,11 @@ export async function startLocalServer(opts) {
4521
5095
  : plan.done
4522
5096
  ? 'Prepared a direct grounded-answer plan.'
4523
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;
4524
5101
  return {
4525
- summary,
5102
+ summary: scopedSummary,
4526
5103
  answer: plan.followUp?.question ?? narration?.summary
4527
5104
  ?? (researchZeroRows ? 'The query executed cleanly and matched 0 rows.' : undefined)
4528
5105
  ?? researchRun?.summary,
@@ -4533,7 +5110,9 @@ export async function startLocalServer(opts) {
4533
5110
  ? []
4534
5111
  : [agentRunArtifact('research_run', 'Research plan', {
4535
5112
  plan,
5113
+ typedResearchPlan,
4536
5114
  researchLedger,
5115
+ researchLedgerV2,
4537
5116
  researchRun,
4538
5117
  researchRuns,
4539
5118
  researchRunId: researchRun?.id,
@@ -4562,6 +5141,9 @@ export async function startLocalServer(opts) {
4562
5141
  ? 'The query executed cleanly against real data and matched 0 rows.'
4563
5142
  : 'The query executed cleanly against real data and returned rows.')
4564
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 }),
4565
5147
  ],
4566
5148
  nextActions: needsClarification
4567
5149
  ? [{ id: 'answer-follow-up', label: 'Answer follow-up', route: 'research' }]
@@ -4999,7 +5581,74 @@ export async function startLocalServer(opts) {
4999
5581
  durationMs: Date.now() - startedAt,
5000
5582
  truncated: pack.retrievalDiagnostics.topRejected.length > 0,
5001
5583
  });
5002
- 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
+ };
5003
5652
  };
5004
5653
  const buildRankedAgentRunCatalogContext = async (request) => {
5005
5654
  const evidence = await buildAgentRunEvidence(request);
@@ -5368,6 +6017,16 @@ export async function startLocalServer(opts) {
5368
6017
  const semanticError = semanticCompose?.diagnostics.find((diagnostic) => diagnostic.severity === 'error')?.message;
5369
6018
  throw analyticalError(semanticError ?? `DQL artifact "${metadata.name ?? 'draft'}" produced no executable SQL.`, { origin: 'dql_compilation', stage: 'compile' });
5370
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;
5371
6030
  const app = loadRuntimeApp(projectRoot, activePersonaAppId());
5372
6031
  const sourceDomain = metadata.domain ?? source.match(/\bdomain\s*=\s*"([^"]+)"/i)?.[1];
5373
6032
  assertAppAccess({ app, domain: sourceDomain ?? app?.domain, level: 'execute' });
@@ -5376,7 +6035,7 @@ export async function startLocalServer(opts) {
5376
6035
  : undefined;
5377
6036
  const semanticExecutionHolder = { value: null };
5378
6037
  const execution = await analyticalExecutionService.execute({
5379
- sql: semanticCompose?.sql ?? plan.sql,
6038
+ sql: executableSql,
5380
6039
  subject: 'DQL artifact query',
5381
6040
  connection: activeConnection,
5382
6041
  // Preserve the existing contract: previewed Ask artifacts are explicitly
@@ -5421,7 +6080,7 @@ export async function startLocalServer(opts) {
5421
6080
  path: metadata.path ?? null,
5422
6081
  domain: sourceDomain ?? null,
5423
6082
  })),
5424
- compiledSqlFingerprint: executionFingerprint(semanticCompose?.sql ?? plan?.sql ?? preparation.sourceSql),
6083
+ compiledSqlFingerprint: executionFingerprint(executableSql ?? preparation.sourceSql),
5425
6084
  normalizedSqlFingerprint: executionFingerprint(preparation.decodedSql),
5426
6085
  parameterFingerprint: executionReceipt.parameterFingerprint,
5427
6086
  provenanceFingerprint: executionFingerprint(stableExecutionValue(invocation.resolvedParameters.map((parameter) => ({
@@ -5431,7 +6090,7 @@ export async function startLocalServer(opts) {
5431
6090
  targetFingerprint: targetGenerationFingerprint(activeConnection, executionConnectionName),
5432
6091
  snapshotFingerprint: executionFingerprint(projectSnapshot().snapshotId),
5433
6092
  planFingerprint: executionFingerprint(stableExecutionValue({
5434
- sqlFingerprint: executionFingerprint(semanticCompose?.sql ?? plan?.sql ?? preparation.sourceSql),
6093
+ sqlFingerprint: executionFingerprint(executableSql ?? preparation.sourceSql),
5435
6094
  parameterCount: plan?.sqlParams?.length ?? 0,
5436
6095
  variableNames: Object.keys(plan?.variables ?? {}).sort(),
5437
6096
  chartConfig: plan?.chartConfig ?? null,
@@ -5463,7 +6122,7 @@ export async function startLocalServer(opts) {
5463
6122
  } : {}),
5464
6123
  };
5465
6124
  };
5466
- const executeCertifiedBlockByNameForAgent = async (blockName, invocationInput, requireCertified = false, executionConnection, executionConnectionName) => {
6125
+ const executeCertifiedBlockByNameForAgent = async (blockName, invocationInput, requireCertified = false, executionConnection, executionConnectionName, qualifiedSchemaContext) => {
5467
6126
  const manifest = buildManifest({ projectRoot });
5468
6127
  const block = manifest.blocks[blockName];
5469
6128
  if (!block) {
@@ -5478,6 +6137,7 @@ export async function startLocalServer(opts) {
5478
6137
  path: block.filePath,
5479
6138
  domain: block.domain,
5480
6139
  chartType: block.chartType,
6140
+ ...(qualifiedSchemaContext?.length ? { qualifiedSchemaContext } : {}),
5481
6141
  }, invocationInput, executionConnection, executionConnectionName);
5482
6142
  return {
5483
6143
  ...result,
@@ -5500,11 +6160,11 @@ export async function startLocalServer(opts) {
5500
6160
  },
5501
6161
  };
5502
6162
  };
5503
- const executeCertifiedBlockForAgent = async (node, invocationInput, executionConnection, executionConnectionName) => {
6163
+ const executeCertifiedBlockForAgent = async (node, invocationInput, executionConnection, executionConnectionName, qualifiedSchemaContext, requireCertified = false) => {
5504
6164
  if (node.kind !== 'block') {
5505
6165
  throw new Error(`Certified ${node.kind} "${node.name}" is a navigation artifact and cannot be executed as a block.`);
5506
6166
  }
5507
- 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);
5508
6168
  };
5509
6169
  // Secondary agent surfaces use this compatibility callback. Delegate to the
5510
6170
  // same artifact-first path as Ask so research/app/notebook execution cannot
@@ -9638,7 +10298,7 @@ export async function startLocalServer(opts) {
9638
10298
  // state wins; the client-built context stays the no-threadId fallback).
9639
10299
  const conversationStore = parsed.request.threadId ? getConversationStore() : null;
9640
10300
  if (conversationStore && parsed.request.threadId && conversationStore.getThread(parsed.request.threadId)) {
9641
- 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));
9642
10302
  // The persisted thread is authoritative for prior turns. Raw client
9643
10303
  // history would duplicate the same conversation into the prompt a
9644
10304
  // second time — dropping it keeps follow-up prompts bounded.
@@ -9646,10 +10306,13 @@ export async function startLocalServer(opts) {
9646
10306
  parsed.request.history = [];
9647
10307
  }
9648
10308
  const wantsStream = url.searchParams.get('stream') === '1' || url.searchParams.get('stream') === 'true';
9649
- 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;
9650
10314
  const runController = new AbortController();
9651
- if (runId)
9652
- activeAgentRunControllers.set(runId, runController);
10315
+ activeAgentRunControllers.set(runId, runController);
9653
10316
  let streamConnected = true;
9654
10317
  res.on('close', () => { streamConnected = false; });
9655
10318
  const writeStream = (event, data) => {
@@ -9670,13 +10333,13 @@ export async function startLocalServer(opts) {
9670
10333
  'X-Accel-Buffering': 'no',
9671
10334
  });
9672
10335
  }
9673
- const operation = runId ? operationCoordinator.create({
10336
+ const operation = operationCoordinator.create({
9674
10337
  type: 'agent_run',
9675
10338
  scope: `agent-run:${runId}`,
9676
10339
  resourceRevision: parsed.request.threadId,
9677
10340
  message: 'AI request accepted. You can change pages while it runs.',
9678
10341
  cancellable: true,
9679
- }) : null;
10342
+ });
9680
10343
  if (wantsStream)
9681
10344
  writeStream('agent-run-accepted', { runId, operationId: operation?.id });
9682
10345
  let completedRun;
@@ -9768,8 +10431,7 @@ export async function startLocalServer(opts) {
9768
10431
  res.end(serializeJSON({ run: slimAgentRunForTransport(completedRun) }));
9769
10432
  }
9770
10433
  finally {
9771
- if (runId)
9772
- activeAgentRunControllers.delete(runId);
10434
+ activeAgentRunControllers.delete(runId);
9773
10435
  }
9774
10436
  }
9775
10437
  catch (error) {
@@ -18513,6 +19175,16 @@ export function resolveDefaultLLMProvider(projectRoot) {
18513
19175
  * answer envelope. Everything else uses the Settings-resolved default runner.
18514
19176
  */
18515
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
+ }
18516
19188
  const active = getActiveProvider(projectRoot);
18517
19189
  if (isGovernedAnswerProviderId(active)) {
18518
19190
  return { provider: active, runner: createDqlAgentProviderRunner(active) };
@@ -18525,6 +19197,14 @@ export function resolveGovernedAnswerRunner(projectRoot) {
18525
19197
  }
18526
19198
  return null;
18527
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
+ }
18528
19208
  function isGovernedAnswerProviderId(value) {
18529
19209
  return value === 'anthropic'
18530
19210
  || value === 'openai'
@@ -22092,7 +22772,7 @@ export function buildAgentPreviewSql(sql, rowLimit = 200) {
22092
22772
  */
22093
22773
  export function repairExploratorySqlBeforeExecution(sql, schemaContext, question = '', dialect = 'duckdb') {
22094
22774
  const repairs = [];
22095
- let repairedSql = qualifyExploratoryRelationsFromSchema(sql, schemaContext, repairs, dialect);
22775
+ let repairedSql = qualifyUnambiguousSqlRelationsFromSchema(sql, schemaContext, repairs, dialect);
22096
22776
  repairedSql = repairExploratoryRelationQualifiers(repairedSql, repairs, dialect);
22097
22777
  repairedSql = repairExploratoryLifetimeMeasureSelection(repairedSql, schemaContext, question, repairs, dialect);
22098
22778
  repairedSql = repairExploratoryMisleadingPercentAliases(repairedSql, question, repairs);
@@ -22151,7 +22831,13 @@ export function applyRequestedTopNToExploratorySql(sql, requestedTopN) {
22151
22831
  }
22152
22832
  return `${withoutTerminator}\nLIMIT ${requestedTopN}`;
22153
22833
  }
22154
- 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') {
22155
22841
  const analysis = analyzeSqlReferences(sql, dialect);
22156
22842
  if (!analysis.parsed)
22157
22843
  return sql;
@@ -26290,6 +26976,14 @@ async function buildBlockStudioAiAssistSummary(projectRoot, action, candidate, v
26290
26976
  }
26291
26977
  }
26292
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;
26293
26987
  const settings = listProviderSettings(projectRoot);
26294
26988
  const activeProvider = getActiveProvider(projectRoot);
26295
26989
  // Subscription CLI providers (Claude Code / Codex) carry no API key — they're
@@ -26339,7 +27033,7 @@ async function createBlockStudioAssistProvider(projectRoot, requestedProvider) {
26339
27033
  // exactly the calls whose non-determinism it was recorded to remove. A local
26340
27034
  // baseline reproduced that: the same question blocked on one run and answered
26341
27035
  // on the next, and zero cassettes were written.
26342
- return await provider.available() ? applyEvalCassette(provider) : null;
27036
+ return await provider.available() ? applyEvalCassette(provider, projectRoot) : null;
26343
27037
  }
26344
27038
  /** Convert a governed answer's result payload into a bounded synthesis preview. */
26345
27039
  function agentResultToSynthesisPreview(result) {
@@ -29966,7 +30660,7 @@ function compactSqlForRunHistory(sql) {
29966
30660
  const clean = sql.replace(/\s+/g, ' ').trim();
29967
30661
  return clean.length > 1200 ? `${clean.slice(0, 1197)}...` : clean;
29968
30662
  }
29969
- function buildAgentSchemaContextFromContextPack(question, contextPack) {
30663
+ function buildAgentSchemaContextFromContextPack(question, contextPack, options = {}) {
29970
30664
  const byRelation = new Map();
29971
30665
  const objectsByKey = new Map(contextPack.objects.map((object) => [object.objectKey, object]));
29972
30666
  const upsert = (table) => {
@@ -30020,11 +30714,75 @@ function buildAgentSchemaContextFromContextPack(question, contextPack) {
30020
30714
  table,
30021
30715
  score: scoreAgentSchemaTable(table, tokens) + (shouldProbeValues ? scoreAgentValueProbeTable(table) : 0),
30022
30716
  }))
30023
- .filter((entry) => entry.table.columns.length > 0 && entry.score > 0)
30717
+ .filter((entry) => entry.table.columns.length > 0 && (options.includeUnscored || entry.score > 0))
30024
30718
  .sort((a, b) => b.score - a.score || a.table.relation.localeCompare(b.table.relation))
30025
- .slice(0, 12)
30719
+ .slice(0, Math.max(1, options.limit ?? 12))
30026
30720
  .map((entry) => entry.table);
30027
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
+ }
30028
30786
  function metadataObjectToAgentSchemaTable(object) {
30029
30787
  if (object.objectType === 'dbt_column' || object.objectType === 'runtime_column') {
30030
30788
  const relation = metadataPayloadString(object, 'relation');