@duckcodeailabs/dql-agent 1.6.33 → 1.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (184) hide show
  1. package/dist/agent-run-engine.d.ts +39 -8
  2. package/dist/agent-run-engine.d.ts.map +1 -1
  3. package/dist/agent-run-engine.js +169 -10
  4. package/dist/agent-run-engine.js.map +1 -1
  5. package/dist/agent-run-gates.d.ts.map +1 -1
  6. package/dist/agent-run-gates.js +21 -3
  7. package/dist/agent-run-gates.js.map +1 -1
  8. package/dist/agent-run-planner.d.ts.map +1 -1
  9. package/dist/agent-run-planner.js +14 -3
  10. package/dist/agent-run-planner.js.map +1 -1
  11. package/dist/agentic/answer-contract.d.ts +37 -0
  12. package/dist/agentic/answer-contract.d.ts.map +1 -0
  13. package/dist/agentic/answer-contract.js +47 -0
  14. package/dist/agentic/answer-contract.js.map +1 -0
  15. package/dist/agentic/tool-loop.d.ts +48 -0
  16. package/dist/agentic/tool-loop.d.ts.map +1 -0
  17. package/dist/agentic/tool-loop.js +181 -0
  18. package/dist/agentic/tool-loop.js.map +1 -0
  19. package/dist/agentic/toolset.d.ts +40 -0
  20. package/dist/agentic/toolset.d.ts.map +1 -0
  21. package/dist/agentic/toolset.js +265 -0
  22. package/dist/agentic/toolset.js.map +1 -0
  23. package/dist/answer-loop.d.ts +82 -6
  24. package/dist/answer-loop.d.ts.map +1 -1
  25. package/dist/answer-loop.js +799 -112
  26. package/dist/answer-loop.js.map +1 -1
  27. package/dist/app-builder.d.ts.map +1 -1
  28. package/dist/app-builder.js +18 -10
  29. package/dist/app-builder.js.map +1 -1
  30. package/dist/cascade/budgets.d.ts +20 -2
  31. package/dist/cascade/budgets.d.ts.map +1 -1
  32. package/dist/cascade/budgets.js +83 -45
  33. package/dist/cascade/budgets.js.map +1 -1
  34. package/dist/cascade/route-policy.d.ts +1 -1
  35. package/dist/cascade/route-policy.d.ts.map +1 -1
  36. package/dist/cascade/route-policy.js +6 -0
  37. package/dist/cascade/route-policy.js.map +1 -1
  38. package/dist/domain-context.d.ts +30 -0
  39. package/dist/domain-context.d.ts.map +1 -0
  40. package/dist/domain-context.js +68 -0
  41. package/dist/domain-context.js.map +1 -0
  42. package/dist/embeddings/provider.d.ts +67 -4
  43. package/dist/embeddings/provider.d.ts.map +1 -1
  44. package/dist/embeddings/provider.js +167 -6
  45. package/dist/embeddings/provider.js.map +1 -1
  46. package/dist/hints/correction-eval.d.ts +30 -0
  47. package/dist/hints/correction-eval.d.ts.map +1 -0
  48. package/dist/hints/correction-eval.js +63 -0
  49. package/dist/hints/correction-eval.js.map +1 -0
  50. package/dist/hints/git-store.d.ts +39 -3
  51. package/dist/hints/git-store.d.ts.map +1 -1
  52. package/dist/hints/git-store.js +172 -2
  53. package/dist/hints/git-store.js.map +1 -1
  54. package/dist/hints/staleness.d.ts +30 -0
  55. package/dist/hints/staleness.d.ts.map +1 -0
  56. package/dist/hints/staleness.js +38 -0
  57. package/dist/hints/staleness.js.map +1 -0
  58. package/dist/hints/store.d.ts.map +1 -1
  59. package/dist/hints/store.js +3 -32
  60. package/dist/hints/store.js.map +1 -1
  61. package/dist/hints/types.d.ts +41 -0
  62. package/dist/hints/types.d.ts.map +1 -1
  63. package/dist/hints/types.js +1 -0
  64. package/dist/hints/types.js.map +1 -1
  65. package/dist/index.d.ts +46 -10
  66. package/dist/index.d.ts.map +1 -1
  67. package/dist/index.js +180 -9
  68. package/dist/index.js.map +1 -1
  69. package/dist/intent-controller.d.ts.map +1 -1
  70. package/dist/intent-controller.js +15 -3
  71. package/dist/intent-controller.js.map +1 -1
  72. package/dist/kg/build.d.ts.map +1 -1
  73. package/dist/kg/build.js +196 -4
  74. package/dist/kg/build.js.map +1 -1
  75. package/dist/kg/sqlite-fts.d.ts +13 -0
  76. package/dist/kg/sqlite-fts.d.ts.map +1 -1
  77. package/dist/kg/sqlite-fts.js +82 -49
  78. package/dist/kg/sqlite-fts.js.map +1 -1
  79. package/dist/kg/types.d.ts +9 -2
  80. package/dist/kg/types.d.ts.map +1 -1
  81. package/dist/memory/fts-query.d.ts +37 -4
  82. package/dist/memory/fts-query.d.ts.map +1 -1
  83. package/dist/memory/fts-query.js +53 -12
  84. package/dist/memory/fts-query.js.map +1 -1
  85. package/dist/metadata/analysis-planner.d.ts.map +1 -1
  86. package/dist/metadata/analysis-planner.js +179 -28
  87. package/dist/metadata/analysis-planner.js.map +1 -1
  88. package/dist/metadata/analytical-policy.d.ts +51 -0
  89. package/dist/metadata/analytical-policy.d.ts.map +1 -0
  90. package/dist/metadata/analytical-policy.js +309 -0
  91. package/dist/metadata/analytical-policy.js.map +1 -0
  92. package/dist/metadata/block-adapt.d.ts +37 -0
  93. package/dist/metadata/block-adapt.d.ts.map +1 -0
  94. package/dist/metadata/block-adapt.js +89 -0
  95. package/dist/metadata/block-adapt.js.map +1 -0
  96. package/dist/metadata/block-fit.js +37 -4
  97. package/dist/metadata/block-fit.js.map +1 -1
  98. package/dist/metadata/catalog.d.ts +85 -1
  99. package/dist/metadata/catalog.d.ts.map +1 -1
  100. package/dist/metadata/catalog.js +674 -92
  101. package/dist/metadata/catalog.js.map +1 -1
  102. package/dist/metadata/dbt-first-safety.d.ts +19 -0
  103. package/dist/metadata/dbt-first-safety.d.ts.map +1 -0
  104. package/dist/metadata/dbt-first-safety.js +17 -0
  105. package/dist/metadata/dbt-first-safety.js.map +1 -0
  106. package/dist/metadata/example-match.d.ts +30 -0
  107. package/dist/metadata/example-match.d.ts.map +1 -0
  108. package/dist/metadata/example-match.js +69 -0
  109. package/dist/metadata/example-match.js.map +1 -0
  110. package/dist/metadata/grain-ledger.d.ts +51 -0
  111. package/dist/metadata/grain-ledger.d.ts.map +1 -0
  112. package/dist/metadata/grain-ledger.js +174 -0
  113. package/dist/metadata/grain-ledger.js.map +1 -0
  114. package/dist/metadata/metric-match.d.ts +21 -1
  115. package/dist/metadata/metric-match.d.ts.map +1 -1
  116. package/dist/metadata/metric-match.js +157 -21
  117. package/dist/metadata/metric-match.js.map +1 -1
  118. package/dist/metadata/sql-context-validation.d.ts.map +1 -1
  119. package/dist/metadata/sql-context-validation.js +42 -2
  120. package/dist/metadata/sql-context-validation.js.map +1 -1
  121. package/dist/metadata/sql-grounding.d.ts +4 -2
  122. package/dist/metadata/sql-grounding.d.ts.map +1 -1
  123. package/dist/metadata/sql-grounding.js +29 -4
  124. package/dist/metadata/sql-grounding.js.map +1 -1
  125. package/dist/metadata/sql-retrieval.d.ts +5 -2
  126. package/dist/metadata/sql-retrieval.d.ts.map +1 -1
  127. package/dist/metadata/sql-retrieval.js +28 -6
  128. package/dist/metadata/sql-retrieval.js.map +1 -1
  129. package/dist/propose/build-from-prompt.d.ts +34 -0
  130. package/dist/propose/build-from-prompt.d.ts.map +1 -1
  131. package/dist/propose/build-from-prompt.js +205 -19
  132. package/dist/propose/build-from-prompt.js.map +1 -1
  133. package/dist/propose/failure-analysis.d.ts +48 -0
  134. package/dist/propose/failure-analysis.d.ts.map +1 -0
  135. package/dist/propose/failure-analysis.js +71 -0
  136. package/dist/propose/failure-analysis.js.map +1 -0
  137. package/dist/propose/join-mining.d.ts +18 -0
  138. package/dist/propose/join-mining.d.ts.map +1 -0
  139. package/dist/propose/join-mining.js +74 -0
  140. package/dist/propose/join-mining.js.map +1 -0
  141. package/dist/providers/claude.d.ts.map +1 -1
  142. package/dist/providers/claude.js +36 -1
  143. package/dist/providers/claude.js.map +1 -1
  144. package/dist/providers/index.d.ts +2 -2
  145. package/dist/providers/index.d.ts.map +1 -1
  146. package/dist/providers/index.js +1 -1
  147. package/dist/providers/index.js.map +1 -1
  148. package/dist/providers/openai.d.ts.map +1 -1
  149. package/dist/providers/openai.js +34 -1
  150. package/dist/providers/openai.js.map +1 -1
  151. package/dist/providers/reasoning-effort.d.ts +27 -0
  152. package/dist/providers/reasoning-effort.d.ts.map +1 -1
  153. package/dist/providers/reasoning-effort.js +32 -0
  154. package/dist/providers/reasoning-effort.js.map +1 -1
  155. package/dist/providers/types.d.ts +2 -1
  156. package/dist/providers/types.d.ts.map +1 -1
  157. package/dist/research-loop.d.ts.map +1 -1
  158. package/dist/research-loop.js +9 -2
  159. package/dist/research-loop.js.map +1 -1
  160. package/dist/semantic-bridge/compose.d.ts.map +1 -1
  161. package/dist/semantic-bridge/compose.js +316 -25
  162. package/dist/semantic-bridge/compose.js.map +1 -1
  163. package/dist/skills/bootstrap.d.ts +57 -0
  164. package/dist/skills/bootstrap.d.ts.map +1 -0
  165. package/dist/skills/bootstrap.js +260 -0
  166. package/dist/skills/bootstrap.js.map +1 -0
  167. package/dist/skills/defaults.d.ts +2 -2
  168. package/dist/skills/defaults.js +2 -2
  169. package/dist/skills/domain-skills.d.ts +24 -0
  170. package/dist/skills/domain-skills.d.ts.map +1 -0
  171. package/dist/skills/domain-skills.js +76 -0
  172. package/dist/skills/domain-skills.js.map +1 -0
  173. package/dist/skills/loader.d.ts +56 -8
  174. package/dist/skills/loader.d.ts.map +1 -1
  175. package/dist/skills/loader.js +257 -41
  176. package/dist/skills/loader.js.map +1 -1
  177. package/dist/synthesize.d.ts.map +1 -1
  178. package/dist/synthesize.js +152 -16
  179. package/dist/synthesize.js.map +1 -1
  180. package/dist/tools/registry.d.ts +1 -1
  181. package/dist/tools/registry.d.ts.map +1 -1
  182. package/dist/tools/registry.js +54 -12
  183. package/dist/tools/registry.js.map +1 -1
  184. package/package.json +4 -4
@@ -14,22 +14,31 @@
14
14
  * step. Tests can mock the provider with a canned response and exercise the
