@duckcodeailabs/dql-agent 1.14.2 → 1.14.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 (196) hide show
  1. package/dist/agent-run-engine.d.ts +239 -4
  2. package/dist/agent-run-engine.d.ts.map +1 -1
  3. package/dist/agent-run-engine.js +2924 -85
  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 +51 -3
  7. package/dist/agent-run-gates.js.map +1 -1
  8. package/dist/agent-run-planner.d.ts +6 -0
  9. package/dist/agent-run-planner.d.ts.map +1 -1
  10. package/dist/agent-run-planner.js +2 -0
  11. package/dist/agent-run-planner.js.map +1 -1
  12. package/dist/agent-run-store.d.ts.map +1 -1
  13. package/dist/agent-run-store.js +591 -11
  14. package/dist/agent-run-store.js.map +1 -1
  15. package/dist/agentic/analyst-loop.d.ts +9 -1
  16. package/dist/agentic/analyst-loop.d.ts.map +1 -1
  17. package/dist/agentic/analyst-loop.js +204 -4
  18. package/dist/agentic/analyst-loop.js.map +1 -1
  19. package/dist/agentic/assumptions.d.ts +1 -1
  20. package/dist/agentic/assumptions.d.ts.map +1 -1
  21. package/dist/agentic/assumptions.js.map +1 -1
  22. package/dist/agentic/ledger-tools.d.ts.map +1 -1
  23. package/dist/agentic/ledger-tools.js +8 -1
  24. package/dist/agentic/ledger-tools.js.map +1 -1
  25. package/dist/agentic/orchestrator-policy.d.ts +3 -1
  26. package/dist/agentic/orchestrator-policy.d.ts.map +1 -1
  27. package/dist/agentic/orchestrator-policy.js +1 -1
  28. package/dist/agentic/orchestrator-policy.js.map +1 -1
  29. package/dist/agentic/research-agent.d.ts.map +1 -1
  30. package/dist/agentic/research-agent.js +10 -1
  31. package/dist/agentic/research-agent.js.map +1 -1
  32. package/dist/agentic/sql-authorization.d.ts +8 -0
  33. package/dist/agentic/sql-authorization.d.ts.map +1 -1
  34. package/dist/agentic/sql-authorization.js +3 -0
  35. package/dist/agentic/sql-authorization.js.map +1 -1
  36. package/dist/agentic/tool-loop.d.ts +13 -1
  37. package/dist/agentic/tool-loop.d.ts.map +1 -1
  38. package/dist/agentic/tool-loop.js +441 -32
  39. package/dist/agentic/tool-loop.js.map +1 -1
  40. package/dist/analytical-compatibility.d.ts.map +1 -1
  41. package/dist/analytical-compatibility.js +8 -1
  42. package/dist/analytical-compatibility.js.map +1 -1
  43. package/dist/analytical-failure-repair.d.ts.map +1 -1
  44. package/dist/analytical-failure-repair.js +6 -2
  45. package/dist/analytical-failure-repair.js.map +1 -1
  46. package/dist/analytical-frame.d.ts +42 -1
  47. package/dist/analytical-frame.d.ts.map +1 -1
  48. package/dist/analytical-frame.js +490 -26
  49. package/dist/analytical-frame.js.map +1 -1
  50. package/dist/analytical-orchestration.d.ts +1259 -9
  51. package/dist/analytical-orchestration.d.ts.map +1 -1
  52. package/dist/analytical-orchestration.js +970 -84
  53. package/dist/analytical-orchestration.js.map +1 -1
  54. package/dist/answer-loop.d.ts +230 -10
  55. package/dist/answer-loop.d.ts.map +1 -1
  56. package/dist/answer-loop.js +1182 -85
  57. package/dist/answer-loop.js.map +1 -1
  58. package/dist/answer-shape.d.ts +27 -0
  59. package/dist/answer-shape.d.ts.map +1 -1
  60. package/dist/answer-shape.js +55 -0
  61. package/dist/answer-shape.js.map +1 -1
  62. package/dist/ask-observability/index.d.ts +11 -0
  63. package/dist/ask-observability/index.d.ts.map +1 -0
  64. package/dist/ask-observability/index.js +7 -0
  65. package/dist/ask-observability/index.js.map +1 -0
  66. package/dist/ask-observability/instrumentation.d.ts +35 -0
  67. package/dist/ask-observability/instrumentation.d.ts.map +1 -0
  68. package/dist/ask-observability/instrumentation.js +523 -0
  69. package/dist/ask-observability/instrumentation.js.map +1 -0
  70. package/dist/ask-observability/observer.d.ts +62 -0
  71. package/dist/ask-observability/observer.d.ts.map +1 -0
  72. package/dist/ask-observability/observer.js +537 -0
  73. package/dist/ask-observability/observer.js.map +1 -0
  74. package/dist/ask-observability/portable.d.ts +98 -0
  75. package/dist/ask-observability/portable.d.ts.map +1 -0
  76. package/dist/ask-observability/portable.js +1089 -0
  77. package/dist/ask-observability/portable.js.map +1 -0
  78. package/dist/ask-observability/store.d.ts +130 -0
  79. package/dist/ask-observability/store.d.ts.map +1 -0
  80. package/dist/ask-observability/store.js +1114 -0
  81. package/dist/ask-observability/store.js.map +1 -0
  82. package/dist/ask-observability/types.d.ts +380 -0
  83. package/dist/ask-observability/types.d.ts.map +1 -0
  84. package/dist/ask-observability/types.js +2 -0
  85. package/dist/ask-observability/types.js.map +1 -0
  86. package/dist/ask-observability/utils.d.ts +21 -0
  87. package/dist/ask-observability/utils.d.ts.map +1 -0
  88. package/dist/ask-observability/utils.js +102 -0
  89. package/dist/ask-observability/utils.js.map +1 -0
  90. package/dist/ask-runtime/ask-agent-runtime-v2.d.ts +850 -0
  91. package/dist/ask-runtime/ask-agent-runtime-v2.d.ts.map +1 -0
  92. package/dist/ask-runtime/ask-agent-runtime-v2.js +1716 -0
  93. package/dist/ask-runtime/ask-agent-runtime-v2.js.map +1 -0
  94. package/dist/ask-runtime/ask-analyst-runtime.d.ts +95 -0
  95. package/dist/ask-runtime/ask-analyst-runtime.d.ts.map +1 -0
  96. package/dist/ask-runtime/ask-analyst-runtime.js +8280 -0
  97. package/dist/ask-runtime/ask-analyst-runtime.js.map +1 -0
  98. package/dist/conversation/session-store.d.ts +31 -0
  99. package/dist/conversation/session-store.d.ts.map +1 -1
  100. package/dist/conversation/session-store.js +38 -0
  101. package/dist/conversation/session-store.js.map +1 -1
  102. package/dist/conversation/snapshot.d.ts +4 -1
  103. package/dist/conversation/snapshot.d.ts.map +1 -1
  104. package/dist/conversation/snapshot.js +85 -3
  105. package/dist/conversation/snapshot.js.map +1 -1
  106. package/dist/conversation/turn-trust.d.ts.map +1 -1
  107. package/dist/conversation/turn-trust.js +2 -0
  108. package/dist/conversation/turn-trust.js.map +1 -1
  109. package/dist/embeddings/provider.d.ts +1 -0
  110. package/dist/embeddings/provider.d.ts.map +1 -1
  111. package/dist/embeddings/provider.js +18 -0
  112. package/dist/embeddings/provider.js.map +1 -1
  113. package/dist/generated-analytical-proposal.d.ts +37 -1
  114. package/dist/generated-analytical-proposal.d.ts.map +1 -1
  115. package/dist/generated-analytical-proposal.js +246 -3
  116. package/dist/generated-analytical-proposal.js.map +1 -1
  117. package/dist/governed-relational-compiler.d.ts +15 -0
  118. package/dist/governed-relational-compiler.d.ts.map +1 -1
  119. package/dist/governed-relational-compiler.js +99 -1
  120. package/dist/governed-relational-compiler.js.map +1 -1
  121. package/dist/index.d.ts +21 -12
  122. package/dist/index.d.ts.map +1 -1
  123. package/dist/index.js +14 -7
  124. package/dist/index.js.map +1 -1
  125. package/dist/intent-controller.d.ts +87 -1
  126. package/dist/intent-controller.d.ts.map +1 -1
  127. package/dist/intent-controller.js +2 -2
  128. package/dist/intent-controller.js.map +1 -1
  129. package/dist/kg/build.js +32 -2
  130. package/dist/kg/build.js.map +1 -1
  131. package/dist/meaning-resolution.d.ts +207 -6
  132. package/dist/meaning-resolution.d.ts.map +1 -1
  133. package/dist/meaning-resolution.js +439 -53
  134. package/dist/meaning-resolution.js.map +1 -1
  135. package/dist/metadata/analysis-planner.d.ts.map +1 -1
  136. package/dist/metadata/analysis-planner.js +78 -7
  137. package/dist/metadata/analysis-planner.js.map +1 -1
  138. package/dist/metadata/catalog.d.ts +14 -0
  139. package/dist/metadata/catalog.d.ts.map +1 -1
  140. package/dist/metadata/catalog.js +29 -2
  141. package/dist/metadata/catalog.js.map +1 -1
  142. package/dist/metadata/meaning-evidence.d.ts +28 -2
  143. package/dist/metadata/meaning-evidence.d.ts.map +1 -1
  144. package/dist/metadata/meaning-evidence.js +373 -16
  145. package/dist/metadata/meaning-evidence.js.map +1 -1
  146. package/dist/plan-execution-adapter.d.ts +9 -1
  147. package/dist/plan-execution-adapter.d.ts.map +1 -1
  148. package/dist/plan-execution-adapter.js +93 -13
  149. package/dist/plan-execution-adapter.js.map +1 -1
  150. package/dist/provider-egress.d.ts +28 -11
  151. package/dist/provider-egress.d.ts.map +1 -1
  152. package/dist/provider-egress.js +72 -22
  153. package/dist/provider-egress.js.map +1 -1
  154. package/dist/providers/claude.d.ts +2 -2
  155. package/dist/providers/claude.d.ts.map +1 -1
  156. package/dist/providers/claude.js +201 -37
  157. package/dist/providers/claude.js.map +1 -1
  158. package/dist/providers/dispatch.d.ts +27 -1
  159. package/dist/providers/dispatch.d.ts.map +1 -1
  160. package/dist/providers/dispatch.js +63 -1
  161. package/dist/providers/dispatch.js.map +1 -1
  162. package/dist/providers/gemini.js +10 -10
  163. package/dist/providers/gemini.js.map +1 -1
  164. package/dist/providers/index.d.ts +2 -2
  165. package/dist/providers/index.d.ts.map +1 -1
  166. package/dist/providers/index.js +1 -1
  167. package/dist/providers/index.js.map +1 -1
  168. package/dist/providers/ollama.js +15 -15
  169. package/dist/providers/ollama.js.map +1 -1
  170. package/dist/providers/openai.d.ts +2 -2
  171. package/dist/providers/openai.d.ts.map +1 -1
  172. package/dist/providers/openai.js +213 -43
  173. package/dist/providers/openai.js.map +1 -1
  174. package/dist/providers/types.d.ts +131 -1
  175. package/dist/providers/types.d.ts.map +1 -1
  176. package/dist/providers/types.js.map +1 -1
  177. package/dist/relationship-proof.d.ts +81 -0
  178. package/dist/relationship-proof.d.ts.map +1 -1
  179. package/dist/relationship-proof.js +228 -4
  180. package/dist/relationship-proof.js.map +1 -1
  181. package/dist/requirement-clauses.d.ts +23 -0
  182. package/dist/requirement-clauses.d.ts.map +1 -0
  183. package/dist/requirement-clauses.js +70 -0
  184. package/dist/requirement-clauses.js.map +1 -0
  185. package/dist/resolved-analytical-plan.d.ts +18 -4
  186. package/dist/resolved-analytical-plan.d.ts.map +1 -1
  187. package/dist/resolved-analytical-plan.js +953 -50
  188. package/dist/resolved-analytical-plan.js.map +1 -1
  189. package/dist/router.d.ts +118 -2
  190. package/dist/router.d.ts.map +1 -1
  191. package/dist/router.js +2481 -232
  192. package/dist/router.js.map +1 -1
  193. package/dist/semantic-bridge/compose.d.ts.map +1 -1
  194. package/dist/semantic-bridge/compose.js +23 -4
  195. package/dist/semantic-bridge/compose.js.map +1 -1
  196. package/package.json +4 -4
@@ -35,13 +35,13 @@ import { analyticalErrorDetail, } from './analytical-error.js';
35
35
  import { fanoutWarningsForSql } from './metadata/grain-ledger.js';
36
36
  import { buildAggregationSafetyProof, buildSemanticCompilationAggregationSafetyProof, } from './aggregation-safety-proof.js';
37
37
  import { buildSemanticProofAuthorityV1 } from './semantic-proof-authority.js';
38
- import { GENERATED_ANALYTICAL_TUPLE_DRIFT_MESSAGE, validateGeneratedAnalyticalProposal, } from './generated-analytical-proposal.js';
38
+ import { GENERATED_ANALYTICAL_TUPLE_DRIFT_MESSAGE, frozenRequiredOutputBindingProofsForPlan, validateFrozenRequiredOutputProjection, validateGeneratedAnalyticalProposal, } from './generated-analytical-proposal.js';
39
39
  import { evaluateDbtFirstGeneratedSql } from './metadata/dbt-first-safety.js';
40
40
  import { planAnalyticalPath, humanizeAnalyticalEntityId, analyticalPolicyUserFacingReason, } from './metadata/analytical-policy.js';
41
41
  import { planCertifiedAdaptation } from './metadata/block-adapt.js';
42
42
  import { compactSqlSnippet, extractSimpleSelectShape, selectExpressionOutputName, } from './metadata/sql-shape.js';
