@duckcodeailabs/dql-agent 1.8.2 → 1.8.4

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 (61) hide show
  1. package/dist/agent-run-engine.d.ts +1 -1
  2. package/dist/agent-run-engine.d.ts.map +1 -1
  3. package/dist/agent-run-gates.d.ts.map +1 -1
  4. package/dist/agent-run-gates.js +2 -1
  5. package/dist/agent-run-gates.js.map +1 -1
  6. package/dist/agent-run-store.d.ts +33 -0
  7. package/dist/agent-run-store.d.ts.map +1 -0
  8. package/dist/agent-run-store.js +167 -0
  9. package/dist/agent-run-store.js.map +1 -0
  10. package/dist/agentic/answer-contract.d.ts +1 -0
  11. package/dist/agentic/answer-contract.d.ts.map +1 -1
  12. package/dist/agentic/answer-contract.js.map +1 -1
  13. package/dist/agentic/toolset.d.ts +7 -0
  14. package/dist/agentic/toolset.d.ts.map +1 -1
  15. package/dist/agentic/toolset.js +24 -3
  16. package/dist/agentic/toolset.js.map +1 -1
  17. package/dist/answer-loop.d.ts +20 -5
  18. package/dist/answer-loop.d.ts.map +1 -1
  19. package/dist/answer-loop.js +384 -26
  20. package/dist/answer-loop.js.map +1 -1
  21. package/dist/grounding/context-ledger.d.ts +2 -0
  22. package/dist/grounding/context-ledger.d.ts.map +1 -1
  23. package/dist/grounding/context-ledger.js +5 -1
  24. package/dist/grounding/context-ledger.js.map +1 -1
  25. package/dist/index.d.ts +6 -3
  26. package/dist/index.d.ts.map +1 -1
  27. package/dist/index.js +5 -3
  28. package/dist/index.js.map +1 -1
  29. package/dist/metadata/analytical-policy.d.ts +40 -2
  30. package/dist/metadata/analytical-policy.d.ts.map +1 -1
  31. package/dist/metadata/analytical-policy.js +276 -53
  32. package/dist/metadata/analytical-policy.js.map +1 -1
  33. package/dist/metadata/block-fit.js +18 -2
  34. package/dist/metadata/block-fit.js.map +1 -1
  35. package/dist/metadata/catalog.d.ts.map +1 -1
  36. package/dist/metadata/catalog.js +10 -3
  37. package/dist/metadata/catalog.js.map +1 -1
  38. package/dist/metadata/dbt-first-safety.d.ts +9 -2
  39. package/dist/metadata/dbt-first-safety.d.ts.map +1 -1
  40. package/dist/metadata/dbt-first-safety.js +8 -3
  41. package/dist/metadata/dbt-first-safety.js.map +1 -1
  42. package/dist/metadata/health.d.ts +19 -0
  43. package/dist/metadata/health.d.ts.map +1 -0
  44. package/dist/metadata/health.js +72 -0
  45. package/dist/metadata/health.js.map +1 -0
  46. package/dist/metadata/sql-context-validation.d.ts +2 -0
  47. package/dist/metadata/sql-context-validation.d.ts.map +1 -1
  48. package/dist/metadata/sql-context-validation.js +31 -5
  49. package/dist/metadata/sql-context-validation.js.map +1 -1
  50. package/dist/metadata/sql-grounding.d.ts +1 -1
  51. package/dist/metadata/sql-grounding.d.ts.map +1 -1
  52. package/dist/metadata/sql-grounding.js +2 -2
  53. package/dist/metadata/sql-grounding.js.map +1 -1
  54. package/dist/semantic-bridge/compose.d.ts +10 -0
  55. package/dist/semantic-bridge/compose.d.ts.map +1 -1
  56. package/dist/semantic-bridge/compose.js +50 -18
  57. package/dist/semantic-bridge/compose.js.map +1 -1
  58. package/dist/tools/registry.d.ts.map +1 -1
  59. package/dist/tools/registry.js +14 -9
  60. package/dist/tools/registry.js.map +1 -1
  61. package/package.json +4 -4
@@ -26,10 +26,10 @@ import { createContextLedger } from './grounding/context-ledger.js';
26
26
  import { validateAnswerResultShape } from './answer-shape.js';
27
27
  import { fanoutWarningsForSql } from './metadata/grain-ledger.js';
28
28
  import { evaluateDbtFirstGeneratedSql } from './metadata/dbt-first-safety.js';
29
- import { planAnalyticalPath } from './metadata/analytical-policy.js';
29
+ import { planAnalyticalPath, humanizeAnalyticalEntityId, analyticalPolicyUserFacingReason, } from './metadata/analytical-policy.js';
30
30
  import { planCertifiedAdaptation } from './metadata/block-adapt.js';
31
31
  import { compactSqlSnippet, extractSimpleSelectShape, selectExpressionOutputName, } from './metadata/sql-shape.js';
32
- import { composeSemanticQueryForQuestion, composeSemanticQueryFromMembers, } from './semantic-bridge/compose.js';
32
+ import { composeSemanticQueryForQuestion, composeSemanticQueryFromCompiledMembers, composeSemanticQueryFromMembers, } from './semantic-bridge/compose.js';
33
33
  import { runAgenticToolLoop } from './agentic/tool-loop.js';
34
34
  import { buildSemanticStageTools } from './agentic/toolset.js';
35
35
  import { deriveAgenticTrust } from './agentic/answer-contract.js';
@@ -125,6 +125,93 @@ export function resolveAgentFilterValueBindings(value, schemaContext) {
125
125
  && normalizeValueIndexText(candidate.canonicalValue) === normalizeValueIndexText(best.canonicalValue))