15
15
  * full pipeline.
16
16
  */
17
- import { buildSkillBlockHints, buildSkillsPrompt, selectRelevantSkills } from './skills/loader.js';
17
+ import { describeDialectForPrompt, } from '@duckcodeailabs/dql-core';
18
+ import { buildSkillBlockHints, buildSkillMetricHints, buildSkillsPrompt, expandQuestionWithSkillVocabulary, selectRelevantSkills } from './skills/loader.js';
19
+ import { domainContextSearchDomains } from './domain-context.js';
18
20
  import { deriveGeneratedDraftSlug, renderGeneratedSqlDqlArtifact } from './metadata/drafts.js';
19
21
  import { buildAnalysisQuestionPlan } from './metadata/analysis-planner.js';
20
22
  import { certifiedFitAllowsTier1, evaluateCertifiedBlockFit } from './metadata/block-fit.js';
21
- import { buildGovernedMetricFirstSql, matchSemanticMetric, resolveGovernedMetricSql } from './metadata/metric-match.js';
23
+ import { buildGovernedMetricFirstSql, matchSemanticMetric, resolveGovernedMetricDefinition, resolveGovernedMetricSql } from './metadata/metric-match.js';
22
24
  import { decideAgentAction } from './intent-controller.js';
23
25
  import { createContextLedger } from './grounding/context-ledger.js';
24
26
  import { validateAnswerResultShape } from './answer-shape.js';
27
+ import { fanoutWarningsForSql } from './metadata/grain-ledger.js';
28
+ import { evaluateDbtFirstGeneratedSql } from './metadata/dbt-first-safety.js';
29
+ import { planAnalyticalPath } from './metadata/analytical-policy.js';
30
+ import { planCertifiedAdaptation } from './metadata/block-adapt.js';
25
31
  import { compactSqlSnippet, extractSimpleSelectShape, selectExpressionOutputName, } from './metadata/sql-shape.js';
26
32
  import { composeSemanticQueryForQuestion, composeSemanticQueryFromMembers } from './semantic-bridge/compose.js';
33
+ import { runAgenticToolLoop } from './agentic/tool-loop.js';
34
+ import { buildSemanticStageTools } from './agentic/toolset.js';
35
+ import { deriveAgenticTrust } from './agentic/answer-contract.js';
27
36
  import { selectSemanticMembersViaLlm } from './semantic-bridge/member-select.js';
28
37
  import { normalizeValueIndexText } from './grounding/value-index.js';
29
38
  import { cascadeTraceToEvidenceRouteSteps, createCascadeAnswerResult, createCascadeTrace, } from './cascade/cascade.js';
30
39
  import { shouldClarifyBeforeGeneration } from './cascade/triage.js';
31
40
  import { stampTrustLabel } from './trust/stamp.js';
32
- import { QUICK_PROMPT_CONTEXT_BUDGET, canUseLaneRepair, cascadeBudgetTrace, createCascadeBudgetState, promptContextBudgetForQuestion, proposalToolBudgetForQuestion, recordLaneRepair, } from './cascade/budgets.js';
41
+ import { QUICK_PROMPT_CONTEXT_BUDGET, canUseLaneRepair, cascadeBudgetTrace, createCascadeBudgetState, deepAlternativeCountForQuestion, promptContextBudgetForQuestion, proposalToolBudgetForQuestion, recordLaneRepair, } from './cascade/budgets.js';
33
42
  /**
34
43
  * Physical column names whose sampled runtime values include `value`. Used by the
35
44
  * semantic bridge to bind a filter literal to the dimension that actually carries
@@ -57,7 +66,7 @@ const EXECUTABLE_ARTIFACT_KINDS = ['block', 'dashboard', 'app', 'notebook'];
57
66
  // be cited, but they never produce the row-level answer to an analytical question,
58
67
  // so they must not terminate a data ask as a "certified answer" with no data.
59
68
  const NAVIGATION_ARTIFACT_KINDS = ['dashboard', 'app', 'notebook'];
60
- const BUSINESS_CONTEXT_KINDS = ['term', 'business_view'];
69
+ const BUSINESS_CONTEXT_KINDS = ['term', 'business_view', 'domain', 'skill', 'relationship', 'contract', 'domain_export', 'domain_import', 'conformance', 'evaluation'];
61
70
  const ARTIFACT_KINDS = [...EXECUTABLE_ARTIFACT_KINDS, ...BUSINESS_CONTEXT_KINDS];
62
71
  const SEMANTIC_KINDS = ['metric', 'dimension', 'measure', 'entity', 'semantic_model', 'saved_query'];
63
72
  const MANIFEST_KINDS = ['dbt_model', 'dbt_source'];
@@ -69,6 +78,46 @@ function refusalCodeForValidation(code) {
69
78
  return 'ambiguous';
70
79
  return 'model_declined';
71
80
  }
81
+ /**
82
+ * An analytical-policy result is never a generic grounding gap. A missing
83
+ * relation/path can enter the bounded exploratory lane; every other result is
84
+ * an explicit governance boundary that the host must surface without retrying
85
+ * the same candidate or asking the user a misleading clarification question.
86
+ */
87
+ function refusalCodeForAnalyticalPolicy(code, hasExploratoryCandidate = false) {
88
+ if (hasExploratoryCandidate || code === 'unbound_relation' || code === 'unplanned_join' || code === 'relationship_not_certified') {
89
+ return 'grounding_gap';
90
+ }
91
+ return 'policy_blocked';
92
+ }
93
+ /**
94
+ * The governed guard is intentionally strict. Only the two outcomes that mean
95
+ * "this repository has not modeled this join yet" may be handed to a host's
96
+ * exploratory lane. All other decisions are explicit governance/safety
97
+ * denials and must remain terminal in this loop.
98
+ */
99
+ function exploratoryCandidateFromDbtFirstGuard(sql, decision) {
100
+ const reason = decision.code;
101
+ if (decision.safe || (reason !== 'unbound_relation' && reason !== 'unplanned_join' && reason !== 'relationship_not_certified')) {
102
+ return undefined;
103
+ }
104
+ // `unplanned_join` can also mean the model ignored an existing certified
105
+ // relationship plan. That is a governed-query error, not missing modeling,
106
+ // and must remain blocked. It is exploratory only when no plan was resolved.
107
+ if (decision.code === 'unplanned_join' && decision.relationshipIds.length > 0) {
108
+ return undefined;
109
+ }
110
+ return {
111
+ kind: 'dbt_grounded_exploration',
112
+ reason,
113
+ sql,
114
+ message: decision.message
115
+ ?? 'This query is grounded in dbt metadata but has no certified DQL relationship path yet.',
116
+ modeledEntityIds: decision.entities,
117
+ relationshipIds: decision.relationshipIds,
118
+ executionStatus: 'not_executed',
119
+ };
120
+ }
72
121
  function formatOffendingValidationToken(offending) {
73
122
  if (!offending?.relation && !offending?.column)
74
123
  return '';
@@ -157,10 +206,13 @@ export async function answer(input) {
157
206
  },
158
207
  isFollowUp: Boolean(input.followUp),
159
208
  });
