@duckcodeailabs/dql-agent 1.6.34 → 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 (90) hide show
  1. package/dist/agent-run-engine.d.ts +14 -1
  2. package/dist/agent-run-engine.d.ts.map +1 -1
  3. package/dist/agent-run-engine.js +123 -3
  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 +6 -0
  7. package/dist/agent-run-gates.js.map +1 -1
  8. package/dist/answer-loop.d.ts +57 -4
  9. package/dist/answer-loop.d.ts.map +1 -1
  10. package/dist/answer-loop.js +435 -44
  11. package/dist/answer-loop.js.map +1 -1
  12. package/dist/app-builder.d.ts.map +1 -1
  13. package/dist/app-builder.js +18 -10
  14. package/dist/app-builder.js.map +1 -1
  15. package/dist/cascade/budgets.d.ts.map +1 -1
  16. package/dist/cascade/budgets.js +8 -3
  17. package/dist/cascade/budgets.js.map +1 -1
  18. package/dist/cascade/route-policy.d.ts.map +1 -1
  19. package/dist/cascade/route-policy.js +5 -0
  20. package/dist/cascade/route-policy.js.map +1 -1
  21. package/dist/domain-context.d.ts +30 -0
  22. package/dist/domain-context.d.ts.map +1 -0
  23. package/dist/domain-context.js +68 -0
  24. package/dist/domain-context.js.map +1 -0
  25. package/dist/hints/git-store.d.ts +39 -3
  26. package/dist/hints/git-store.d.ts.map +1 -1
  27. package/dist/hints/git-store.js +172 -2
  28. package/dist/hints/git-store.js.map +1 -1
  29. package/dist/hints/types.d.ts +41 -0
  30. package/dist/hints/types.d.ts.map +1 -1
  31. package/dist/hints/types.js +1 -0
  32. package/dist/hints/types.js.map +1 -1
  33. package/dist/index.d.ts +7 -4
  34. package/dist/index.d.ts.map +1 -1
  35. package/dist/index.js +37 -2
  36. package/dist/index.js.map +1 -1
  37. package/dist/kg/build.d.ts.map +1 -1
  38. package/dist/kg/build.js +196 -4
  39. package/dist/kg/build.js.map +1 -1
  40. package/dist/kg/sqlite-fts.d.ts.map +1 -1
  41. package/dist/kg/sqlite-fts.js +8 -3
  42. package/dist/kg/sqlite-fts.js.map +1 -1
  43. package/dist/kg/types.d.ts +9 -2
  44. package/dist/kg/types.d.ts.map +1 -1
  45. package/dist/metadata/analysis-planner.d.ts.map +1 -1
  46. package/dist/metadata/analysis-planner.js +134 -18
  47. package/dist/metadata/analysis-planner.js.map +1 -1
  48. package/dist/metadata/analytical-policy.d.ts +51 -0
  49. package/dist/metadata/analytical-policy.d.ts.map +1 -0
  50. package/dist/metadata/analytical-policy.js +309 -0
  51. package/dist/metadata/analytical-policy.js.map +1 -0
  52. package/dist/metadata/block-fit.js +37 -4
  53. package/dist/metadata/block-fit.js.map +1 -1
  54. package/dist/metadata/catalog.d.ts +46 -1
  55. package/dist/metadata/catalog.d.ts.map +1 -1
  56. package/dist/metadata/catalog.js +313 -51
  57. package/dist/metadata/catalog.js.map +1 -1
  58. package/dist/metadata/dbt-first-safety.d.ts +19 -0
  59. package/dist/metadata/dbt-first-safety.d.ts.map +1 -0
  60. package/dist/metadata/dbt-first-safety.js +17 -0
  61. package/dist/metadata/dbt-first-safety.js.map +1 -0
  62. package/dist/metadata/sql-context-validation.d.ts.map +1 -1
  63. package/dist/metadata/sql-context-validation.js +42 -2
  64. package/dist/metadata/sql-context-validation.js.map +1 -1
  65. package/dist/metadata/sql-grounding.d.ts.map +1 -1
  66. package/dist/metadata/sql-grounding.js +25 -2
  67. package/dist/metadata/sql-grounding.js.map +1 -1
  68. package/dist/metadata/sql-retrieval.d.ts +2 -0
  69. package/dist/metadata/sql-retrieval.d.ts.map +1 -1
  70. package/dist/metadata/sql-retrieval.js +5 -1
  71. package/dist/metadata/sql-retrieval.js.map +1 -1
  72. package/dist/propose/build-from-prompt.d.ts +34 -0
  73. package/dist/propose/build-from-prompt.d.ts.map +1 -1
  74. package/dist/propose/build-from-prompt.js +199 -19
  75. package/dist/propose/build-from-prompt.js.map +1 -1
  76. package/dist/semantic-bridge/compose.d.ts.map +1 -1
  77. package/dist/semantic-bridge/compose.js +169 -5
  78. package/dist/semantic-bridge/compose.js.map +1 -1
  79. package/dist/skills/loader.d.ts +10 -0
  80. package/dist/skills/loader.d.ts.map +1 -1
  81. package/dist/skills/loader.js +81 -10
  82. package/dist/skills/loader.js.map +1 -1
  83. package/dist/synthesize.d.ts.map +1 -1
  84. package/dist/synthesize.js +152 -16
  85. package/dist/synthesize.js.map +1 -1
  86. package/dist/tools/registry.d.ts +1 -1
  87. package/dist/tools/registry.d.ts.map +1 -1
  88. package/dist/tools/registry.js +42 -0
  89. package/dist/tools/registry.js.map +1 -1
  90. package/package.json +4 -4