126
126
  .map(({ distance: _distance, ...binding }) => binding);
127
127
  }
128
+ /**
129
+ * AGT-005/AGT-012 — turn bounded value-index evidence into typed question
130
+ * state before route compatibility is evaluated. The value index, never an
131
+ * LLM or a metadata-token match, owns the canonical warehouse member. A value
132
+ * that matches multiple dimensions is intentionally left unbound so the
133
+ * certified lane cannot silently run with the wrong parameter.
134
+ */
135
+ function questionPlanWithResolvedMemberBindings(plan, schemaContext) {
136
+ const existing = plan.requestedShape.memberBindings ?? [];
137
+ const existingValues = new Set(existing.flatMap((binding) => binding.values.map(normalizeValueIndexText)));
138
+ const candidates = Array.from(new Set([
139
+ ...(plan.valueMentions ?? []).map((mention) => mention.text.trim()),
140
+ ...(plan.requestedShape.filters ?? []).map((value) => value.trim()),
141
+ ].filter(Boolean)));
142
+ const resolved = candidates.flatMap((value) => {
143
+ if (existingValues.has(normalizeValueIndexText(value)))
144
+ return [];
145
+ const bindings = resolveAgentFilterValueBindings(value, schemaContext);
146
+ const dimensions = Array.from(new Set(bindings.map((binding) => binding.column.trim()).filter(Boolean)));
147
+ const canonicalValues = Array.from(new Set(bindings.map((binding) => binding.canonicalValue.trim()).filter(Boolean)));
148
+ if (dimensions.length !== 1 || canonicalValues.length !== 1)
149
+ return [];
150
+ const binding = bindings[0];
151
+ return [{
152
+ dimension: dimensions[0],
153
+ values: [canonicalValues[0]],
154
+ source: 'question',
155
+ confidence: binding.match === 'fuzzy' ? 'unique_partial' : 'exact',
156
+ }];
157
+ });
158
+ if (resolved.length === 0)
159
+ return plan;
160
+ const byIdentity = new Map();
161
+ for (const binding of [...existing, ...resolved]) {
162
+ const key = `${normalizeValueIndexText(binding.dimension)}\0${binding.values.map(normalizeValueIndexText).join('\0')}`;
163
+ if (!byIdentity.has(key))
164
+ byIdentity.set(key, binding);
165
+ }
166
+ return {
167
+ ...plan,
168
+ requestedShape: {
169
+ ...plan.requestedShape,
170
+ memberBindings: Array.from(byIdentity.values()),
171
+ },
172
+ };
173
+ }
174
+ /**
175
+ * Map typed member bindings to a block's declared parameter contract. The
176
+ * mapping is identifier-based (parameter name, semantic field, or declared
177
+ * filter binding) and must select exactly one parameter. Structural SQL is
178
+ * never accepted as a value.
179
+ */
180
+ function certifiedInvocationInputs(block, plan) {
181
+ const definitions = block.parameters ?? [];
182
+ if (definitions.length === 0)
183
+ return {};
184
+ const parameters = {};
185
+ const parameterSources = {};
186
+ const declaredFilterBindings = new Map((block.filterBindings ?? []).map((entry) => [entry.filter, entry.binding]));
187
+ const topN = plan.requestedShape.topN?.n;
188
+ if (topN) {
189
+ const limitCandidates = definitions.filter((definition) => definition.name === 'top_n' || definition.binding?.kind === 'limit');
190
+ if (limitCandidates.length === 1) {
191
+ parameters[limitCandidates[0].name] = topN;
192
+ parameterSources[limitCandidates[0].name] = 'question';
193
+ }
194
+ }
195
+ for (const member of plan.requestedShape.memberBindings ?? []) {
196
+ const candidates = definitions.filter((definition) => {
197
+ if (Object.prototype.hasOwnProperty.call(parameters, definition.name))
198
+ return false;
199
+ const target = definition.binding?.kind === 'semantic_filter'
200
+ ? definition.binding.field
201
+ : declaredFilterBindings.get(definition.name) ?? definition.name;
202
+ return outputConceptMatches(target, member.dimension);
203
+ });
204
+ if (candidates.length !== 1)
205
+ continue;
206
+ const definition = candidates[0];
207
+ const values = Array.from(new Set(member.values.map((value) => value.trim()).filter(Boolean)));
208
+ if (values.length === 0 || (!definition.type.endsWith('[]') && values.length !== 1))
209
+ continue;
210
+ parameters[definition.name] = definition.type.endsWith('[]') ? values : values[0];
211
+ parameterSources[definition.name] = member.source === 'prior_result' ? 'prior_result' : 'question';
212
+ }
213
+ return Object.keys(parameters).length > 0 ? { parameters, parameterSources } : {};
214
+ }
128
215
  function damerauLevenshteinDistance(left, right) {
129
216
  const rows = left.length + 1;
130
217
  const columns = right.length + 1;
@@ -158,6 +245,41 @@ const BUSINESS_CONTEXT_KINDS = ['term', 'business_view', 'domain', 'skill', 'rel
158
245
  const ARTIFACT_KINDS = [...EXECUTABLE_ARTIFACT_KINDS, ...BUSINESS_CONTEXT_KINDS];
159
246
  const SEMANTIC_KINDS = ['metric', 'dimension', 'measure', 'entity', 'semantic_model', 'saved_query'];
160
247
  const MANIFEST_KINDS = ['dbt_model', 'dbt_source'];
248
+ /**
249
+ * Business-language explanation for a failed SQL context validation. The chat
250
+ * surface shows THIS; the validator's machine message (relation ids, repair
251
+ * tool guidance) belongs in refusalDetails/validationWarnings only.
252
+ */
253
+ function renderContextValidationRefusalForUser(code, machineError, memberBindings) {
254
+ switch (code) {
255
+ case 'unknown_relation':
256
+ return 'I drafted a query, but it uses a table that was not part of the metadata retrieved for this question, so I did not run it. Ask again (retrieval usually finds it on a follow-up), name the table or metric explicitly, or re-sync the dbt metadata.';
257
+ case 'unknown_column':
258
+ return 'I drafted a query, but it references a column that is not in the inspected metadata for those tables, so I did not run it. Name the exact field you mean, or re-sync the dbt metadata if the column is new.';
259
+ case 'misbound_filter': {
260
+ const binding = memberBindings?.[0];
261
+ const target = binding ? `${humanizeAnalyticalEntityId(binding.dimension)} "${binding.values[0] ?? ''}"` : 'the requested value';
262
+ return `I need to filter this answer to ${target}, but the query I drafted did not apply that filter to a matching column, so I did not run it. Tell me which column identifies ${binding ? humanizeAnalyticalEntityId(binding.dimension) : 'that value'}, or rephrase with the exact field name.`;
263
+ }
264
+ case 'ambiguous_filter':
265
+ return 'The value you asked about matches more than one column in the inspected data, so I did not guess. Tell me which field it belongs to and I will run it.';
266
+ case 'missing_baseline':
267
+ return 'This comparison needs a baseline period or value that the drafted query did not include. Say what to compare against (for example, the prior month) and I will run it.';
268
+ case 'unsafe_sql':
269
+ return 'The drafted query used a statement type that is not allowed in this governed preview, so I did not run it.';
270
+ case 'insufficient_context':
271
+ if (/could not be parsed|parse error|syntax/i.test(machineError)) {
272
+ return 'I drafted a query, but its SQL syntax did not match the connected warehouse, so I did not run it. The failed draft is available in Inspect; retrying will generate a warehouse-specific query.';
273
+ }
274
+ return machineError.trim().length > 0 && !/inspect_metadata_context/i.test(machineError)
275
+ ? `I could not prepare a governed query yet: ${machineError.replace(/\s*Use inspect_metadata_context[^.]*\.\s*$/i, '').trim()}`
276
+ : 'I could not prepare a governed query from the retrieved metadata. Name the specific metric or table and how to break it down, and I can generate a review-required draft.';
277
+ default:
278
+ return machineError.trim().length > 0 && !/inspect_metadata_context/i.test(machineError)
279
+ ? `I could not prepare a governed query yet: ${machineError.replace(/\s*Use inspect_metadata_context[^.]*\.\s*$/i, '').trim()}`
280
+ : 'I could not prepare a governed query from the retrieved metadata. Name the specific metric or table and how to break it down, and I can generate a review-required draft.';
281
+ }
282
+ }
161
283
  function refusalCodeForValidation(code) {
162
284
  if (code === 'unknown_relation' || code === 'unknown_column' || code === 'insufficient_context' || code === 'missing_baseline') {
163
285
  return 'grounding_gap';
@@ -187,6 +309,22 @@ function refusalCodeForAnalyticalPolicy(code, hasExploratoryCandidate = false) {
187
309
  * denials and must remain terminal in this loop.
188
310
  */
189
311
  function exploratoryCandidateFromDbtFirstGuard(sql, decision) {
312
+ // The policy service's disposition is authoritative: a declared draft path
313
+ // re-bound to this SQL's actual joins enters the bounded lane directly.
314
+ if (decision.disposition === 'exploratory_candidate') {
315
+ return {
316
+ kind: 'dbt_grounded_exploration',
317
+ reason: 'relationship_not_certified',
318
+ sql,
319
+ message: decision.technicalDetail ?? decision.message
320
+ ?? 'This query follows a declared but uncertified DQL relationship path.',
321
+ userFacingReason: decision.userFacingReason,
322
+ modeledEntityIds: decision.entities,
323
+ relationshipIds: decision.relationshipIds,
324
+ exploratoryPath: decision.exploratoryPath,
325
+ executionStatus: 'not_executed',
326
+ };
327
+ }
190
328
  const reason = decision.code;
191
329
  const missingPathWithoutUnsafeProof = reason === 'unsafe_relationship' && decision.relationshipIds.length === 0;
192
330
  if (decision.safe || (!missingPathWithoutUnsafeProof
@@ -207,6 +345,7 @@ function exploratoryCandidateFromDbtFirstGuard(sql, decision) {
207
345
  sql,
208
346
  message: decision.message
209
347
  ?? 'This query is grounded in dbt metadata but has no certified DQL relationship path yet.',
348
+ userFacingReason: decision.userFacingReason,
210
349
  modeledEntityIds: decision.entities,
211
350
  relationshipIds: decision.relationshipIds,
212
351
  executionStatus: 'not_executed',
@@ -452,9 +591,10 @@ async function runAnswerLoop(input) {
452
591
  const considered = mergeHits(artifactHits, semanticHits, manifestHits, kg.search({ query: question, domain, limit: 10 })).slice(0, 30);
453
592
  const schemaContext = schemaContextWithAllowedSqlContext(schemaContextWithinQuestionScope(input.schemaContext ?? [], input.contextPack, scopedContextPack), scopedContextPack);
454
593
  const catalogRoute = input.contextPack?.routeDecision;
455
- const questionPlan = input.contextPack?.questionPlan?.requestedShape
594
+ const baseQuestionPlan = input.contextPack?.questionPlan?.requestedShape
456
595
  ? input.contextPack.questionPlan
457
596
  : buildAnalysisQuestionPlan(question, input.followUp);
597
+ const questionPlan = questionPlanWithResolvedMemberBindings(baseQuestionPlan, schemaContext);
458
598
  // Retrieval may surface a high-trust block because its source tables and
459
599
  // vocabulary overlap the question even when its output contract does not.
460
600
  // Keep such candidates in the audit trail, but do not put their SQL or a stale
@@ -500,7 +640,15 @@ async function runAnswerLoop(input) {
500
640
  kg,
501
641
  })
502
642
  : null;
503
- const shouldTryCertifiedRoute = shouldUseCertifiedRoute(catalogRoute, intent);
643
+ const explicitlyRequestedCertifiedBlock = effectiveBlockHints.some((hint) => {
644
+ const node = kg.getNode(`block:${hint}`);
645
+ return Boolean(node
646
+ && node.status === 'certified'
647
+ && objectNameInQuestion(question, node)
648
+ && /\b(run|use|open|show|execute|certified|saved|block)\b/i.test(question));
649
+ });
650
+ const shouldTryCertifiedRoute = shouldUseCertifiedRoute(catalogRoute, intent)
651
+ || explicitlyRequestedCertifiedBlock;
504
652
  const catalogCertifiedHit = shouldTryCertifiedRoute
505
653
  ? certifiedHitFromContextPack(input.contextPack, kg)
506
654
  : null;
@@ -567,7 +715,10 @@ async function runAnswerLoop(input) {
567
715
  let executionError;
568
716
  if (artifactHit.node.kind === 'block' && input.executeCertifiedBlock) {
569
717
  try {
570
- result = await input.executeCertifiedBlock(artifactHit.node, { question });
718
+ result = await input.executeCertifiedBlock(artifactHit.node, {
719
+ question,
720
+ ...certifiedInvocationInputs(artifactHit.node, questionPlan),
721
+ });
571
722
  result = trimResultToRequestedTopN(result, questionPlan);
572
723
  }
573
724
  catch (err) {
@@ -882,6 +1033,8 @@ async function runAnswerLoop(input) {
882
1033
  let governedMetricAnswer = false;
883
1034
  const semanticBridgeToolCalls = [];
884
1035
  let semanticBridgeAnswer;
1036
+ let semanticRuntimeFailure;
1037
+ let semanticRuntimeCompiledAnswer = false;
885
1038
  if (input.semanticLayer && semanticMetricMatch) {
886
1039
  semanticBridgeAnswer = composeSemanticQueryForQuestion({
887
1040
  semanticLayer: input.semanticLayer,
@@ -893,6 +1046,44 @@ async function runAnswerLoop(input) {
893
1046
  ...(input.semanticDriver ? { driver: input.semanticDriver } : {}),
894
1047
  ...(input.semanticTableMapping ? { tableMapping: input.semanticTableMapping } : {}),
895
1048
  });
1049
+ // Exact scalar metrics need no AI member selection. If the native compiler
1050
+ // cannot express a derived/ratio/cumulative metric, hand the identifier to
1051
+ // the host's shared dbt Cloud/MetricFlow adapter immediately.
1052
+ if (!semanticBridgeAnswer
1053
+ && input.semanticQueryCompiler
1054
+ && questionPlan.requestedShape.dimensions.length === 0
1055
+ && questionPlan.dimensionTerms.length === 0
1056
+ && questionPlan.requestedShape.filters.length === 0
1057
+ && questionPlan.timeTerms.length === 0) {
1058
+ const matchedName = input.semanticLayer.listMetrics().find((metric) => metric.name === semanticMetricMatch.metric.name
1059
+ || metric.name.endsWith(`.${semanticMetricMatch.metric.name}`)
1060
+ || semanticMetricMatch.metric.name.endsWith(`.${metric.name}`))?.name;
1061
+ if (matchedName) {
1062
+ const selection = { metrics: [matchedName], dimensions: [] };
1063
+ try {
1064
+ const compiled = await input.semanticQueryCompiler(selection);
1065
+ semanticBridgeAnswer = composeSemanticQueryFromCompiledMembers({
1066
+ semanticLayer: input.semanticLayer,
1067
+ question,
1068
+ selection,
1069
+ sql: compiled.sql,
1070
+ });
1071
+ if (semanticBridgeAnswer) {
1072
+ semanticRuntimeCompiledAnswer = compiled.engine !== 'native';
1073
+ semanticBridgeToolCalls.push({
1074
+ name: 'compile_semantic_query',
1075
+ status: 'checked',
1076
+ inputSummary: `metric: ${matchedName}`,
1077
+ outputSummary: `Compiled through ${compiled.engine}`,
1078
+ order: semanticBridgeToolCalls.length + 1,
1079
+ });
1080
+ }
1081
+ }
1082
+ catch (error) {
1083
+ semanticRuntimeFailure = error instanceof Error ? error.message : String(error);
1084
+ }
1085
+ }
1086
+ }
896
1087
  // Lane-2 LLM fallback: the deterministic token matcher missed, but a metric
897
1088
  // matched so the semantic layer IS relevant. Spend ONE call to have the model
898
1089
  // pick members (the query_semantic_model contract); the compiler still owns the
@@ -907,13 +1098,37 @@ async function runAnswerLoop(input) {
907
1098
  reasoningEffort: input.reasoningEffort,
908
1099
  });
909
1100
  if (selection) {
910
- const composed = composeSemanticQueryFromMembers({
1101
+ let composed = composeSemanticQueryFromMembers({
911
1102
  semanticLayer: input.semanticLayer,
912
1103
  question,
913
1104
  selection,
914
1105
  ...(input.semanticDriver ? { driver: input.semanticDriver } : {}),
915
1106
  ...(input.semanticTableMapping ? { tableMapping: input.semanticTableMapping } : {}),
916
1107
  });
1108
+ if (!composed && input.semanticQueryCompiler) {
1109
+ try {
1110
+ const compiled = await input.semanticQueryCompiler(selection);
1111
+ composed = composeSemanticQueryFromCompiledMembers({
1112
+ semanticLayer: input.semanticLayer,
1113
+ question,
1114
+ selection,
1115
+ sql: compiled.sql,
1116
+ });
1117
+ if (composed) {
1118
+ semanticRuntimeCompiledAnswer = compiled.engine !== 'native';
1119
+ semanticBridgeToolCalls.push({
1120
+ name: 'compile_semantic_query',
1121
+ status: 'checked',
1122
+ inputSummary: `metrics: ${selection.metrics.join(', ')}${selection.dimensions?.length ? `; by ${selection.dimensions.join(', ')}` : ''}`,
1123
+ outputSummary: `Compiled through ${compiled.engine}`,
1124
+ order: semanticBridgeToolCalls.length + 1,
1125
+ });
1126
+ }
1127
+ }
1128
+ catch (error) {
1129
+ semanticRuntimeFailure = error instanceof Error ? error.message : String(error);
1130
+ }
1131
+ }
917
1132
  // Coverage guard: if the question asked for a breakdown but the LLM
918
1133
  // selection produced none, the governed answer would silently DROP the
919
1134
  // requested grouping (governed-but-wrong). Fall through to Lane-3
@@ -938,6 +1153,36 @@ async function runAnswerLoop(input) {
938
1153
  }
939
1154
  }
940
1155
  }
1156
+ if (!semanticBridgeAnswer && semanticRuntimeFailure) {
1157
+ const text = `The governed semantic metric was found, but its semantic runtime could not compile the request: ${semanticRuntimeFailure}`;
1158
+ return {
1159
+ kind: 'no_answer',
1160
+ sourceTier: 'no_answer',
1161
+ certification: 'analyst_review_required',
1162
+ reviewStatus: 'none',
1163
+ confidence: 0,
1164
+ text,
1165
+ answer: text,
1166
+ refusalCode: 'modeling_gap',
1167
+ refusalDetails: { code: 'semantic_runtime_required', message: text },
1168
+ citations: [],
1169
+ memoryContext: input.memoryContext,
1170
+ evidence: buildNoAnswerEvidence({
1171
+ question,
1172
+ reason: text,
1173
+ artifactHits,
1174
+ businessHits,
1175
+ semanticHits,
1176
+ manifestHits,
1177
+ considered,
1178
+ memoryContext: input.memoryContext ?? [],
1179
+ toolCalls: semanticBridgeToolCalls,
1180
+ }),
1181
+ contextPack: input.contextPack,
1182
+ considered,
1183
+ providerUsed: provider.name,
1184
+ };
1185
+ }
941
1186
  const metricFirst = semanticMetricMatch
942
1187
  ? buildGovernedMetricFirstSql({
943
1188
  metric: semanticMetricMatch.metric,
@@ -954,6 +1199,7 @@ async function runAnswerLoop(input) {
954
1199
  let contextLedger = createContextLedger({
955
1200
  contextPack: input.contextPack,
956
1201
  schemaContext,
1202
+ dialect: input.semanticDriver,
957
1203
  });
958
1204
  // W2.2 — certified-block adaptation lane. When a certified block is context-only
959
1205
  // ONLY because the question adds exactly one filter whose value maps to a column
@@ -996,6 +1242,7 @@ async function runAnswerLoop(input) {
996
1242
  kg,
997
1243
  driver: input.semanticDriver,
998
1244
  tableMapping: input.semanticTableMapping,
1245
+ semanticQueryCompiler: input.semanticQueryCompiler,
999
1246
  onCompiled: (record) => compiledSemanticRecords.push(record),
1000
1247
  }),
1001
1248
  ];
@@ -1110,6 +1357,7 @@ async function runAnswerLoop(input) {
1110
1357
  if (trust.tier === 'semantic_metric' && trust.compiled) {
1111
1358
  governedMetricAnswer = true;
1112
1359
  const compiled = trust.compiled;
1360
+ semanticRuntimeCompiledAnswer = compiled.engine === 'metricflow-cli' || compiled.engine === 'dbt-cloud';
1113
1361
  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.`;
1114
1362
  parsed = { ...parsed, text: parsed.text ? `${parsed.text}\n\n${governedNote}` : governedNote };
1115
1363
  proposalToolCalls.push({
@@ -1209,7 +1457,12 @@ async function runAnswerLoop(input) {
1209
1457
  });
1210
1458
  parsed = parseProposal(proposed);
1211
1459
  }
1212
- catch {
1460
+ catch (err) {
1461
+ // Deadline/cancellation must surface as itself, never as a policy refusal.
1462
+ if (input.signal?.aborted)
1463
+ throw input.signal.reason ?? err;
1464
+ if (err instanceof Error && (err.name === 'AbortError' || err.name === 'TimeoutError'))
1465
+ throw err;
1213
1466
  // keep the original decline; fall through to the honest no_answer.
1214
1467
  }
1215
1468
  }
@@ -1222,9 +1475,15 @@ async function runAnswerLoop(input) {
1222
1475
  // consistent, actionable message so the same question yields the same outcome
1223
1476
  // every run and every surface — instead of passing through the model's varying
1224
1477
  // text. A genuinely context-less ask keeps the plain honest message.
1478
+ // The user-facing text is BUSINESS language; the machine policy detail
1479
+ // (qualified ids, codes) lives only in refusalDetails + validationWarnings.
1225
1480
  const declinedDespiteContext = wantsGeneratedData && hasGeneratableContext;
1481
+ const policyDetail = analyticalPlan?.safe === false
1482
+ ? analyticalPlan.technicalDetail ?? analyticalPlan.message ?? 'DQL could not prove a safe analytical relationship path.'
1483
+ : undefined;
1226
1484
  const text = analyticalPlan?.safe === false
1227
- ? analyticalPlan.message ?? 'DQL could not prove a safe analytical relationship path.'
1485
+ ? analyticalPlan.userFacingReason
1486
+ ?? analyticalPolicyUserFacingReason(analyticalPlan.code ?? 'unsafe_relationship', analyticalPlan.entities)
1228
1487
  : declinedDespiteContext
1229
1488
  ? '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.'
1230
1489
  : parsed.text || 'No answer (the model declined to propose SQL).';
@@ -1236,14 +1495,17 @@ async function runAnswerLoop(input) {
1236
1495
  confidence: 0.1,
1237
1496
  text,
1238
1497
  refusalCode: analyticalPlan?.safe === false
1239
- ? refusalCodeForAnalyticalPolicy(analyticalPlan.code)
1498
+ ? analyticalPlan.disposition === 'exploratory_candidate'
1499
+ ? 'modeling_gap'
1500
+ : refusalCodeForAnalyticalPolicy(analyticalPlan.code, analyticalPlanAllowsDbtExploration(analyticalPlan))
1240
1501
  : 'model_declined',
1241
1502
  refusalDetails: {
1242
1503
  code: analyticalPlan?.safe === false
1243
1504
  ? analyticalPlan.code ?? 'unsafe_relationship'
1244
1505
  : 'model_declined',
1245
- message: text,
1506
+ message: policyDetail ?? text,
1246
1507
  },
1508
+ validationWarnings: policyDetail ? [`Analytical policy detail: ${policyDetail}`] : undefined,
1247
1509
  answer: text,
1248
1510
  citations: [],
1249
1511
  memoryContext: input.memoryContext,
@@ -1292,13 +1554,19 @@ async function runAnswerLoop(input) {
1292
1554
  && questionPlan.requestedShape.filters.length === 0
1293
1555
  && !questionPlan.requestedShape.topN;
1294
1556
  let contextValidation;
1295
- const initialValidation = contextLedger.validateSql(parsed.sql, {
1296
- question,
1297
- intent,
1298
- filterValues: input.followUp?.filters,
1299
- trustedFilterValues: trustedFollowUpFilterValues(input.followUp),
1300
- memberBindings: input.followUp?.memberBindings,
1301
- });
1557
+ // dbt Cloud/MetricFlow SQL has already been compiled against the selected
1558
+ // semantic environment. A thin local retrieval pack cannot safely reject it
1559
+ // and replace it with a guessed leaf-measure SQL. Warehouse execution remains
1560
+ // the final binder/runtime check.
1561
+ const initialValidation = semanticRuntimeCompiledAnswer
1562
+ ? { ok: true, warnings: ['SQL compiled by the configured semantic runtime.'] }
1563
+ : contextLedger.validateSql(parsed.sql, {
1564
+ question,
1565
+ intent,
1566
+ filterValues: input.followUp?.filters,
1567
+ trustedFilterValues: trustedFollowUpFilterValues(input.followUp),
1568
+ memberBindings: input.followUp?.memberBindings,
1569
+ });
1302
1570
  const rankedGrainGap = missingRankedGrainOutput(questionPlan, parsed.sql, semanticBridgeAnswer?.dimensions);
1303
1571
  contextValidation = initialValidation.ok && rankedGrainGap
1304
1572
  ? {
@@ -1448,7 +1716,11 @@ async function runAnswerLoop(input) {
1448
1716
  };
1449
1717
  }
1450
1718
  }
1451
- catch {
1719
+ catch (err) {
1720
+ if (input.signal?.aborted)
1721
+ throw input.signal.reason ?? err;
1722
+ if (err instanceof Error && (err.name === 'AbortError' || err.name === 'TimeoutError'))
1723
+ throw err;
1452
1724
  // Re-grounding is best-effort; the bounded self-repair below still runs.
1453
1725
  }
1454
1726
  }
@@ -1503,7 +1775,11 @@ async function runAnswerLoop(input) {
1503
1775
  }
1504
1776
  }
1505
1777
  }
1506
- catch {
1778
+ catch (err) {
1779
+ if (input.signal?.aborted)
1780
+ throw input.signal.reason ?? err;
1781
+ if (err instanceof Error && (err.name === 'AbortError' || err.name === 'TimeoutError'))
1782
+ throw err;
1507
1783
  // Repair is best-effort — the refusal below stays the honest fallback.
1508
1784
  }
1509
1785
  }
@@ -1523,7 +1799,10 @@ async function runAnswerLoop(input) {
1523
1799
  }
1524
1800
  }
1525
1801
  if (!contextValidation.ok) {
1526
- const text = `I could not safely prepare this review-required DQL artifact from the inspected context. The SQL preview failed validation: ${contextValidation.error}`;
1802
+ // Business-language chat text; the validator's machine message (with
1803
+ // relation ids and tool guidance for the repair prompt) stays in
1804
+ // refusalDetails + validationWarnings for the Inspect surface.
1805
+ const text = renderContextValidationRefusalForUser(contextValidation.code, contextValidation.error, input.followUp?.memberBindings);
1527
1806
  const analysisPlan = buildAnalysisPlan({
1528
1807
  question,
1529
1808
  intent,
@@ -1557,7 +1836,11 @@ async function runAnswerLoop(input) {
1557
1836
  trustLabel: input.contextPack?.trustLabel,
1558
1837
  sourceCertifiedBlock: followUpSourceBlock?.name ?? input.followUp?.sourceBlockName,
1559
1838
  contextPackId: input.contextPack?.id,
1560
- validationWarnings: [...contextValidation.warnings, ...deepCandidateNotes],
1839
+ validationWarnings: [
1840
+ ...contextValidation.warnings,
1841
+ `SQL context validation detail: ${contextValidation.error}`,
1842
+ ...deepCandidateNotes,
1843
+ ],
1561
1844
  selectedEvidence: input.contextPack?.evidenceRoles?.slice(0, 12),
1562
1845
  citations: [],
1563
1846
  memoryContext: input.memoryContext,
@@ -1579,11 +1862,15 @@ async function runAnswerLoop(input) {
1579
1862
  };
1580
1863
  }
1581
1864
  const dbtFirstJoinSafety = input.manifest
1582
- ? evaluateDbtFirstGeneratedSql(parsed.sql, input.manifest, input.domainContext?.purpose, input.domainContext)
1865
+ ? evaluateDbtFirstGeneratedSql(parsed.sql, input.manifest, input.domainContext?.purpose, input.domainContext, input.semanticDriver)
1583
1866
  : undefined;
1584
1867
  if (dbtFirstJoinSafety && !dbtFirstJoinSafety.safe) {
1585
- const text = dbtFirstJoinSafety.message
1868
+ // Business-language text for the chat surface; the machine policy message
1869
+ // stays in refusalDetails + validationWarnings for Inspect.
1870
+ const policyDetail = dbtFirstJoinSafety.technicalDetail ?? dbtFirstJoinSafety.message
1586
1871
  ?? 'DQL could not prove this generated join from certified analytical relationships.';
1872
+ const text = dbtFirstJoinSafety.userFacingReason
1873
+ ?? analyticalPolicyUserFacingReason(dbtFirstJoinSafety.code ?? 'unsafe_relationship', dbtFirstJoinSafety.entities);
1587
1874
  const exploratoryCandidate = exploratoryCandidateFromDbtFirstGuard(parsed.sql, dbtFirstJoinSafety);
1588
1875
  const analysisPlan = buildAnalysisPlan({
1589
1876
  question,
@@ -1611,13 +1898,16 @@ async function runAnswerLoop(input) {
1611
1898
  proposedSql: parsed.sql,
1612
1899
  sql: parsed.sql,
1613
1900
  ...(exploratoryCandidate ? { exploratoryCandidate } : {}),
1614
- refusalCode: refusalCodeForAnalyticalPolicy(dbtFirstJoinSafety.code, Boolean(exploratoryCandidate)),
1901
+ refusalCode: dbtFirstJoinSafety.disposition === 'exploratory_candidate'
1902
+ ? 'modeling_gap'
1903
+ : refusalCodeForAnalyticalPolicy(dbtFirstJoinSafety.code, Boolean(exploratoryCandidate)),
1615
1904
  refusalDetails: {
1616
1905
  code: dbtFirstJoinSafety.code ?? 'unsafe_relationship',
1617
- message: text,
1906
+ message: policyDetail,
1618
1907
  },
1619
1908
  validationWarnings: [
1620
1909
  ...contextValidation.warnings,
1910
+ `Analytical policy detail: ${policyDetail}`,
1621
1911
  `DQL v3 relationship guard: ${dbtFirstJoinSafety.code ?? 'unsafe relationship'}.`,
1622
1912
  ...(exploratoryCandidate
1623
1913
  ? ['A bounded DBT-grounded exploratory route may validate this missing modeled relationship; governed SQL was not executed.']
@@ -2272,10 +2562,21 @@ function renderAnalyticalPlanPrompt(plan) {
2272
2562
  return undefined;
2273
2563
  if (!plan.safe) {
2274
2564
  if (analyticalPlanAllowsDbtExploration(plan)) {
2565
+ const declaredPath = plan.exploratoryPath?.edges.length
2566
+ ? [
2567
+ 'DECLARED (UNCERTIFIED) DRAFT JOIN PATH — suggestion only, NOT authorization:',
2568
+ ...plan.exploratoryPath.edges.map((edge, index) => [
2569
+ `${index + 1}. ${edge.fromEntity} (${edge.fromRelation ?? 'bound dbt model'}) -> ${edge.toEntity} (${edge.toRelation ?? 'bound dbt model'})`,
2570
+ ` relationship=${edge.relationshipId}; keys=${edge.keys.map((key) => `${key.from}=${key.to}`).join(', ')}; cardinality=${edge.cardinality}; fanout=${edge.fanout}; status=${edge.lifecycle}`,
2571
+ ].join('\n')),
2572
+ 'Prefer these declared relations and exact key pairs over inferring joins from column names. The result remains analyst-review-required.',
2573
+ ]
2574
+ : [];
2275
2575
  return [
2276
2576
  'DQL GOVERNED RELATIONSHIP COVERAGE: MISSING.',
2277
2577
  plan.message ?? 'No certified analytical relationship path is available.',
2278
2578
  `Policy code: ${plan.code ?? 'unsafe_relationship'}.`,
2579
+ ...declaredPath,
2279
2580
  'This is not authorization for governed SQL. You MAY propose one bounded, read-only DBT-grounded exploratory SELECT/WITH using only the inspected relations and columns supplied below.',
2280
2581
  'Use explicit aliases and equality join keys visible in the inspected schema. Preserve the requested member bindings exactly. Do not introduce an uninspected relation, a cross-domain path, an attribution/allocation rule, or more than four joins.',
2281
2582
  'The host will independently parse, bound, probe, validate, and execute the candidate as analyst-review-required. If the inspected evidence cannot express the request, return no SQL.',
@@ -2301,8 +2602,12 @@ function renderAnalyticalPlanPrompt(plan) {
2301
2602
  /**
2302
2603
  * Missing modeling may enter the narrow EXP-001 host-validated lane. Explicit
2303
2604
  * unsafe, cross-domain, attribution, expiry, purpose, and proof failures never do.
2605
+ * The planner's `disposition` is authoritative; the code checks remain only for
2606
+ * plans produced by older manifests/paths that predate the disposition contract.
2304
2607
  */
2305
2608
  function analyticalPlanAllowsDbtExploration(plan) {
2609
+ if (plan.disposition === 'exploratory_candidate')
2610
+ return true;
2306
2611
  if (plan.safe)
2307
2612
  return false;
2308
2613
  if (plan.code === 'unbound_relation' || plan.code === 'relationship_not_certified')
@@ -3065,6 +3370,15 @@ function applyParsedProposalMetadata(target, source) {
3065
3370
  function buildCertifiedBlockDqlArtifact(block, result) {
3066
3371
  if (block.kind !== 'block')
3067
3372
  return undefined;
3373
+ const parameters = block.parameters ?? [];
3374
+ const parameterValues = Object.fromEntries((result?.parameters ?? []).map((entry) => [entry.name, entry.value]));
3375
+ const parameterContract = renderCertifiedParameterContract(block);
3376
+ const artifactRuntime = {
3377
+ ...(parameters.length > 0 ? { parameters } : {}),
3378
+ ...(Object.keys(parameterValues).length > 0 ? { parameterValues } : {}),
3379
+ persistence: 'saved',
3380
+ trustState: 'certified',
3381
+ };
3068
3382
  const sql = block.sql?.trim() ?? result?.sql?.trim() ?? block.examples?.find((example) => example.sql?.trim())?.sql?.trim();
3069
3383
  if (!sql) {
3070
3384
  // The block answered but its SQL was not inlined in the index (navigation /
@@ -3080,6 +3394,7 @@ function buildCertifiedBlockDqlArtifact(block, result) {
3080
3394
  source: `// Certified DQL block "${escapeDqlArtifactString(block.name)}"`
3081
3395
  + `${block.sourcePath ? `\n// source: ${block.sourcePath}` : ''}`
3082
3396
  + `\n// SQL is not inlined in the index — open the source file to view or edit the query.`,
3397
+ ...artifactRuntime,
3083
3398
  };
3084
3399
  }
3085
3400
  const domain = block.domain ?? 'misc';
@@ -3089,7 +3404,7 @@ function buildCertifiedBlockDqlArtifact(block, result) {
3089
3404
  domain = "${escapeDqlArtifactString(domain)}"
3090
3405
  type = "custom"
3091
3406
  status = "certified"${block.owner ? `\n owner = "${escapeDqlArtifactString(block.owner)}"` : ''}
3092
- description = """${escapeDqlArtifactTripleString(description)}"""${sourcePathComment}
3407
+ description = """${escapeDqlArtifactTripleString(description)}"""${sourcePathComment}${parameterContract}
3093
3408
 
3094
3409
  query = """
3095
3410
  ${sql.replace(/"""/g, '\\"\\"\\"').split('\n').join('\n ')}
@@ -3101,8 +3416,51 @@ function buildCertifiedBlockDqlArtifact(block, result) {
3101
3416
  name: block.name,
3102
3417
  sourcePath: block.sourcePath,
3103
3418
  source,
3419
+ compiledSql: result?.sql ?? sql,
3420
+ ...artifactRuntime,
3104
3421
  };
3105
3422
  }
3423
+ function renderCertifiedParameterContract(block) {
3424
+ const parameters = block.parameters ?? [];
3425
+ if (parameters.length === 0)
3426
+ return '';
3427
+ const lines = ['\n\n params {'];
3428
+ for (const parameter of parameters) {
3429
+ const defaultValue = parameter.default === undefined ? '' : ` = ${renderCertifiedDqlParameterValue(parameter.default)}`;
3430
+ lines.push(` ${parameter.name}: ${parameter.type}${defaultValue}`);
3431
+ }
3432
+ lines.push(' }', ' parameterPolicy {');
3433
+ for (const parameter of parameters)
3434
+ lines.push(` ${parameter.name} = "${parameter.policy}"`);
3435
+ lines.push(' }');
3436
+ const explicitBindings = new Map((block.filterBindings ?? []).map((entry) => [entry.filter, entry.binding]));
3437
+ const bindings = parameters.flatMap((parameter) => {
3438
+ const explicit = explicitBindings.get(parameter.name);
3439
+ if (explicit)
3440
+ return [{ name: parameter.name, value: explicit }];
3441
+ if (parameter.binding?.kind === 'semantic_filter')
3442
+ return [{ name: parameter.name, value: parameter.binding.field }];
3443
+ if (parameter.binding?.kind === 'limit')
3444
+ return [{ name: parameter.name, value: 'limit' }];
3445
+ return [];
3446
+ });
3447
+ if (bindings.length > 0) {
3448
+ lines.push(' filterBindings {');
3449
+ for (const binding of bindings)
3450
+ lines.push(` ${binding.name} = "${escapeDqlArtifactString(binding.value)}"`);
3451
+ lines.push(' }');
3452
+ }
3453
+ return lines.join('\n');
3454
+ }
3455
+ function renderCertifiedDqlParameterValue(value) {
3456
+ if (Array.isArray(value))
3457
+ return `[${value.map(renderCertifiedDqlParameterValue).join(', ')}]`;
3458
+ if (typeof value === 'number' && Number.isFinite(value))
3459
+ return String(value);
3460
+ if (typeof value === 'boolean')
3461
+ return value ? 'true' : 'false';
3462
+ return `"${escapeDqlArtifactString(String(value ?? ''))}"`;
3463
+ }
3106
3464
  function buildGeneratedSqlDqlArtifact(input) {
3107
3465
  const sql = input.sql?.trim();
3108
3466
  if (!sql)