209
+ const trustLabelInfo = stampTrustLabel(result);
160
210
  return {
161
211
  ...publicResult,
212
+ domainContext: input.domainContext,
162
213
  intentDecision,
163
- trustLabelInfo: stampTrustLabel(result),
214
+ trustLabelInfo,
215
+ provenanceFooter: buildProvenanceFooter(result, trustLabelInfo),
164
216
  cascade: publicResult.cascade ?? createCascadeAnswerResult({
165
217
  routeTier: chosenRoute.tier,
166
218
  label: chosenRoute.label,
@@ -178,7 +230,15 @@ export async function answer(input) {
178
230
  // Stamp the SELECTED skills that shaped the answer (transparency). Computed
179
231
  // here so every return site inside runAnswerLoop stays untouched.
180
232
  appliedSkills: result.appliedSkills ??
181
- selectRelevantSkills(input.skills ?? [], input.question, { userId: input.userId ?? null }).map((s) => ({
233
+ selectRelevantSkills(input.skills ?? [], input.question, {
234
+ userId: input.userId ?? null,
235
+ modelAreaIds: input.domainContext?.modelAreaId ? [input.domainContext.modelAreaId] : [],
236
+ domains: Array.from(new Set([
237
+ ...domainContextSearchDomains(input.domainContext),
238
+ ...(input.domain ? [input.domain] : []),
239
+ ...(input.contextPack?.objects ?? []).slice(0, 20).flatMap((object) => object.domain ? [object.domain] : []),
240
+ ])),
241
+ }).map((s) => ({
182
242
  id: s.id,
183
243
  description: s.description,
184
244
  })),
@@ -205,6 +265,12 @@ function cascadeExecutionStatus(result) {
205
265
  */
206
266
  function deriveAiRoute(result, metricMatch) {
207
267
  if (result.kind === 'no_answer') {
268
+ if (result.exploratoryCandidate) {
269
+ return {
270
+ tier: 'no_answer',
271
+ label: 'Governed SQL stopped at missing relationship modeling; a DBT-grounded exploratory candidate is ready for bounded validation.',
272
+ };
273
+ }
208
274
  return { tier: 'no_answer', label: 'No governed answer — needs more context or review.' };
209
275
  }
210
276
  if (result.kind === 'certified') {
@@ -237,27 +303,60 @@ function deriveAiRoute(result, metricMatch) {
237
303
  }
238
304
  async function runAnswerLoop(input) {
239
305
  const { question, userId, domain, provider, kg, skills = [], blockHints = [] } = input;
306
+ // AGT-004: with no explicit domain selection, let direct question evidence
307
+ // establish a narrow prompt boundary before broad retrieval can introduce an
308
+ // unrelated domain. This is deliberately a prompt/retrieval preference, not
309
+ // an authorization decision; the final manifest guard remains authoritative.
310
+ const directQuestionEntityIds = input.manifest
311
+ ? inferAnalyticalEntityIds(question, [], input.manifest)
312
+ : [];
313
+ const directQuestionDomains = input.manifest?.modeling
314
+ ? Array.from(new Set(directQuestionEntityIds.map((id) => input.manifest.modeling.entities[id]?.domain).filter((value) => Boolean(value))))
315
+ : [];
316
+ const hasExplicitDomainScope = Boolean(input.domain || input.domainContext?.activeDomain);
317
+ const questionDomainScope = hasExplicitDomainScope ? [] : directQuestionDomains;
318
+ const promptContextPack = questionDomainScope.length > 0
319
+ ? scopeContextPackToQuestionDomains(input.contextPack, questionDomainScope, input.manifest)
320
+ : input.contextPack;
240
321
  // Select the RELEVANT skills (not all) for this question; keep pinned project
241
322
  // skills (SQL conventions). Block hints still come from the full set so a
242
323
  // preferred-block mapping is never lost.
243
- const selectedSkills = selectRelevantSkills(skills, question, { userId: userId ?? null });
324
+ const authorizedDomains = domainContextSearchDomains(input.domainContext);
325
+ const inferredDomains = Array.from(new Set([
326
+ ...authorizedDomains,
327
+ ...questionDomainScope,
328
+ ...(domain ? [domain] : []),
329
+ ...(questionDomainScope.length === 0
330
+ ? (input.contextPack?.objects ?? []).slice(0, 20).flatMap((object) => object.domain ? [object.domain] : [])
331
+ : []),
332
+ ]));
333
+ const selectedSkills = selectRelevantSkills(skills, question, {
334
+ userId: userId ?? null,
335
+ domains: inferredDomains,
336
+ modelAreaIds: input.domainContext?.modelAreaId ? [input.domainContext.modelAreaId] : [],
337
+ });
244
338
  const effectiveBlockHints = Array.from(new Set([
245
339
  ...blockHints,
246
- ...buildSkillBlockHints(skills, userId ?? null),
340
+ // Only selected skills may influence block ranking. Previously a preferred
341
+ // block from any active but unrelated domain skill could jump to the front.
342
+ ...buildSkillBlockHints(selectedSkills, userId ?? null),
247
343
  ]));
344
+ const effectiveMetricHints = buildSkillMetricHints(selectedSkills, userId ?? null);
345
+ const semanticQuestion = expandQuestionWithSkillVocabulary(question, selectedSkills, userId ?? null);
248
346
  const followUpSourceBlock = input.followUp?.sourceBlockName
249
347
  ? kg.getNode(`block:${input.followUp.sourceBlockName}`)
250
348
  : null;
251
349
  const excludedArtifactIds = input.followUp?.kind === 'drilldown' && followUpSourceBlock
252
350
  ? new Set([followUpSourceBlock.nodeId])
253
351
  : undefined;
254
- const executableArtifactHits = kg.search({ query: question, domain, kinds: EXECUTABLE_ARTIFACT_KINDS, limit: 10 });
255
- const businessHits = kg.search({ query: question, domain, kinds: BUSINESS_CONTEXT_KINDS, limit: 10 });
352
+ const searchScope = authorizedDomains.length > 0 ? { domains: authorizedDomains } : { domain };
353
+ const executableArtifactHits = kg.search({ query: question, ...searchScope, kinds: EXECUTABLE_ARTIFACT_KINDS, limit: 10 });
354
+ const businessHits = kg.search({ query: question, ...searchScope, kinds: BUSINESS_CONTEXT_KINDS, limit: 10 });
256
355
  const artifactHits = mergeHits(executableArtifactHits, businessHits).slice(0, 12);
257
- const semanticHits = kg.search({ query: question, domain, kinds: SEMANTIC_KINDS, limit: 12 });
258
- const manifestHits = kg.search({ query: question, domain, kinds: MANIFEST_KINDS, limit: 12 });
356
+ const semanticHits = kg.search({ query: question, ...searchScope, kinds: SEMANTIC_KINDS, limit: 12 });
357
+ const manifestHits = kg.search({ query: question, ...searchScope, kinds: MANIFEST_KINDS, limit: 12 });
259
358
  const considered = mergeHits(artifactHits, semanticHits, manifestHits, kg.search({ query: question, domain, limit: 10 })).slice(0, 30);
260
- const schemaContext = schemaContextWithAllowedSqlContext(input.schemaContext ?? [], input.contextPack);
359
+ const schemaContext = schemaContextWithAllowedSqlContext(schemaContextWithinQuestionScope(input.schemaContext ?? [], input.contextPack, promptContextPack), promptContextPack);
261
360
  const catalogRoute = input.contextPack?.routeDecision;
262
361
  const questionPlan = input.contextPack?.questionPlan?.requestedShape
263
362
  ? input.contextPack.questionPlan
@@ -272,6 +371,19 @@ async function runAnswerLoop(input) {
272
371
  schemaContext,
273
372
  });
274
373
  let intent = catalogRoute ? agentIntentFromCatalogRoute(catalogRoute) : fallbackIntent;
374
+ // Resolve a governed metric before accepting a catalog-proposed certified
375
+ // block. Catalog token overlap can call a generic word such as "total" an
376
+ // exact block match (for example total tax → total revenue); the metric match
377
+ // is the precise measure signal that prevents that wrong Tier-1 shortcut.
378
+ const semanticMetricNodes = collectMetricCandidates(semanticHits, considered, kg);
379
+ for (const metric of effectiveMetricHints) {
380
+ const node = kg.getNode(`metric:${metric}`);
381
+ if (node && !semanticMetricNodes.some((candidate) => candidate.nodeId === node.nodeId))
382
+ semanticMetricNodes.push(node);
383
+ }
384
+ let semanticMetricMatch = await matchSemanticMetric(semanticQuestion, semanticMetricNodes, {
385
+ measureTerms: [...questionPlan.requestedShape.measures, ...questionPlan.metricTerms],
386
+ }).catch(() => null);
275
387
  // Stage 1: certified artifact match. Blocks can be executed; dashboards,
276
388
  // Apps, and notebooks are returned as governed citations/navigation targets.
277
389
  const drilldownCertifiedHit = input.followUp?.kind === 'drilldown'
@@ -284,19 +396,29 @@ async function runAnswerLoop(input) {
284
396
  kg,
285
397
  })
286
398
  : null;
287
- const artifactHit = drilldownCertifiedHit ?? (shouldUseCertifiedRoute(catalogRoute, intent)
399
+ const shouldTryCertifiedRoute = shouldUseCertifiedRoute(catalogRoute, intent);
400
+ const catalogCertifiedHit = shouldTryCertifiedRoute
288
401
  ? certifiedHitFromContextPack(input.contextPack, kg)
289
- ?? pickCertifiedArtifact({
290
- artifactHits,
291
- executableArtifactHits,
292
- businessHits,
293
- question,
294
- questionPlan,
295
- blockHints: input.followUp?.kind === 'drilldown' ? [] : effectiveBlockHints,
296
- excludedArtifactIds,
297
- kg,
298
- })
299
- : null);
402
+ : null;
403
+ const unsafeCatalogCertifiedHit = catalogCertifiedHit?.node.kind === 'block' && semanticMetricMatch
404
+ && !hasCertifiedNodeFit(question, questionPlan, catalogCertifiedHit.node)
405
+ ? null
406
+ : catalogCertifiedHit;
407
+ const fallbackCertifiedHit = shouldTryCertifiedRoute ? pickCertifiedArtifact({
408
+ artifactHits,
409
+ executableArtifactHits,
410
+ businessHits,
411
+ question,
412
+ questionPlan,
413
+ blockHints: input.followUp?.kind === 'drilldown' ? [] : effectiveBlockHints,
414
+ excludedArtifactIds,
415
+ kg,
416
+ }) : null;
417
+ let artifactHit = drilldownCertifiedHit ?? unsafeCatalogCertifiedHit
418
+ ?? (catalogCertifiedHit ? null : fallbackCertifiedHit);
419
+ // Certified remains first when it actually covers the question. If the
420
+ // retrieved block does not fit but a governed semantic metric does, never
421
+ // let the broad catalog match pre-empt Lane 2.
300
422
  // A certified TERM / BUSINESS VIEW is documentation, not data: it can be the
301
423
  // terminal answer only for a definition-style question with no data ask. A
302
424
  // question that requests a data shape (dimensions/measures/outputs/top-N) or
@@ -322,30 +444,80 @@ async function runAnswerLoop(input) {
322
444
  if (NAVIGATION_ARTIFACT_KINDS.includes(artifactHit.node.kind)) {
323
445
  return objectNameInQuestion(question, artifactHit.node);
324
446
  }
325
- // Cold questions fall through only on STRONG data-shape signals — bare
326
- // measure/dimension words are normal in definition asks ("what is revenue
327
- // health?") and must keep answering from the certified business context.
328
- // "Strong" = outputs requested BEYOND the measure/dimension words themselves
329
- // (e.g. product_name), explicit filters, or a top-N.
330
- const requested = questionPlan.requestedShape;
331
- const nonTrivialOutputs = requested.requiredOutputs.filter((output) => !requested.measures.includes(output) && !requested.dimensions.includes(output));
332
- const strongShape = nonTrivialOutputs.length > 0
333
- || requested.filters.length > 0
334
- || Boolean(requested.topN);
335
- return !strongShape;
447
+ // Terms, skills, domains, relationships, and business views are grounding
448
+ // documents, not executable data. They may terminate only when the user
449
+ // explicitly names the object in a definition request ("what is Revenue
450
+ // Health?"). A broad lexical match must never turn an analytical question
451
+ // into a no-data Certified answer merely because it starts with "what is".
452
+ return isBusinessDefinitionQuestion(question)
453
+ && objectNameInQuestion(question, artifactHit.node);
336
454
  })();
337
455
  if (artifactHit && businessContextTerminal) {
338
456
  let result;
339
457
  let executionError;
340
458
  if (artifactHit.node.kind === 'block' && input.executeCertifiedBlock) {
341
459
  try {
342
- result = await input.executeCertifiedBlock(artifactHit.node);
460
+ result = await input.executeCertifiedBlock(artifactHit.node, { question });
343
461
  result = trimResultToRequestedTopN(result, questionPlan);
344
462
  }
345
463
  catch (err) {
346
464
  executionError = err instanceof Error ? err.message : String(err);
347
465
  }
348
466
  }
467
+ const missingParameters = /^I need values for:\s*(.+?)\.?$/i.exec(executionError ?? '')?.[1]
468
+ ?.split(',')
469
+ .map((value) => value.trim())
470
+ .filter(Boolean) ?? [];
471
+ if (missingParameters.length > 0) {
472
+ const text = `The certified block "${artifactHit.node.name}" needs ${missingParameters.join(', ')} before it can run. Please provide ${missingParameters.length === 1 ? 'that value' : 'those values'}; I will reuse the same certified block.`;
473
+ const citations = [{
474
+ nodeId: artifactHit.node.nodeId,
475
+ kind: artifactHit.node.kind,
476
+ name: artifactHit.node.name,
477
+ gitSha: artifactHit.node.gitSha,
478
+ sourceTier: 'certified_artifact',
479
+ provenance: artifactHit.node.provenance,
480
+ }];
481
+ const analysisPlan = buildAnalysisPlan({
482
+ question,
483
+ intent: 'clarify',
484
+ routeReason: 'A certified block matched, but a required values-only parameter is unresolved.',
485
+ selectedNodes: [artifactHit.node],
486
+ schemaContext,
487
+ assumptions: [`Required parameter values: ${missingParameters.join(', ')}.`],
488
+ });
489
+ return {
490
+ kind: 'no_answer',
491
+ sourceTier: 'certified_artifact',
492
+ certification: 'analyst_review_required',
493
+ reviewStatus: 'none',
494
+ confidence: 0.95,
495
+ text,
496
+ answer: text,
497
+ block: artifactHit.node,
498
+ executionError,
499
+ sourceCertifiedBlock: artifactHit.node.name,
500
+ contextPackId: input.contextPack?.id,
501
+ citations,
502
+ memoryContext: input.memoryContext,
503
+ analysisPlan,
504
+ evidence: buildNoAnswerEvidence({
505
+ question,
506
+ reason: text,
507
+ artifactHits,
508
+ businessHits,
509
+ semanticHits,
510
+ manifestHits,
511
+ considered,
512
+ memoryContext: input.memoryContext ?? [],
513
+ analysisPlan,
514
+ budgetTrace: cascadeBudgetTrace(repairBudgetState),
515
+ }),
516
+ contextPack: input.contextPack,
517
+ considered,
518
+ providerUsed: provider.name,
519
+ };
520
+ }
349
521
  const resultShapeWarnings = result ? validateAnswerResultShape(questionPlan, result).warnings : [];
350
522
  // When a certified block's execution was ATTEMPTED and FAILED, the answer
351
523
  // cannot wear the certified badge — a failed run has no data to stand behind.
@@ -429,8 +601,6 @@ async function runAnswerLoop(input) {
429
601
  // a governed metric can answer. Match by name + synonyms + measure family + hybrid
430
602
  // rank over the FTS semantic hits, then ALL metric KG nodes (revenue ⇄
431
603
  // cumulative_revenue). Certified-first is still preserved (checked above).
432
- const semanticMetricNodes = collectMetricCandidates(semanticHits, considered, kg);
433
- let semanticMetricMatch = await matchSemanticMetric(question, semanticMetricNodes).catch(() => null);
434
604
  const clarifyBeforeGeneration = shouldClarifyBeforeGeneration({
435
605
  intent,
436
606
  routeDecision: catalogRoute,
@@ -506,10 +676,28 @@ async function runAnswerLoop(input) {
506
676
  const matchedMetricHit = semanticMetricMatch
507
677
  ? [{ node: semanticMetricMatch.metric, score: Math.max(semanticMetricMatch.score, CERTIFIED_HIT_THRESHOLD) }]
508
678
  : [];
679
+ // Reserve prompt slots per source instead of appending semantic hits LAST and
680
+ // truncating the whole list to 14 — the old behavior dropped semantic-model hits
681
+ // out of the generation prompt entirely whenever artifact/business hits filled
682
+ // the budget first, so even a question a semantic metric could answer never saw
683
+ // the metric. Each group gets a guaranteed minimum; leftover budget is filled
684
+ // round-robin. See interleaveContextHits.
509
685
  const contextHits = activeTier === 'semantic_layer'
510
- ? [...matchedMetricHit, ...trustedArtifactContext, ...reviewRequiredArtifactHits, ...businessHits.slice(0, 4), ...semanticHits, ...manifestHits].slice(0, 14)
511
- : [...trustedArtifactContext, ...reviewRequiredArtifactHits, ...businessHits.slice(0, 4), ...manifestHits].slice(0, 14);
512
- const contextNodes = mergeNodes(followUpSourceBlock && input.followUp?.kind === 'drilldown' ? [followUpSourceBlock] : [], (contextHits.length > 0 ? contextHits : considered.slice(0, 6)).map((h) => h.node));
686
+ ? interleaveContextHits([
687
+ { hits: matchedMetricHit, reserve: matchedMetricHit.length },
688
+ { hits: semanticHits, reserve: 5 },
689
+ { hits: trustedArtifactContext, reserve: 3 },
690
+ { hits: reviewRequiredArtifactHits, reserve: 2 },
691
+ { hits: businessHits.slice(0, 4), reserve: 2 },
692
+ { hits: manifestHits, reserve: 2 },
693
+ ], 14)
694
+ : interleaveContextHits([
695
+ { hits: trustedArtifactContext, reserve: 5 },
696
+ { hits: reviewRequiredArtifactHits, reserve: 3 },
697
+ { hits: businessHits.slice(0, 4), reserve: 3 },
698
+ { hits: manifestHits, reserve: 3 },
699
+ ], 14);
700
+ const contextNodes = mergeNodes(followUpSourceBlock && input.followUp?.kind === 'drilldown' ? [followUpSourceBlock] : [], (contextHits.length > 0 ? contextHits : considered.slice(0, 6)).map((h) => h.node)).filter((node) => questionDomainScope.length === 0 || !node.domain || questionDomainScope.includes(node.domain));
513
701
  const kgJoinPathHints = buildKgJoinPathHints(kg, contextNodes, questionPlan);
514
702
  const contextBlocks = contextNodes.filter((n) => n.kind === 'block');
515
703
  const contextBusiness = contextNodes.filter((n) => BUSINESS_CONTEXT_KINDS.includes(n.kind));
@@ -525,9 +713,32 @@ async function runAnswerLoop(input) {
525
713
  const skillsPrompt = buildSkillsPrompt(selectedSkills, userId ?? null);
526
714
  if (skillsPrompt)
527
715
  messages.push({ role: 'system', content: skillsPrompt });
716
+ const analyticalPlan = input.manifest
717
+ ? planAnalyticalPath(input.manifest, {
718
+ entityIds: inferAnalyticalEntityIds(question, contextNodes, input.manifest),
719
+ ownerDomain: input.domainContext?.activeDomain ?? input.domain,
720
+ purpose: input.domainContext?.purpose,
721
+ domainContext: input.domainContext,
722
+ })
723
+ : undefined;
724
+ const analyticalPlanPrompt = renderAnalyticalPlanPrompt(analyticalPlan);
725
+ if (analyticalPlanPrompt)
726
+ messages.push({ role: 'system', content: analyticalPlanPrompt });
727
+ if (questionDomainScope.length > 0) {
728
+ messages.push({
729
+ role: 'system',
730
+ content: [
731
+ 'QUESTION DOMAIN BOUNDARY (authoritative for this generation):',
732
+ `The question is directly grounded in: ${questionDomainScope.join(', ')}.`,
733
+ 'Use only relations and business context from those domains plus unscoped runtime/dbt relations supplied below.',
734
+ 'Do not introduce, search for, or join another domain (including cross-domain acquisition/attribution paths) unless the user explicitly asks for that business concept or supplies a domain/purpose.',
735
+ 'If the required relation is not modeled in this domain, prefer the bounded DBT-grounded exploratory candidate over an unrelated cross-domain relationship.',
736
+ ].join('\n'),
737
+ });
738
+ }
528
739
  messages.push({
529
740
  role: 'system',
530
- content: renderContextPrompt(contextBlocks, contextBusiness, contextOther, activeTier, input.memoryContext ?? [], input.extraContext, input.followUp, schemaContext, intent, input.contextPack, input.conversationSnapshot, kgJoinPathHints, promptBudget),
741
+ content: renderContextPrompt(contextBlocks, contextBusiness, contextOther, activeTier, input.memoryContext ?? [], input.extraContext, input.followUp, schemaContext, intent, promptContextPack, input.conversationSnapshot, kgJoinPathHints, promptBudget, input.semanticDriver),
531
742
  });
532
743
  messages.push({ role: 'user', content: question });
533
744
  // ── Tier 2: semantic-layer metrics + dimensions (governed hierarchy) ──────
@@ -611,14 +822,55 @@ async function runAnswerLoop(input) {
611
822
  contextPack: input.contextPack,
612
823
  schemaContext,
613
824
  });
825
+ // W2.2 — certified-block adaptation lane. When a certified block is context-only
826
+ // ONLY because the question adds exactly one filter whose value maps to a column
827
+ // the block already outputs, adapt the certified SQL (wrap + filter its result)
828
+ // instead of regenerating from scratch. It executes through the governed path
829
+ // (pre-validated, since the wrapper only restricts an already-certified result)
830
+ // and is labeled BELOW certified. Falls through to generation on any miss.
831
+ let certifiedAdaptation;
832
+ if (!semanticBridgeAnswer && !metricFirst && input.executeGeneratedSql) {
833
+ const fit = input.contextPack?.routeDecision?.blockFit;
834
+ const sourceBlock = input.contextPack?.allowedSqlContext?.sourceBlockSql?.[0];
835
+ if (fit && sourceBlock?.sql) {
836
+ const shape = extractSimpleSelectShape(sourceBlock.sql);
837
+ const blockOutputs = shape
838
+ ? shape.selectExpressions.map((expression) => selectExpressionOutputName(expression)).filter((name) => Boolean(name))
839
+ : [];
840
+ certifiedAdaptation = planCertifiedAdaptation({
841
+ blockFit: fit,
842
+ certifiedSql: sourceBlock.sql,
843
+ blockName: sourceBlock.name,
844
+ blockOutputs,
845
+ resolveFilterColumn: (value) => resolveFilterValueColumns(value, schemaContext),
846
+ }) ?? undefined;
847
+ }
848
+ }
614
849
  let proposed = '';
615
850
  let parsed;
616
851
  const proposalToolCalls = [...semanticBridgeToolCalls];
852
+ // Stage-B toolset: the host's warehouse/validation tools PLUS the answer loop's
853
+ // own governed semantic tools (search_semantic_layer, compile_semantic_query,
854
+ // scan_manifest), so tool-driven generation can compile governed SQL itself and
855
+ // grep the live graph — across every provider (native or text-protocol). Every
856
+ // governed compile is recorded so the answer can be labeled governed, not
857
+ // hand-written, downstream (deriveAgenticTrust).
858
+ const compiledSemanticRecords = [];
859
+ const stageBTools = [
860
+ ...(input.answerLoopTools ?? []),
861
+ ...buildSemanticStageTools({
862
+ semanticLayer: input.semanticLayer,
863
+ kg,
864
+ driver: input.semanticDriver,
865
+ tableMapping: input.semanticTableMapping,
866
+ onCompiled: (record) => compiledSemanticRecords.push(record),
867
+ }),
868
+ ];
617
869
  if (semanticBridgeAnswer) {
618
870
  governedMetricAnswer = true;
619
871
  parsed = {
620
872
  sql: semanticBridgeAnswer.sql,
621
- text: `Answered from governed semantic metric${semanticBridgeAnswer.metrics.length === 1 ? '' : 's'} ${semanticBridgeAnswer.metrics.join(', ')}${semanticBridgeAnswer.dimensions.length > 0 ? ` by ${semanticBridgeAnswer.dimensions.join(', ')}` : ''}. This result is uncertified until reviewed and promoted.`,
873
+ text: `Answered from governed semantic metric${semanticBridgeAnswer.metrics.length === 1 ? '' : 's'} ${semanticBridgeAnswer.metrics.join(', ')}${semanticBridgeAnswer.dimensions.length > 0 ? ` by ${semanticBridgeAnswer.dimensions.join(', ')}` : ''}. The semantic compiler owns this query; saving it as a reusable certified block still requires review.`,
622
874
  viz: semanticBridgeAnswer.dimensions.length === 0 && !semanticBridgeAnswer.timeDimension ? 'single_value' : undefined,
623
875
  };
624
876
  }
@@ -627,16 +879,39 @@ async function runAnswerLoop(input) {
627
879
  governedMetricAnswer = true;
628
880
  parsed = {
629
881
  sql: metricFirst.sql,
630
- text: `Answered from the governed metric ${metricFirst.metric.name}${metricFirst.dimensions.length > 0 ? ` by ${metricFirst.dimensions.join(', ')}` : ''}. This result is uncertified until reviewed and promoted.`,
882
+ text: `Answered from the governed metric ${metricFirst.metric.name}${metricFirst.dimensions.length > 0 ? ` by ${metricFirst.dimensions.join(', ')}` : ''}. The semantic definition owns the calculation; reusable block certification remains a separate review.`,
631
883
  viz: metricFirst.dimensions.length === 0 ? 'single_value' : undefined,
632
884
  };
633
885
  }
886
+ else if (certifiedAdaptation) {
887
+ // Pre-validated governed path (the wrapper only restricts a certified result).
888
+ governedMetricAnswer = true;
889
+ parsed = {
890
+ sql: certifiedAdaptation.sql,
891
+ text: certifiedAdaptation.provenance,
892
+ };
893
+ }
634
894
  else {
895
+ // Tier 2.5 — METRIC-ANCHORED generation. A governed metric matched this question,
896
+ // but its semantic definition couldn't compose the exact requested shape (typically
897
+ // the breakdown needs a join the semantic layer doesn't own — e.g. a location-grain
898
+ // tax metric asked for "by product"). Rather than throw the metric away and let the
899
+ // model reinvent the aggregation from scratch, inject the metric's CERTIFIED
900
+ // definition as a required building block, so the generated SQL reuses the trusted
901
+ // measure and only generates the join/grouping around it. Keeps the number
902
+ // consistent with the governed metric. Degrades to plain generation when the metric
903
+ // has no resolvable definition. The grain ledger + validation still gate the output.
904
+ const metricAnchor = semanticMetricMatch && input.semanticLayer
905
+ ? resolveGovernedMetricDefinition(semanticMetricMatch.metric, semanticMetricNodes, input.semanticLayer)
906
+ : undefined;
907
+ const generationMessages = metricAnchor
908
+ ? [...messages, { role: 'system', content: metricAnchorInstruction(metricAnchor.metric.name, metricAnchor.def) }]
909
+ : messages;
635
910
  try {
636
911
  proposed = await generateProposalWithOptionalTools({
637
912
  provider,
638
- messages,
639
- tools: input.answerLoopTools,
913
+ messages: generationMessages,
914
+ tools: stageBTools,
640
915
  questionPlan,
641
916
  intent,
642
917
  signal: input.signal,
@@ -676,6 +951,35 @@ async function runAnswerLoop(input) {
676
951
  };
677
952
  }
678
953
  parsed = parseProposal(proposed);
954
+ // Surface the tier choice (WS3): when generation was anchored on a governed
955
+ // metric, say so, so the answer reads as "reused the certified measure" rather
956
+ // than an opaque hand-written query.
957
+ if (metricAnchor && parsed.sql) {
958
+ const anchorNote = `Computed using the governed metric ${metricAnchor.metric.name}'s certified definition, joined to add the requested breakdown. Review-required (not itself certified).`;
959
+ parsed = { ...parsed, text: parsed.text ? `${parsed.text}\n\n${anchorNote}` : anchorNote };
960
+ }
961
+ // Stage-B governed promotion: when the model drove compile_semantic_query and
962
+ // adopted its output VERBATIM (deriveAgenticTrust — exact match only), the SQL
963
+ // is governed semantic SQL the compiler owns and already validated, not
964
+ // hand-written. Treat it like the deterministic governed path: skip the
965
+ // hallucination guard (governedMetricAnswer) and note the provenance. Any edit
966
+ // to the compiled SQL falls back to generated/review-required by construction.
967
+ if (!governedMetricAnswer && parsed.sql) {
968
+ const trust = deriveAgenticTrust(parsed.sql, compiledSemanticRecords);
969
+ if (trust.tier === 'semantic_metric' && trust.compiled) {
970
+ governedMetricAnswer = true;
971
+ const compiled = trust.compiled;
972
+ const governedNote = `Answered from governed semantic metric${compiled.metrics.length === 1 ? '' : 's'} ${compiled.metrics.join(', ')}${compiled.dimensions.length > 0 ? ` by ${compiled.dimensions.join(', ')}` : ''} (compiled via the semantic layer). Reusable block certification remains a separate review.`;
973
+ parsed = { ...parsed, text: parsed.text ? `${parsed.text}\n\n${governedNote}` : governedNote };
974
+ proposalToolCalls.push({
975
+ name: 'compile_semantic_query',
976
+ status: 'checked',
977
+ inputSummary: `metrics: ${compiled.metrics.join(', ')}${compiled.dimensions.length ? `; by ${compiled.dimensions.join(', ')}` : ''}`,
978
+ outputSummary: 'Model-selected semantic members compiled to governed SQL',
979
+ order: proposalToolCalls.length + 1,
980
+ });
981
+ }
982
+ }
679
983
  }
680
984
  let deepCandidateResult;
681
985
  let deepCandidateExecutionError;
@@ -692,6 +996,7 @@ async function runAnswerLoop(input) {
692
996
  executeGeneratedSql: input.executeGeneratedSql,
693
997
  signal: input.signal,
694
998
  reasoningEffort: input.reasoningEffort,
999
+ maxAlternatives: deepAlternativeCountForQuestion(questionPlan, intent),
695
1000
  });
696
1001
  if (selection.selected) {
697
1002
  proposed = selection.selected.raw;
@@ -725,7 +1030,7 @@ async function runAnswerLoop(input) {
725
1030
  parsed = {
726
1031
  sql: resolved.sql,
727
1032
  text: parsed.text ||
728
- `Answered from the governed metric ${resolved.metric.name}. This result is uncertified until reviewed and promoted.`,
1033
+ `Answered from the governed metric ${resolved.metric.name}. The semantic definition owns the calculation; reusable block certification remains a separate review.`,
729
1034
  viz: parsed.viz ?? 'single_value',
730
1035
  };
731
1036
  }
@@ -746,12 +1051,12 @@ async function runAnswerLoop(input) {
746
1051
  || (input.contextPack?.allowedSqlContext?.relations.length ?? 0) > 0
747
1052
  || (input.contextPack?.allowedSqlContext?.sourceBlockSql.length ?? 0) > 0
748
1053
  || contextBlocks.length > 0;
749
- if (!parsed.sql && !governedMetricAnswer && wantsGeneratedData && hasGeneratableContext) {
1054
+ if (!parsed.sql && !governedMetricAnswer && wantsGeneratedData && hasGeneratableContext && analyticalPlan?.safe !== false) {
750
1055
  try {
751
1056
  proposed = await generateProposalWithOptionalTools({
752
1057
  provider,
753
1058
  messages: [...messages, { role: 'system', content: FORCE_JOIN_INSTRUCTION }],
754
- tools: input.answerLoopTools,
1059
+ tools: stageBTools,
755
1060
  questionPlan,
756
1061
  intent,
757
1062
  signal: input.signal,
@@ -775,9 +1080,11 @@ async function runAnswerLoop(input) {
775
1080
  // every run and every surface — instead of passing through the model's varying
776
1081
  // text. A genuinely context-less ask keeps the plain honest message.
777
1082
  const declinedDespiteContext = wantsGeneratedData && hasGeneratableContext;
778
- const text = declinedDespiteContext
779
- ? 'I could not compose a governed query for this from the available tables and metrics. This usually needs a clearer join path or an explicit metric and grouping — name the specific measure and how to break it down, and I can generate a review-required draft.'
780
- : parsed.text || 'No answer (the model declined to propose SQL).';
1083
+ const text = analyticalPlan?.safe === false
1084
+ ? analyticalPlan.message ?? 'DQL could not prove a safe analytical relationship path.'
1085
+ : declinedDespiteContext
1086
+ ? 'I could not compose a governed query for this from the available tables and metrics. This usually needs a clearer join path or an explicit metric and grouping — name the specific measure and how to break it down, and I can generate a review-required draft.'
1087
+ : parsed.text || 'No answer (the model declined to propose SQL).';
781
1088
  return {
782
1089
  kind: 'no_answer',
783
1090
  sourceTier: 'no_answer',
@@ -785,9 +1092,13 @@ async function runAnswerLoop(input) {
785
1092
  reviewStatus: 'none',
786
1093
  confidence: 0.1,
787
1094
  text,
788
- refusalCode: 'model_declined',
1095
+ refusalCode: analyticalPlan?.safe === false
1096
+ ? refusalCodeForAnalyticalPolicy(analyticalPlan.code)
1097
+ : 'model_declined',
789
1098
  refusalDetails: {
790
- code: 'model_declined',
1099
+ code: analyticalPlan?.safe === false
1100
+ ? analyticalPlan.code ?? 'unsafe_relationship'
1101
+ : 'model_declined',
791
1102
  message: text,
792
1103
  },
793
1104
  answer: text,
@@ -823,20 +1134,27 @@ async function runAnswerLoop(input) {
823
1134
  && questionPlan.requestedShape.filters.length === 0
824
1135
  && !questionPlan.requestedShape.topN;
825
1136
  let contextValidation;
826
- if (governedMetricAnswer) {
827
- contextValidation = { ok: true, warnings: [] };
828
- }
829
- else {
830
- const c = contextLedger.validateSql(parsed.sql, {
831
- question,
832
- intent,
833
- filterValues: input.followUp?.filters,
834
- trustedFilterValues: trustedFollowUpFilterValues(input.followUp),
835
- });
836
- contextValidation = c.ok
837
- ? { ok: true, warnings: c.warnings }
838
- : { ok: false, code: c.code, error: c.error, warnings: c.warnings, offending: c.offending };
839
- }
1137
+ const initialValidation = contextLedger.validateSql(parsed.sql, {
1138
+ question,
1139
+ intent,
1140
+ filterValues: input.followUp?.filters,
1141
+ trustedFilterValues: trustedFollowUpFilterValues(input.followUp),
1142
+ });
1143
+ contextValidation = initialValidation.ok
1144
+ ? { ok: true, warnings: initialValidation.warnings }
1145
+ : {
1146
+ ok: false,
1147
+ code: initialValidation.code,
1148
+ error: initialValidation.error,
1149
+ warnings: initialValidation.warnings,
1150
+ offending: initialValidation.offending,
1151
+ };
1152
+ // Semantic compilation owns metric meaning, but a later dimensional join can
1153
+ // still make a compiled bare expression ambiguous at the warehouse binder.
1154
+ // Revoke governed trust for an invalid composition so the bounded repair lane
1155
+ // can qualify it and keep the repaired SQL review-required.
1156
+ if (governedMetricAnswer && !contextValidation.ok)
1157
+ governedMetricAnswer = false;
840
1158
  // Spec 17, part C — semantic-metric route recovery. A metric question is often
841
1159
  // catalog-routed to clarify, leaving a thin contextPack that rejects otherwise-valid
842
1160
  // SQL. Rather than refuse, recover in two steps:
@@ -959,6 +1277,21 @@ async function runAnswerLoop(input) {
959
1277
  // Repair is best-effort — the refusal below stays the honest fallback.
960
1278
  }
961
1279
  }
1280
+ // W1.3 — deterministic, warn-only fan-out check. When the generated SQL aggregates
1281
+ // an additive measure across a one-to-many join (grain ledger knows the keys), the
1282
+ // number can silently double-count. Surface a caution so it is reviewed. This never
1283
+ // blocks: it only appends to warnings, and it is conservative (no key data ⇒ no flag).
1284
+ if (contextValidation.ok && parsed.sql && input.contextPack?.objects?.length) {
1285
+ try {
1286
+ const fanoutWarnings = fanoutWarningsForSql(parsed.sql, input.contextPack.objects);
1287
+ if (fanoutWarnings.length > 0) {
1288
+ contextValidation = { ok: true, warnings: [...contextValidation.warnings, ...fanoutWarnings] };
1289
+ }
1290
+ }
1291
+ catch {
1292
+ // Fan-out detection is advisory; any failure must not affect the answer.
1293
+ }
1294
+ }
962
1295
  if (!contextValidation.ok) {
963
1296
  const text = `I could not safely prepare this review-required DQL artifact from the inspected context. The SQL preview failed validation: ${contextValidation.error}`;
964
1297
  const analysisPlan = buildAnalysisPlan({
@@ -1015,6 +1348,70 @@ async function runAnswerLoop(input) {
1015
1348
  providerUsed: provider.name,
1016
1349
  };
1017
1350
  }
1351
+ const dbtFirstJoinSafety = input.manifest
1352
+ ? evaluateDbtFirstGeneratedSql(parsed.sql, input.manifest, input.domainContext?.purpose, input.domainContext)
1353
+ : undefined;
1354
+ if (dbtFirstJoinSafety && !dbtFirstJoinSafety.safe) {
1355
+ const text = dbtFirstJoinSafety.message
1356
+ ?? 'DQL could not prove this generated join from certified analytical relationships.';
1357
+ const exploratoryCandidate = exploratoryCandidateFromDbtFirstGuard(parsed.sql, dbtFirstJoinSafety);
1358
+ const analysisPlan = buildAnalysisPlan({
1359
+ question,
1360
+ intent,
1361
+ routeReason: exploratoryCandidate
1362
+ ? 'Governed relationship coverage is missing, so DQL prepared a bounded DBT-grounded exploratory candidate for host validation.'
1363
+ : 'The generated SQL did not pass the governed relationship policy.',
1364
+ selectedNodes: contextNodes,
1365
+ schemaContext,
1366
+ sql: parsed.sql,
1367
+ suggestedViz: parsed.viz ?? 'table',
1368
+ assumptions: [
1369
+ ...contextValidation.warnings,
1370
+ 'DBT metadata is grounding evidence, not certified relationship proof.',
1371
+ ],
1372
+ });
1373
+ return {
1374
+ kind: 'no_answer',
1375
+ sourceTier: 'no_answer',
1376
+ certification: 'analyst_review_required',
1377
+ reviewStatus: 'none',
1378
+ confidence: 0.1,
1379
+ text,
1380
+ answer: text,
1381
+ proposedSql: parsed.sql,
1382
+ sql: parsed.sql,
1383
+ ...(exploratoryCandidate ? { exploratoryCandidate } : {}),
1384
+ refusalCode: refusalCodeForAnalyticalPolicy(dbtFirstJoinSafety.code, Boolean(exploratoryCandidate)),
1385
+ refusalDetails: {
1386
+ code: dbtFirstJoinSafety.code ?? 'unsafe_relationship',
1387
+ message: text,
1388
+ },
1389
+ validationWarnings: [
1390
+ ...contextValidation.warnings,
1391
+ `DQL v3 relationship guard: ${dbtFirstJoinSafety.code ?? 'unsafe relationship'}.`,
1392
+ ...(exploratoryCandidate
1393
+ ? ['A bounded DBT-grounded exploratory route may validate this missing modeled relationship; governed SQL was not executed.']
1394
+ : []),
1395
+ ],
1396
+ citations: [],
1397
+ memoryContext: input.memoryContext,
1398
+ analysisPlan,
1399
+ evidence: buildNoAnswerEvidence({
1400
+ question,
1401
+ reason: text,
1402
+ artifactHits,
1403
+ businessHits,
1404
+ semanticHits,
1405
+ manifestHits,
1406
+ considered,
1407
+ memoryContext: input.memoryContext ?? [],
1408
+ analysisPlan,
1409
+ }),
1410
+ contextPack: input.contextPack,
1411
+ considered,
1412
+ providerUsed: provider.name,
1413
+ };
1414
+ }
1018
1415
  const generatedCitations = [
1019
1416
  ...contextPackCitations(input.contextPack, 4),
1020
1417
  ...contextNodes.slice(0, 4).map((n) => ({
@@ -1239,26 +1636,32 @@ async function runAnswerLoop(input) {
1239
1636
  ? { ...dqlArtifact, sourcePath: dqlArtifact.sourcePath ?? draftBlock.path }
1240
1637
  : dqlArtifact;
1241
1638
  const sourceCertifiedBlock = followUpSourceBlock?.name ?? input.followUp?.sourceBlockName;
1242
- const trustExplanation = generatedTrustExplanation({
1243
- followUp: input.followUp,
1244
- sourceCertifiedBlock,
1245
- draftBlock,
1246
- });
1639
+ const trustExplanation = governedMetricAnswer
1640
+ ? undefined
1641
+ : generatedTrustExplanation({
1642
+ followUp: input.followUp,
1643
+ sourceCertifiedBlock,
1644
+ draftBlock,
1645
+ });
1247
1646
  const cleanedSummary = cleanGeneratedSummary(parsed.text);
1248
1647
  const generatedText = [partialShapeWarning, trustExplanation, cleanedSummary]
1249
1648
  .filter(Boolean)
1250
1649
  .join('\n\n');
1251
- const semanticMetricCertification = governedMetricAnswer && activeTier === 'semantic_layer'
1650
+ // A metric can be resolved directly from the loaded semantic layer even if
1651
+ // the small metadata pack happened to contain only dbt objects. The answer
1652
+ // is still compiler-owned semantic SQL in that case; do not accidentally
1653
+ // downgrade its route/trust to generated SQL because of retrieval order.
1654
+ const semanticMetricCertification = governedMetricAnswer
1252
1655
  ? semanticMetricMatch?.metric.certification
1253
1656
  : undefined;
1254
1657
  const certifiedMetricAnswer = semanticMetricCertification === 'certified' || semanticMetricCertification === 'reviewed';
1255
1658
  return {
1256
1659
  kind: 'uncertified',
1257
- sourceTier: activeTier,
1258
- certification: 'ai_generated',
1259
- reviewStatus: 'draft_ready',
1660
+ sourceTier: governedMetricAnswer ? 'semantic_layer' : activeTier,
1661
+ certification: governedMetricAnswer ? 'governed' : 'ai_generated',
1662
+ reviewStatus: governedMetricAnswer ? 'governed' : 'draft_ready',
1260
1663
  semanticMetricCertification,
1261
- confidence: certifiedMetricAnswer ? 0.8 : governedMetricAnswer && activeTier === 'semantic_layer' ? 0.72 : 0.55,
1664
+ confidence: certifiedMetricAnswer ? 0.8 : governedMetricAnswer ? 0.72 : 0.55,
1262
1665
  text: generatedText,
1263
1666
  answer: generatedText,
1264
1667
  proposedSql: parsed.sql,
@@ -1308,7 +1711,7 @@ async function runAnswerLoop(input) {
1308
1711
  providerUsed: provider.name,
1309
1712
  // Carry the governed metric match so the exit point can name a
1310
1713
  // `semantic_metric` route (spec 17, part C).
1311
- _semanticMetricMatch: governedMetricAnswer && activeTier === 'semantic_layer' ? semanticMetricMatch ?? undefined : undefined,
1714
+ _semanticMetricMatch: governedMetricAnswer ? semanticMetricMatch ?? undefined : undefined,
1312
1715
  };
1313
1716
  }
1314
1717
  function trimResultToRequestedTopN(result, plan) {
@@ -1353,13 +1756,35 @@ Rules:
1353
1756
  separate SQL code fence when using JSON. The optional "dql" object is
1354
1757
  metadata for the deterministic draft-DQL renderer; do not hand-write a full
1355
1758
  DQL block.
1759
+ Design the SELECT for a business reader: prefer a joined/display name, label,
1760
+ title, or business value over raw *_id, *_uuid, *_key, or technical codes.
1761
+ Alias calculated outputs with clear business names. Include identifiers only
1762
+ when the question asks for them or no readable field exists. Give every FROM
1763
+ and JOIN relation an explicit short alias, and use only those exact aliases in
1764
+ SELECT, ON, WHERE, GROUP BY, and ORDER BY. When a query reads more than one
1765
+ relation, qualify every column reference with its exact relation alias; never
1766
+ emit a bare column name. Include every requested output. Match the requested
1767
+ result grain exactly: project and GROUP BY only the entities or dimensions the
1768
+ user asked to compare. A category/value used only to filter the result is not
1769
+ an additional output dimension. An entity ranking filtered to a category
1770
+ value must return one row per requested entity, not one row per
1771
+ entity-category-member pair.
1356
1772
  4. In summary, state your QUERY PLAN first: the grain (one row per WHAT), the
1357
1773
  measures and how they aggregate, the dimensions/filters, and the exact join
1358
1774
  path + join keys between the grounded tables. Then make the SQL match that
1359
1775
  plan — an explicit grain and join path prevents wrong-grain answers and
1360
- fan-out (row-multiplying) joins.
1361
- 5. Use a visualization type from this list in the "viz" JSON field: line, bar,
1362
- area, pie, single_value, table, pivot, kpi.
1776
+ fan-out (row-multiplying) joins. Treat lifetime, cumulative, balance,
1777
+ snapshot, rate, ratio, average, and already-aggregated values as
1778
+ non-additive across lower-grain joins: pre-aggregate at their native grain,
1779
+ or use a grain-preserving value only when the owning entity remains in the
1780
+ output. Never SUM the repeated parent value at child grain. If the question
1781
+ requires allocating a parent value to children, ask for the allocation rule.
1782
+ 5. Choose a visualization deliberately in the "viz" JSON field: use single_value
1783
+ or kpi for one aggregate, line/area only for an ordered time series, bar for a
1784
+ categorical comparison, grouped-bar for multiple measures by one category,
1785
+ scatter for two continuous measures, and table when the result is not chartable.
1786
+ Do not default to bar. The runtime validates this preference against returned
1787
+ rows before displaying it.
1363
1788
  6. NEVER fabricate column names that are not present in the supplied schema,
1364
1789
  dbt metadata, or certified source SQL shape context. If a requested filter
1365
1790
  value is supplied as a matched value, prefer the table and column that
@@ -1368,15 +1793,19 @@ Rules:
1368
1793
  Do NOT use dbt/Jinja macros such as {{ ref(...) }} or {{ source(...) }} in
1369
1794
  proposed SQL. Do not emit INSERT, UPDATE, DELETE, CREATE, DROP, ALTER, COPY,
1370
1795
  PRAGMA, SET, or multiple statements.
1371
- 8. If the schema is insufficient to answer, say so explicitly and ask a
1372
- clarifying question instead of guessing. But a MULTI-ENTITY question ("top
1373
- customers who bought the top products", "accounts with the most overdue
1374
- invoices") is NOT insufficient context when the grounded tables and a join
1375
- route between them are supplied (see any "Knowledge graph join routes"
1376
- section). In that case you MUST compose the joined SELECT that answers it
1377
- directly never refuse it or offer to show the parts as separate datasets.
1378
- Reserve the clarifying question for a genuinely absent table, column, or join
1379
- key.
1796
+ 8. If the schema is insufficient, DISCOVER before you decline: use
1797
+ search_metadata to find candidate relations, get_table_schema to confirm a
1798
+ relation's real columns and inferred join keys, and expand_context for a
1799
+ relation you already know by name. Only ask a clarifying question once those
1800
+ come up empty AND the question is genuinely ambiguous never decline just
1801
+ because the initially supplied context pack didn't already include the table.
1802
+ A MULTI-ENTITY question ("top customers who bought the top products", "accounts
1803
+ with the most overdue invoices") is NOT insufficient context when the grounded
1804
+ tables and a join route between them are supplied (see any "Knowledge graph
1805
+ join routes" section) or discoverable via the tools above. In that case you
1806
+ MUST compose the joined SELECT that answers it directly — never refuse it or
1807
+ offer to show the parts as separate datasets. Reserve the clarifying question
1808
+ for a genuinely absent table, column, or join key.
1380
1809
  9. Write directly to the analyst. Do not say "the user is asking", "the user
1381
1810
  requested", "I will generate", or describe internal routing. State the
1382
1811
  answer, the certified context used, the DQL artifact expectation, and the
@@ -1390,7 +1819,173 @@ Rules:
1390
1819
  // combined dataset — show them separately" refusal into the join the user asked
1391
1820
  // for, while still allowing an honest refusal if context is truly missing.
1392
1821
  const FORCE_JOIN_INSTRUCTION = `Your previous attempt declined to produce SQL. Re-read the supplied schema, metadata, and any "Knowledge graph join routes": this question CAN be answered by joining the grounded tables along their documented keys. Do NOT refuse, and do NOT suggest showing the datasets separately — compose ONE read-only SELECT/WITH that joins the relevant tables to answer it directly, following the JSON contract from rule 3. State the grain and the exact join path in the summary. Only if a required table, column, or join key is truly absent from the supplied context may you still ask a clarifying question.`;
1393
- function renderContextPrompt(blocks, businessContext, others, activeTier, memoryContext, extraContext, followUp, schemaContext = [], intent = 'ad_hoc_analysis', contextPack, conversationSnapshot, kgJoinPathHints = [], budget = QUICK_PROMPT_CONTEXT_BUDGET) {
1822
+ /**
1823
+ * Produces the prompt-facing subset of a broad local context pack. Global
1824
+ * records remain available, while a record explicitly owned by another domain
1825
+ * cannot steer an otherwise single-domain question into its relationship path.
1826
+ * This is not a security boundary; manifest/domain-context validation still
1827
+ * happens after generation and at execution.
1828
+ */
1829
+ function scopeContextPackToQuestionDomains(contextPack, domains, manifest) {
1830
+ if (!contextPack || domains.length === 0)
1831
+ return contextPack;
1832
+ const allowedDomains = new Set(domains);
1833
+ const relationDomains = manifestRelationDomains(manifest);
1834
+ const objectDomain = (object) => object.domain ?? inferredManifestDomainForMetadataObject(object, relationDomains);
1835
+ const objects = contextPack.objects.filter((object) => {
1836
+ const domain = objectDomain(object);
1837
+ return !domain || allowedDomains.has(domain);
1838
+ });
1839
+ const objectKeys = new Set(objects.map((object) => object.objectKey));
1840
+ const relations = contextPack.allowedSqlContext.relations.filter((relation) => (!relation.objectKey || objectKeys.has(relation.objectKey))
1841
+ && (!relationDomains.get(normalizeRelationKey(relation.relation))
1842
+ || allowedDomains.has(relationDomains.get(normalizeRelationKey(relation.relation)))));
1843
+ const relationKeys = new Set(relations.map((relation) => normalizeRelationKey(relation.relation)));
1844
+ const selectedRelations = contextPack.retrievalDiagnostics.selectedRelations?.filter((relation) => relationKeys.has(normalizeRelationKey(relation.relation)));
1845
+ const selectedJoinPaths = contextPack.retrievalDiagnostics.selectedJoinPaths?.filter((path) => relationKeys.has(normalizeRelationKey(path.leftRelation))
1846
+ && relationKeys.has(normalizeRelationKey(path.rightRelation)));
1847
+ return {
1848
+ ...contextPack,
1849
+ focusObjectKey: contextPack.focusObjectKey && objectKeys.has(contextPack.focusObjectKey)
1850
+ ? contextPack.focusObjectKey
1851
+ : objects[0]?.objectKey ?? null,
1852
+ objects,
1853
+ skills: contextPack.skills.filter((skill) => !skill.domain || allowedDomains.has(skill.domain)),
1854
+ edges: contextPack.edges.filter((edge) => objectKeys.has(edge.fromKey) && objectKeys.has(edge.toKey)),
1855
+ citations: contextPack.citations.filter((citation) => objectKeys.has(citation.objectKey)),
1856
+ evidenceSummaries: contextPack.evidenceSummaries.filter((summary) => !summary.objectKey || objectKeys.has(summary.objectKey)),
1857
+ evidenceRoles: contextPack.evidenceRoles.filter((role) => objectKeys.has(role.objectKey)),
1858
+ allowedSqlContext: {
1859
+ relations,
1860
+ sourceBlockSql: contextPack.allowedSqlContext.sourceBlockSql.filter((source) => objectKeys.has(source.objectKey)),
1861
+ },
1862
+ retrievalDiagnostics: {
1863
+ ...contextPack.retrievalDiagnostics,
1864
+ selectedObjects: objects.length,
1865
+ selectedEvidence: contextPack.retrievalDiagnostics.selectedEvidence.filter((evidence) => objectKeys.has(evidence.objectKey)),
1866
+ selectedRelations,
1867
+ selectedJoinPaths,
1868
+ schemaShapeCandidates: contextPack.retrievalDiagnostics.schemaShapeCandidates?.filter((candidate) => objectKeys.has(candidate.objectKey)),
1869
+ certifiedCandidateFits: contextPack.retrievalDiagnostics.certifiedCandidateFits.filter((candidate) => objectKeys.has(candidate.objectKey)),
1870
+ },
1871
+ };
1872
+ }
1873
+ function manifestRelationDomains(manifest) {
1874
+ const domains = new Map();
1875
+ if (!manifest?.modeling || !manifest.dbtProvenance)
1876
+ return domains;
1877
+ for (const entity of Object.values(manifest.modeling.entities)) {
1878
+ const relation = manifest.dbtProvenance.nodes[entity.dbtUniqueId]?.relation;
1879
+ if (relation && entity.domain)
1880
+ domains.set(normalizeRelationKey(relation), entity.domain);
1881
+ }
1882
+ return domains;
1883
+ }
1884
+ function inferredManifestDomainForMetadataObject(object, relationDomains) {
1885
+ const payload = object.payload ?? {};
1886
+ const relation = typeof payload.relation === 'string' ? payload.relation : undefined;
1887
+ if (relation) {
1888
+ const direct = relationDomains.get(normalizeRelationKey(relation));
1889
+ if (direct)
1890
+ return direct;
1891
+ }
1892
+ const haystack = `${object.objectKey} ${object.name}`.toLowerCase();
1893
+ for (const [relation, domain] of relationDomains) {
1894
+ const model = relation.split('.').at(-1);
1895
+ if (model && haystack.includes(model))
1896
+ return domain;
1897
+ }
1898
+ return undefined;
1899
+ }
1900
+ /** Removes runtime tables that were explicitly present in, then excluded from, a broad context pack. */
1901
+ function schemaContextWithinQuestionScope(schemaContext, fullContextPack, scopedContextPack) {
1902
+ if (!fullContextPack || fullContextPack === scopedContextPack)
1903
+ return schemaContext;
1904
+ const fullRelations = new Set(fullContextPack.allowedSqlContext.relations.map((relation) => normalizeRelationKey(relation.relation)));
1905
+ const scopedRelations = new Set((scopedContextPack?.allowedSqlContext.relations ?? []).map((relation) => normalizeRelationKey(relation.relation)));
1906
+ return schemaContext.filter((table) => {
1907
+ const key = normalizeRelationKey(table.relation);
1908
+ return !fullRelations.has(key) || scopedRelations.has(key);
1909
+ });
1910
+ }
1911
+ export function inferAnalyticalEntityIds(question, contextNodes, manifest) {
1912
+ if (manifest.manifestVersion !== 3 || !manifest.modeling || !manifest.dbtProvenance)
1913
+ return [];
1914
+ const normalizedQuestion = question.toLowerCase().replace(/[^a-z0-9]+/g, ' ');
1915
+ const contextIds = new Set(contextNodes.map((node) => node.nodeId.toLowerCase()));
1916
+ const entityIdentifierTokens = new Set(Object.values(manifest.modeling.entities).flatMap((entity) => entityIdentifierTokensForMatching(entity).map((token) => token.toLowerCase())));
1917
+ const scored = Object.entries(manifest.modeling.entities).map(([key, entity]) => {
1918
+ const dbt = manifest.dbtProvenance?.nodes[entity.dbtUniqueId];
1919
+ const entityTokens = entityIdentifierTokensForMatching(entity);
1920
+ // A backing dbt model may contain another entity's name (for example
1921
+ // `dim_customer_acquisition`). Its `customer` token must not turn a plain
1922
+ // customer question into an acquisition request. Only model tokens that are
1923
+ // unique to this entity are supplemental lexical evidence.
1924
+ const modelTokens = (dbt?.name ?? '')
1925
+ .toLowerCase()
1926
+ .replace(/^(fct|dim|stg)_/, '')
1927
+ .split(/[^a-z0-9]+/)
1928
+ .filter((token) => token.length > 2 && !entityIdentifierTokens.has(token));
1929
+ const direct = entityTokens.some((token) => normalizedQuestion.includes(token))
1930
+ || modelTokens.some((token) => normalizedQuestion.includes(token));
1931
+ const inContext = contextIds.has(`entity:${entity.qualifiedId ?? entity.id}`.toLowerCase())
1932
+ || contextIds.has(`dbt_model:${entity.dbtUniqueId}`.toLowerCase())
1933
+ || contextNodes.some((node) => node.kind === 'dbt_model' && node.name === dbt?.name);
1934
+ return { id: key, direct, inContext };
1935
+ });
1936
+ // AGT-004: context retrieval is useful for completing a clearly named model,
1937
+ // but it must not manufacture a second entity merely because an unrelated
1938
+ // cross-domain block ranked in the context window. That was how a Commerce
1939
+ // product question reached Growth's attribution path. Prefer lexical evidence
1940
+ // whenever the question names at least one modeled entity; use context-only
1941
+ // inference solely as a fallback for genuinely indirect wording.
1942
+ const direct = scored.filter((entry) => entry.direct);
1943
+ const candidates = direct.length > 0 ? direct : scored.filter((entry) => entry.inContext);
1944
+ return candidates
1945
+ .sort((a, b) => Number(b.direct) - Number(a.direct) || a.id.localeCompare(b.id))
1946
+ .slice(0, 5)
1947
+ .map((entry) => entry.id);
1948
+ }
1949
+ function entityIdentifierTokensForMatching(entity) {
1950
+ return [entity.localId ?? entity.id]
1951
+ .flatMap((value) => value.toLowerCase().replace(/^(fct|dim|stg)_/, '').split(/[^a-z0-9]+/))
1952
+ .filter((token) => token.length > 2 && token !== 'entity');
1953
+ }
1954
+ function renderAnalyticalPlanPrompt(plan) {
1955
+ if (!plan || plan.entities.length < 2)
1956
+ return undefined;
1957
+ if (!plan.safe) {
1958
+ return [
1959
+ 'DQL ANALYTICAL POLICY DECISION: BLOCKED.',
1960
+ plan.message ?? 'No certified analytical relationship path is available.',
1961
+ `Policy code: ${plan.code ?? 'unsafe_relationship'}.`,
1962
+ 'Do not invent a join from dbt lineage or shared column names. Return a clarification or refusal that asks for the missing relationship, export/import, or attribution policy.',
1963
+ ].join('\n');
1964
+ }
1965
+ return [
1966
+ 'DQL CERTIFIED ANALYTICAL JOIN PLAN (authoritative):',
1967
+ ...plan.edges.map((edge, index) => [
1968
+ `${index + 1}. ${edge.fromEntity} (${edge.fromRelation ?? 'bound dbt model'}) -> ${edge.toEntity} (${edge.toRelation ?? 'bound dbt model'})`,
1969
+ ` relationship=${edge.relationshipId}; keys=${edge.keys.map((key) => `${key.from}=${key.to}`).join(', ')}; cardinality=${edge.cardinality}; fanout=${edge.fanout}`,
1970
+ edge.importRefs.length ? ` imports=${edge.importRefs.join(', ')}` : '',
1971
+ ].filter(Boolean).join('\n')),
1972
+ 'Use only these relationships and exact key pairs. dbt DAG lineage and same-named columns are not join authorization. Any different join must be refused.',
1973
+ ].join('\n');
1974
+ }
1975
+ // Metric-anchored generation (Tier 2.5): a governed metric matched the question but
1976
+ // the semantic layer couldn't compose the exact shape. Reuse the metric's CERTIFIED
1977
+ // definition as the measure rather than reinventing it, so the number stays consistent
1978
+ // with the governed metric — only the join/grouping around it is generated.
1979
+ function metricAnchorInstruction(metricName, def) {
1980
+ return `A GOVERNED metric matched this question, but the semantic layer could not compose the exact requested shape on its own (the breakdown likely needs a join the metric's table does not own). You MUST compute the measure using this certified definition — do NOT redefine, rename, or approximate it:\n metric "${metricName}": ${def.expr} (defined over ${def.table})\nCompose ONE read-only SELECT/WITH using explicit aliases for every relation and include every output the user requested. Preserve the metric at its native grain before joining to a lower-grain dimension. NEVER SUM a lifetime, cumulative, balance, snapshot, rate, ratio, or already-aggregated value after a one-to-many join. If the requested result retains the metric's owning entity, expose the native-grain value (or a grain-preserving MAX after deduplication) for each entity/dimension pair; if it asks to allocate that value to the child dimension, request the missing allocation policy instead of inventing one. Join ${def.table} to other grounded tables only along documented keys. State the join path, output grain, and whether each measure is an entity-level attribute or an allocated child-level measure. Reusing the governed definition keeps the answer consistent with the certified metric; if the semantic layer cannot authorize the requested dimensional composition, keep the result exploratory/review-required rather than claiming semantic provenance.`;
1981
+ }
1982
+ function renderContextPrompt(blocks, businessContext, others, activeTier, memoryContext, extraContext, followUp, schemaContext = [], intent = 'ad_hoc_analysis', contextPack, conversationSnapshot, kgJoinPathHints = [], budget = QUICK_PROMPT_CONTEXT_BUDGET, driver) {
1983
+ // W1.5 — dialect conventions so the model writes warehouse-correct SQL
1984
+ // (quoting, row-limiting, date functions) instead of a DuckDB/Postgres default
1985
+ // that fails on Snowflake/BigQuery/etc. Only emitted when a driver is known.
1986
+ const dialectSection = driver
1987
+ ? `\n\n## SQL dialect\n\n${describeDialectForPrompt(driver)}`
1988
+ : '';
1394
1989
  const intentSection = `## Routing intent\n\nintent: ${intent}\n${intent === 'exact_certified_lookup'
1395
1990
  ? 'Use a certified artifact only if it exactly answers the question.'
1396
1991
  : 'Prepare a review-required DQL artifact with a SQL preview for this question. Certified blocks are trusted context, not a reason to answer the wrong grain.'}`;
@@ -1459,7 +2054,7 @@ function renderContextPrompt(blocks, businessContext, others, activeTier, memory
1459
2054
  const contextPackSection = contextPack
1460
2055
  ? `\n\n## Local metadata context pack\n\n${renderContextPackForPrompt(contextPack, budget)}`
1461
2056
  : '';
1462
- return `${intentSection}${budgetSection}\n\n${blockSection}${businessSection}${otherSection}${kgJoinSection}${schemaSection}${contextPackSection}${memorySection}${hintsSection}${conversationSection}${extraSection}${followUpSection}`;
2057
+ return `${intentSection}${budgetSection}${dialectSection}\n\n${blockSection}${businessSection}${otherSection}${kgJoinSection}${schemaSection}${contextPackSection}${memorySection}${hintsSection}${conversationSection}${extraSection}${followUpSection}`;
1463
2058
  }
1464
2059
  function buildKgJoinPathHints(kg, nodes, questionPlan) {
1465
2060
  const candidates = nodes
@@ -2968,25 +3563,29 @@ async function generateProposalWithOptionalTools(input) {
2968
3563
  signal: input.signal,
2969
3564
  reasoningEffort: input.reasoningEffort,
2970
3565
  };
2971
- if (!input.provider.generateWithTools || tools.length === 0) {
3566
+ // No tools → plain generation (nothing for the loop to drive).
3567
+ if (tools.length === 0) {
2972
3568
  return input.provider.generate(input.messages, options);
2973
3569
  }
2974
3570
  const toolBudget = proposalToolBudgetForQuestion(input.questionPlan, input.intent, {
2975
3571
  analysisDepth: input.analysisDepth,
2976
3572
  reasoningEffort: input.reasoningEffort,
2977
3573
  });
2978
- const toolPolicy = {
2979
- role: 'system',
2980
- content: [
2981
- 'You may use the supplied DQL tools to inspect semantic members, certified context, metadata context, and bounded repair options.',
2982
- `Tool budget for this question: ${toolBudget.maxToolCalls} call(s) (${toolBudget.effortClass}: ${toolBudget.reason}). Stop as soon as a lane can answer.`,
2983
- 'Prefer semantic compile before deep warehouse search when the semantic layer contains the requested metric/dimensions/time grain.',
2984
- 'Use context expansion only to repair a named, known relation/column gap; do not loop on the same failed context.',
2985
- 'Final response must be a single ```json fenced object with summary, sql, viz, outputs, and optional dql metadata fields.',
2986
- ].join('\n'),
2987
- };
2988
- return input.provider.generateWithTools([...input.messages, toolPolicy], tools, {
3574
+ const toolPolicy = [
3575
+ 'You may use the supplied DQL tools to inspect semantic members, certified context, metadata context, and bounded repair options.',
3576
+ `Tool budget for this question: ${toolBudget.maxToolCalls} call(s) (${toolBudget.effortClass}: ${toolBudget.reason}). Stop as soon as a lane can answer.`,
3577
+ 'Prefer a governed semantic compile (search_semantic_layer compile_semantic_query) over hand-written SQL when the semantic layer contains the requested metric/dimensions/time grain.',
3578
+ 'When the supplied context is missing a table, column, or join key, DISCOVER it with `search_metadata` (find candidate relations) then `get_table_schema` (confirm real columns + inferred join keys) before declining. Use `search_project_files` for a bounded live source grep when indexed retrieval missed an identifier; use `scan_manifest` for cached graph objects; do not loop on the same failed context.',
3579
+ 'When unsure a relation/column exists, validate a composed query with `validate_sql` rather than guessing.',
3580
+ 'Final response must be a single ```json fenced object with summary, sql, viz, outputs, and optional dql metadata fields.',
3581
+ ].join('\n');
3582
+ // runAgenticToolLoop drives the loop over ANY provider: native tool use where the
3583
+ // provider implements generateWithTools (Claude/OpenAI), and an equivalent text
3584
+ // protocol otherwise (subscription-CLI passthrough, Ollama). This is what gives
3585
+ // every provider — not just the two API ones — a real tool-driven Stage B.
3586
+ return runAgenticToolLoop(input.provider, [...input.messages], tools, {
2989
3587
  ...options,
3588
+ toolPolicy,
2990
3589
  maxToolCalls: toolBudget.maxToolCalls,
2991
3590
  onToolCall: (event) => {
2992
3591
  const sink = input.toolCalls;
@@ -3130,8 +3729,14 @@ async function generateDeepAlternativeProposals(input) {
3130
3729
  'Create a fourth candidate by DECOMPOSITION: break the question into sub-questions, solve each as a CTE, then compose the final SELECT — avoiding assumptions hidden in the first candidate.',
3131
3730
  ];
3132
3731
  const temperatures = [0.2, 0.35, 0.5];
3133
- const out = [];
3134
- for (const [index, instruction] of variants.entries()) {
3732
+ // How many diverse alternatives to generate is set by the question SHAPE (S1):
3733
+ // a lightweight 1-candidate agreement check for join/breakdown shapes, the full
3734
+ // 3-candidate vote for deep-research, none for a single-table lookup. The
3735
+ // generations are independent, so they run in PARALLEL — the deep vote's
3736
+ // wall-clock cost is one generation, not the serial sum of all of them.
3737
+ const count = Math.max(0, Math.min(input.maxAlternatives ?? variants.length, variants.length));
3738
+ const selected = variants.slice(0, count);
3739
+ const results = await Promise.all(selected.map(async (instruction, index) => {
3135
3740
  try {
3136
3741
  const raw = await input.provider.generate([
3137
3742
  ...input.messages,
@@ -3151,14 +3756,14 @@ async function generateDeepAlternativeProposals(input) {
3151
3756
  reasoningEffort: input.reasoningEffort,
3152
3757
  temperature: temperatures[index] ?? 0.4,
3153
3758
  });
3154
- if (raw.trim())
3155
- out.push(raw);
3759
+ return raw.trim() ? raw : '';
3156
3760
  }
3157
3761
  catch {
3158
3762
  // Alternative candidates are opportunistic; the initial candidate remains.
3763
+ return '';
3159
3764
  }
3160
- }
3161
- return out;
3765
+ }));
3766
+ return results.filter((raw) => raw.trim());
3162
3767
  }
3163
3768
  function cloneParsedProposal(proposal) {
3164
3769
  return {
@@ -3190,6 +3795,7 @@ function evidenceToolCallFromEvent(event, order) {
3190
3795
  inputSummary: summarizeEvidencePayload(event.input),
3191
3796
  outputSummary: summarizeEvidencePayload(event.output),
3192
3797
  order,
3798
+ ...(typeof event.durationMs === 'number' ? { durationMs: event.durationMs } : {}),
3193
3799
  };
3194
3800
  }
3195
3801
  function summarizeEvidencePayload(value, maxLength = 700) {
@@ -3295,6 +3901,40 @@ function certifiedFreshnessCaveat(artifact) {
3295
3901
  return undefined;
3296
3902
  }
3297
3903
  }
3904
+ function provenanceSourceTierLabel(tier) {
3905
+ switch (tier) {
3906
+ case 'certified_artifact': return 'Certified block';
3907
+ case 'semantic_layer': return 'Governed semantic metric';
3908
+ case 'business_context': return 'Business context';
3909
+ case 'dbt_manifest': return 'Generated SQL';
3910
+ default: return undefined;
3911
+ }
3912
+ }
3913
+ /**
3914
+ * Anthropic-style provenance footer: one line telling a stakeholder WHERE the
3915
+ * answer came from (source tier), how much to trust it, who owns the source, and
3916
+ * whether the underlying data is current. Built once at the single exit from the
3917
+ * finished answer; omitted for no-answer outcomes.
3918
+ */
3919
+ export function buildProvenanceFooter(result, trust) {
3920
+ if (result.kind === 'no_answer')
3921
+ return undefined;
3922
+ const parts = [];
3923
+ const tierLabel = provenanceSourceTierLabel(result.sourceTier);
3924
+ if (tierLabel)
3925
+ parts.push(`Source: ${tierLabel}`);
3926
+ if (trust?.display)
3927
+ parts.push(`Trust: ${trust.display}`);
3928
+ if (result.block?.owner)
3929
+ parts.push(`Owner: ${result.block.owner}`);
3930
+ const freshness = result.block?.dataState === 'stale' ? 'stale — verify currency'
3931
+ : result.block?.dataState === 'failed' ? 'upstream run failed'
3932
+ : result.block?.dataState === 'fresh' ? 'current'
3933
+ : undefined;
3934
+ if (freshness)
3935
+ parts.push(`Data: ${freshness}`);
3936
+ return parts.length > 0 ? parts.join(' · ') : undefined;
3937
+ }
3298
3938
  function mergeHits(...groups) {
3299
3939
  const byId = new Map();
3300
3940
  for (const group of groups) {
@@ -3316,6 +3956,53 @@ function mergeNodes(...groups) {
3316
3956
  }
3317
3957
  return Array.from(byId.values());
3318
3958
  }
3959
+ /**
3960
+ * Merge prioritized hit groups into a single deduped list under a total budget,
3961
+ * giving each group a guaranteed minimum number of slots. Pass 1 takes up to
3962
+ * `reserve` from each group in priority order; pass 2 fills any remaining budget
3963
+ * round-robin from each group's leftovers. This prevents a high-priority group
3964
+ * from starving a lower one out of the prompt entirely (the semantic-hit
3965
+ * truncation bug) while still honoring priority for the reserved slots.
3966
+ */
3967
+ function interleaveContextHits(groups, budget) {
3968
+ const seen = new Set();
3969
+ const out = [];
3970
+ const cursors = groups.map(() => 0);
3971
+ const take = (hit) => {
3972
+ if (out.length >= budget || seen.has(hit.node.nodeId))
3973
+ return false;
3974
+ seen.add(hit.node.nodeId);
3975
+ out.push(hit);
3976
+ return true;
3977
+ };
3978
+ // Pass 1: reserved minimums, in priority order.
3979
+ groups.forEach((group, gi) => {
3980
+ let taken = 0;
3981
+ while (cursors[gi] < group.hits.length && taken < group.reserve && out.length < budget) {
3982
+ if (take(group.hits[cursors[gi]]))
3983
+ taken += 1;
3984
+ cursors[gi] += 1;
3985
+ }
3986
+ });
3987
+ // Pass 2: round-robin fill from leftovers until the budget is exhausted.
3988
+ let progressed = true;
3989
+ while (out.length < budget && progressed) {
3990
+ progressed = false;
3991
+ for (let gi = 0; gi < groups.length && out.length < budget; gi += 1) {
3992
+ const group = groups[gi];
3993
+ while (cursors[gi] < group.hits.length) {
3994
+ const before = out.length;
3995
+ take(group.hits[cursors[gi]]);
3996
+ cursors[gi] += 1;
3997
+ if (out.length > before) {
3998
+ progressed = true;
3999
+ break;
4000
+ }
4001
+ }
4002
+ }
4003
+ }
4004
+ return out;
4005
+ }
3319
4006
  /**
3320
4007
  * Candidate metric KG nodes for semantic-metric matching (spec 17, part C).
3321
4008
  * Starts with the FTS semantic + considered hits, then folds in EVERY metric