@@ -16,6 +16,7 @@
16
16
  */
17
17
  import { describeDialectForPrompt, } from '@duckcodeailabs/dql-core';
18
18
  import { buildSkillBlockHints, buildSkillMetricHints, buildSkillsPrompt, expandQuestionWithSkillVocabulary, selectRelevantSkills } from './skills/loader.js';
19
+ import { domainContextSearchDomains } from './domain-context.js';
19
20
  import { deriveGeneratedDraftSlug, renderGeneratedSqlDqlArtifact } from './metadata/drafts.js';
20
21
  import { buildAnalysisQuestionPlan } from './metadata/analysis-planner.js';
21
22
  import { certifiedFitAllowsTier1, evaluateCertifiedBlockFit } from './metadata/block-fit.js';
@@ -24,6 +25,8 @@ import { decideAgentAction } from './intent-controller.js';
24
25
  import { createContextLedger } from './grounding/context-ledger.js';
25
26
  import { validateAnswerResultShape } from './answer-shape.js';
26
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';
27
30
  import { planCertifiedAdaptation } from './metadata/block-adapt.js';
28
31
  import { compactSqlSnippet, extractSimpleSelectShape, selectExpressionOutputName, } from './metadata/sql-shape.js';
29
32
  import { composeSemanticQueryForQuestion, composeSemanticQueryFromMembers } from './semantic-bridge/compose.js';
@@ -63,7 +66,7 @@ const EXECUTABLE_ARTIFACT_KINDS = ['block', 'dashboard', 'app', 'notebook'];
63
66
  // be cited, but they never produce the row-level answer to an analytical question,
64
67
  // so they must not terminate a data ask as a "certified answer" with no data.
65
68
  const NAVIGATION_ARTIFACT_KINDS = ['dashboard', 'app', 'notebook'];
66
- 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'];
67
70
  const ARTIFACT_KINDS = [...EXECUTABLE_ARTIFACT_KINDS, ...BUSINESS_CONTEXT_KINDS];
68
71
  const SEMANTIC_KINDS = ['metric', 'dimension', 'measure', 'entity', 'semantic_model', 'saved_query'];
69
72
  const MANIFEST_KINDS = ['dbt_model', 'dbt_source'];