43
43
  import { composeSemanticQueryForQuestion, composeSemanticQueryFromCompiledMembers, composeSemanticQueryFromMembers, renderSemanticDqlArtifact, semanticDqlArtifactName, } from './semantic-bridge/compose.js';
44
- import { runAgenticToolLoop } from './agentic/tool-loop.js';
44
+ import { runAgenticToolLoopDetailed } from './agentic/tool-loop.js';
45
45
  import { buildSemanticStageTools } from './agentic/toolset.js';
46
46
  import { deriveAgenticTrust } from './agentic/answer-contract.js';
47
47
  import { selectSemanticMembersViaLlm } from './semantic-bridge/member-select.js';
@@ -384,6 +384,572 @@ function certifiedInvocationInputs(block, plan) {
384
384
  }
385
385
  return Object.keys(parameters).length > 0 ? { parameters, parameterSources } : {};
386
386
  }
387
+ /**
388
+ * Build the one typed certified-block invocation used by both the ordinary
389
+ * answer loop and the authoritative V2 exact-fit path. Keeping this at the
390
+ * DQL boundary is important: a zero-provider Tier 1 shortcut may avoid
391
+ * conversational planning, but it must not avoid declared parameter binding,
392
+ * validation, or the normal overall top-N execution bound.
393
+ */
394
+ export function buildCertifiedBlockInvocationInput(block, plan, question) {
395
+ return {
396
+ question,
397
+ ...certifiedInvocationInputs(block, plan),
398
+ ...(plan.requestedShape.topN?.scope === 'per_group'
399
+ ? {}
400
+ : plan.requestedShape.topN?.n
401
+ ? { rowLimit: plan.requestedShape.topN.n }
402
+ : {}),
403
+ };
404
+ }
405
+ /**
406
+ * Prove that an immutable certified block may satisfy a question-driven
407
+ * overall top-N result contract without conversational planning. A declared
408
+ * limit parameter alone is not enough: the authored SQL must order rows and
409
+ * consume that exact parameter in its LIMIT clause. This prevents a host
410
+ * from slicing arbitrary connector order and presenting it as certified.
411
+ */
412
+ export function certifiedBlockProvesRequestedTopN(block, plan, options = {}) {
413
+ const topN = plan.requestedShape.topN;
414
+ if (!topN)
415
+ return true;
416
+ // The artifact is loaded from the snapshot-bound KG, but that persisted
417
+ // payload still crosses an older JSON schema boundary. Treat a malformed
418
+ // parameter contract as *not proved*, never as a reason to crash the Ask
419
+ // run or to infer an unordered ranking from connector row order.
420
+ if (topN.scope !== 'overall' || !block || block.kind !== 'block')
421
+ return false;
422
+ const limitParameters = (Array.isArray(block.parameters) ? block.parameters : []).flatMap((parameter) => {
423
+ if (!parameter || typeof parameter.name !== 'string' || parameter.name.trim() === '')
424
+ return [];
425
+ return parameter.name === 'top_n' || parameter.binding?.kind === 'limit'
426
+ ? [parameter]
427
+ : [];
428
+ });
429
+ if (typeof block.sql !== 'string')
430
+ return false;
431
+ const outerClauses = scanOutermostTopNClauses(block.sql);
432
+ if (!outerClauses)
433
+ return false;
434
+ const { orderBy, limitValue } = outerClauses;
435
+ // There are exactly two safe ways to prove the overall row bound:
436
+ //
437
+ // 1. The immutable artifact consumes its own declared top-N parameter in
438
+ // the outer LIMIT clause (the ordinary certified contract); or
439
+ // 2. The artifact intentionally has no outer LIMIT and the local host owns
440
+ // a frozen, typed execution row limit at the read-only SQL boundary.
441
+ //
442
+ // We never treat a fixed, driver-style, compound, or stale outer LIMIT as
443
+ // equivalent to the requested value. In particular, an existing fixed
444
+ // LIMIT prevents the host executor from appending the frozen limit, so it
445
+ // cannot prove a different user-requested top N.
446
+ const limitUsesDeclaredParameter = limitParameters.length === 1
447
+ && typeof limitValue === 'string'
448
+ && outerLimitUsesDeclaredTopNParameter(limitValue, limitParameters[0].name);
449
+ const hostOwnsFrozenRowLimit = limitValue === undefined
450
+ && Number.isInteger(options.hostEnforcedRowLimit)
451
+ && options.hostEnforcedRowLimit === topN.n
452
+ && options.hostEnforcedRowLimit > 0;
453
+ if (!limitUsesDeclaredParameter && !hostOwnsFrozenRowLimit)
454
+ return false;
455
+ // A question-driven ranking is only exact when the *primary* authored sort
456
+ // expression proves the requested measure. Finding `revenue DESC` later in
457
+ // `ORDER BY customer_name ASC, revenue DESC` is not sufficient: the result
458
+ // is primarily alphabetical, not a top-revenue result. Do not infer a
459
+ // ranking measure from row shape or candidate tags; that would turn an
460
+ // unproven certified artifact into a false exact answer.
461
+ const requestedMeasures = plan.requestedShape.measures
462
+ .map(normalizedTopNMetricId)
463
+ .filter(Boolean);
464
+ // An omitted measure is not normally a license to infer a ranking from a
465
+ // certified block. The narrow exception is server-owned evidence that this
466
+ // is either a direct certified question/title/alias match *or* the one
467
+ // complete certified tuple admitted by this immutable retrieval snapshot.
468
+ // In both cases the artifact's own primary non-dimension output supplies
469
+ // the authored default ranking contract. A provider cannot manufacture
470
+ // either flag from text or a card count.
471
+ const useAuthoredRankingDefault = requestedMeasures.length === 0
472
+ && (options.exactCertifiedQuestionMatch === true
473
+ || options.uniqueCompleteCertifiedFit === true);
474
+ if (requestedMeasures.length === 0 && !useAuthoredRankingDefault)
475
+ return false;
476
+ const firstOrderExpression = splitTopLevelSqlList(orderBy)[0];
477
+ if (!firstOrderExpression)
478
+ return false;
479
+ const firstOrder = parseTopNOrderExpression(firstOrderExpression);
480
+ if (!firstOrder)
481
+ return false;
482
+ const requiredDirection = plan.requestedShape.rankingDirection === 'bottom' ? 'asc' : 'desc';
483
+ if (firstOrder.direction !== requiredDirection)
484
+ return false;
485
+ const orderMetricIds = topNOrderMetricIds(firstOrder.expression, block);
486
+ if (requestedMeasures.length > 0) {
487
+ return requestedMeasures.some((measure) => orderMetricIds.has(measure));
488
+ }
489
+ return [...authoredTopNRankingMetricIds(block)].some((metric) => orderMetricIds.has(metric));
490
+ }
491
+ /**
492
+ * The implicit-ranking exception still needs an authored metric, not merely a
493
+ * sortable dimension. Keep this proof inside the immutable block contract:
494
+ * declared outputs, compiler output lineage, and typed output roles are all
495
+ * captured with the artifact. If an older block cannot distinguish its
496
+ * measures from dimensions, fail closed into the Ask planner.
497
+ */
498
+ function authoredTopNRankingMetricIds(block) {
499
+ const dimensionIds = new Set((block.dimensions ?? []).map(normalizedTopNMetricId).filter(Boolean));
500
+ const outputRoles = new Map((block.outputContract ?? [])
501
+ .filter((output) => typeof output?.name === 'string')
502
+ .map((output) => [normalizedTopNMetricId(output.name), String(output.role ?? '').toLowerCase()]));
503
+ const outputIds = new Set([
504
+ ...(block.declaredOutputs ?? []),
505
+ ...(block.outputs ?? []).map((output) => output.name),
506
+ ...(block.outputContract ?? []).map((output) => output.name),
507
+ ].map(normalizedTopNMetricId).filter(Boolean));
508
+ const metrics = new Set();
509
+ for (const outputId of outputIds) {
510
+ const role = outputRoles.get(outputId) ?? '';
511
+ if (dimensionIds.has(outputId) || /(?:dimension|entity|label|attribute)/.test(role))
512
+ continue;
513
+ metrics.add(outputId);
514
+ }
515
+ return metrics;
516
+ }
517
+ function outerLimitUsesDeclaredTopNParameter(limitValue, parameterName) {
518
+ const escapedParameter = parameterName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
519
+ // This proof is about an authored DQL artifact, not a connector SQL
520
+ // statement. Accept only the two compiler-recognized interpolation forms
521
+ // (`${name}` and `{name}`); driver placeholders, bare identifiers, fixed
522
+ // values, and compound expressions do not prove that the requested typed
523
+ // top-N binding controls the outer result contract.
524
+ return new RegExp(`^(?:\\$\\{\\s*${escapedParameter}\\s*\\}|\\{\\s*${escapedParameter}\\s*\\})$`, 'i').test(topNVisibleSql(limitValue).trim());
525
+ }
526
+ /**
527
+ * Locate the outer SELECT/ORDER BY/LIMIT clauses without treating text in a
528
+ * CTE, subquery, quoted identifier/string, or comment as part of the answer
529
+ * contract. A ranking proof must be about the query that actually returns the
530
+ * block rows, never an unused inner query that happens to mention revenue.
531
+ */
532
+ function scanOutermostTopNClauses(sql) {
533
+ const tokens = topLevelSqlTokens(sql);
534
+ if (!tokens)
535
+ return undefined;
536
+ let orderByIndex = -1;
537
+ for (let index = 0; index + 1 < tokens.length; index += 1) {
538
+ if (tokens[index].text === 'order' && tokens[index + 1].text === 'by')
539
+ orderByIndex = index;
540
+ }
541
+ if (orderByIndex < 0)
542
+ return undefined;
543
+ let selectIndex = -1;
544
+ for (let index = 0; index < orderByIndex; index += 1) {
545
+ if (tokens[index].text === 'select')
546
+ selectIndex = index;
547
+ }
548
+ if (selectIndex < 0)
549
+ return undefined;
550
+ let fromIndex = -1;
551
+ for (let index = selectIndex + 1; index < orderByIndex; index += 1) {
552
+ if (tokens[index].text === 'from') {
553
+ fromIndex = index;
554
+ break;
555
+ }
556
+ }
557
+ if (fromIndex < 0)
558
+ return undefined;
559
+ let limitIndex = -1;
560
+ for (let index = orderByIndex + 2; index < tokens.length; index += 1) {
561
+ if (tokens[index].text === 'limit') {
562
+ limitIndex = index;
563
+ break;
564
+ }
565
+ }
566
+ const orderByEnd = limitIndex >= 0 ? tokens[limitIndex].start : sql.length;
567
+ // The outer ORDER BY must directly govern the outer LIMIT when one exists.
568
+ // A set operation, second SELECT, or a second ORDER BY in between is too
569
+ // complex for this exact shortcut and correctly falls back to the bounded
570
+ // Ask tool runtime. The same guard applies through end-of-query for an
571
+ // intentionally unbounded artifact whose host owns the frozen row limit.
572
+ if (tokens.slice(orderByIndex + 2, limitIndex >= 0 ? limitIndex : tokens.length).some((token) => token.text === 'select' || token.text === 'union' || token.text === 'intersect' || token.text === 'except')) {
573
+ return undefined;
574
+ }
575
+ const afterLimit = limitIndex >= 0 ? tokens.find((token, index) => index > limitIndex && (token.text === 'offset' || token.text === 'fetch' || token.text === 'for'))?.start ?? sql.length : sql.length;
576
+ const selectList = sql.slice(tokens[selectIndex].end, tokens[fromIndex].start).trim();
577
+ const orderBy = sql.slice(tokens[orderByIndex + 1].end, orderByEnd).trim();
578
+ const limitValue = limitIndex >= 0
579
+ ? sql.slice(tokens[limitIndex].end, afterLimit).replace(/;\s*$/, '').trim()
580
+ : undefined;
581
+ if (!selectList || !orderBy || (limitIndex >= 0 && !limitValue))
582
+ return undefined;
583
+ return {
584
+ selectList,
585
+ orderBy,
586
+ ...(limitValue ? { limitValue } : {}),
587
+ };
588
+ }
589
+ /**
590
+ * Tokenize only depth-zero SQL words. The state machine deliberately removes
591
+ * comments and literals from consideration while retaining positional ranges
592
+ * against the original SQL for clause slicing.
593
+ */
594
+ function topLevelSqlTokens(sql) {
595
+ const tokens = [];
596
+ let depth = 0;
597
+ let quote;
598
+ let lineComment = false;
599
+ let blockCommentDepth = 0;
600
+ for (let index = 0; index < sql.length; index += 1) {
601
+ const character = sql[index];
602
+ const next = sql[index + 1];
603
+ if (lineComment) {
604
+ if (character === '\n' || character === '\r')
605
+ lineComment = false;
606
+ continue;
607
+ }
608
+ if (blockCommentDepth > 0) {
609
+ if (character === '/' && next === '*') {
610
+ blockCommentDepth += 1;
611
+ index += 1;
612
+ }
613
+ else if (character === '*' && next === '/') {
614
+ blockCommentDepth -= 1;
615
+ index += 1;
616
+ }
617
+ continue;
618
+ }
619
+ if (quote) {
620
+ if (quote === ']' && character === ']') {
621
+ if (next === ']')
622
+ index += 1;
623
+ else
624
+ quote = undefined;
625
+ }
626
+ else if (quote !== ']' && character === quote) {
627
+ if (next === quote)
628
+ index += 1;
629
+ else
630
+ quote = undefined;
631
+ }
632
+ continue;
633
+ }
634
+ if (character === '-' && next === '-') {
635
+ lineComment = true;
636
+ index += 1;
637
+ continue;
638
+ }
639
+ if (character === '/' && next === '*') {
640
+ blockCommentDepth = 1;
641
+ index += 1;
642
+ continue;
643
+ }
644
+ if (character === "'" || character === '"' || character === '`') {
645
+ quote = character;
646
+ continue;
647
+ }
648
+ if (character === '[') {
649
+ quote = ']';
650
+ continue;
651
+ }
652
+ if (character === '(') {
653
+ depth += 1;
654
+ continue;
655
+ }
656
+ if (character === ')') {
657
+ if (depth === 0)
658
+ return undefined;
659
+ depth -= 1;
660
+ continue;
661
+ }
662
+ if (depth !== 0 || !/[A-Za-z_]/.test(character))
663
+ continue;
664
+ const start = index;
665
+ index += 1;
666
+ while (index < sql.length && /[A-Za-z0-9_$]/.test(sql[index]))
667
+ index += 1;
668
+ tokens.push({ text: sql.slice(start, index).toLowerCase(), start, end: index });
669
+ index -= 1;
670
+ }
671
+ // A line comment is valid through EOF. Unterminated string, bracket, block
672
+ // comment, or parenthesis state is not a trustworthy exact-proof input.
673
+ return depth === 0 && !quote && blockCommentDepth === 0 ? tokens : undefined;
674
+ }
675
+ /**
676
+ * Split an authored comma-separated SQL list without treating a function
677
+ * argument, quoted string, or quoted identifier as a second ORDER BY key.
678
+ * This intentionally stays small and fails closed for malformed SQL; DQL's
679
+ * compiler is still the authority for execution syntax.
680
+ */
681
+ function splitTopLevelSqlList(value) {
682
+ const expressions = [];
683
+ let start = 0;
684
+ let depth = 0;
685
+ let quote;
686
+ let lineComment = false;
687
+ let blockCommentDepth = 0;
688
+ for (let index = 0; index < value.length; index += 1) {
689
+ const character = value[index];
690
+ const next = value[index + 1];
691
+ if (lineComment) {
692
+ if (character === '\n' || character === '\r')
693
+ lineComment = false;
694
+ continue;
695
+ }
696
+ if (blockCommentDepth > 0) {
697
+ if (character === '/' && next === '*') {
698
+ blockCommentDepth += 1;
699
+ index += 1;
700
+ }
701
+ else if (character === '*' && next === '/') {
702
+ blockCommentDepth -= 1;
703
+ index += 1;
704
+ }
705
+ continue;
706
+ }
707
+ if (quote) {
708
+ if (quote === ']' && character === ']') {
709
+ if (next === ']')
710
+ index += 1;
711
+ else
712
+ quote = undefined;
713
+ }
714
+ else if (quote !== ']' && character === quote) {
715
+ // SQL escapes an in-string quote by doubling it. Keep scanning inside
716
+ // the quoted value rather than mistaking a later comma for a list
717
+ // separator.
718
+ if (value[index + 1] === quote) {
719
+ index += 1;
720
+ }
721
+ else {
722
+ quote = undefined;
723
+ }
724
+ }
725
+ continue;
726
+ }
727
+ if (character === '-' && next === '-') {
728
+ lineComment = true;
729
+ index += 1;
730
+ continue;
731
+ }
732
+ if (character === '/' && next === '*') {
733
+ blockCommentDepth = 1;
734
+ index += 1;
735
+ continue;
736
+ }
737
+ if (character === "'" || character === '"' || character === '`') {
738
+ quote = character;
739
+ continue;
740
+ }
741
+ if (character === '[') {
742
+ quote = ']';
743
+ continue;
744
+ }
745
+ if (character === '(') {
746
+ depth += 1;
747
+ continue;
748
+ }
749
+ if (character === ')' && depth > 0) {
750
+ depth -= 1;
751
+ continue;
752
+ }
753
+ if (character === ',' && depth === 0) {
754
+ const expression = value.slice(start, index).trim();
755
+ if (!expression)
756
+ return [];
757
+ expressions.push(expression);
758
+ start = index + 1;
759
+ }
760
+ }
761
+ if (quote || blockCommentDepth > 0 || depth !== 0)
762
+ return [];
763
+ const finalExpression = value.slice(start).trim();
764
+ return finalExpression ? [...expressions, finalExpression] : [];
765
+ }
766
+ function parseTopNOrderExpression(expression) {
767
+ const visible = topNVisibleSql(expression);
768
+ const match = visible.match(/^(.*?)(?:\s+(asc|desc))(?:\s+nulls\s+(?:first|last))?\s*$/i);
769
+ if (!match?.[1] || !match[2])
770
+ return undefined;
771
+ return {
772
+ expression: expression.slice(0, match[1].length).trim(),
773
+ direction: match[2].toLowerCase(),
774
+ };
775
+ }
776
+ /** Preserve SQL positions while blanking literals/comments that cannot prove a ranking contract. */
777
+ function topNVisibleSql(value) {
778
+ let output = '';
779
+ let quote;
780
+ let lineComment = false;
781
+ let blockCommentDepth = 0;
782
+ for (let index = 0; index < value.length; index += 1) {
783
+ const character = value[index];
784
+ const next = value[index + 1];
785
+ const blank = () => { output += character === '\n' || character === '\r' ? character : ' '; };
786
+ if (lineComment) {
787
+ blank();
788
+ if (character === '\n' || character === '\r')
789
+ lineComment = false;
790
+ continue;
791
+ }
792
+ if (blockCommentDepth > 0) {
793
+ blank();
794
+ if (character === '/' && next === '*') {
795
+ blockCommentDepth += 1;
796
+ index += 1;
797
+ output += ' ';
798
+ }
799
+ else if (character === '*' && next === '/') {
800
+ blockCommentDepth -= 1;
801
+ index += 1;
802
+ output += ' ';
803
+ }
804
+ continue;
805
+ }
806
+ if (quote === "'") {
807
+ blank();
808
+ if (character === "'") {
809
+ if (next === "'") {
810
+ index += 1;
811
+ output += ' ';
812
+ }
813
+ else {
814
+ quote = undefined;
815
+ }
816
+ }
817
+ continue;
818
+ }
819
+ if (quote) {
820
+ // Keep quoted identifiers intact for identifier normalization while the
821
+ // outer-clause scanner itself ignores their contents.
822
+ output += character;
823
+ if (quote === ']' && character === ']') {
824
+ if (next === ']') {
825
+ index += 1;
826
+ output += next;
827
+ }
828
+ else {
829
+ quote = undefined;
830
+ }
831
+ }
832
+ else if (quote !== ']' && character === quote) {
833
+ if (next === quote) {
834
+ index += 1;
835
+ output += next;
836
+ }
837
+ else {
838
+ quote = undefined;
839
+ }
840
+ }
841
+ continue;
842
+ }
843
+ if (character === '-' && next === '-') {
844
+ output += ' ';
845
+ index += 1;
846
+ lineComment = true;
847
+ continue;
848
+ }
849
+ if (character === '/' && next === '*') {
850
+ output += ' ';
851
+ index += 1;
852
+ blockCommentDepth = 1;
853
+ continue;
854
+ }
855
+ if (character === "'") {
856
+ output += ' ';
857
+ quote = character;
858
+ continue;
859
+ }
860
+ if (character === '"' || character === '`') {
861
+ output += character;
862
+ quote = character;
863
+ continue;
864
+ }
865
+ if (character === '[') {
866
+ output += character;
867
+ quote = ']';
868
+ continue;
869
+ }
870
+ output += character;
871
+ }
872
+ return output;
873
+ }
874
+ /** Normalize an identifier or business measure into its opaque-free SQL key. */
875
+ function normalizedTopNMetricId(value) {
876
+ return value
877
+ .trim()
878
+ .replace(/^['"`\[]|['"`\]]$/g, '')
879
+ .split('.')
880
+ .at(-1)
881
+ .trim()
882
+ .replace(/^['"`\[]|['"`\]]$/g, '')
883
+ .toLowerCase()
884
+ .replace(/[^a-z0-9]+/g, '');
885
+ }
886
+ /**
887
+ * Resolve the primary ORDER BY expression through selected-output aliases and
888
+ * captured output lineage. Qualified columns (`c.revenue`), SQL aliases
889
+ * (`SUM(o.revenue) AS revenue` / `ORDER BY revenue`), and compiler-captured
890
+ * output sources all converge on the same normalized key. Unknown or
891
+ * ambiguous expressions deliberately yield no proof.
892
+ */
893
+ function topNOrderMetricIds(expression, block) {
894
+ const keys = topNIdentifierIds(expression);
895
+ const selectedAliases = selectedSqlOutputAliases(block.sql);
896
+ const outputLineage = block.outputs ?? [];
897
+ for (const key of [...keys]) {
898
+ const selectedExpression = selectedAliases.get(key);
899
+ if (selectedExpression) {
900
+ for (const selectedKey of topNIdentifierIds(selectedExpression))
901
+ keys.add(selectedKey);
902
+ }
903
+ for (const output of outputLineage) {
904
+ if (normalizedTopNMetricId(output.name) !== key)
905
+ continue;
906
+ for (const source of output.sources ?? []) {
907
+ const sourceKey = normalizedTopNMetricId(source.column);
908
+ if (sourceKey)
909
+ keys.add(sourceKey);
910
+ }
911
+ }
912
+ }
913
+ return keys;
914
+ }
915
+ function topNIdentifierIds(expression) {
916
+ const keys = new Set();
917
+ // Quoted identifiers remain intact, while literals/comments were blanked.
918
+ // Therefore a string such as `'revenue'` cannot prove a ranking measure.
919
+ const identifierPattern = /(?:\[[^\]]+\]|"(?:""|[^"])+"|`(?:``|[^`])+`|[A-Za-z_][A-Za-z0-9_$]*)/g;
920
+ for (const identifier of topNVisibleSql(expression).match(identifierPattern) ?? []) {
921
+ const key = normalizedTopNMetricId(identifier);
922
+ if (key && !TOP_N_SQL_WORDS.has(key))
923
+ keys.add(key);
924
+ }
925
+ return keys;
926
+ }
927
+ function selectedSqlOutputAliases(sql) {
928
+ if (!sql)
929
+ return new Map();
930
+ const select = scanOutermostTopNClauses(sql)?.selectList;
931
+ if (!select)
932
+ return new Map();
933
+ const aliases = new Map();
934
+ for (const projection of splitTopLevelSqlList(select)) {
935
+ const visibleProjection = topNVisibleSql(projection);
936
+ const explicitAlias = visibleProjection.match(/^(.*?)\s+as\s+((?:\[[^\]]+\])|(?:"(?:""|[^"])+")|(?:`(?:``|[^`])+`)|(?:[A-Za-z_][A-Za-z0-9_$]*))\s*$/i);
937
+ const implicitAlias = explicitAlias
938
+ ? undefined
939
+ : visibleProjection.match(/^(.*?)\s+((?:\[[^\]]+\])|(?:"(?:""|[^"])+")|(?:`(?:``|[^`])+`)|(?:[A-Za-z_][A-Za-z0-9_$]*))\s*$/i);
940
+ const expressionLength = (explicitAlias?.[1] ?? implicitAlias?.[1] ?? visibleProjection).length;
941
+ const expression = projection.slice(0, expressionLength).trim();
942
+ const alias = (explicitAlias?.[2] ?? implicitAlias?.[2] ?? projection).trim();
943
+ const aliasId = normalizedTopNMetricId(alias);
944
+ if (aliasId)
945
+ aliases.set(aliasId, expression);
946
+ }
947
+ return aliases;
948
+ }
949
+ const TOP_N_SQL_WORDS = new Set([
950
+ 'asc', 'desc', 'nulls', 'first', 'last', 'sum', 'avg', 'average', 'count',
951
+ 'min', 'max', 'cast', 'coalesce', 'case', 'when', 'then', 'else', 'end',
952
+ ]);
387
953
  /**
388
954
  * Return only output aliases that the selected certified artifact itself
389
955
  * declares. This is deliberately built after the router has frozen the plan:
@@ -625,6 +1191,41 @@ export function renderContextValidationRefusalForUser(code, machineError, member
625
1191
  : '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.';
626
1192
  }
627
1193
  }
1194
+ /**
1195
+ * Did validation fail because something the USER asked for is not modeled?
1196
+ *
1197
+ * "What region does he belong to" against a warehouse with no customer→region
1198
+ * path fails as `unknown_column: region`. Reported as a grounding gap that
1199
+ * reads "Not enough context to answer safely", which is untrue and unhelpful:
1200
+ * no amount of extra context will produce a column the business has never
1201
+ * modeled, and the user is left to guess whether to rephrase, re-ask, or give
1202
+ * up. The truthful answer names the gap and offers the way forward.
1203
+ *
1204
+ * Deliberately narrow. A column the MODEL invented is a grounding failure and
1205
+ * must keep its existing code — the distinction between "you asked for
1206
+ * something we don't have" and "the draft referenced something that isn't
1207
+ * there" is the whole safety value of this classification.
1208
+ */
1209
+ function requestedDimensionModelingGap(code, offending, requestedTerms) {
1210
+ if (code !== 'unknown_column' && code !== 'unknown_relation')
1211
+ return undefined;
1212
+ const identifier = offending?.column ?? offending?.relation;
1213
+ if (!identifier)
1214
+ return undefined;
1215
+ const canonical = (value) => value
1216
+ .split(/[.:]/).pop()
1217
+ .toLowerCase().replace(/[^a-z0-9]+/g, ' ').trim();
1218
+ const target = canonical(identifier);
1219
+ if (!target)
1220
+ return undefined;
1221
+ const requested = requestedTerms
1222
+ .map(canonical)
1223
+ .filter((term) => term.length > 2);
1224
+ const matched = requested.find((term) => term === target
1225
+ || target.split(' ').includes(term)
1226
+ || term.split(' ').includes(target));
1227
+ return matched ? identifier.split(/[.:]/).pop() : undefined;
1228
+ }
628
1229
  function refusalCodeForValidation(code) {
629
1230
  if (code === 'unknown_relation' || code === 'unknown_column' || code === 'insufficient_context' || code === 'missing_baseline') {
630
1231
  return 'grounding_gap';
@@ -1024,7 +1625,9 @@ export async function answer(input) {
1024
1625
  // Cross-result computations are intentionally independent of the KG and
1025
1626
  // warehouse. Preserve this earliest exit before building any execution
1026
1627
  // registry so lightweight follow-up callers do not need runtime services.
1027
- const earlyCrossResult = tryCrossResultAnswer(normalizedInput);
1628
+ const earlyCrossResult = normalizedInput.skipCrossResultComputation
1629
+ ? null
1630
+ : tryCrossResultAnswer(normalizedInput);
1028
1631
  if (earlyCrossResult) {
1029
1632
  return {
1030
1633
  ...earlyCrossResult,
@@ -1161,9 +1764,17 @@ export async function answer(input) {
1161
1764
  const analyticalExecutionGraph = analyticalGraphBuild?.status === 'ready'
1162
1765
  ? analyticalGraphBuild.graph
1163
1766
  : undefined;
1767
+ // A v2 plan can also be synthesized for legacy/direct AnswerLoop callers so
1768
+ // they can opt into the deterministic compiler. That compatibility bridge is
1769
+ // not a router freeze: it must not turn a missing optional graph/adapter into
1770
+ // a terminal answer before the established, safe legacy semantic execution
1771
+ // path is considered. Router-selected semantic plans carry the explicit
1772
+ // `selectedCascadeTier` signal and remain immutable/terminal below.
1773
+ const routerFrozenSemanticPlan = Boolean(frozenSemanticRouteForInput(normalizedInput));
1164
1774
  const analyticalExecutionGraphFailure = normalizedInput.resolvedAnalyticalPlan?.schemaVersion === 2
1165
1775
  && !multiMetricPlan
1166
1776
  && !generatedProposalPlan
1777
+ && routerFrozenSemanticPlan
1167
1778
  ? analyticalGraphBuild?.status === 'blocked'
1168
1779
  ? {
1169
1780
  code: analyticalGraphBuild.code,
@@ -1248,7 +1859,23 @@ export async function answer(input) {
1248
1859
  }
1249
1860
  : compiledInput;
1250
1861
  const loopResult = await runAnswerLoop(executionInput);
1251
- const result = applyHollowAnswerGate(validateCertifiedAnalyticalGraphAnswer(loopResult, executionInput.analyticalExecutionGraph, executionInput.resolvedAnalyticalPlan?.analyticalFrame));
1862
+ let result = applyHollowAnswerGate(validateCertifiedAnalyticalGraphAnswer(loopResult, executionInput.analyticalExecutionGraph, executionInput.resolvedAnalyticalPlan?.analyticalFrame));
1863
+ const frozenSemanticRoute = frozenSemanticRouteForInput(executionInput);
1864
+ // A final defensive boundary keeps future return sites from accidentally
1865
+ // restoring the old semantic -> generated fallthrough. Do not relabel an
1866
+ // actual generated result as semantic: replace it with an inspectable,
1867
+ // same-tier terminal failure instead.
1868
+ const returnedNonSemanticRoute = frozenSemanticRoute && ((result.route !== undefined && result.route.tier !== 'semantic_metric')
1869
+ || (result.route === undefined && result.kind !== 'no_answer' && result.sourceTier !== 'semantic_layer'));
1870
+ if (returnedNonSemanticRoute && frozenSemanticRoute) {
1871
+ result = frozenSemanticPlanFailureAnswer({
1872
+ answerInput: executionInput,
1873
+ considered: result.considered ?? [],
1874
+ providerName: executionInput.provider.name,
1875
+ code: 'EXECUTION_GRAPH_MISMATCH',
1876
+ reason: `The pinned semantic plan ${frozenSemanticRoute.ref ?? 'selected by the router'} returned a non-semantic answer route. DQL did not substitute generated SQL.`,
1877
+ });
1878
+ }
1252
1879
  // Attach the canonical trust label once, at the single exit point, so every
1253
1880
  // return site inside runAnswerLoop stays untouched and backward compatible.
1254
1881
  // Freshness-aware trust: for a certified answer, fold the source block's data
@@ -1256,7 +1883,7 @@ export async function answer(input) {
1256
1883
  // stale data" / "Certified · upstream failed". Non-certified or fresh answers
1257
1884
  // are unaffected.
1258
1885
  const { _semanticMetricMatch, ...publicResult } = result;
1259
- const chosenRoute = result.route ?? deriveAiRoute(result, _semanticMetricMatch);
1886
+ const chosenRoute = frozenSemanticRoute ?? result.route ?? deriveAiRoute(result, _semanticMetricMatch);
1260
1887
  // P0 — record the high-level action this turn warranted, so callers can route
1261
1888
  // (compose_app → app build, investigate → research) and the UI can show the
1262
1889
  // agent's reasoning. Computed once at the single exit from the finished answer.
@@ -1390,6 +2017,69 @@ function deriveAiRoute(result, metricMatch) {
1390
2017
  ? { tier: 'generated_sql', label: 'Prepared review-required DQL artifact with SQL preview.' }
1391
2018
  : { tier: 'generated_sql', label: 'Prepared review-required SQL preview.' };
1392
2019
  }
2020
+ /**
2021
+ * The router owns the cascade decision. Once it freezes a semantic plan, the
2022
+ * answer loop may either execute that exact plan or return a semantic-tier
2023
+ * terminal failure. It must never let an unavailable semantic adapter fall
2024
+ * through to the legacy generated-SQL lane merely because the latter can still
2025
+ * construct a preview.
2026
+ */
2027
+ function frozenSemanticRouteForInput(input) {
2028
+ const plan = input.resolvedAnalyticalPlan;
2029
+ // `freezeLegacySemanticSelection()` upgrades older direct AnswerLoop callers
2030
+ // to a typed plan so they can use the deterministic semantic compiler. That
2031
+ // migration is not the router's immutable cascade freeze. The server-owned
2032
+ // cascade explicitly carries `selectedCascadeTier: 'semantic'`; only that
2033
+ // signal activates the no-downgrade boundary below. Otherwise direct legacy
2034
+ // callers retain their established deterministic semantic behavior.
2035
+ if (input.selectedCascadeTier !== 'semantic'
2036
+ || plan?.mode !== 'authoritative'
2037
+ || plan.capability !== 'semantic_execution')
2038
+ return undefined;
2039
+ const executionId = plan.executionId ?? plan.selectedConceptIds[0];
2040
+ return {
2041
+ tier: 'semantic_metric',
2042
+ label: executionId
2043
+ ? `Frozen semantic plan ${executionId}`
2044
+ : 'Frozen semantic plan',
2045
+ ...(executionId ? { ref: executionId } : {}),
2046
+ };
2047
+ }
2048
+ function frozenSemanticPlanFailureAnswer(input) {
2049
+ const { answerInput, considered, providerName, code, reason } = input;
2050
+ const route = frozenSemanticRouteForInput(answerInput);
2051
+ // Callers use this only for an authoritative semantic plan. Keep the
2052
+ // fallback defensive so an internal misuse still produces a safe no-answer.
2053
+ const analyticalFailure = analyticalFailureForInput(answerInput, {
2054
+ error: { code, message: reason },
2055
+ phase: 'compilation',
2056
+ failedBindings: answerInput.resolvedAnalyticalPlan?.executionId
2057
+ ? [{
2058
+ qualifiedId: answerInput.resolvedAnalyticalPlan.executionId,
2059
+ role: 'metric',
2060
+ reasonCode: code,
2061
+ }]
2062
+ : undefined,
2063
+ });
2064
+ return {
2065
+ kind: 'no_answer',
2066
+ sourceTier: 'no_answer',
2067
+ certification: 'analyst_review_required',
2068
+ reviewStatus: 'none',
2069
+ confidence: 0,
2070
+ text: analyticalFailure.message,
2071
+ answer: analyticalFailure.message,
2072
+ executionError: analyticalFailure.message,
2073
+ refusalCode: 'grounding_gap',
2074
+ refusalDetails: { code, message: analyticalFailure.message },
2075
+ analyticalFailure,
2076
+ ...(route ? { route } : {}),
2077
+ citations: contextPackCitations(answerInput.contextPack, 8),
2078
+ considered,
2079
+ contextPack: answerInput.contextPack,
2080
+ providerUsed: providerName,
2081
+ };
2082
+ }
1393
2083
  /** "customers_customer_name" → "customers" for a question the user has to read. */
1394
2084
  function humanizeDeicticDimension(dimension) {
1395
2085
  const leaf = dimension.split(/[.:]/).pop() ?? dimension;
@@ -1419,6 +2109,7 @@ async function runAnswerLoop(input) {
1419
2109
  // or provider is dispatched. Full retrieval stays on `input.contextPack`
1420
2110
  // for receipts and diagnostics only.
1421
2111
  const forcedExploratoryTier = input.selectedCascadeTier === 'exploratory_sql';
2112
+ const frozenSemanticRoute = frozenSemanticRouteForInput(input);
1422
2113
  const exploratoryClosureContextPack = forcedExploratoryTier
1423
2114
  ? scopeContextPackToExploratoryCandidateClosure(scopedContextPack, input.exploratoryCandidateIds)
1424
2115
  : undefined;
@@ -1510,6 +2201,31 @@ async function runAnswerLoop(input) {
1510
2201
  const authoritativePlanBinding = !forcedExploratoryTier && input.resolvedAnalyticalPlan?.mode === 'authoritative'
1511
2202
  ? input.resolvedPlanExecutionBinding
1512
2203
  : undefined;
2204
+ // `freezeLegacySemanticSelection()` attaches an authoritative-shaped v2
2205
+ // plan for direct AnswerLoop compatibility. It is not the router-owned
2206
+ // semantic cascade decision. Only an explicit selected semantic tier may
2207
+ // make an adapter/graph miss terminal; direct callers retain the established
2208
+ // deterministic metric path, which still applies the ordinary SQL safety
2209
+ // validation before it can execute.
2210
+ const legacyDirectSemanticPlan = input.selectedCascadeTier === undefined
2211
+ && input.resolvedAnalyticalPlan?.mode === 'authoritative'
2212
+ && input.resolvedAnalyticalPlan.recommendedRoute === 'semantic'
2213
+ && !frozenSemanticRoute;
2214
+ // A semantic freeze is an immutable execution authority, not a hint for the
2215
+ // legacy loop. In particular, projects that have indexed semantic metadata
2216
+ // but have not configured the corresponding local semantic adapter used to
2217
+ // continue into generated SQL. Stop here with the selected plan's typed
2218
+ // diagnostic instead; no provider, tool, or SQL work is allowed to replace
2219
+ // it after freeze.
2220
+ if (!forcedExploratoryTier && frozenSemanticRoute && !input.semanticLayer) {
2221
+ return frozenSemanticPlanFailureAnswer({
2222
+ answerInput: input,
2223
+ considered,
2224
+ providerName: provider.name,
2225
+ code: 'SEMANTIC_LAYER_REQUIRED',
2226
+ reason: `The pinned semantic adapter for ${frozenSemanticRoute.ref ?? 'the selected semantic plan'} is unavailable.`,
2227
+ });
2228
+ }
1513
2229
  if (!forcedExploratoryTier && input.analyticalPeriodResolutionFailure) {
1514
2230
  const failure = input.analyticalPeriodResolutionFailure;
1515
2231
  const structuredFailureCode = failure.error && typeof failure.error === 'object'
@@ -1601,7 +2317,11 @@ async function runAnswerLoop(input) {
1601
2317
  input,
1602
2318
  binding: input.semanticGraphExecutionBinding,
1603
2319
  graph: input.analyticalExecutionGraph,
1604
- capability: normalizeMetricCapabilityContract(input.semanticGraphExecutionBinding.metricNode.payload?.analyticalCapability),
2320
+ // The execution registry identifies the adapter metric, but its compact
2321
+ // node payload may omit the relationship-path metadata that was frozen
2322
+ // on the RAP. Re-deriving aggregation proof from that projection makes
2323
+ // an already accepted native MetricFlow grouping fail post-freeze.
2324
+ capability: input.semanticGraphExecutionBinding.capability,
1605
2325
  considered,
1606
2326
  providerName: provider.name,
1607
2327
  });
@@ -1716,7 +2436,7 @@ async function runAnswerLoop(input) {
1716
2436
  providerUsed: provider.name,
1717
2437
  };
1718
2438
  }
1719
- if (authoritativePlanBinding?.status === 'blocked') {
2439
+ if (authoritativePlanBinding?.status === 'blocked' && !legacyDirectSemanticPlan) {
1720
2440
  // An ambiguous singular reference is answerable the moment the user says
1721
2441
  // which one they meant, so offer the candidates rather than dead-ending.
1722
2442
  // The old refusal shipped the binder's internal sentence as the answer
@@ -1792,7 +2512,7 @@ async function runAnswerLoop(input) {
1792
2512
  ? authoritativePlanBinding
1793
2513
  : undefined;
1794
2514
  const preferredSemanticMetric = authoritativeSemanticBinding?.metricNode
1795
- ?? (input.resolvedAnalyticalPlan?.mode === 'authoritative'
2515
+ ?? (input.resolvedAnalyticalPlan?.mode === 'authoritative' && !legacyDirectSemanticPlan
1796
2516
  ? undefined
1797
2517
  : resolvePreferredSemanticMetric([input.preferredExecutionId, ...(input.preferredEvidenceIds ?? [])], semanticMetricNodes, kg));
1798
2518
  const semanticLayerForExec = input.semanticLayer;
@@ -1802,7 +2522,7 @@ async function runAnswerLoop(input) {
1802
2522
  ? null
1803
2523
  : preferredSemanticMetric
1804
2524
  ? { metric: preferredSemanticMetric, score: 1, basis: 'name' }
1805
- : input.resolvedAnalyticalPlan?.mode === 'authoritative'
2525
+ : input.resolvedAnalyticalPlan?.mode === 'authoritative' && !legacyDirectSemanticPlan
1806
2526
  ? null
1807
2527
  : await matchSemanticMetric(semanticQuestion, semanticMetricNodes, {
1808
2528
  measureTerms: [...questionPlan.requestedShape.measures, ...questionPlan.metricTerms],
@@ -1981,18 +2701,14 @@ async function runAnswerLoop(input) {
1981
2701
  if (artifactHit && businessContextTerminal) {
1982
2702
  let result;
1983
2703
  let executionError;
2704
+ let executionFailureDetail;
1984
2705
  if (artifactHit.node.kind === 'block' && input.executeCertifiedBlock) {
1985
2706
  try {
1986
- result = await input.executeCertifiedBlock(artifactHit.node, {
1987
- question,
1988
- ...certifiedInvocationInputs(artifactHit.node, questionPlan),
1989
- rowLimit: questionPlan.requestedShape.topN?.scope === 'per_group'
1990
- ? undefined
1991
- : questionPlan.requestedShape.topN?.n,
1992
- });
1993
- result = trimResultToRequestedTopN(result, questionPlan);
2707
+ result = await input.executeCertifiedBlock(artifactHit.node, buildCertifiedBlockInvocationInput(artifactHit.node, questionPlan, question));
2708
+ result = trimCertifiedBlockResultToRequestedTopN(result, questionPlan);
1994
2709
  }
1995
2710
  catch (err) {
2711
+ executionFailureDetail = analyticalErrorDetail(err);
1996
2712
  executionError = err instanceof Error ? err.message : String(err);
1997
2713
  }
1998
2714
  }
@@ -2098,6 +2814,9 @@ async function runAnswerLoop(input) {
2098
2814
  ? undefined
2099
2815
  : questionPlan.requestedShape.topN?.n);
2100
2816
  const authoritativeCertifiedFailure = Boolean(authoritativeCertifiedBinding && executionError);
2817
+ const observabilityExecutionFailure = authoritativeCertifiedFailure
2818
+ ? observabilityFailureForFrozenExecution(executionFailureDetail)
2819
+ : undefined;
2101
2820
  // A selected certified block which returns an incomplete tuple is a
2102
2821
  // same-tier terminal failure. It is not a reason to reinterpret meaning,
2103
2822
  // label the partial rows generated, or retry another route after freeze.
@@ -2143,6 +2862,7 @@ async function runAnswerLoop(input) {
2143
2862
  block: artifactHit.node.kind === 'block' ? artifactHit.node : undefined,
2144
2863
  result,
2145
2864
  executionError,
2865
+ ...(observabilityExecutionFailure ? { observabilityExecutionFailure } : {}),
2146
2866
  ...(authoritativeCertifiedFailure || authoritativeCertifiedShapeFailure
2147
2867
  ? { refusalCode: 'grounding_gap' }
2148
2868
  : {}),
@@ -2637,8 +3357,21 @@ async function runAnswerLoop(input) {
2637
3357
  contextPack: input.contextPack,
2638
3358
  considered,
2639
3359
  providerUsed: provider.name,
3360
+ ...(frozenSemanticRoute ? { route: frozenSemanticRoute } : {}),
2640
3361
  };
2641
3362
  }
3363
+ // The semantic branch above has exhausted the exact plan's compiler and
3364
+ // adapter choices. Do not let `metricFirst` or the ordinary generated
3365
+ // proposal branch reinterpret the question after a semantic freeze.
3366
+ if (frozenSemanticRoute && !semanticBridgeAnswer) {
3367
+ return frozenSemanticPlanFailureAnswer({
3368
+ answerInput: input,
3369
+ considered,
3370
+ providerName: provider.name,
3371
+ code: 'COMPILATION_FAILED',
3372
+ reason: `The pinned semantic adapter could not compose ${frozenSemanticRoute.ref ?? 'the selected semantic plan'} without changing the frozen plan.`,
3373
+ });
3374
+ }
2642
3375
  const metricFirst = semanticMetricMatch
2643
3376
  ? buildGovernedMetricFirstSql({
2644
3377
  metric: semanticMetricMatch.metric,
@@ -2841,9 +3574,15 @@ async function runAnswerLoop(input) {
2841
3574
  confidence: 0,
2842
3575
  text,
2843
3576
  answer: text,
2844
- refusalCode: 'provider_error',
3577
+ // DQL stopping itself is not the provider failing. Reporting both as
3578
+ // `provider_error` put "The AI provider did not respond" above a body
3579
+ // that said the opposite, and sent the reader to check provider health
3580
+ // for a run that ended on an internal ceiling.
3581
+ refusalCode: admissionDeclined || orchestrationBudget ? 'orchestration_budget_exhausted' : 'provider_error',
2845
3582
  refusalDetails: {
2846
- code: orchestrationBudget ? 'orchestration_budget_exhausted' : 'provider_error',
3583
+ code: admissionDeclined
3584
+ ? 'RUN_DEADLINE_INSUFFICIENT'
3585
+ : orchestrationBudget ? 'orchestration_budget_exhausted' : 'provider_error',
2847
3586
  message: orchestrationBudget ? `${text} (${err.message})` : text,
2848
3587
  },
2849
3588
  citations: [],
@@ -2970,14 +3709,36 @@ async function runAnswerLoop(input) {
2970
3709
  || (input.contextPack?.allowedSqlContext?.relations.length ?? 0) > 0
2971
3710
  || (input.contextPack?.allowedSqlContext?.sourceBlockSql.length ?? 0) > 0
2972
3711
  || contextBlocks.length > 0;
3712
+ // A selected bounded-exploration RAP is stronger than the advisory legacy
3713
+ // analytical-path diagnosis. The latter may still call raw output terms a
3714
+ // semantic "missing dimension" even after the router has proven one safe
3715
+ // physical relation and frozen the exploratory tuple. Do not let that
3716
+ // advisory pre-freeze diagnosis suppress the one explicitly authorized
3717
+ // same-plan model-decline repair; the host re-validates the exact proposal
3718
+ // against the frozen closure before it can execute.
3719
+ const frozenExploratoryRepair = frozenExploratoryModelRepairAuthority(input);
2973
3720
  if (!parsed.sql && !governedMetricAnswer && wantsGeneratedData && hasGeneratableContext
2974
- && (analyticalPlan?.safe !== false || analyticalPlanAllowsDbtExploration(analyticalPlan))) {
3721
+ && (frozenExploratoryRepair
3722
+ || analyticalPlan?.safe !== false
3723
+ || analyticalPlanAllowsDbtExploration(analyticalPlan))) {
2975
3724
  try {
2976
3725
  proposed = await generateProposalWithOptionalTools({
2977
3726
  provider,
2978
- messages: [...messages, { role: 'system', content: FORCE_JOIN_INSTRUCTION }],
2979
- // One tool round per ordinary Ask. The second generation is a bounded
2980
- // composition correction over already-retrieved evidence.
3727
+ messages: [...messages, {
3728
+ role: 'system',
3729
+ content: [
3730
+ FORCE_JOIN_INSTRUCTION,
3731
+ ...(frozenExploratoryRepair
3732
+ ? [renderFrozenExploratoryRepairContract(input.resolvedAnalyticalPlan, questionPlan)]
3733
+ : []),
3734
+ ].join('\n\n'),
3735
+ }],
3736
+ // A router-frozen exploratory plan may consume exactly one additional
3737
+ // provider transport to correct a model decline. The host carries the
3738
+ // immutable plan and output tuple into this retry; it is neither a
3739
+ // replan nor a route change. Legacy direct callers retain their
3740
+ // historical composition correction, but do not receive the typed
3741
+ // repair authority below.
2981
3742
  tools: [],
2982
3743
  questionPlan,
2983
3744
  intent,
@@ -2986,6 +3747,13 @@ async function runAnswerLoop(input) {
2986
3747
  analysisDepth: input.analysisDepth,
2987
3748
  toolCalls: proposalToolCalls,
2988
3749
  providerPayloadGuard: input.providerPayloadGuard,
3750
+ ...(frozenExploratoryRepair
3751
+ ? {
3752
+ dispatchPhase: 'repair',
3753
+ egressPurpose: 'repair_sql',
3754
+ maxProviderDispatches: 1,
3755
+ }
3756
+ : {}),
2989
3757
  });
2990
3758
  parsed = parseProposal(proposed);
2991
3759
  }
@@ -3060,6 +3828,49 @@ async function runAnswerLoop(input) {
3060
3828
  }
3061
3829
  if (parsed.sql) {
3062
3830
  const frozenPlan = input.resolvedAnalyticalPlan;
3831
+ // The result contract is host-owned even in tests or embedded callers
3832
+ // that provide their own execution adapter. Stop before capability
3833
+ // preparation when a generated alias is backed by a different physical
3834
+ // column than the frozen plan (for example `product_id AS order_id`).
3835
+ // The local runtime repeats this check while minting the capability and
3836
+ // persists the matching proof on its receipt.
3837
+ if (input.selectedCascadeTier === 'exploratory_sql' && frozenPlan) {
3838
+ const requiredOutputValidation = validateFrozenRequiredOutputProjection({
3839
+ plan: frozenPlan,
3840
+ sql: parsed.sql,
3841
+ ...(input.semanticDriver ? { dialect: input.semanticDriver } : {}),
3842
+ });
3843
+ if (!requiredOutputValidation.ok) {
3844
+ const failedOutputs = [...new Set([
3845
+ ...requiredOutputValidation.missingOutputs,
3846
+ ...requiredOutputValidation.bindingMismatches,
3847
+ ])];
3848
+ const message = `The generated exploratory query did not prove the frozen source binding for required output ${failedOutputs.join(', ')}, so DQL did not execute it.`;
3849
+ return {
3850
+ kind: 'no_answer',
3851
+ sourceTier: 'no_answer',
3852
+ certification: 'analyst_review_required',
3853
+ reviewStatus: 'none',
3854
+ confidence: 0,
3855
+ text: message,
3856
+ answer: message,
3857
+ refusalCode: 'policy_blocked',
3858
+ refusalDetails: {
3859
+ code: 'OUTPUT_BINDING_TUPLE_DRIFT',
3860
+ message,
3861
+ },
3862
+ validationWarnings: [
3863
+ 'OUTPUT_BINDING_TUPLE_DRIFT',
3864
+ ...failedOutputs.map((output) => `Frozen output binding not proven: ${output}`),
3865
+ ],
3866
+ citations: contextPackCitations(input.contextPack, 8),
3867
+ memoryContext: input.memoryContext,
3868
+ contextPack: input.contextPack,
3869
+ considered,
3870
+ providerUsed: provider.name,
3871
+ };
3872
+ }
3873
+ }
3063
3874
  if (!governedMetricAnswer && frozenPlan?.mode === 'authoritative' && frozenPlan.schemaVersion === 2) {
3064
3875
  const targetFingerprint = input.generatedProposalTargetFingerprint ?? '';
3065
3876
  const validation = validateGeneratedAnalyticalProposal({
@@ -3499,7 +4310,13 @@ async function runAnswerLoop(input) {
3499
4310
  // Business-language chat text; the validator's machine message (with
3500
4311
  // relation ids and tool guidance for the repair prompt) stays in
3501
4312
  // refusalDetails + validationWarnings for the Inspect surface.
3502
- const text = renderContextValidationRefusalForUser(contextValidation.code, contextValidation.error, input.followUp?.memberBindings, contextValidation.aggregationSafetyProof?.issueCodes);
4313
+ // A dimension the user explicitly asked for that the business has not
4314
+ // modeled is a modeling gap, not a retrieval gap. Say so, and say what
4315
+ // would let the question be answered.
4316
+ const unmodeledRequestedDimension = requestedDimensionModelingGap(contextValidation.code, contextValidation.offending, [...questionPlan.dimensionTerms, ...(input.followUp?.dimensions ?? [])]);
4317
+ const text = unmodeledRequestedDimension
4318
+ ? `"${unmodeledRequestedDimension}" is not modeled. Which governed dimension should I use instead?`
4319
+ : renderContextValidationRefusalForUser(contextValidation.code, contextValidation.error, input.followUp?.memberBindings, contextValidation.aggregationSafetyProof?.issueCodes);
3503
4320
  const analysisPlan = buildAnalysisPlan({
3504
4321
  question,
3505
4322
  intent,
@@ -3521,7 +4338,9 @@ async function runAnswerLoop(input) {
3521
4338
  reviewStatus: 'none',
3522
4339
  confidence: 0.15,
3523
4340
  text,
3524
- refusalCode: refusalCodeForValidation(contextValidation.code),
4341
+ refusalCode: unmodeledRequestedDimension
4342
+ ? 'modeling_gap'
4343
+ : refusalCodeForValidation(contextValidation.code),
3525
4344
  refusalDetails: {
3526
4345
  code: contextValidation.code ?? 'insufficient_context',
3527
4346
  message: contextValidation.error,
@@ -3664,12 +4483,14 @@ async function runAnswerLoop(input) {
3664
4483
  let executionError;
3665
4484
  let warehouseFailure;
3666
4485
  let exploratoryClosureDenied = false;
4486
+ let semanticFanoutProbeFailure;
3667
4487
  let repairAttempts = 0;
3668
4488
  // A forced proposal may originate from the bounded analyst loop, but it
3669
4489
  // cannot bypass a router-owned exploratory decision. In that case the host
3670
4490
  // still prepares and freezes the exact validated SQL before execution.
3671
4491
  const exploratoryExecutionSelected = input.selectedCascadeTier === 'exploratory_sql';
3672
- let preparedExploratoryExecution;
4492
+ let initialExploratoryExecution;
4493
+ let exploratoryRepairExecution;
3673
4494
  // Repair candidates that were generated but rejected before execution. These
3674
4495
  // are diagnostics ABOUT the recovery attempt, not the reason the run failed,
3675
4496
  // so they are reported alongside the original error rather than replacing it.
@@ -3684,7 +4505,7 @@ async function runAnswerLoop(input) {
3684
4505
  executionError = deepCandidateExecutionError;
3685
4506
  warehouseFailure = normalizeWarehouseSqlFailure(deepCandidateExecutionError, input.semanticDriver);
3686
4507
  }
3687
- const executeCurrentSql = async () => {
4508
+ const executeCurrentSql = async (authorizationAttempt) => {
3688
4509
  const requestedLimit = questionPlan.requestedShape.topN?.scope === 'per_group'
3689
4510
  ? 200
3690
4511
  : questionPlan.requestedShape.topN?.n ?? 200;
@@ -3725,17 +4546,23 @@ async function runAnswerLoop(input) {
3725
4546
  });
3726
4547
  }
3727
4548
  // This is the only point at which a router-selected exploratory proposal
3728
- // crosses from validated text into executable authority. The callback
3729
- // rechecks the snapshot, physical target, and qualified runtime columns,
3730
- // then returns a one-shot capability bound to these exact SQL bytes.
3731
- preparedExploratoryExecution ??= await input.prepareExploratorySqlExecution(parsed.sql, boundedArtifact);
3732
- return input.executeAgenticGeneratedSql(preparedExploratoryExecution.capability, parsed.sql, boundedArtifact);
4549
+ // crosses from validated text into executable authority. The first call
4550
+ // receives the original one-shot capability. A retryable warehouse
4551
+ // failure may mint exactly one *new* repair capability, explicitly bound
4552
+ // to the first SQL fingerprint; it never reopens meaning or routing.
4553
+ const prepared = authorizationAttempt
4554
+ ? await input.prepareExploratorySqlExecution(parsed.sql, boundedArtifact, authorizationAttempt)
4555
+ : (initialExploratoryExecution ??= await input.prepareExploratorySqlExecution(parsed.sql, boundedArtifact));
4556
+ if (authorizationAttempt)
4557
+ exploratoryRepairExecution = prepared;
4558
+ return input.executeAgenticGeneratedSql(prepared.capability, parsed.sql, boundedArtifact);
3733
4559
  }
3734
4560
  if (!input.executeGeneratedSql)
3735
4561
  throw new Error('No generated SQL executor is configured.');
3736
4562
  return input.executeGeneratedSql(parsed.sql, boundedArtifact);
3737
4563
  };
3738
4564
  if ((input.executeGeneratedSql
4565
+ || input.executeDqlArtifact
3739
4566
  || (input.forcedGeneratedProposal && input.executeAgenticGeneratedSql)
3740
4567
  || exploratoryExecutionSelected) && !result) {
3741
4568
  // Fanout gate for native semantic direct-joins: a duplicate join-key row on
@@ -3744,17 +4571,31 @@ async function runAnswerLoop(input) {
3744
4571
  // answers). Probe first; on structural contradiction, refuse with the cause
3745
4572
  // instead of presenting wrong numbers — and never hand the error to SQL
3746
4573
  // repair, which would regenerate the same multiplying join.
3747
- let fanoutContradiction = false;
4574
+ let semanticFanoutBlocked = false;
3748
4575
  if (!executionError
3749
4576
  && !input.forcedGeneratedProposal
3750
4577
  && !exploratoryExecutionSelected
3751
- && input.executeGeneratedSql
3752
4578
  && semanticBridgeAnswer?.composeResult?.fanoutProbeSql
3753
4579
  && semanticBridgeAnswer.sql.trim() === parsed.sql?.trim()) {
3754
- const inflationError = await probeSemanticJoinFanout(semanticBridgeAnswer.composeResult.fanoutProbeSql, semanticBridgeAnswer.composeResult.tables, input.executeGeneratedSql);
3755
- if (inflationError) {
3756
- executionError = inflationError;
3757
- fanoutContradiction = true;
4580
+ // A DQL-artifact executor owns the semantic artifact, not an arbitrary
4581
+ // one-row structural SQL probe. If the host did not also provide the
4582
+ // read-only SQL executor needed for that probe, native direct joins fail
4583
+ // closed rather than treating an artifact handoff as proof of uniqueness.
4584
+ const fanoutProbe = input.executeGeneratedSql
4585
+ ? await probeSemanticJoinFanout(semanticBridgeAnswer.composeResult.fanoutProbeSql, semanticBridgeAnswer.composeResult.tables, input.executeGeneratedSql)
4586
+ : {
4587
+ status: 'blocked',
4588
+ code: 'SEMANTIC_FANOUT_PROBE_ERROR',
4589
+ message: 'DQL did not execute this governed semantic answer because the host could not verify native join fanout before aggregation. Configure the semantic runtime or a read-only SQL probe executor, then retry.',
4590
+ };
4591
+ if (fanoutProbe.status === 'blocked') {
4592
+ // A governed semantic route freezes before execution. If DQL cannot
4593
+ // prove its native join is fanout-safe, it must fail closed rather than
4594
+ // execute, label the result governed, or quietly repair into a
4595
+ // different plan.
4596
+ executionError = fanoutProbe.message;
4597
+ semanticFanoutProbeFailure = fanoutProbe;
4598
+ semanticFanoutBlocked = true;
3758
4599
  }
3759
4600
  }
3760
4601
  try {
@@ -3776,9 +4617,15 @@ async function runAnswerLoop(input) {
3776
4617
  executionError = warehouseFailure.redactedMessage;
3777
4618
  }
3778
4619
  if (executionError
3779
- && !input.forcedGeneratedProposal
3780
- && !exploratoryExecutionSelected
3781
- && !fanoutContradiction
4620
+ // A host-supplied SQL proposal normally bypasses repair. A router-owned
4621
+ // exploratory proposal is the exception: its frozen plan may authorize
4622
+ // one corrected SQL statement after a retryable warehouse failure.
4623
+ && (!input.forcedGeneratedProposal || exploratoryExecutionSelected)
4624
+ // A router-selected exploratory plan may receive one bounded
4625
+ // same-plan repair. The repair still goes through a fresh host
4626
+ // authorization; it does not reuse the consumed initial capability.
4627
+ && (!exploratoryExecutionSelected || Boolean(initialExploratoryExecution))
4628
+ && !semanticFanoutBlocked
3782
4629
  && !authoritativeSemanticBinding) {
3783
4630
  warehouseFailure ??= normalizeWarehouseSqlFailure(executionError, input.semanticDriver);
3784
4631
  if (isRetryableGeneratedSqlError(warehouseFailure)) {
@@ -3804,7 +4651,13 @@ async function runAnswerLoop(input) {
3804
4651
  ],
3805
4652
  };
3806
4653
  try {
3807
- result = await executeCurrentSql();
4654
+ result = await executeCurrentSql(exploratoryExecutionSelected
4655
+ ? {
4656
+ version: 1,
4657
+ index: 1,
4658
+ parentSqlFingerprint: initialExploratoryExecution.freeze.sqlFingerprint,
4659
+ }
4660
+ : undefined);
3808
4661
  executionError = undefined;
3809
4662
  warehouseFailure = undefined;
3810
4663
  }
@@ -3836,6 +4689,18 @@ async function runAnswerLoop(input) {
3836
4689
  schemaContext,
3837
4690
  signal: input.signal,
3838
4691
  reasoningEffort: input.reasoningEffort,
4692
+ // Only the router-frozen exploratory lane has authority to spend
4693
+ // the exceptional post-warehouse repair transport. Legacy callers
4694
+ // keep their prior unlabelled repair behavior.
4695
+ ...(exploratoryExecutionSelected
4696
+ && Boolean(initialExploratoryExecution)
4697
+ && frozenExploratoryModelRepairAuthority(input)
4698
+ ? {
4699
+ dispatchPhase: 'repair',
4700
+ egressPurpose: 'repair_sql',
4701
+ maxProviderDispatches: 1,
4702
+ }
4703
+ : {}),
3839
4704
  });
3840
4705
  const repaired = parseProposal(repairedRaw);
3841
4706
  if (repaired.sql) {
@@ -3854,7 +4719,12 @@ async function runAnswerLoop(input) {
3854
4719
  repairNarrative = repaired.text?.trim() || undefined;
3855
4720
  parsed.sql = repaired.sql;
3856
4721
  parsed.viz = repaired.viz ?? parsed.viz;
3857
- applyParsedProposalMetadata(parsed, repaired);
4722
+ // A repair fixes SQL bytes only. On the router-owned
4723
+ // exploratory lane, model prose or metadata must never mutate
4724
+ // the host-selected frame, route, outputs, or trust state after
4725
+ // the plan froze.
4726
+ if (!exploratoryExecutionSelected)
4727
+ applyParsedProposalMetadata(parsed, repaired);
3858
4728
  contextValidation = {
3859
4729
  ok: true,
3860
4730
  warnings: [
@@ -3863,7 +4733,13 @@ async function runAnswerLoop(input) {
3863
4733
  ],
3864
4734
  };
3865
4735
  try {
3866
- result = await executeCurrentSql();
4736
+ result = await executeCurrentSql(exploratoryExecutionSelected
4737
+ ? {
4738
+ version: 1,
4739
+ index: 1,
4740
+ parentSqlFingerprint: initialExploratoryExecution.freeze.sqlFingerprint,
4741
+ }
4742
+ : undefined);
3867
4743
  executionError = undefined;
3868
4744
  warehouseFailure = undefined;
3869
4745
  }
@@ -3893,12 +4769,14 @@ async function runAnswerLoop(input) {
3893
4769
  // A router-selected physical-closure denial happens before a capability
3894
4770
  // exists. Its rejected bytes are diagnostics for the host, never a SQL
3895
4771
  // preview that a user can review, copy, or turn into a draft.
3896
- sql: exploratoryClosureDenied ? undefined : parsed.sql,
4772
+ sql: exploratoryClosureDenied || semanticFanoutProbeFailure ? undefined : parsed.sql,
3897
4773
  suggestedViz: parsed.viz ?? 'table',
3898
4774
  assumptions: [
3899
4775
  ...(exploratoryClosureDenied
3900
4776
  ? ['The router-selected physical closure rejected the generated SQL before capability minting.']
3901
- : ['The SQL preview is uncertified until an analyst reviews and promotes the DQL artifact.']),
4777
+ : semanticFanoutProbeFailure
4778
+ ? ['The native semantic join could not be proven fanout-safe before plan freeze, so DQL did not execute or expose its SQL.']
4779
+ : ['The SQL preview is uncertified until an analyst reviews and promotes the DQL artifact.']),
3902
4780
  ...(certifiedExecutionFallback
3903
4781
  ? [`Certified block ${certifiedExecutionFallback.node.name} failed execution and was bypassed: ${certifiedExecutionFallback.error}`]
3904
4782
  : []),
@@ -3911,17 +4789,34 @@ async function runAnswerLoop(input) {
3911
4789
  // Global top-N asks must return exactly N rows even when the generated SQL
3912
4790
  // returned more (missing/oversized LIMIT) — mirrors the certified path so a
3913
4791
  // "top 10" question never shows 200 rows. per_group scope is left intact by
3914
- // trimResultToRequestedTopN. Domain-agnostic.
4792
+ // trimCertifiedBlockResultToRequestedTopN. Domain-agnostic.
3915
4793
  let topNTrimNote;
3916
4794
  if (result) {
3917
4795
  const beforeRows = Array.isArray(result.rows) ? result.rows.length : result.rowCount;
3918
- result = trimResultToRequestedTopN(result, questionPlan);
4796
+ result = trimCertifiedBlockResultToRequestedTopN(result, questionPlan);
3919
4797
  const afterRows = Array.isArray(result.rows) ? result.rows.length : result.rowCount;
3920
4798
  if (afterRows < beforeRows) {
3921
4799
  topNTrimNote = `Showed the top ${questionPlan.requestedShape.topN?.n ?? afterRows} of ${beforeRows} rows the query returned.`;
3922
4800
  }
3923
4801
  }
3924
4802
  const resultShape = result ? validateAnswerResultShape(questionPlan, result) : undefined;
4803
+ // A router-selected exploratory plan carries a host-bound output
4804
+ // projection. The ordinary generated-answer UX may show a partial result
4805
+ // with a warning, but a frozen exploratory plan may not: displaying it
4806
+ // would silently change the tuple DQL authorized. Keep the connector rows
4807
+ // out of the answer/artifact when a broken executor returns them anyway.
4808
+ const missingFrozenExploratoryOutputs = exploratoryExecutionSelected && result
4809
+ ? missingFrozenRequiredOutputProjection(input.resolvedAnalyticalPlan, result)
4810
+ : [];
4811
+ const activeExploratoryOutputFreeze = exploratoryRepairExecution?.freeze ?? initialExploratoryExecution?.freeze;
4812
+ const missingFrozenExploratoryBindingProofs = exploratoryExecutionSelected && result
4813
+ ? missingFrozenRequiredOutputBindingProof(input.resolvedAnalyticalPlan, activeExploratoryOutputFreeze)
4814
+ : [];
4815
+ const frozenExploratoryOutputFailures = [...new Set([
4816
+ ...missingFrozenExploratoryOutputs,
4817
+ ...missingFrozenExploratoryBindingProofs,
4818
+ ])];
4819
+ const frozenExploratoryOutputContractFailure = frozenExploratoryOutputFailures.length > 0;
3925
4820
  // ANY question whose SQL executed but dropped multiple requested columns used to
3926
4821
  // REFUSE outright ("no governed answer"), throwing away a result that actually
3927
4822
  // ran. Instead, SURFACE the partial result (review-required) with a warning that
@@ -3944,13 +4839,16 @@ async function runAnswerLoop(input) {
3944
4839
  ...repairNotes,
3945
4840
  ...(resultShape?.warnings ?? []),
3946
4841
  ...(partialShapeWarning ? [partialShapeWarning] : []),
4842
+ ...(frozenExploratoryOutputContractFailure
4843
+ ? [`The executed exploratory result violated the frozen output contract: ${frozenExploratoryOutputFailures.join(', ')}.`]
4844
+ : []),
3947
4845
  ...(topNTrimNote ? [topNTrimNote] : []),
3948
4846
  ...(executionError ? ['The preview execution error must be reviewed before reuse.'] : []),
3949
4847
  ];
3950
4848
  const generatedOutputs = parsed.outputs?.length ? parsed.outputs : resultColumnNames(result);
3951
4849
  const generatedRequestedFilters = mergeProposalStringLists(input.followUp?.filters, parsed.requestedFilters);
3952
4850
  const generatedRequestedDimensions = mergeProposalStringLists(input.followUp?.dimensions, parsed.requestedDimensions);
3953
- const baseDqlArtifact = exploratoryClosureDenied
4851
+ const baseDqlArtifact = exploratoryClosureDenied || semanticFanoutProbeFailure || frozenExploratoryOutputContractFailure
3954
4852
  ? undefined
3955
4853
  : result?.dqlArtifact ?? semanticBridgeAnswer?.dqlArtifact ?? buildGeneratedSqlDqlArtifact({
3956
4854
  question,
@@ -3979,7 +4877,7 @@ async function runAnswerLoop(input) {
3979
4877
  : undefined;
3980
4878
  let draftBlock;
3981
4879
  let draftCaptureError;
3982
- if (!exploratoryClosureDenied && input.captureGeneratedDraft && parsed.sql) {
4880
+ if (!exploratoryClosureDenied && !semanticFanoutProbeFailure && !frozenExploratoryOutputContractFailure && input.captureGeneratedDraft && parsed.sql) {
3983
4881
  try {
3984
4882
  draftBlock = await input.captureGeneratedDraft({
3985
4883
  question,
@@ -4026,10 +4924,21 @@ async function runAnswerLoop(input) {
4026
4924
  : undefined;
4027
4925
  const certifiedMetricAnswer = semanticMetricCertification === 'certified' || semanticMetricCertification === 'reviewed';
4028
4926
  const governedMetricExecutionFailure = governedMetricAnswer && Boolean(executionError);
4029
- const terminalExecutionFailure = governedMetricExecutionFailure || exploratoryClosureDenied;
4927
+ const terminalExecutionFailure = governedMetricExecutionFailure || exploratoryClosureDenied || frozenExploratoryOutputContractFailure;
4928
+ const semanticFanoutTraceError = semanticFanoutProbeFailure
4929
+ ? Object.assign(new Error(semanticFanoutProbeFailure.message), {
4930
+ code: semanticFanoutProbeFailure.code,
4931
+ details: { phase: 'validation' },
4932
+ })
4933
+ : executionError;
4934
+ const terminalFailureMessage = frozenExploratoryOutputContractFailure
4935
+ ? `The exploratory execution result did not retain proven frozen output binding ${frozenExploratoryOutputFailures.join(', ')}, so DQL did not display it.`
4936
+ : exploratoryClosureDenied
4937
+ ? 'The generated SQL referenced a relation outside the router-selected physical closure, so DQL did not execute it.'
4938
+ : semanticFanoutProbeFailure?.message;
4030
4939
  const finalSemanticExecutionTrace = semanticTraceAfterExecution(semanticExecutionTrace, {
4031
4940
  executed: Boolean(result),
4032
- ...(executionError ? { error: executionError } : {}),
4941
+ ...(semanticFanoutTraceError ? { error: semanticFanoutTraceError } : {}),
4033
4942
  ...(result ? { result } : {}),
4034
4943
  });
4035
4944
  return {
@@ -4039,21 +4948,25 @@ async function runAnswerLoop(input) {
4039
4948
  reviewStatus: terminalExecutionFailure ? 'none' : governedMetricAnswer ? 'governed' : 'draft_ready',
4040
4949
  semanticMetricCertification,
4041
4950
  confidence: terminalExecutionFailure ? 0 : certifiedMetricAnswer ? 0.8 : governedMetricAnswer ? 0.72 : 0.55,
4042
- text: exploratoryClosureDenied
4043
- ? 'The generated SQL referenced a relation outside the router-selected physical closure, so DQL did not execute it.'
4044
- : generatedText,
4045
- answer: exploratoryClosureDenied
4046
- ? 'The generated SQL referenced a relation outside the router-selected physical closure, so DQL did not execute it.'
4047
- : generatedText,
4048
- ...(exploratoryClosureDenied ? {} : { proposedSql: parsed.sql, sql: parsed.sql }),
4049
- result,
4951
+ text: terminalFailureMessage ?? generatedText,
4952
+ answer: terminalFailureMessage ?? generatedText,
4953
+ ...(exploratoryClosureDenied || semanticFanoutProbeFailure || frozenExploratoryOutputContractFailure ? {} : { proposedSql: parsed.sql, sql: parsed.sql }),
4954
+ result: frozenExploratoryOutputContractFailure ? undefined : result,
4050
4955
  executionError,
4051
4956
  ...(warehouseFailure ? { warehouseFailure } : {}),
4052
4957
  ...(finalSemanticExecutionTrace ? { semanticExecutionTrace: finalSemanticExecutionTrace } : {}),
4053
- ...(terminalExecutionFailure ? { refusalCode: 'grounding_gap' } : {}),
4958
+ ...(terminalExecutionFailure ? {
4959
+ refusalCode: semanticFanoutProbeFailure ? 'policy_blocked' : 'grounding_gap',
4960
+ } : {}),
4961
+ ...(semanticFanoutProbeFailure ? {
4962
+ refusalDetails: {
4963
+ code: semanticFanoutProbeFailure.code,
4964
+ message: semanticFanoutProbeFailure.message,
4965
+ },
4966
+ } : {}),
4054
4967
  suggestedViz: parsed.viz ?? 'table',
4055
- ...(exploratoryClosureDenied ? {} : { dqlArtifact: answerDqlArtifact }),
4056
- ...(exploratoryClosureDenied ? {} : {
4968
+ ...(exploratoryClosureDenied || semanticFanoutProbeFailure || frozenExploratoryOutputContractFailure ? {} : { dqlArtifact: answerDqlArtifact }),
4969
+ ...(exploratoryClosureDenied || semanticFanoutProbeFailure || frozenExploratoryOutputContractFailure ? {} : {
4057
4970
  draftBlock,
4058
4971
  draftBlockId: draftBlock?.path,
4059
4972
  promoteCommand: draftBlock ? `dql certify --from-draft ${draftBlock.path}` : undefined,
@@ -4095,7 +5008,8 @@ async function runAnswerLoop(input) {
4095
5008
  considered,
4096
5009
  providerUsed: provider.name,
4097
5010
  aggregationSafetyProof,
4098
- ...(preparedExploratoryExecution ? { exploratoryExecutionFreeze: preparedExploratoryExecution.freeze } : {}),
5011
+ ...(initialExploratoryExecution ? { exploratoryExecutionFreeze: initialExploratoryExecution.freeze } : {}),
5012
+ ...(exploratoryRepairExecution ? { exploratoryRepairExecutionFreeze: exploratoryRepairExecution.freeze } : {}),
4099
5013
  // Carry the governed metric match so the exit point can name a
4100
5014
  // `semantic_metric` route (spec 17, part C).
4101
5015
  _semanticMetricMatch: governedMetricAnswer ? semanticMetricMatch ?? undefined : undefined,
@@ -4110,7 +5024,13 @@ function renderExecutionSchemaForRepair(schemaContext) {
4110
5024
  ? `Current execution-target columns (authoritative for this repair):\n${tables.join('\n')}`
4111
5025
  : 'No execution-target columns were available; do not invent a replacement column.';
4112
5026
  }
4113
- function trimResultToRequestedTopN(result, plan) {
5027
+ /**
5028
+ * Preserve the declared answer shape even when an authored certified block
5029
+ * returns a broader result set than its typed `top_n` input. The V2 exact
5030
+ * path uses this same normalization so a provider-free execution cannot
5031
+ * present a different result than the ordinary certified route.
5032
+ */
5033
+ export function trimCertifiedBlockResultToRequestedTopN(result, plan) {
4114
5034
  const topN = plan.requestedShape.topN;
4115
5035
  if (!topN || topN.scope === 'per_group' || !Array.isArray(result.rows) || result.rows.length <= topN.n)
4116
5036
  return result;
@@ -4135,6 +5055,70 @@ function resultColumnNames(result) {
4135
5055
  .filter(Boolean);
4136
5056
  return columns?.length ? columns : undefined;
4137
5057
  }
5058
+ /**
5059
+ * Check only the host-bound aliases from a frozen plan. This is intentionally
5060
+ * stricter than the conversational shape helper: synonym matching is useful
5061
+ * for ordinary narration, but it cannot substitute a requested order/product
5062
+ * identifier after exploratory SQL has been authorized.
5063
+ */
5064
+ function missingFrozenRequiredOutputProjection(plan, result) {
5065
+ const required = plan?.outputContract?.requiredOutputs ?? [];
5066
+ if (required.length === 0)
5067
+ return [];
5068
+ const columns = new Set((resultColumnNames(result) ?? []).map(canonicalFrozenOutputAlias));
5069
+ return required.flatMap((binding) => {
5070
+ const alias = binding.outputName
5071
+ ?? binding.qualifiedId?.split(/[:.]/).at(-1)
5072
+ ?? binding.requested;
5073
+ const normalized = canonicalFrozenOutputAlias(alias);
5074
+ return normalized && columns.has(normalized) ? [] : [alias];
5075
+ });
5076
+ }
5077
+ /**
5078
+ * A column name in a returned result does not prove where it came from. The
5079
+ * result is accepted only when the server-owned authorization receipt carries
5080
+ * the exact frozen output-source binding that was parser-validated for the SQL
5081
+ * handed to the connector. Old persisted receipts remain readable; they
5082
+ * simply cannot authorize a new result for a plan that has explicit outputs.
5083
+ */
5084
+ function missingFrozenRequiredOutputBindingProof(plan, freeze) {
5085
+ const required = plan?.outputContract?.requiredOutputs ?? [];
5086
+ if (required.length === 0)
5087
+ return [];
5088
+ const expected = frozenRequiredOutputBindingProofsForPlan(plan);
5089
+ const missingSourceBindingAliases = required
5090
+ .filter((binding) => !expected.some((proof) => canonicalFrozenOutputAlias(proof.outputName)
5091
+ === canonicalFrozenOutputAlias(binding.outputName
5092
+ ?? binding.qualifiedId?.split(/[:.]/).at(-1)
5093
+ ?? binding.requested)))
5094
+ .map((binding) => binding.outputName
5095
+ ?? binding.qualifiedId?.split(/[:.]/).at(-1)
5096
+ ?? binding.requested);
5097
+ const actual = freeze?.requiredOutputBindings ?? [];
5098
+ const missingProofAliases = expected
5099
+ .filter((proof) => !actual.some((candidate) => sameFrozenRequiredOutputBindingProof(candidate, proof)))
5100
+ .map((proof) => proof.outputName);
5101
+ return [...new Set([...missingSourceBindingAliases, ...missingProofAliases])];
5102
+ }
5103
+ function sameFrozenRequiredOutputBindingProof(actual, expected) {
5104
+ return actual.version === 1
5105
+ && actual.qualifiedId === expected.qualifiedId
5106
+ && canonicalFrozenOutputAlias(actual.outputName) === canonicalFrozenOutputAlias(expected.outputName)
5107
+ && canonicalFrozenSourceIdentifier(actual.relation) === canonicalFrozenSourceIdentifier(expected.relation)
5108
+ && canonicalFrozenSourceIdentifier(actual.column) === canonicalFrozenSourceIdentifier(expected.column);
5109
+ }
5110
+ function canonicalFrozenOutputAlias(value) {
5111
+ return value.toLowerCase()
5112
+ .replace(/["`]/g, '')
5113
+ .replace(/[^a-z0-9]+/g, '_')
5114
+ .replace(/^_+|_+$/g, '');
5115
+ }
5116
+ function canonicalFrozenSourceIdentifier(value) {
5117
+ return value.toLowerCase()
5118
+ .replace(/["`\[\]]/g, '')
5119
+ .replace(/\s*\.\s*/g, '.')
5120
+ .trim();
5121
+ }
4138
5122
  function analyticalCapabilityForPlan(plan, contextPack, registry = []) {
4139
5123
  const selected = normalizeMetricCapabilityContract(plan.selectedCapability);
4140
5124
  if (selected) {
@@ -4501,8 +5485,11 @@ async function executeSemanticAnalyticalGraph(input) {
4501
5485
  });
4502
5486
  aggregationSafetyProof = proof;
4503
5487
  if (proof.status !== 'safe') {
4504
- return analyticalGraphFailureAnswer(input, 'EXECUTION_GRAPH_MISMATCH', semanticAggregationSafetyFailure(proof), {
4505
- phase: 'validation',
5488
+ return analyticalGraphFailureAnswer(input, 'COMPILATION_FAILED', semanticAggregationSafetyFailure(proof), {
5489
+ // This is compiler/plan validation before a warehouse statement is
5490
+ // dispatched. It must be represented as semantic.compile, never as
5491
+ // result normalization or a SQL execution failure.
5492
+ phase: 'compilation',
4506
5493
  dqlArtifact: composed.dqlArtifact,
4507
5494
  compiledSql: composed.sql,
4508
5495
  semanticExecutionTrace,
@@ -4528,11 +5515,25 @@ async function executeSemanticAnalyticalGraph(input) {
4528
5515
  }
4529
5516
  catch (error) {
4530
5517
  const message = error instanceof Error ? error.message : String(error);
5518
+ // A missing local execution target fails at the host's pre-execution
5519
+ // boundary. It must not be recast as a semantic compiler failure merely
5520
+ // because the semantic graph owns the callback. Preserve the narrow,
5521
+ // producer-tagged fact for the durable Ask receipt; it does not alter
5522
+ // the frozen route, trust, or user-facing analytical failure.
5523
+ const observabilityExecutionFailure = observabilityFailureForFrozenExecution(analyticalErrorDetail(error));
4531
5524
  return analyticalGraphFailureAnswer(input, 'EXECUTION_FAILED', message, {
4532
5525
  phase: 'execution',
4533
5526
  dqlArtifact: composed.dqlArtifact,
4534
5527
  compiledSql: composed.sql,
4535
5528
  semanticExecutionTrace: semanticTraceAfterExecution(semanticExecutionTrace, { executed: false, error }),
5529
+ // The exact aggregation proof already passed before the connector
5530
+ // call. Preserve it so presentation never recasts a warehouse binder
5531
+ // error as a failed semantic aggregation proof.
5532
+ aggregationSafetyProof,
5533
+ // This producer-tagged fact is diagnostic only: it does not alter
5534
+ // routing, trust, or the immutable frozen plan.
5535
+ warehouseFailure: normalizeWarehouseSqlFailure(error, input.input.semanticDriver),
5536
+ ...(observabilityExecutionFailure ? { observabilityExecutionFailure } : {}),
4536
5537
  });
4537
5538
  }
4538
5539
  const normalized = normalizeAnalyticalSourceResult({
@@ -4962,6 +5963,10 @@ function analyticalGraphFailureAnswer(input, code, reason, artifacts = {}) {
4962
5963
  ...(artifacts.dqlArtifact ? { dqlArtifact: artifacts.dqlArtifact } : {}),
4963
5964
  ...(artifacts.semanticExecutionTrace ? { semanticExecutionTrace: artifacts.semanticExecutionTrace } : {}),
4964
5965
  ...(artifacts.aggregationSafetyProof ? { aggregationSafetyProof: artifacts.aggregationSafetyProof } : {}),
5966
+ ...(artifacts.warehouseFailure ? { warehouseFailure: artifacts.warehouseFailure } : {}),
5967
+ ...(artifacts.observabilityExecutionFailure
5968
+ ? { observabilityExecutionFailure: artifacts.observabilityExecutionFailure }
5969
+ : {}),
4965
5970
  citations: contextPackCitations(input.input.contextPack, 8),
4966
5971
  considered: input.considered,
4967
5972
  contextPack: input.input.contextPack,
@@ -5133,6 +6138,38 @@ Rules:
5133
6138
  // combined dataset — show them separately" refusal into the join the user asked
5134
6139
  // for, while still allowing an honest refusal if context is truly missing.
5135
6140
  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.`;
6141
+ /**
6142
+ * A model-decline correction is only a repair when the router already froze a
6143
+ * bounded exploratory RAP and the host supplied the matching capability
6144
+ * mint/consume closures. Anything weaker is a new planning attempt and must
6145
+ * not borrow the repair transport allowance.
6146
+ */
6147
+ function frozenExploratoryModelRepairAuthority(input) {
6148
+ const plan = input.resolvedAnalyticalPlan;
6149
+ return input.selectedCascadeTier === 'exploratory_sql'
6150
+ && plan?.mode === 'authoritative'
6151
+ && plan.capability === 'bounded_exploration'
6152
+ && Boolean(plan.planId && plan.fingerprint && plan.snapshotId)
6153
+ && Boolean(input.prepareExploratorySqlExecution && input.executeAgenticGeneratedSql);
6154
+ }
6155
+ /**
6156
+ * The corrective prompt repeats only host-owned tuple facts. It never asks
6157
+ * the model to choose a new route, relation closure, output contract, or
6158
+ * trust label; SQL authorization re-proves those facts after the response.
6159
+ */
6160
+ function renderFrozenExploratoryRepairContract(plan, questionPlan) {
6161
+ const requiredOutputs = plan.outputContract?.requiredOutputs?.filter((output) => output.status === 'resolved' && output.outputName && output.qualifiedId)
6162
+ .map((output) => `${output.outputName} <- ${output.qualifiedId}`)
6163
+ ?? [];
6164
+ return [
6165
+ 'This is the one permitted correction for an already frozen, review-required exploratory plan.',
6166
+ renderRequestedShapeForRepair(questionPlan),
6167
+ requiredOutputs.length > 0
6168
+ ? `Frozen required output bindings (preserve exactly): ${requiredOutputs.join('; ')}`
6169
+ : 'Preserve the frozen requested tuple exactly.',
6170
+ 'Use only the relations and columns already supplied in the bounded context. Do not add a relation, change the ranking, remove an output, change the limit, or alter the analytical meaning. The host will validate the same frozen snapshot and closure before execution.',
6171
+ ].join('\n');
6172
+ }
5136
6173
  /**
5137
6174
  * Produces the prompt-facing subset of a broad local context pack. Global
5138
6175
  * records remain available, while a record explicitly owned by another domain
@@ -7842,6 +8879,11 @@ async function requestSqlRepair(input) {
7842
8879
  // bumping would let an internal preview-repair exceed that cap. Escalation-level
7843
8880
  // repairs bump-then-clamp in the host (resolveRunReasoningEffort).
7844
8881
  reasoningEffort: input.reasoningEffort,
8882
+ ...(input.dispatchPhase ? { dispatchPhase: input.dispatchPhase } : {}),
8883
+ ...(input.egressPurpose ? { egressPurpose: input.egressPurpose } : {}),
8884
+ ...(input.maxProviderDispatches !== undefined
8885
+ ? { maxProviderDispatches: input.maxProviderDispatches }
8886
+ : {}),
7845
8887
  });
7846
8888
  }
7847
8889
  async function generateProposalWithOptionalTools(input) {
@@ -7849,6 +8891,11 @@ async function generateProposalWithOptionalTools(input) {
7849
8891
  const options = {
7850
8892
  signal: input.signal,
7851
8893
  reasoningEffort: input.reasoningEffort,
8894
+ ...(input.dispatchPhase ? { dispatchPhase: input.dispatchPhase } : {}),
8895
+ ...(input.egressPurpose ? { egressPurpose: input.egressPurpose } : {}),
8896
+ ...(input.maxProviderDispatches !== undefined
8897
+ ? { maxProviderDispatches: input.maxProviderDispatches }
8898
+ : {}),
7852
8899
  };
7853
8900
  // No tools → plain generation (nothing for the loop to drive).
7854
8901
  if (tools.length === 0) {
@@ -7871,7 +8918,7 @@ async function generateProposalWithOptionalTools(input) {
7871
8918
  // provider implements generateWithTools (Claude/OpenAI), and an equivalent text
7872
8919
  // protocol otherwise (subscription-CLI passthrough, Ollama). This is what gives
7873
8920
  // every provider — not just the two API ones — a real tool-driven Stage B.
7874
- return runAgenticToolLoop(input.provider, [...input.messages], tools, {
8921
+ const result = await runAgenticToolLoopDetailed(input.provider, [...input.messages], tools, {
7875
8922
  ...options,
7876
8923
  toolPolicy,
7877
8924
  maxToolCalls: toolBudget.maxToolCalls,
@@ -7882,7 +8929,26 @@ async function generateProposalWithOptionalTools(input) {
7882
8929
  sink.push(evidenceToolCallFromEvent(event, sink.length + 1));
7883
8930
  },
7884
8931
  });
8932
+ // The tool loop converts a budget stop into a TERMINAL RESULT rather than
8933
+ // rethrowing, so the reason reached this caller as an empty string and the
8934
+ // run reported "the model declined to propose SQL" — blaming the model for
8935
+ // a ceiling DQL imposed on itself. The plain-generation branch above throws
8936
+ // these, so rethrow here too and let one handler label them honestly.
8937
+ const budgetStop = PROPOSAL_BUDGET_STOP_CODES[result.stop ?? ''];
8938
+ if (budgetStop) {
8939
+ throw Object.assign(new Error(`The Ask runtime stopped before a query was settled (${result.stop}).`), { code: budgetStop });
8940
+ }
8941
+ return result.text;
7885
8942
  }
8943
+ /**
8944
+ * Tool-loop terminal reasons that mean DQL stopped itself, mapped back to the
8945
+ * dispatch codes the answer loop's provider-failure handler understands.
8946
+ */
8947
+ const PROPOSAL_BUDGET_STOP_CODES = {
8948
+ provider_dispatch_budget_exhausted: 'PROVIDER_DISPATCH_BUDGET_EXHAUSTED',
8949
+ run_soft_target_exceeded: 'RUN_SOFT_TARGET_EXCEEDED',
8950
+ run_deadline_insufficient: 'RUN_DEADLINE_INSUFFICIENT',
8951
+ };
7886
8952
  async function selectDeepGeneratedProposalCandidate(input) {
7887
8953
  const initial = await scoreDeepGeneratedProposalCandidate(input, {
7888
8954
  raw: input.initial.raw,
@@ -8245,10 +9311,25 @@ function isRetryableGeneratedSqlError(failure) {
8245
9311
  return failure.retryDisposition === 'model_repair';
8246
9312
  }
8247
9313
  /**
8248
- * Only SQL-shape/binding failures may leave the certified lane. Connectivity,
8249
- * authorization, cancellation, and timeout failures would affect every query
8250
- * route and must remain terminal rather than triggering wasteful provider work.
9314
+ * Only SQL-shape/binding failures may leave a frozen governed lane.
9315
+ * Connectivity, authorization, cancellation, and timeout failures would affect
9316
+ * every query route and must remain terminal rather than triggering wasteful
9317
+ * provider work.
8251
9318
  */
9319
+ function observabilityFailureForFrozenExecution(detail) {
9320
+ // Do not infer this from user-facing error text. The local runtime tags the
9321
+ // physical pre-execution boundary; this projection only carries that exact
9322
+ // typed fact into the redacted trace after a governed plan has frozen.
9323
+ if (detail?.origin !== 'host' || detail.stage !== 'execute' || detail.code !== 'connection_not_configured') {
9324
+ return undefined;
9325
+ }
9326
+ return {
9327
+ version: 1,
9328
+ phase: 'execution',
9329
+ cause: 'connection_not_configured',
9330
+ safeAction: 'configure_connection',
9331
+ };
9332
+ }
8252
9333
  function isRetryableCertifiedExecutionError(error) {
8253
9334
  return /\b(?:binder|parser|catalog)\s+error\b/i.test(error)
8254
9335
  // DuckDB says "ambiguous reference"; Snowflake says "ambiguous column name".
@@ -9213,29 +10294,45 @@ function uniqueAssets(assets) {
9213
10294
  return Array.from(byId.values());
9214
10295
  }
9215
10296
  /**
9216
- * Execute the semantic layer's fanout probe and translate a structural
9217
- * contradiction into an actionable refusal message. Probe failures (missing
9218
- * permissions, dialect quirks) return undefined the probe protects against
9219
- * silent inflation but must never become a new way for a healthy query to fail.
10297
+ * Execute the semantic layer's fanout probe before a governed native semantic
10298
+ * join freezes. A native join that cannot be checked is not a governed-safe
10299
+ * join: fail closed with a redacted typed failure instead of executing a query
10300
+ * that could multiply aggregates. MetricFlow/dbt Cloud paths have their own
10301
+ * compiler guarantees and do not use this native direct-join guard.
9220
10302
  */
9221
10303
  export async function probeSemanticJoinFanout(probeSql, joinedTables, executeSql) {
9222
10304
  try {
9223
10305
  const payload = await executeSql(probeSql);
9224
10306
  const counts = parseFanoutProbeCounts(payload);
9225
- if (!counts || counts.base <= 0 || counts.joined <= counts.base)
9226
- return undefined;
10307
+ if (!counts || counts.base <= 0) {
10308
+ return {
10309
+ status: 'blocked',
10310
+ code: 'SEMANTIC_FANOUT_PROBE_UNPARSEABLE',
10311
+ message: 'DQL did not execute this governed semantic answer because its join-safety probe did not return verifiable row counts. Check the declared relationship or run the metric through MetricFlow / dbt Cloud, then retry.',
10312
+ };
10313
+ }
10314
+ if (counts.joined <= counts.base)
10315
+ return { status: 'safe' };
9227
10316
  const factor = counts.joined / counts.base;
9228
10317
  const tables = joinedTables.filter(Boolean).join(', ');
9229
- return [
9230
- `Blocked a governed semantic answer whose join inflates results: joining ${tables || 'the declared tables'}`,
9231
- `turned ${counts.base.toLocaleString('en-US')} base rows into ${counts.joined.toLocaleString('en-US')} (×${factor >= 10 ? Math.round(factor) : factor.toFixed(1)}).`,
9232
- 'The declared join key is not unique on the joined side, so every aggregated value would be multiplied.',
9233
- 'Deduplicate the joined model (for example, filter a slowly-changing dimension to current records)',
9234
- 'or execute this metric through MetricFlow / dbt Cloud, then retry. No numbers were shown because they would be wrong.',
9235
- ].join(' ');
10318
+ return {
10319
+ status: 'blocked',
10320
+ code: 'SEMANTIC_FANOUT_DUPLICATE_KEY',
10321
+ message: [
10322
+ `Blocked a governed semantic answer whose join inflates results: joining ${tables || 'the declared tables'}`,
10323
+ `turned ${counts.base.toLocaleString('en-US')} base rows into ${counts.joined.toLocaleString('en-US')} (×${factor >= 10 ? Math.round(factor) : factor.toFixed(1)}).`,
10324
+ 'The declared join key is not unique on the joined side, so every aggregated value would be multiplied.',
10325
+ 'Deduplicate the joined model (for example, filter a slowly-changing dimension to current records)',
10326
+ 'or execute this metric through MetricFlow / dbt Cloud, then retry. No numbers were shown because they would be wrong.',
10327
+ ].join(' '),
10328
+ };
9236
10329
  }
9237
10330
  catch {
9238
- return undefined;
10331
+ return {
10332
+ status: 'blocked',
10333
+ code: 'SEMANTIC_FANOUT_PROBE_ERROR',
10334
+ message: 'DQL did not execute this governed semantic answer because it could not verify join fanout before aggregation. Check the declared relationship or run the metric through MetricFlow / dbt Cloud, then retry.',
10335
+ };
9239
10336
  }
9240
10337
  }
9241
10338
  function parseFanoutProbeCounts(payload) {