@@ -75,6 +78,46 @@ function refusalCodeForValidation(code) {
75
78
  return 'ambiguous';
76
79
  return 'model_declined';
77
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
+ }
78
121
  function formatOffendingValidationToken(offending) {
79
122
  if (!offending?.relation && !offending?.column)
80
123
  return '';
@@ -166,6 +209,7 @@ export async function answer(input) {
166
209
  const trustLabelInfo = stampTrustLabel(result);
167
210
  return {
168
211
  ...publicResult,
212
+ domainContext: input.domainContext,
169
213
  intentDecision,
170
214
  trustLabelInfo,
171
215
  provenanceFooter: buildProvenanceFooter(result, trustLabelInfo),
@@ -188,7 +232,9 @@ export async function answer(input) {
188
232
  appliedSkills: result.appliedSkills ??
189
233
  selectRelevantSkills(input.skills ?? [], input.question, {
190
234
  userId: input.userId ?? null,
235
+ modelAreaIds: input.domainContext?.modelAreaId ? [input.domainContext.modelAreaId] : [],
191
236
  domains: Array.from(new Set([
237
+ ...domainContextSearchDomains(input.domainContext),
192
238
  ...(input.domain ? [input.domain] : []),
193
239
  ...(input.contextPack?.objects ?? []).slice(0, 20).flatMap((object) => object.domain ? [object.domain] : []),
194
240
  ])),
@@ -219,6 +265,12 @@ function cascadeExecutionStatus(result) {
219
265
  */
220
266
  function deriveAiRoute(result, metricMatch) {
221
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
+ }
222
274
  return { tier: 'no_answer', label: 'No governed answer — needs more context or review.' };
223
275
  }
224
276
  if (result.kind === 'certified') {
@@ -251,16 +303,37 @@ function deriveAiRoute(result, metricMatch) {
251
303
  }
252
304
  async function runAnswerLoop(input) {
253
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;
254
321
  // Select the RELEVANT skills (not all) for this question; keep pinned project
255
322
  // skills (SQL conventions). Block hints still come from the full set so a
256
323
  // preferred-block mapping is never lost.
324
+ const authorizedDomains = domainContextSearchDomains(input.domainContext);
257
325
  const inferredDomains = Array.from(new Set([
326
+ ...authorizedDomains,
327
+ ...questionDomainScope,
258
328
  ...(domain ? [domain] : []),
259
- ...(input.contextPack?.objects ?? []).slice(0, 20).flatMap((object) => object.domain ? [object.domain] : []),
329
+ ...(questionDomainScope.length === 0
330
+ ? (input.contextPack?.objects ?? []).slice(0, 20).flatMap((object) => object.domain ? [object.domain] : [])
331
+ : []),
260
332
  ]));
261
333
  const selectedSkills = selectRelevantSkills(skills, question, {
262
334
  userId: userId ?? null,
263
335
  domains: inferredDomains,
336
+ modelAreaIds: input.domainContext?.modelAreaId ? [input.domainContext.modelAreaId] : [],
264
337
  });
265
338
  const effectiveBlockHints = Array.from(new Set([
266
339
  ...blockHints,
@@ -276,13 +349,14 @@ async function runAnswerLoop(input) {
276
349
  const excludedArtifactIds = input.followUp?.kind === 'drilldown' && followUpSourceBlock
277
350
  ? new Set([followUpSourceBlock.nodeId])
278
351
  : undefined;
279
- const executableArtifactHits = kg.search({ query: question, domain, kinds: EXECUTABLE_ARTIFACT_KINDS, limit: 10 });
280
- 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 });
281
355
  const artifactHits = mergeHits(executableArtifactHits, businessHits).slice(0, 12);
282
- const semanticHits = kg.search({ query: question, domain, kinds: SEMANTIC_KINDS, limit: 12 });
283
- 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 });
284
358
  const considered = mergeHits(artifactHits, semanticHits, manifestHits, kg.search({ query: question, domain, limit: 10 })).slice(0, 30);
285
- const schemaContext = schemaContextWithAllowedSqlContext(input.schemaContext ?? [], input.contextPack);
359
+ const schemaContext = schemaContextWithAllowedSqlContext(schemaContextWithinQuestionScope(input.schemaContext ?? [], input.contextPack, promptContextPack), promptContextPack);
286
360
  const catalogRoute = input.contextPack?.routeDecision;
287
361
  const questionPlan = input.contextPack?.questionPlan?.requestedShape
288
362
  ? input.contextPack.questionPlan
@@ -370,30 +444,80 @@ async function runAnswerLoop(input) {
370
444
  if (NAVIGATION_ARTIFACT_KINDS.includes(artifactHit.node.kind)) {
371
445
  return objectNameInQuestion(question, artifactHit.node);
372
446
  }
373
- // Cold questions fall through only on STRONG data-shape signals — bare
374
- // measure/dimension words are normal in definition asks ("what is revenue
375
- // health?") and must keep answering from the certified business context.
376
- // "Strong" = outputs requested BEYOND the measure/dimension words themselves
377
- // (e.g. product_name), explicit filters, or a top-N.
378
- const requested = questionPlan.requestedShape;
379
- const nonTrivialOutputs = requested.requiredOutputs.filter((output) => !requested.measures.includes(output) && !requested.dimensions.includes(output));
380
- const strongShape = nonTrivialOutputs.length > 0
381
- || requested.filters.length > 0
382
- || Boolean(requested.topN);
383
- 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);
384
454
  })();
385
455
  if (artifactHit && businessContextTerminal) {
386
456
  let result;
387
457
  let executionError;
388
458
  if (artifactHit.node.kind === 'block' && input.executeCertifiedBlock) {
389
459
  try {
390
- result = await input.executeCertifiedBlock(artifactHit.node);
460
+ result = await input.executeCertifiedBlock(artifactHit.node, { question });
391
461
  result = trimResultToRequestedTopN(result, questionPlan);
392
462
  }
393
463
  catch (err) {
394
464
  executionError = err instanceof Error ? err.message : String(err);
395
465
  }
396
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
+ }
397
521
  const resultShapeWarnings = result ? validateAnswerResultShape(questionPlan, result).warnings : [];
398
522
  // When a certified block's execution was ATTEMPTED and FAILED, the answer
399
523
  // cannot wear the certified badge — a failed run has no data to stand behind.
@@ -573,7 +697,7 @@ async function runAnswerLoop(input) {
573
697
  { hits: businessHits.slice(0, 4), reserve: 3 },
574
698
  { hits: manifestHits, reserve: 3 },
575
699
  ], 14);
576
- const contextNodes = mergeNodes(followUpSourceBlock && input.followUp?.kind === 'drilldown' ? [followUpSourceBlock] : [], (contextHits.length > 0 ? contextHits : considered.slice(0, 6)).map((h) => h.node));
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));
577
701
  const kgJoinPathHints = buildKgJoinPathHints(kg, contextNodes, questionPlan);
578
702
  const contextBlocks = contextNodes.filter((n) => n.kind === 'block');
579
703
  const contextBusiness = contextNodes.filter((n) => BUSINESS_CONTEXT_KINDS.includes(n.kind));
@@ -589,9 +713,32 @@ async function runAnswerLoop(input) {
589
713
  const skillsPrompt = buildSkillsPrompt(selectedSkills, userId ?? null);
590
714
  if (skillsPrompt)
591
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
+ }
592
739
  messages.push({
593
740
  role: 'system',
594
- content: renderContextPrompt(contextBlocks, contextBusiness, contextOther, activeTier, input.memoryContext ?? [], input.extraContext, input.followUp, schemaContext, intent, input.contextPack, input.conversationSnapshot, kgJoinPathHints, promptBudget, input.semanticDriver),
741
+ content: renderContextPrompt(contextBlocks, contextBusiness, contextOther, activeTier, input.memoryContext ?? [], input.extraContext, input.followUp, schemaContext, intent, promptContextPack, input.conversationSnapshot, kgJoinPathHints, promptBudget, input.semanticDriver),
595
742
  });
596
743
  messages.push({ role: 'user', content: question });
597
744
  // ── Tier 2: semantic-layer metrics + dimensions (governed hierarchy) ──────
@@ -904,7 +1051,7 @@ async function runAnswerLoop(input) {
904
1051
  || (input.contextPack?.allowedSqlContext?.relations.length ?? 0) > 0
905
1052
  || (input.contextPack?.allowedSqlContext?.sourceBlockSql.length ?? 0) > 0
906
1053
  || contextBlocks.length > 0;
907
- if (!parsed.sql && !governedMetricAnswer && wantsGeneratedData && hasGeneratableContext) {
1054
+ if (!parsed.sql && !governedMetricAnswer && wantsGeneratedData && hasGeneratableContext && analyticalPlan?.safe !== false) {
908
1055
  try {
909
1056
  proposed = await generateProposalWithOptionalTools({
910
1057
  provider,
@@ -933,9 +1080,11 @@ async function runAnswerLoop(input) {
933
1080
  // every run and every surface — instead of passing through the model's varying
934
1081
  // text. A genuinely context-less ask keeps the plain honest message.
935
1082
  const declinedDespiteContext = wantsGeneratedData && hasGeneratableContext;
936
- const text = declinedDespiteContext
937
- ? '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.'
938
- : 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).';
939
1088
  return {
940
1089
  kind: 'no_answer',
941
1090
  sourceTier: 'no_answer',
@@ -943,9 +1092,13 @@ async function runAnswerLoop(input) {
943
1092
  reviewStatus: 'none',
944
1093
  confidence: 0.1,
945
1094
  text,
946
- refusalCode: 'model_declined',
1095
+ refusalCode: analyticalPlan?.safe === false
1096
+ ? refusalCodeForAnalyticalPolicy(analyticalPlan.code)
1097
+ : 'model_declined',
947
1098
  refusalDetails: {
948
- code: 'model_declined',
1099
+ code: analyticalPlan?.safe === false
1100
+ ? analyticalPlan.code ?? 'unsafe_relationship'
1101
+ : 'model_declined',
949
1102
  message: text,
950
1103
  },
951
1104
  answer: text,
@@ -981,20 +1134,27 @@ async function runAnswerLoop(input) {
981
1134
  && questionPlan.requestedShape.filters.length === 0
982
1135
  && !questionPlan.requestedShape.topN;
983
1136
  let contextValidation;
984
- if (governedMetricAnswer) {
985
- contextValidation = { ok: true, warnings: [] };
986
- }
987
- else {
988
- const c = contextLedger.validateSql(parsed.sql, {
989
- question,
990
- intent,
991
- filterValues: input.followUp?.filters,
992
- trustedFilterValues: trustedFollowUpFilterValues(input.followUp),
993
- });
994
- contextValidation = c.ok
995
- ? { ok: true, warnings: c.warnings }
996
- : { ok: false, code: c.code, error: c.error, warnings: c.warnings, offending: c.offending };
997
- }
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;
998
1158
  // Spec 17, part C — semantic-metric route recovery. A metric question is often
999
1159
  // catalog-routed to clarify, leaving a thin contextPack that rejects otherwise-valid
1000
1160
  // SQL. Rather than refuse, recover in two steps:
@@ -1188,6 +1348,70 @@ async function runAnswerLoop(input) {
1188
1348
  providerUsed: provider.name,
1189
1349
  };
1190
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
+ }
1191
1415
  const generatedCitations = [
1192
1416
  ...contextPackCitations(input.contextPack, 4),
1193
1417
  ...contextNodes.slice(0, 4).map((n) => ({
@@ -1535,12 +1759,26 @@ Rules:
1535
1759
  Design the SELECT for a business reader: prefer a joined/display name, label,
1536
1760
  title, or business value over raw *_id, *_uuid, *_key, or technical codes.
1537
1761
  Alias calculated outputs with clear business names. Include identifiers only
1538
- when the question asks for them or no readable field exists.
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.
1539
1772
  4. In summary, state your QUERY PLAN first: the grain (one row per WHAT), the
1540
1773
  measures and how they aggregate, the dimensions/filters, and the exact join
1541
1774
  path + join keys between the grounded tables. Then make the SQL match that
1542
1775
  plan — an explicit grain and join path prevents wrong-grain answers and
1543
- fan-out (row-multiplying) joins.
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.
1544
1782
  5. Choose a visualization deliberately in the "viz" JSON field: use single_value
1545
1783
  or kpi for one aggregate, line/area only for an ordered time series, bar for a
1546
1784
  categorical comparison, grouped-bar for multiple measures by one category,
@@ -1581,12 +1819,165 @@ Rules:
1581
1819
  // combined dataset — show them separately" refusal into the join the user asked
1582
1820
  // for, while still allowing an honest refusal if context is truly missing.
1583
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.`;
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
+ }
1584
1975
  // Metric-anchored generation (Tier 2.5): a governed metric matched the question but
1585
1976
  // the semantic layer couldn't compose the exact shape. Reuse the metric's CERTIFIED
1586
1977
  // definition as the measure rather than reinventing it, so the number stays consistent
1587
1978
  // with the governed metric — only the join/grouping around it is generated.
1588
1979
  function metricAnchorInstruction(metricName, def) {
1589
- 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 that computes exactly this expression as the measure, joining ${def.table} to the other grounded tables along their documented keys to add the requested dimensions, then GROUP BY those dimensions. State the join path and grain in the summary. Reusing the governed definition keeps the answer consistent with the certified metric; only fall back to redefining the measure if ${def.table} genuinely cannot be joined to the requested breakdown.`;
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.`;
1590
1981
  }
1591
1982
  function renderContextPrompt(blocks, businessContext, others, activeTier, memoryContext, extraContext, followUp, schemaContext = [], intent = 'ad_hoc_analysis', contextPack, conversationSnapshot, kgJoinPathHints = [], budget = QUICK_PROMPT_CONTEXT_BUDGET, driver) {
1592
1983
  // W1.5 — dialect conventions so the model writes warehouse-correct SQL