@duckcodeailabs/dql-agent 1.14.2 → 1.14.3-rc.1

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 (145) hide show
  1. package/dist/agent-run-engine.d.ts +63 -2
  2. package/dist/agent-run-engine.d.ts.map +1 -1
  3. package/dist/agent-run-engine.js +864 -29
  4. package/dist/agent-run-engine.js.map +1 -1
  5. package/dist/agent-run-planner.d.ts +6 -0
  6. package/dist/agent-run-planner.d.ts.map +1 -1
  7. package/dist/agent-run-planner.js +2 -0
  8. package/dist/agent-run-planner.js.map +1 -1
  9. package/dist/agent-run-store.d.ts.map +1 -1
  10. package/dist/agent-run-store.js +48 -3
  11. package/dist/agent-run-store.js.map +1 -1
  12. package/dist/agentic/analyst-loop.d.ts +7 -1
  13. package/dist/agentic/analyst-loop.d.ts.map +1 -1
  14. package/dist/agentic/analyst-loop.js +163 -0
  15. package/dist/agentic/analyst-loop.js.map +1 -1
  16. package/dist/agentic/assumptions.d.ts +1 -1
  17. package/dist/agentic/assumptions.d.ts.map +1 -1
  18. package/dist/agentic/assumptions.js.map +1 -1
  19. package/dist/agentic/research-agent.d.ts.map +1 -1
  20. package/dist/agentic/research-agent.js +10 -1
  21. package/dist/agentic/research-agent.js.map +1 -1
  22. package/dist/agentic/sql-authorization.d.ts +8 -0
  23. package/dist/agentic/sql-authorization.d.ts.map +1 -1
  24. package/dist/agentic/sql-authorization.js +3 -0
  25. package/dist/agentic/sql-authorization.js.map +1 -1
  26. package/dist/analytical-compatibility.d.ts.map +1 -1
  27. package/dist/analytical-compatibility.js +8 -1
  28. package/dist/analytical-compatibility.js.map +1 -1
  29. package/dist/analytical-failure-repair.d.ts.map +1 -1
  30. package/dist/analytical-failure-repair.js +6 -2
  31. package/dist/analytical-failure-repair.js.map +1 -1
  32. package/dist/analytical-frame.d.ts +23 -1
  33. package/dist/analytical-frame.d.ts.map +1 -1
  34. package/dist/analytical-frame.js +322 -16
  35. package/dist/analytical-frame.js.map +1 -1
  36. package/dist/analytical-orchestration.d.ts +314 -3
  37. package/dist/analytical-orchestration.d.ts.map +1 -1
  38. package/dist/analytical-orchestration.js +486 -43
  39. package/dist/analytical-orchestration.js.map +1 -1
  40. package/dist/answer-loop.d.ts +64 -8
  41. package/dist/answer-loop.d.ts.map +1 -1
  42. package/dist/answer-loop.js +528 -67
  43. package/dist/answer-loop.js.map +1 -1
  44. package/dist/ask-observability/index.d.ts +11 -0
  45. package/dist/ask-observability/index.d.ts.map +1 -0
  46. package/dist/ask-observability/index.js +7 -0
  47. package/dist/ask-observability/index.js.map +1 -0
  48. package/dist/ask-observability/instrumentation.d.ts +35 -0
  49. package/dist/ask-observability/instrumentation.d.ts.map +1 -0
  50. package/dist/ask-observability/instrumentation.js +472 -0
  51. package/dist/ask-observability/instrumentation.js.map +1 -0
  52. package/dist/ask-observability/observer.d.ts +62 -0
  53. package/dist/ask-observability/observer.d.ts.map +1 -0
  54. package/dist/ask-observability/observer.js +537 -0
  55. package/dist/ask-observability/observer.js.map +1 -0
  56. package/dist/ask-observability/portable.d.ts +98 -0
  57. package/dist/ask-observability/portable.d.ts.map +1 -0
  58. package/dist/ask-observability/portable.js +527 -0
  59. package/dist/ask-observability/portable.js.map +1 -0
  60. package/dist/ask-observability/store.d.ts +130 -0
  61. package/dist/ask-observability/store.d.ts.map +1 -0
  62. package/dist/ask-observability/store.js +1114 -0
  63. package/dist/ask-observability/store.js.map +1 -0
  64. package/dist/ask-observability/types.d.ts +357 -0
  65. package/dist/ask-observability/types.d.ts.map +1 -0
  66. package/dist/ask-observability/types.js +2 -0
  67. package/dist/ask-observability/types.js.map +1 -0
  68. package/dist/ask-observability/utils.d.ts +21 -0
  69. package/dist/ask-observability/utils.d.ts.map +1 -0
  70. package/dist/ask-observability/utils.js +102 -0
  71. package/dist/ask-observability/utils.js.map +1 -0
  72. package/dist/generated-analytical-proposal.d.ts +37 -1
  73. package/dist/generated-analytical-proposal.d.ts.map +1 -1
  74. package/dist/generated-analytical-proposal.js +211 -3
  75. package/dist/generated-analytical-proposal.js.map +1 -1
  76. package/dist/governed-relational-compiler.d.ts +15 -0
  77. package/dist/governed-relational-compiler.d.ts.map +1 -1
  78. package/dist/governed-relational-compiler.js +99 -1
  79. package/dist/governed-relational-compiler.js.map +1 -1
  80. package/dist/index.d.ts +9 -7
  81. package/dist/index.d.ts.map +1 -1
  82. package/dist/index.js +6 -4
  83. package/dist/index.js.map +1 -1
  84. package/dist/intent-controller.d.ts +10 -0
  85. package/dist/intent-controller.d.ts.map +1 -1
  86. package/dist/intent-controller.js +1 -1
  87. package/dist/intent-controller.js.map +1 -1
  88. package/dist/meaning-resolution.d.ts +78 -6
  89. package/dist/meaning-resolution.d.ts.map +1 -1
  90. package/dist/meaning-resolution.js +331 -38
  91. package/dist/meaning-resolution.js.map +1 -1
  92. package/dist/metadata/analysis-planner.d.ts.map +1 -1
  93. package/dist/metadata/analysis-planner.js +21 -1
  94. package/dist/metadata/analysis-planner.js.map +1 -1
  95. package/dist/metadata/catalog.d.ts.map +1 -1
  96. package/dist/metadata/catalog.js +1 -1
  97. package/dist/metadata/catalog.js.map +1 -1
  98. package/dist/metadata/meaning-evidence.d.ts +9 -2
  99. package/dist/metadata/meaning-evidence.d.ts.map +1 -1
  100. package/dist/metadata/meaning-evidence.js +261 -11
  101. package/dist/metadata/meaning-evidence.js.map +1 -1
  102. package/dist/plan-execution-adapter.d.ts +9 -1
  103. package/dist/plan-execution-adapter.d.ts.map +1 -1
  104. package/dist/plan-execution-adapter.js +93 -13
  105. package/dist/plan-execution-adapter.js.map +1 -1
  106. package/dist/provider-egress.d.ts +24 -10
  107. package/dist/provider-egress.d.ts.map +1 -1
  108. package/dist/provider-egress.js +22 -17
  109. package/dist/provider-egress.js.map +1 -1
  110. package/dist/providers/claude.d.ts.map +1 -1
  111. package/dist/providers/claude.js +10 -9
  112. package/dist/providers/claude.js.map +1 -1
  113. package/dist/providers/dispatch.d.ts +27 -1
  114. package/dist/providers/dispatch.d.ts.map +1 -1
  115. package/dist/providers/dispatch.js +63 -1
  116. package/dist/providers/dispatch.js.map +1 -1
  117. package/dist/providers/gemini.js +10 -10
  118. package/dist/providers/gemini.js.map +1 -1
  119. package/dist/providers/index.d.ts +2 -2
  120. package/dist/providers/index.d.ts.map +1 -1
  121. package/dist/providers/index.js +1 -1
  122. package/dist/providers/index.js.map +1 -1
  123. package/dist/providers/ollama.js +15 -15
  124. package/dist/providers/ollama.js.map +1 -1
  125. package/dist/providers/openai.js +22 -22
  126. package/dist/providers/openai.js.map +1 -1
  127. package/dist/providers/types.d.ts +52 -0
  128. package/dist/providers/types.d.ts.map +1 -1
  129. package/dist/providers/types.js.map +1 -1
  130. package/dist/relationship-proof.d.ts +81 -0
  131. package/dist/relationship-proof.d.ts.map +1 -1
  132. package/dist/relationship-proof.js +228 -4
  133. package/dist/relationship-proof.js.map +1 -1
  134. package/dist/resolved-analytical-plan.d.ts +18 -4
  135. package/dist/resolved-analytical-plan.d.ts.map +1 -1
  136. package/dist/resolved-analytical-plan.js +764 -47
  137. package/dist/resolved-analytical-plan.js.map +1 -1
  138. package/dist/router.d.ts +10 -1
  139. package/dist/router.d.ts.map +1 -1
  140. package/dist/router.js +1785 -213
  141. package/dist/router.js.map +1 -1
  142. package/dist/semantic-bridge/compose.d.ts.map +1 -1
  143. package/dist/semantic-bridge/compose.js +23 -4
  144. package/dist/semantic-bridge/compose.js.map +1 -1
  145. package/package.json +4 -4
package/dist/router.js CHANGED
@@ -16,12 +16,13 @@ import { assumeDominantCandidate } from './agentic/assumptions.js';
16
16
  * question never pays twice.
17
17
  */
18
18
  import { classifyConversationalTurn, decideAgentAction, } from "./intent-controller.js";
19
- import { buildMeaningEvidencePackage, canonicalizeMetricMeasureCandidates, certifiedCandidateExplicitlyCoversMeasures, defaultQueryIntent, findExplicitEvidenceReference, questionTypeFromText, routeForEvidenceCandidate, validateMeaningResolution, } from "./meaning-resolution.js";
19
+ import { buildMeaningEvidencePackage, canonicalizeMetricMeasureCandidates, certifiedCandidateExplicitlyCoversMeasures, defaultQueryIntent, findExplicitEvidenceReference, mergeMeaningResolutionWithRequirementSeed, questionTypeFromText, routeForEvidenceCandidate, validateMeaningResolution, } from "./meaning-resolution.js";
20
20
  import { normalizeAnalyticalQuestionFrameV2 } from "@duckcodeailabs/dql-core";
21
21
  import { buildResolvedAnalyticalPlan, } from "./resolved-analytical-plan.js";
22
- import { buildAnalyticalCascadeDecision, buildAnalyticalRequirementSet, } from './analytical-orchestration.js';
22
+ import { buildAnalyticalCascadeDecision, buildAnalyticalRequirementSeedV1, buildAnalyticalRequirementSet, categoricalDimensionRequirementTerms, candidateConflictsWithExplicitRankingMeasure, candidateMatchesCategoricalDimensionRequirement, evidenceCandidateRoles, isEntityAttributeCandidate, } from './analytical-orchestration.js';
23
+ import { askTraceObserverForV1 } from './ask-observability/index.js';
23
24
  import { normalizeEvidenceAnalyticalCapability, solveAnalyticalCompatibility, } from "./analytical-compatibility.js";
24
- import { buildDeterministicAnalyticalFrame, projectResolvedAnalyticalFrame } from "./analytical-frame.js";
25
+ import { buildDeterministicAnalyticalFrame, projectResolvedAnalyticalFrame, resolveMetricCapabilityDimension, } from "./analytical-frame.js";
25
26
  import { conversationHistoryFromContext, renderConversationEnvelopeForPrompt, } from "./conversation/snapshot.js";
26
27
  const DEFAULT_THRESHOLD = 0.7;
27
28
  const DEFAULT_CACHE_SIZE = 200;
@@ -197,17 +198,19 @@ function parseClassification(raw) {
197
198
  function buildMeaningSystemPrompt() {
198
199
  return [
199
200
  "You resolve business meaning for DQL, a governed analytics system.",
200
- "The host already performed broad retrieval. Compare ONLY the supplied candidate cards.",
201
- "Select the concept that best matches the full question, entity, dimensions, filters, time grain, formula, aggregation, domain, and conversation context.",
202
- "Trust is an execution preference only after relevance and compatibility. Never select an unrelated certified block over a relevant semantic metric.",
201
+ "The host already performed broad retrieval and supplied a host-owned requirement seed. Compare ONLY the supplied candidate cards against that seed.",
202
+ "Select the supplied candidate IDs that best bind the host-owned metric, entity, dimensions, filters, time grain, ranking, and output requirements.",
203
+ "Trust, route selection, SQL, and the final analytical frame are host-owned. Never select an unrelated certified block over a relevant semantic metric.",
203
204
  "You may reference ONLY candidate IDs supplied below. Never invent an ID, table, column, metric, relationship, or filter value.",
205
+ "Do not rewrite, add, remove, or replace any host-owned requirement. Do not use prior conversation unless the host seed says it is a continuation.",
206
+ "Do not emit SQL, trust labels, an execution route, query intent, or an analytical frame. Those fields are ignored if present for legacy compatibility.",
204
207
  "Use low confidence and recommend clarify when material business meanings remain unresolved.",
205
- "Respond with ONLY one JSON object matching this shape:",
206
- '{"interpretedQuestion":string,"questionType":"definition"|"value"|"ranking"|"trend"|"comparison"|"diagnosis"|"research","selectedConceptIds":string[],"recommendedExecutionId"?:string,"queryIntent":{"measures":string[],"dimensions":string[],"filters":[{"field":string,"value":string}],"timeRange"?:string,"timeGrain"?:string,"order"?:"asc"|"desc","limit"?:number},"analyticalFrame"?:AnalyticalQuestionFrameV2,"rejectedCandidates":[{"id":string,"reason":string}],"confidence":"high"|"medium"|"low","missingInformation":string[],"recommendedRoute":"certified"|"semantic"|"governed_sql"|"exploratory"|"clarify","clarifyingQuestion"?:string}',
207
- "AnalyticalQuestionFrameV2 uses version 2 and exact supplied IDs for metricConceptIds, entityGrainIds, dimensions with roles group_by/filter/display/rank_entity/time_axis, memberBindings, timeContext with bounded periods, comparison, ranking, requestedOutputs, and ambiguity. Include it for value/ranking/trend/comparison requests when the supplied capability facts are sufficient; otherwise report missingInformation.",
208
+ "Respond with ONLY one JSON object matching this minimal candidate-ID shape:",
209
+ '{"selectedCandidateIds":string[],"recommendedExecutionId"?:string,"confidence"?:"high"|"medium"|"low","missingInformation"?:string[]}',
210
+ "`selectedConceptIds` is accepted only for legacy compatibility. All omitted optional fields receive host-owned safe defaults.",
208
211
  ].join("\n");
209
212
  }
210
- function buildMeaningUserPrompt(request, evidence, candidates) {
213
+ function buildMeaningUserPrompt(request, evidence, candidates, requirementSeed) {
211
214
  const cards = candidates.map((candidate) => ({
212
215
  id: candidate.id,
213
216
  kind: candidate.kind,
@@ -232,21 +235,23 @@ function buildMeaningUserPrompt(request, evidence, candidates) {
232
235
  analyticalCapability: candidate.analyticalCapability,
233
236
  }));
234
237
  const lines = [
235
- `Question: ${compactText(request.question, 2_000)}`,
236
- `Parsed request hints: ${JSON.stringify(compactQueryIntent(defaultQueryIntent(evidence)))}`,
238
+ `Host-owned requirement seed: ${JSON.stringify(requirementSeed)}`,
237
239
  `Candidate cards: ${JSON.stringify(cards)}`,
238
240
  ];
239
241
  const history = effectiveConversationHistory(request);
240
- if (history.length) {
242
+ const continuation = request.conversationBinding ?? 'none';
243
+ if (history.length && continuation !== 'none') {
241
244
  lines.push(`Recent conversation: ${JSON.stringify(history.slice(-4).map((turn) => ({
242
245
  role: turn.role,
243
246
  text: compactText(turn.text, 1_200),
244
247
  })))}`);
245
248
  }
246
- const envelope = renderConversationEnvelopeForPrompt(request.conversationContext);
249
+ const envelope = continuation === 'none'
250
+ ? undefined
251
+ : renderConversationEnvelopeForPrompt(request.conversationContext);
247
252
  if (envelope)
248
253
  lines.push(`Structured conversation state: ${JSON.stringify(envelope)}`);
249
- lines.push("Resolve the intended meaning and return JSON only.");
254
+ lines.push("Bind only supplied candidate IDs to the host-owned seed and return JSON only.");
250
255
  return lines.join("\n");
251
256
  }
252
257
  function compactText(value, maxLength) {
@@ -275,42 +280,62 @@ function compactQueryIntent(intent) {
275
280
  const QUESTION_TYPES = new Set(["definition", "value", "ranking", "trend", "comparison", "diagnosis", "research"]);
276
281
  const MEANING_CONFIDENCES = new Set(["high", "medium", "low"]);
277
282
  const MEANING_ROUTES = new Set(["certified", "semantic", "governed_sql", "exploratory", "clarify"]);
278
- function parseMeaningResolution(raw) {
283
+ function parseMeaningResolution(raw, requirementSeed) {
279
284
  const parsed = extractJsonObject(raw);
280
285
  if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
281
286
  return undefined;
282
287
  const record = parsed;
283
- if (typeof record.interpretedQuestion !== "string" || !record.interpretedQuestion.trim())
284
- return undefined;
285
- if (typeof record.questionType !== "string" || !QUESTION_TYPES.has(record.questionType))
288
+ const interpretedQuestion = typeof record.interpretedQuestion === "string" && record.interpretedQuestion.trim()
289
+ ? record.interpretedQuestion.trim()
290
+ : requirementSeed?.sourceQuestion;
291
+ if (!interpretedQuestion)
286
292
  return undefined;
287
- if (!Array.isArray(record.selectedConceptIds) || record.selectedConceptIds.some((id) => typeof id !== "string"))
293
+ const questionType = typeof record.questionType === "string" && QUESTION_TYPES.has(record.questionType)
294
+ ? record.questionType
295
+ : requirementSeed ? questionTypeFromText(requirementSeed.sourceQuestion) : undefined;
296
+ if (!questionType)
288
297
  return undefined;
289
- if (typeof record.confidence !== "string" || !MEANING_CONFIDENCES.has(record.confidence))
298
+ const selectedConceptIds = Array.isArray(record.selectedCandidateIds)
299
+ ? record.selectedCandidateIds
300
+ : record.selectedConceptIds;
301
+ if (!Array.isArray(selectedConceptIds) || selectedConceptIds.some((id) => typeof id !== "string"))
290
302
  return undefined;
291
- if (typeof record.recommendedRoute !== "string" || !MEANING_ROUTES.has(record.recommendedRoute))
303
+ const confidence = typeof record.confidence === "string" && MEANING_CONFIDENCES.has(record.confidence)
304
+ ? record.confidence
305
+ : requirementSeed
306
+ ? selectedConceptIds.length > 0 ? 'medium' : 'low'
307
+ : undefined;
308
+ if (!confidence)
292
309
  return undefined;
293
- if (!record.queryIntent || typeof record.queryIntent !== "object" || Array.isArray(record.queryIntent))
310
+ const recommendedRoute = typeof record.recommendedRoute === "string" && MEANING_ROUTES.has(record.recommendedRoute)
311
+ ? record.recommendedRoute
312
+ : requirementSeed ? "clarify" : undefined;
313
+ if (!recommendedRoute)
294
314
  return undefined;
295
- const query = record.queryIntent;
296
- const measures = stringArray(query.measures);
297
- const dimensions = stringArray(query.dimensions);
298
- const missingInformation = stringArray(record.missingInformation);
299
- if (!measures || !dimensions || !missingInformation || !Array.isArray(query.filters))
315
+ const missingInformation = stringArray(record.missingInformation) ?? (requirementSeed ? [] : undefined);
316
+ if (!missingInformation)
300
317
  return undefined;
301
- const filters = query.filters.flatMap((filter) => {
302
- if (!filter || typeof filter !== "object" || Array.isArray(filter))
303
- return [];
304
- const item = filter;
305
- return typeof item.field === "string" && typeof item.value === "string"
306
- ? [{ field: item.field, value: item.value }]
307
- : [];
308
- });
309
- if (filters.length !== query.filters.length)
318
+ const query = record.queryIntent && typeof record.queryIntent === "object" && !Array.isArray(record.queryIntent)
319
+ ? record.queryIntent
320
+ : undefined;
321
+ const measures = query ? stringArray(query.measures) : undefined;
322
+ const dimensions = query ? stringArray(query.dimensions) : undefined;
323
+ const filters = Array.isArray(query?.filters)
324
+ ? query.filters.flatMap((filter) => {
325
+ if (!filter || typeof filter !== "object" || Array.isArray(filter))
326
+ return [];
327
+ const item = filter;
328
+ return typeof item.field === "string" && typeof item.value === "string"
329
+ ? [{ field: item.field, value: item.value }]
330
+ : [];
331
+ })
332
+ : undefined;
333
+ if (!requirementSeed && (!measures || !dimensions || !filters || !Array.isArray(query?.filters) || filters.length !== query.filters.length))
310
334
  return undefined;
311
- if (!Array.isArray(record.rejectedCandidates))
335
+ const rejectedCandidatesRaw = record.rejectedCandidates;
336
+ if (rejectedCandidatesRaw !== undefined && !Array.isArray(rejectedCandidatesRaw))
312
337
  return undefined;
313
- const rejectedCandidates = record.rejectedCandidates.flatMap((candidate) => {
338
+ const rejectedCandidates = (rejectedCandidatesRaw ?? []).flatMap((candidate) => {
314
339
  if (!candidate || typeof candidate !== "object" || Array.isArray(candidate))
315
340
  return [];
316
341
  const item = candidate;
@@ -318,42 +343,66 @@ function parseMeaningResolution(raw) {
318
343
  ? [{ id: item.id, reason: item.reason }]
319
344
  : [];
320
345
  });
321
- if (rejectedCandidates.length !== record.rejectedCandidates.length)
346
+ if (rejectedCandidates.length !== (rejectedCandidatesRaw?.length ?? 0))
322
347
  return undefined;
323
348
  const recommendedExecutionId = typeof record.recommendedExecutionId === "string"
324
349
  ? record.recommendedExecutionId
325
350
  : undefined;
326
- const clarifyingQuestion = typeof record.clarifyingQuestion === "string" &&
327
- record.clarifyingQuestion.trim()
351
+ const clarifyingQuestion = typeof record.clarifyingQuestion === "string" && record.clarifyingQuestion.trim()
328
352
  ? record.clarifyingQuestion.trim()
329
353
  : undefined;
330
- const analyticalFrame = record.analyticalFrame === undefined
354
+ const providerExplicitlyClarified = (recommendedRoute === 'clarify'
355
+ && typeof record.recommendedRoute === 'string')
356
+ || Boolean(clarifyingQuestion)
357
+ || missingInformation.length > 0
358
+ || rejectedCandidates.length > 0;
359
+ // Legacy callers may still send a V2 frame. It remains readable without a
360
+ // seed, but a seeded meaning call must never grant a model frame authority.
361
+ const analyticalFrame = requirementSeed || record.analyticalFrame === undefined
331
362
  ? undefined
332
363
  : normalizeAnalyticalQuestionFrameV2(record.analyticalFrame);
333
- if (record.analyticalFrame !== undefined && !analyticalFrame)
364
+ if (!requirementSeed && record.analyticalFrame !== undefined && !analyticalFrame)
334
365
  return undefined;
335
366
  return {
336
- interpretedQuestion: record.interpretedQuestion.trim(),
337
- questionType: record.questionType,
338
- selectedConceptIds: record.selectedConceptIds,
367
+ interpretedQuestion,
368
+ questionType,
369
+ selectedConceptIds: selectedConceptIds,
339
370
  ...(recommendedExecutionId ? { recommendedExecutionId } : {}),
340
- queryIntent: {
341
- measures,
342
- dimensions,
343
- filters,
344
- ...(typeof query.timeRange === "string" ? { timeRange: query.timeRange } : {}),
345
- ...(typeof query.timeGrain === "string" ? { timeGrain: query.timeGrain } : {}),
346
- ...(query.order === "asc" || query.order === "desc" ? { order: query.order } : {}),
347
- ...(typeof query.limit === "number" && Number.isFinite(query.limit) && query.limit > 0
348
- ? { limit: Math.floor(query.limit) }
349
- : {}),
350
- },
371
+ queryIntent: requirementSeed
372
+ ? queryIntentFromRequirementSeed(requirementSeed)
373
+ : {
374
+ measures: measures,
375
+ dimensions: dimensions,
376
+ filters: filters,
377
+ ...(typeof query?.timeRange === "string" ? { timeRange: query.timeRange } : {}),
378
+ ...(typeof query?.timeGrain === "string" ? { timeGrain: query.timeGrain } : {}),
379
+ ...(query?.order === "asc" || query?.order === "desc" ? { order: query.order } : {}),
380
+ ...(typeof query?.limit === "number" && Number.isFinite(query.limit) && query.limit > 0
381
+ ? { limit: Math.floor(query.limit) }
382
+ : {}),
383
+ },
351
384
  rejectedCandidates,
352
- confidence: record.confidence,
385
+ confidence,
353
386
  missingInformation,
354
- recommendedRoute: record.recommendedRoute,
387
+ recommendedRoute,
355
388
  ...(clarifyingQuestion ? { clarifyingQuestion } : {}),
356
389
  ...(analyticalFrame ? { analyticalFrame } : {}),
390
+ ...(selectedConceptIds.length === 0 && !recommendedExecutionId && !providerExplicitlyClarified
391
+ ? { emptyCandidateBinding: true }
392
+ : {}),
393
+ };
394
+ }
395
+ function queryIntentFromRequirementSeed(seed) {
396
+ return {
397
+ measures: [...seed.queryIntent.measures],
398
+ dimensions: [...seed.queryIntent.dimensions],
399
+ filters: seed.queryIntent.filters.map((filter) => ({ field: filter.field, value: filter.value })),
400
+ ...(seed.queryIntent.timeRange ? { timeRange: seed.queryIntent.timeRange } : {}),
401
+ ...(seed.queryIntent.timeGrain ? { timeGrain: seed.queryIntent.timeGrain } : {}),
402
+ ...(seed.queryIntent.order ? { order: seed.queryIntent.order } : {}),
403
+ ...(seed.queryIntent.limit !== undefined ? { limit: seed.queryIntent.limit } : {}),
404
+ ...(seed.queryIntent.fiscalCalendarId ? { fiscalCalendarId: seed.queryIntent.fiscalCalendarId } : {}),
405
+ ...(seed.queryIntent.fiscalDateRoleId ? { fiscalDateRoleId: seed.queryIntent.fiscalDateRoleId } : {}),
357
406
  };
358
407
  }
359
408
  function stringArray(value) {
@@ -408,12 +457,204 @@ function conversationPositionToken(context) {
408
457
  return `${latest}:${turns.length}:${recent}:${tailId}`;
409
458
  }
410
459
  function retrievalTrace(evidence, candidates) {
460
+ const candidateTraceMetadata = candidates.flatMap((candidate) => evidenceCandidateRoles(candidate).map((role) => ({
461
+ // `id` is the identity accepted by the meaning resolver. It is already
462
+ // source-qualified by the retrieval contract; keep it so later selection
463
+ // IDs can be joined without guessing a legacy-to-canonical mapping.
464
+ candidateId: candidate.id,
465
+ role,
466
+ source: traceSourceForCandidate(candidate),
467
+ ...(candidate.retrievalLanes?.length ? { lanes: candidate.retrievalLanes } : {}),
468
+ })));
411
469
  return {
412
470
  ...(evidence.snapshotId ? { snapshotId: evidence.snapshotId } : {}),
413
471
  ...(evidence.sourceFingerprint ? { sourceFingerprint: evidence.sourceFingerprint } : {}),
414
472
  candidateCount: candidates.length,
415
473
  candidateIds: candidates.map((candidate) => candidate.id),
474
+ ...(candidateTraceMetadata.length ? { candidateTraceMetadata } : {}),
475
+ };
476
+ }
477
+ /**
478
+ * Observability is a projection of snapshot-bound evidence, never a second
479
+ * retriever or a route authority. Keep source classification conservative so
480
+ * an absent lane is not manufactured from an arbitrary ID prefix.
481
+ */
482
+ function traceSourceForCandidate(candidate) {
483
+ // Qualified candidate identities preserve their originating index. Prefer
484
+ // that declared provenance for collapsed `sql_column`/`sql_table` kinds: a
485
+ // dbt manifest column must not be relabelled as runtime-schema evidence.
486
+ const identity = candidate.qualifiedId ?? candidate.id;
487
+ if (identity.startsWith('dbt:'))
488
+ return 'dbt_manifest';
489
+ if (identity.startsWith('runtime:'))
490
+ return 'runtime_schema';
491
+ if (identity.startsWith('semantic:'))
492
+ return 'semantic';
493
+ if (identity.startsWith('dql:'))
494
+ return candidate.kind === 'certified_block' ? 'certified' : 'governed_relational';
495
+ if (candidate.kind === 'certified_block')
496
+ return 'certified';
497
+ if (candidate.kind === 'semantic_metric' || candidate.kind === 'semantic_member')
498
+ return 'semantic';
499
+ if (candidate.kind === 'dql_modeling')
500
+ return 'governed_relational';
501
+ if (candidate.kind === 'dbt_model' || candidate.kind === 'dbt_source')
502
+ return 'dbt_manifest';
503
+ if (candidate.kind === 'sql_table' || candidate.kind === 'sql_column')
504
+ return 'runtime_schema';
505
+ return 'exploratory';
506
+ }
507
+ function traceCandidateLifecycleBeforePruning(request, evidence, candidates, packageCandidates) {
508
+ const observer = askTraceObserverForV1(request);
509
+ if (!observer.enabled)
510
+ return;
511
+ const packageIds = new Set(packageCandidates.map((candidate) => candidate.id));
512
+ const fuse = observer.startSpan({
513
+ name: 'retrieval.fuse',
514
+ stage: 'retrieval',
515
+ payload: { kind: 'retrieval', candidateCount: candidates.length },
516
+ });
517
+ const requirements = buildAnalyticalRequirementSet({ question: request.question, parsedIntent: evidence.parsedIntent });
518
+ const requestedRoles = new Set([
519
+ ...(requirements.measures.length || requirements.ranking?.metricTerms.length ? ['metric'] : []),
520
+ ...(requirements.entityTerms.length || requirements.entityDisplayTerms.length ? ['entity_label'] : []),
521
+ ...(requirements.time ? ['time_dimension'] : []),
522
+ ...(requirements.dimensions.length ? ['categorical_dimension'] : []),
523
+ ...(requirements.dimensions.length > 1 || requirements.entityTerms.length ? ['relationship'] : []),
524
+ ]);
525
+ const candidateLimit = 32;
526
+ const hasRequestedEntityLabel = requirements.entityTerms.length > 0 || requirements.entityDisplayTerms.length > 0;
527
+ for (const [index, candidate] of candidates.slice(0, candidateLimit).entries()) {
528
+ const roles = evidenceCandidateRoles(candidate);
529
+ const source = traceSourceForCandidate(candidate);
530
+ // This receipt must retain the actual retrieval memberships captured by
531
+ // the snapshot. Do not infer a lane from aliases, IDs, or a relationship
532
+ // attribute after fusion has already lost that physical provenance.
533
+ const lanes = candidate.retrievalLanes
534
+ ?.map((entry) => ({ ...entry }))
535
+ .sort((left, right) => (left.rank ?? Number.MAX_SAFE_INTEGER) - (right.rank ?? Number.MAX_SAFE_INTEGER)
536
+ || left.lane.localeCompare(right.lane));
537
+ const lane = lanes?.[0]?.lane;
538
+ const laneRank = lanes?.[0]?.rank;
539
+ const initialReason = candidate.exactMatch
540
+ ? 'exact_name_match'
541
+ : 'unknown';
542
+ const compatibilityCode = candidate.compatibility === 'compatible' ? 'compatible'
543
+ : candidate.compatibility === 'incompatible' ? 'operation_unsupported'
544
+ : 'unknown';
545
+ for (const role of roles) {
546
+ const common = {
547
+ // The resolver and cascade use `id`; keeping that exact stable identity
548
+ // makes retrieval, admission, and model-selection receipts joinable.
549
+ candidateId: candidate.id,
550
+ role,
551
+ source,
552
+ ...(lane ? { lane } : {}),
553
+ ...(laneRank !== undefined ? { laneRank } : {}),
554
+ ...(lanes?.length ? { lanes } : {}),
555
+ fusedRank: index + 1,
556
+ compatibilityCode,
557
+ };
558
+ observer.recordCandidateDecision({
559
+ ...common,
560
+ decision: 'retrieved',
561
+ reasonCode: candidate.eligible === false ? 'role_mismatch' : initialReason,
562
+ });
563
+ // An explicit ranking measure is a typed request, not a relevance hint.
564
+ // Keep correlated metrics in the receipt, but record their exclusion at
565
+ // the metric admission boundary instead of allowing them to become
566
+ // clarification options or to fill an unrelated analytical role.
567
+ if (role === 'metric' && candidateConflictsWithExplicitRankingMeasure(candidate, requirements)) {
568
+ observer.recordCandidateDecision({
569
+ ...common,
570
+ decision: 'excluded',
571
+ reasonCode: 'explicit_measure_conflict',
572
+ });
573
+ continue;
574
+ }
575
+ if (candidate.eligible === false || candidate.compatibility === 'incompatible') {
576
+ observer.recordCandidateDecision({
577
+ ...common,
578
+ decision: 'excluded',
579
+ reasonCode: candidate.compatibility === 'incompatible' ? 'capability_incompatible' : 'role_mismatch',
580
+ });
581
+ continue;
582
+ }
583
+ if (packageIds.has(candidate.id)) {
584
+ const reservedForRole = requestedRoles.has(role);
585
+ observer.recordCandidateDecision({
586
+ ...common,
587
+ decision: 'reserved',
588
+ reasonCode: candidate.exactMatch ? 'exact_name_match' : reservedForRole ? 'role_reserved' : 'fused_relevance_fill',
589
+ });
590
+ observer.recordCandidateDecision({
591
+ ...common,
592
+ decision: 'admitted',
593
+ reasonCode: candidate.exactMatch ? 'exact_name_match' : reservedForRole ? 'role_reserved' : 'fused_relevance_fill',
594
+ });
595
+ }
596
+ else {
597
+ const isNoisyEntityAttribute = hasRequestedEntityLabel
598
+ && isEntityAttributeCandidate(candidate)
599
+ && role === 'categorical_dimension';
600
+ observer.recordCandidateDecision({
601
+ ...common,
602
+ decision: 'excluded',
603
+ reasonCode: isNoisyEntityAttribute ? 'entity_label_mismatch' : 'below_fused_limit',
604
+ });
605
+ }
606
+ }
607
+ }
608
+ observer.finishSpan(fuse, { outcome: 'ok', reasonCode: 'completed' });
609
+ }
610
+ /**
611
+ * Emit source-bound lane summaries before the meaning-package cap applies.
612
+ * These spans are evidence about the existing retrieval result only; they do
613
+ * not invoke, retry, or reinterpret any retrieval source.
614
+ */
615
+ function traceRetrievalLanesBeforePruning(request, evidence, candidates) {
616
+ const observer = askTraceObserverForV1(request);
617
+ if (!observer.enabled)
618
+ return;
619
+ const sourceSpan = {
620
+ certified: 'retrieval.certified',
621
+ semantic: 'retrieval.semantic',
622
+ governed_relational: 'retrieval.governed_relational',
623
+ dbt_manifest: 'retrieval.dbt_manifest',
624
+ runtime_schema: 'retrieval.runtime_schema',
625
+ vector: 'retrieval.vector',
626
+ conversation: 'retrieval.conversation',
627
+ };
628
+ const record = (name, source, count, coverage, lane) => {
629
+ const span = observer.startSpan({
630
+ name,
631
+ stage: 'retrieval',
632
+ reasonCode: coverage === 'unavailable' ? 'source_unavailable' : coverage === 'empty' ? 'source_empty' : coverage === 'stale' ? 'source_stale' : 'completed',
633
+ payload: { kind: 'retrieval', ...(source ? { source } : {}), ...(lane ? { lane } : {}), candidateCount: count, ...(coverage ? { coverage } : {}) },
634
+ });
635
+ observer.finishSpan(span, {
636
+ outcome: coverage === 'unavailable' ? 'unavailable' : 'ok',
637
+ reasonCode: coverage === 'unavailable' ? 'source_unavailable' : coverage === 'empty' ? 'source_empty' : coverage === 'stale' ? 'source_stale' : 'completed',
638
+ });
639
+ };
640
+ for (const coverage of sourceCoverageFromEvidence(evidence, candidates)) {
641
+ const name = sourceSpan[coverage.source];
642
+ if (!name)
643
+ continue;
644
+ record(name, coverage.source, coverage.candidateIds.length, coverage.status, coverage.source === 'vector' ? 'vector' : coverage.source === 'conversation' ? 'conversation' : undefined);
645
+ }
646
+ const laneSpan = {
647
+ exact: 'retrieval.exact',
648
+ lexical: 'retrieval.lexical',
649
+ vector: 'retrieval.vector',
650
+ graph: 'retrieval.graph',
651
+ conversation: 'retrieval.conversation',
416
652
  };
653
+ for (const lane of Object.keys(laneSpan)) {
654
+ const count = candidates.filter((candidate) => candidate.retrievalLanes?.some((membership) => membership.lane === lane)).length;
655
+ if (count > 0)
656
+ record(laneSpan[lane], undefined, count, 'available', lane);
657
+ }
417
658
  }
418
659
  /** Preserve actual retrieval provenance; never infer lane state from an ID regex. */
419
660
  function sourceCoverageFromEvidence(evidence, candidates) {
@@ -535,6 +776,9 @@ function withNormalizedAnalyticalRequirements(evidence, question) {
535
776
  ...evidence.parsedIntent,
536
777
  measures: requirements.measures,
537
778
  dimensions: requirements.dimensions,
779
+ ...(requirements.time?.grain && !evidence.parsedIntent.timeGrain
780
+ ? { timeGrain: requirements.time.grain }
781
+ : {}),
538
782
  },
539
783
  };
540
784
  }
@@ -577,11 +821,62 @@ function relationshipSafetyAllowsAutomaticJoin(safety, requiredJoinKey) {
577
821
  return safety.keys.some((key) => metricTermsMatch(normalizeMetricPhrase(key.from), normalizedKey)
578
822
  || metricTermsMatch(normalizeMetricPhrase(key.to), normalizedKey));
579
823
  }
580
- function safeRelationshipProofsForCandidate(candidate, requiredJoinKey) {
824
+ /**
825
+ * Relationship evidence has three deliberately different authorities:
826
+ *
827
+ * - governed: a certified, fresh proof may compile a governed relational plan;
828
+ * - exploratory: an explicitly allowed, validated relation may close a raw
829
+ * review-required SQL plan after normal runtime validation; and
830
+ * - hint: anything weaker may improve retrieval, but can never add a join.
831
+ *
832
+ * The first authority remains intentionally stricter. Reusing it for raw
833
+ * fallback is what made a missing semantic capability look like a terminal
834
+ * absence even when the dbt/runtime snapshot had the exact relations, keys,
835
+ * and fields required to inspect it safely. This helper never upgrades a
836
+ * draft relationship to governed trust: callers that use it must select the
837
+ * exploratory tier and retain review_required provenance.
838
+ */
839
+ function relationshipSafetyAllowsExploratoryJoin(safety, requiredJoinKey) {
840
+ const validation = safety.validation;
841
+ const checkedAt = Date.parse(validation?.checkedAt ?? '');
842
+ const expiresAt = safety.evidenceExpiresAt ? Date.parse(safety.evidenceExpiresAt) : undefined;
843
+ const expirationInvalid = safety.evidenceExpiresAt !== undefined
844
+ && (expiresAt === undefined || !Number.isFinite(expiresAt) || expiresAt <= Date.now());
845
+ const lifecycleAllowsExploration = safety.status === 'certified'
846
+ || safety.status === 'validated'
847
+ || safety.status === 'draft';
848
+ if (!lifecycleAllowsExploration
849
+ || safety.staleCertification === true
850
+ || safety.automaticJoinAllowed !== true
851
+ || safety.fanout !== 'safe'
852
+ || !['one_to_one', 'one_to_many', 'many_to_one'].includes(safety.cardinality ?? '')
853
+ || !safety.from?.trim()
854
+ || !safety.to?.trim()
855
+ || !validation
856
+ || validation.status !== 'passed'
857
+ || !validation.queryFingerprint?.trim()
858
+ || !validation.proofFingerprint?.trim()
859
+ || !Number.isFinite(checkedAt)
860
+ || expirationInvalid
861
+ || safety.keys.length === 0
862
+ || safety.keys.some((key) => !key.from.trim() || !key.to.trim()))
863
+ return false;
864
+ if (!requiredJoinKey)
865
+ return true;
866
+ const normalizedKey = normalizeMetricPhrase(requiredJoinKey);
867
+ return safety.keys.some((key) => metricTermsMatch(normalizeMetricPhrase(key.from), normalizedKey)
868
+ || metricTermsMatch(normalizeMetricPhrase(key.to), normalizedKey));
869
+ }
870
+ function relationshipSafetyAllowsJoin(safety, authority, requiredJoinKey) {
871
+ return authority === 'governed'
872
+ ? relationshipSafetyAllowsAutomaticJoin(safety, requiredJoinKey)
873
+ : relationshipSafetyAllowsExploratoryJoin(safety, requiredJoinKey);
874
+ }
875
+ function safeRelationshipProofsForCandidate(candidate, requiredJoinKey, authority = 'governed') {
581
876
  const referenced = new Set((candidate.relationshipEvidence ?? []).map(normalizedRelationshipIdentity));
582
877
  const proofs = new Map();
583
878
  for (const safety of candidate.relationshipSafety ?? []) {
584
- if (!relationshipSafetyAllowsAutomaticJoin(safety, requiredJoinKey))
879
+ if (!relationshipSafetyAllowsJoin(safety, authority, requiredJoinKey))
585
880
  continue;
586
881
  if (!relationshipSafetyIdentities(safety).some((identity) => referenced.has(identity)))
587
882
  continue;
@@ -596,6 +891,176 @@ function candidateRelationshipEndpoints(candidate) {
596
891
  candidate.analyticalCapability?.primaryEntityId ?? '',
597
892
  ].map(normalizedRelationshipIdentity).filter(Boolean));
598
893
  }
894
+ /**
895
+ * An attribution-required relationship is intentionally not a generic join
896
+ * failure. It becomes terminal only when the user explicitly asks to rank an
897
+ * un-attributed/attribution-scoped signal and the same immutable snapshot
898
+ * proves that getting from that signal to the requested entity would require
899
+ * the declared attribution edge. This keeps neutral unsafe relationship
900
+ * evidence available for the normal clarification path (AGT-029), while
901
+ * preventing a bare-ranking metric picker from masking a known governance
902
+ * boundary with unrelated options.
903
+ */
904
+ const EXPLICIT_ATTRIBUTION_REQUEST_RE = /\b(?:un[-\s]?attributed|attribution|allocation|allocate)\b/i;
905
+ function relationshipEndpointMatchesQuestionConcept(endpoint, question) {
906
+ const leaf = normalizedRelationshipIdentity(endpoint).split('::').at(-1) ?? endpoint;
907
+ const concept = normalizeMetricPhrase(leaf);
908
+ const conceptTokens = substantiveLexicalTokens(concept);
909
+ const questionTokens = new Set(substantiveLexicalTokens(question));
910
+ return conceptTokens.length >= 2 && conceptTokens.every((token) => questionTokens.has(token));
911
+ }
912
+ function relationshipEndpointMatchesEntityTerms(endpoint, entityTerms) {
913
+ const normalizedEndpoint = normalizeMetricPhrase(endpoint);
914
+ return entityTerms.some((term) => metricTermsMatch(normalizedEndpoint, normalizeMetricPhrase(term)));
915
+ }
916
+ function relationshipGraphReaches(starts, targets, relationships) {
917
+ if (starts.size === 0 || targets.size === 0)
918
+ return false;
919
+ const graph = new Map();
920
+ for (const relationship of relationships) {
921
+ const from = normalizedRelationshipIdentity(relationship.from ?? '');
922
+ const to = normalizedRelationshipIdentity(relationship.to ?? '');
923
+ if (!from || !to)
924
+ continue;
925
+ const fromNeighbors = graph.get(from) ?? new Set();
926
+ fromNeighbors.add(to);
927
+ graph.set(from, fromNeighbors);
928
+ const toNeighbors = graph.get(to) ?? new Set();
929
+ toNeighbors.add(from);
930
+ graph.set(to, toNeighbors);
931
+ }
932
+ const pending = [...starts];
933
+ const visited = new Set(pending);
934
+ while (pending.length > 0) {
935
+ const current = pending.shift();
936
+ if (targets.has(current))
937
+ return true;
938
+ for (const next of graph.get(current) ?? []) {
939
+ if (!visited.has(next)) {
940
+ visited.add(next);
941
+ pending.push(next);
942
+ }
943
+ }
944
+ }
945
+ return false;
946
+ }
947
+ function attributionRequiredRelationshipGapDecision(input) {
948
+ if (!EXPLICIT_ATTRIBUTION_REQUEST_RE.test(input.request.question)
949
+ || input.requirements.entityTerms.length === 0)
950
+ return undefined;
951
+ // Use the complete same-snapshot retrieval result. The compact provider
952
+ // package is intentionally allowed to omit a relationship card; omission
953
+ // cannot erase an authored governance restriction before it is evaluated.
954
+ const snapshotCandidates = [
955
+ ...input.evidence.candidates,
956
+ ...(input.evidence.clarificationCandidates ?? []),
957
+ ].filter((candidate, index, all) => all.findIndex((other) => other.id === candidate.id) === index);
958
+ const relationships = [...new Map(snapshotCandidates
959
+ .flatMap((candidate) => candidate.relationshipSafety ?? [])
960
+ .map((relationship) => [relationship.id, relationship])).values()]
961
+ .filter((relationship) => Boolean(relationship.from?.trim() && relationship.to?.trim()));
962
+ const attributionRequired = relationships.filter((relationship) => relationship.fanout === 'attribution_required');
963
+ if (attributionRequired.length === 0)
964
+ return undefined;
965
+ const targetEndpoints = new Set(snapshotCandidates
966
+ .flatMap((candidate) => [...candidateRelationshipEndpoints(candidate)])
967
+ .filter((endpoint) => relationshipEndpointMatchesEntityTerms(endpoint, input.requirements.entityTerms)));
968
+ if (targetEndpoints.size === 0)
969
+ return undefined;
970
+ const attributed = attributionRequired.find((relationship) => {
971
+ const endpoints = [
972
+ normalizedRelationshipIdentity(relationship.from ?? ''),
973
+ normalizedRelationshipIdentity(relationship.to ?? ''),
974
+ ].filter(Boolean);
975
+ const requestedSignalEndpoints = new Set(endpoints.filter((endpoint) => relationshipEndpointMatchesQuestionConcept(endpoint, input.request.question)));
976
+ if (requestedSignalEndpoints.size === 0)
977
+ return false;
978
+ // The declared graph can demonstrate why the requested entity would need
979
+ // this relation, but only a fully certified/fanout-safe graph may
980
+ // authorize automatic composition. A path that requires this edge is a
981
+ // typed modeling gap, never a reason to invent an attribution join.
982
+ const allReachable = relationshipGraphReaches(requestedSignalEndpoints, targetEndpoints, relationships);
983
+ const safeRelationships = relationships.filter((candidate) => relationshipSafetyAllowsAutomaticJoin(candidate));
984
+ const safelyReachable = relationshipGraphReaches(requestedSignalEndpoints, targetEndpoints, safeRelationships);
985
+ return allReachable && !safelyReachable;
986
+ });
987
+ if (!attributed)
988
+ return undefined;
989
+ const coverage = sourceCoverageFromEvidence(input.evidence, snapshotCandidates);
990
+ const coverageFor = (source) => coverage.find((item) => item.source === source);
991
+ const skipped = (tier) => {
992
+ const item = coverageFor(tier);
993
+ return {
994
+ version: 1,
995
+ tier,
996
+ outcome: item?.status === 'available' ? 'ineligible' : 'unavailable',
997
+ candidateIds: item?.candidateIds ?? [],
998
+ reason: item?.status === 'available'
999
+ ? `The ${tier} tier did not prove the complete attribution-safe requested tuple before plan freeze.`
1000
+ : `The ${tier} source was ${item?.status ?? 'unavailable'} in this snapshot.`,
1001
+ planFrozen: false,
1002
+ };
1003
+ };
1004
+ const message = 'The requested un-attributed signal requires a certified attribution relationship or approved allocation policy before it can be ranked by the requested entity. DQL did not infer a relationship or execute a query.';
1005
+ const witnessCandidateIds = [attributed.id];
1006
+ const terminalGap = {
1007
+ code: 'MISSING_RELATIONSHIP',
1008
+ missing: ['a certified attribution relationship or approved allocation policy for the requested un-attributed signal'],
1009
+ witnessCandidateIds,
1010
+ };
1011
+ const observer = askTraceObserverForV1(input.request);
1012
+ for (const candidate of snapshotCandidates.filter((candidate) => (candidate.relationshipSafety ?? []).some((relationship) => relationship.id === attributed.id))) {
1013
+ observer.recordCandidateDecision({
1014
+ candidateId: candidate.id,
1015
+ role: 'relationship',
1016
+ source: traceSourceForCandidate(candidate),
1017
+ decision: 'excluded',
1018
+ reasonCode: 'policy_denied',
1019
+ compatibilityCode: 'unknown',
1020
+ });
1021
+ }
1022
+ return {
1023
+ ...input.base,
1024
+ action: 'block',
1025
+ confidence: 1,
1026
+ reason: message,
1027
+ source: 'heuristic',
1028
+ category: 'data_lookup',
1029
+ depth: 'quick',
1030
+ requiresClarification: false,
1031
+ clarifyingQuestion: undefined,
1032
+ clarificationOptions: undefined,
1033
+ retrievalEvidence: retrievalTrace(input.evidence, snapshotCandidates),
1034
+ terminalOutcome: {
1035
+ kind: 'modeling_gap',
1036
+ code: 'ANALYTICAL_MODELING_GAP',
1037
+ message,
1038
+ candidateIds: witnessCandidateIds,
1039
+ gap: terminalGap,
1040
+ },
1041
+ analyticalCascadeDecision: buildAnalyticalCascadeDecision({
1042
+ requirements: input.requirements,
1043
+ sourceCoverage: coverage,
1044
+ attempts: [
1045
+ skipped('certified'),
1046
+ skipped('semantic'),
1047
+ {
1048
+ version: 1,
1049
+ tier: 'governed_relational',
1050
+ outcome: 'denied',
1051
+ candidateIds: witnessCandidateIds,
1052
+ reason: 'The declared relationship requires attribution or allocation approval and cannot authorize the requested ranking.',
1053
+ planFrozen: false,
1054
+ },
1055
+ ],
1056
+ planFrozen: false,
1057
+ terminalGap: cascadeTerminalRelationshipGap(terminalGap),
1058
+ stopReason: 'denied',
1059
+ }),
1060
+ resolvedAnalyticalPlan: undefined,
1061
+ meaningResolution: undefined,
1062
+ };
1063
+ }
599
1064
  /**
600
1065
  * A proof may be structurally safe yet belong to a different domain's relation
601
1066
  * with the same leaf name. The selected pair must match the proof's canonical
@@ -609,9 +1074,9 @@ function relationshipProofMatchesSelectedPair(safety, left, right) {
609
1074
  return Boolean(from && to && ((leftEndpoints.has(from) && rightEndpoints.has(to))
610
1075
  || (leftEndpoints.has(to) && rightEndpoints.has(from))));
611
1076
  }
612
- function safeRelationshipProofsForPair(left, right, requiredJoinKey) {
613
- const leftProofs = safeRelationshipProofsForCandidate(left, requiredJoinKey);
614
- const rightProofs = safeRelationshipProofsForCandidate(right, requiredJoinKey);
1077
+ function safeRelationshipProofsForPair(left, right, requiredJoinKey, authority = 'governed') {
1078
+ const leftProofs = safeRelationshipProofsForCandidate(left, requiredJoinKey, authority);
1079
+ const rightProofs = safeRelationshipProofsForCandidate(right, requiredJoinKey, authority);
615
1080
  const shared = new Map();
616
1081
  for (const [id, proof] of leftProofs) {
617
1082
  const pairedProof = rightProofs.get(id);
@@ -637,7 +1102,13 @@ function hasSafeExploratoryPhysicalPath(requirements, candidates, missingDimensi
637
1102
  // their safe-value validation happens independently from proving the raw
638
1103
  // physical closure. Treating them as fields made a valid relation appear
639
1104
  // unmodeled and produced a false absence diagnostic.
640
- const terms = [...new Set([...missingDimensions, ...requirements.measures, ...requiredPhysicalFieldTerms]
1105
+ const terms = [...new Set([
1106
+ ...missingDimensions,
1107
+ ...requirements.measures,
1108
+ ...requirements.entityDisplayTerms,
1109
+ ...(requirements.outputTerms ?? []),
1110
+ ...requiredPhysicalFieldTerms,
1111
+ ]
641
1112
  .map(normalizeMetricPhrase)
642
1113
  .filter(Boolean))];
643
1114
  if (terms.length === 0) {
@@ -668,6 +1139,8 @@ function hasSafeExploratoryPhysicalPath(requirements, candidates, missingDimensi
668
1139
  const physicalRoleAliases = {
669
1140
  'product category': ['product type'],
670
1141
  category: ['product type'],
1142
+ customer: ['customer name'],
1143
+ account: ['account name'],
671
1144
  // `product_price` is a local revenue witness only when the same request
672
1145
  // also requires product-category grain. It must not let a bare selected
673
1146
  // revenue metric bypass its semantic contract through an unrelated raw
@@ -762,7 +1235,8 @@ function hasSafeExploratoryPhysicalPath(requirements, candidates, missingDimensi
762
1235
  // routinely contains unrelated raw tables; requiring every one to join made
763
1236
  // a complete local path appear unavailable. Conversely, never infer a join
764
1237
  // from names or shared column strings: the proof must retain the structured
765
- // certified, validated, fanout-safe disposition above.
1238
+ // exploratory-safe disposition above. A selected path remains
1239
+ // review-required; it is not a governed relational plan.
766
1240
  const adjacent = relations.map(() => new Set());
767
1241
  const joinColumnsForProof = (proof, left, right) => {
768
1242
  const leftEndpoints = candidateRelationshipEndpoints(left);
@@ -793,7 +1267,7 @@ function hasSafeExploratoryPhysicalPath(requirements, candidates, missingDimensi
793
1267
  const witnessesForEdge = new Map();
794
1268
  for (let left = 0; left < relations.length; left += 1) {
795
1269
  for (let right = left + 1; right < relations.length; right += 1) {
796
- const shared = [...safeRelationshipProofsForPair(relations[left], relations[right]).values()]
1270
+ const shared = [...safeRelationshipProofsForPair(relations[left], relations[right], undefined, 'exploratory').values()]
797
1271
  .sort((first, second) => first.id.localeCompare(second.id))
798
1272
  .flatMap((proof) => {
799
1273
  const joinColumns = joinColumnsForProof(proof, relations[left], relations[right]);
@@ -853,7 +1327,7 @@ function hasSafeExploratoryPhysicalPath(requirements, candidates, missingDimensi
853
1327
  return {
854
1328
  ok: false,
855
1329
  candidateIds: [],
856
- reason: 'Multiple physical relations lacked one connected, certified, validated, fanout-safe automatic-join path.',
1330
+ reason: 'Multiple physical relations lacked one connected, structured, fanout-safe exploratory join path.',
857
1331
  // This is not a lexical conclusion. Every requested physical field was
858
1332
  // found above, no one relation covered the tuple, and the structured
859
1333
  // relationship/fanout proof graph could not connect the required
@@ -861,7 +1335,7 @@ function hasSafeExploratoryPhysicalPath(requirements, candidates, missingDimensi
861
1335
  // receipts and repair guidance.
862
1336
  gap: {
863
1337
  code: 'MISSING_RELATIONSHIP',
864
- missing: ['a connected certified, validated, fanout-safe relationship proof'],
1338
+ missing: ['a connected, explicitly allowed, validated, fanout-safe relationship proof'],
865
1339
  witnessCandidateIds: stableCandidates(relations).map(stableCandidateId),
866
1340
  },
867
1341
  };
@@ -875,7 +1349,7 @@ function hasSafeExploratoryPhysicalPath(requirements, candidates, missingDimensi
875
1349
  if (selectedWitnesses.length !== selected.edges.length) {
876
1350
  return { ok: false, candidateIds: [], reason: 'The selected relationship closure lacked qualified join-key witnesses for every automatic join.' };
877
1351
  }
878
- return physicalEvidence(selectedRelations, requiredColumns, selectedWitnesses.flatMap((witness) => witness.columns), selectedWitnesses.map((witness) => witness.proof.id), 'Qualified physical relations and structured relationship safety proofs support a bounded exploratory plan.');
1352
+ return physicalEvidence(selectedRelations, requiredColumns, selectedWitnesses.flatMap((witness) => witness.columns), selectedWitnesses.map((witness) => witness.proof.id), 'Qualified physical relations and explicitly allowed relationship proofs support a bounded exploratory plan.');
879
1353
  }
880
1354
  /**
881
1355
  * The pre-freeze cascade is router authority, including when the bounded
@@ -888,15 +1362,69 @@ function hasSafeExploratoryPhysicalPath(requirements, candidates, missingDimensi
888
1362
  function preFreezePhysicalCascadeDecision(input) {
889
1363
  // `candidates` can be the capped meaning package. Physical eligibility is
890
1364
  // allowed one same-snapshot extension, never a new retrieval/domain scope.
891
- const snapshotCandidates = input.evidence.candidates.length > 0
892
- ? input.evidence.candidates
893
- : input.candidates;
1365
+ const snapshotCandidates = immutableSnapshotCandidates(input.evidence, input.candidates);
894
1366
  const physicalPath = hasSafeExploratoryPhysicalPath(input.requirements, snapshotCandidates, input.missingTerms, input.requiredPhysicalFieldTerms ?? []);
895
1367
  const missingRankingMetric = Boolean(physicalPath.ok
896
1368
  && input.requireRankingMetric
897
1369
  && input.requirements.ranking
898
1370
  && input.requirements.ranking.metricTerms.length === 0);
899
1371
  const exploratoryExecutable = physicalPath.ok && !missingRankingMetric;
1372
+ const selectedPhysicalLegacyIds = snapshotCandidates
1373
+ .filter((candidate) => physicalPath.candidateIds.includes(candidate.qualifiedId ?? candidate.id))
1374
+ .map((candidate) => candidate.id);
1375
+ const selectedPhysicalIdsForPlan = selectedPhysicalLegacyIds.length > 0
1376
+ ? selectedPhysicalLegacyIds
1377
+ : physicalPath.candidateIds;
1378
+ // The physical cascade is still an authoritative Ask route, not a legacy
1379
+ // escape hatch. Recreate the same host-owned seed used before the meaning
1380
+ // call so a semantic miss cannot erase an explicit entity display key,
1381
+ // member/filter, ranking limit, time role, or output tuple on the way to
1382
+ // review-required exploration.
1383
+ const hostRequirementSeed = buildAnalyticalRequirementSeedV1({
1384
+ question: input.question,
1385
+ parsedIntent: input.evidence.parsedIntent,
1386
+ requirements: input.requirements,
1387
+ fiscalCalendar: declaredFiscalCalendar(input.evidence, snapshotCandidates),
1388
+ });
1389
+ // The router, not the SQL generator, owns analytical meaning and the
1390
+ // physical closure. Freeze that selected exploratory plan before a model
1391
+ // sees SQL work. The host later attaches an authorization receipt for the
1392
+ // exact read-only SQL/target; it must never be the event that changes the
1393
+ // selected tier or reinterprets this request.
1394
+ const exploratoryMeaning = exploratoryExecutable
1395
+ ? {
1396
+ interpretedQuestion: input.question,
1397
+ questionType: questionTypeFromText(input.question),
1398
+ selectedConceptIds: selectedPhysicalIdsForPlan,
1399
+ recommendedExecutionId: selectedPhysicalIdsForPlan[0],
1400
+ queryIntent: queryIntentFromRequirementSeed(hostRequirementSeed),
1401
+ rejectedCandidates: [],
1402
+ // Low confidence is intentionally non-executable in a resolved plan.
1403
+ // This is a router-proven physical closure, not a speculative model
1404
+ // answer, so the confidence describes the selected route only.
1405
+ confidence: 'high',
1406
+ missingInformation: [],
1407
+ recommendedRoute: 'exploratory',
1408
+ hostRequirementSeed,
1409
+ compatibilityOutcome: 'modeling_gap',
1410
+ compatibilityFailures: input.missingTerms.map((term) => ({
1411
+ code: 'MISSING_DIMENSION',
1412
+ field: term,
1413
+ message: `${term} was not complete in the earlier governed tiers.`,
1414
+ candidateIds: [],
1415
+ })),
1416
+ }
1417
+ : undefined;
1418
+ const resolvedAnalyticalPlan = exploratoryMeaning
1419
+ ? buildResolvedAnalyticalPlan({
1420
+ question: input.question,
1421
+ resolution: exploratoryMeaning,
1422
+ evidence: input.evidence,
1423
+ candidates: snapshotCandidates,
1424
+ mode: 'authoritative',
1425
+ })
1426
+ : undefined;
1427
+ const exploratoryPlanFrozen = resolvedAnalyticalPlan?.capability === 'bounded_exploration';
900
1428
  const coverage = sourceCoverageFromEvidence(input.evidence, snapshotCandidates);
901
1429
  const coverageFor = (source) => coverage.find((item) => item.source === source);
902
1430
  const skippedAttempt = (tier, source) => {
@@ -934,7 +1462,7 @@ function preFreezePhysicalCascadeDecision(input) {
934
1462
  reason: missingRankingMetric
935
1463
  ? `${physicalPath.reason} A ranking measure remains unbound, so exploration cannot be selected.`
936
1464
  : physicalPath.reason,
937
- planFrozen: false,
1465
+ planFrozen: exploratoryPlanFrozen,
938
1466
  },
939
1467
  ];
940
1468
  const message = exploratoryExecutable
@@ -956,7 +1484,7 @@ function preFreezePhysicalCascadeDecision(input) {
956
1484
  planFrozen: false,
957
1485
  }],
958
1486
  ...(exploratoryExecutable ? { selectedTier: 'exploratory_sql' } : {}),
959
- planFrozen: false,
1487
+ planFrozen: exploratoryPlanFrozen,
960
1488
  stopReason: exploratoryExecutable ? 'selected' : missingRankingMetric ? 'ambiguous' : 'coverage_gap',
961
1489
  });
962
1490
  if (!exploratoryExecutable) {
@@ -997,29 +1525,96 @@ function preFreezePhysicalCascadeDecision(input) {
997
1525
  clarificationOptions: undefined,
998
1526
  retrievalEvidence: retrievalTrace(input.evidence, snapshotCandidates),
999
1527
  analyticalCascadeDecision,
1000
- meaningResolution: {
1001
- interpretedQuestion: input.question,
1002
- questionType: questionTypeFromText(input.question),
1003
- selectedConceptIds: physicalPath.candidateIds,
1004
- queryIntent: {
1005
- ...defaultQueryIntent(input.evidence),
1006
- measures: input.requirements.measures,
1007
- dimensions: input.requirements.dimensions,
1008
- filters: input.evidence.parsedIntent?.filters ?? [],
1009
- },
1010
- rejectedCandidates: [],
1011
- confidence: 'low',
1012
- missingInformation: [message],
1013
- recommendedRoute: 'exploratory',
1014
- compatibilityOutcome: 'modeling_gap',
1015
- compatibilityFailures: input.missingTerms.map((term) => ({
1016
- code: 'MISSING_DIMENSION',
1017
- field: term,
1018
- message: `${term} was not complete in the earlier governed tiers.`,
1019
- candidateIds: [],
1020
- })),
1021
- },
1022
- resolvedAnalyticalPlan: undefined,
1528
+ meaningResolution: exploratoryMeaning,
1529
+ resolvedAnalyticalPlan,
1530
+ };
1531
+ }
1532
+ /**
1533
+ * The compact meaning package is deliberately not a proof-of-absence set.
1534
+ * Preserve the complete immutable retrieval snapshot for physical closure and
1535
+ * structured-continuation checks, while retaining a caller's server-issued
1536
+ * selection when it was supplied as a same-snapshot clarification card.
1537
+ */
1538
+ function immutableSnapshotCandidates(evidence, candidates) {
1539
+ const byId = new Map();
1540
+ for (const candidate of [
1541
+ ...evidence.candidates,
1542
+ ...(evidence.clarificationCandidates ?? []),
1543
+ ...candidates,
1544
+ ]) {
1545
+ if (!byId.has(candidate.id))
1546
+ byId.set(candidate.id, candidate);
1547
+ }
1548
+ return [...byId.values()];
1549
+ }
1550
+ /**
1551
+ * A normal meaning call may nominate either a semantic metric or a governed
1552
+ * modeling card whose declared execution contract cannot satisfy the exact
1553
+ * tuple. Before presenting that *pre-freeze* modeling gap as terminal,
1554
+ * inspect the complete immutable snapshot for one bounded, relationship-safe
1555
+ * physical closure. This is not a post-freeze downgrade: policy, fiscal, and
1556
+ * unsafe failures remain blocked and a safe result is explicitly
1557
+ * review-required exploratory work.
1558
+ */
1559
+ function continuePreFreezeModelingGapThroughPhysicalSnapshot(input) {
1560
+ const { decision, evidence } = input;
1561
+ const plan = decision.resolvedAnalyticalPlan;
1562
+ if (!plan
1563
+ || decision.terminalOutcome?.kind !== 'modeling_gap'
1564
+ || decision.analyticalCascadeDecision?.planFrozen === true
1565
+ || (plan.recommendedRoute !== 'semantic' && plan.recommendedRoute !== 'governed_sql'))
1566
+ return decision;
1567
+ const selectedPreFreezeTier = plan.recommendedRoute;
1568
+ const requirements = buildAnalyticalRequirementSet({
1569
+ question: input.question,
1570
+ parsedIntent: input.evidence.parsedIntent,
1571
+ });
1572
+ const unresolved = plan.resolutionFailure?.bindings
1573
+ .filter((binding) => binding.status !== 'resolved')
1574
+ .map((binding) => binding.requested) ?? [];
1575
+ const missingTerms = [...new Set([
1576
+ ...unresolved,
1577
+ ...requirements.dimensions,
1578
+ ...requirements.entityTerms,
1579
+ ...requirements.entityDisplayTerms,
1580
+ ...(plan.outputContract.requiredOutputs ?? []).map((binding) => binding.requested),
1581
+ ])];
1582
+ const requiredPhysicalFieldTerms = [...new Set([
1583
+ ...requirements.dimensions,
1584
+ ...requirements.entityTerms,
1585
+ ...requirements.entityDisplayTerms,
1586
+ ...(plan.outputContract.requiredOutputs ?? []).map((binding) => binding.requested),
1587
+ ...(plan.query.filters ?? []).map((filter) => filter.field),
1588
+ ...unresolved,
1589
+ ])];
1590
+ const physicalContinuation = preFreezePhysicalCascadeDecision({
1591
+ base: input.base,
1592
+ evidence,
1593
+ candidates: immutableSnapshotCandidates(evidence, input.candidates),
1594
+ question: input.question,
1595
+ requirements,
1596
+ missingTerms,
1597
+ requiredPhysicalFieldTerms,
1598
+ messagePrefix: selectedPreFreezeTier === 'governed_sql'
1599
+ ? 'The selected governed relational interpretation did not prove a compiler-owned DQL projection before plan freeze.'
1600
+ : 'The selected semantic interpretation was pre-freeze-ineligible for the complete requested tuple.',
1601
+ terminalCandidateIds: plan.selectedConceptIds,
1602
+ });
1603
+ if (physicalContinuation.action === 'answer'
1604
+ && physicalContinuation.analyticalCascadeDecision?.selectedTier === 'exploratory_sql') {
1605
+ return physicalContinuation;
1606
+ }
1607
+ // The physical extension was considered and was not safe/executable. Keep
1608
+ // the original immutable semantic plan and its typed failure rather than
1609
+ // replacing it with a synthetic exploratory meaning; surface the full
1610
+ // snapshot cascade evidence so the terminal block explains why it could not
1611
+ // continue. This retains policy/unsafe/fiscal terminal semantics.
1612
+ return {
1613
+ ...decision,
1614
+ reason: physicalContinuation.reason,
1615
+ retrievalEvidence: physicalContinuation.retrievalEvidence,
1616
+ analyticalCascadeDecision: physicalContinuation.analyticalCascadeDecision,
1617
+ terminalOutcome: physicalContinuation.terminalOutcome ?? decision.terminalOutcome,
1023
1618
  };
1024
1619
  }
1025
1620
  /**
@@ -1102,7 +1697,7 @@ function findSafeComplementaryPhysicalComposition(question, candidates) {
1102
1697
  const joinKey = displayColumnsOnRelation.flatMap(columnJoinKeys).find((key) => predicateJoinKeys.has(key));
1103
1698
  if (!joinKey)
1104
1699
  continue;
1105
- const sharedRelationshipProofIds = [...safeRelationshipProofsForPair(displayRelation, predicateRelation, joinKey).keys()];
1700
+ const sharedRelationshipProofIds = [...safeRelationshipProofsForPair(displayRelation, predicateRelation, joinKey, 'exploratory').keys()];
1106
1701
  if (sharedRelationshipProofIds.length === 0)
1107
1702
  continue;
1108
1703
  const selectedPhysical = [
@@ -1148,10 +1743,40 @@ function complementaryExploratoryDecision(base, evidence, candidates, question)
1148
1743
  // Use all already-retrieved cards from the same snapshot. The bounded meaning
1149
1744
  // package can omit a supporting raw join column, but no new retrieval/domain
1150
1745
  // scope is opened here.
1151
- const composition = findSafeComplementaryPhysicalComposition(question, evidence.candidates);
1746
+ const snapshotCandidates = immutableSnapshotCandidates(evidence, candidates);
1747
+ const composition = findSafeComplementaryPhysicalComposition(question, snapshotCandidates);
1152
1748
  if (!composition)
1153
1749
  return undefined;
1154
- const coverage = sourceCoverageFromEvidence(evidence, evidence.candidates);
1750
+ const coverage = sourceCoverageFromEvidence(evidence, snapshotCandidates);
1751
+ const exploratoryMeaning = {
1752
+ interpretedQuestion: question,
1753
+ questionType: questionTypeFromText(question),
1754
+ selectedConceptIds: composition.candidateIds,
1755
+ recommendedExecutionId: composition.displayColumn.qualifiedId ?? composition.displayColumn.id,
1756
+ queryIntent: {
1757
+ measures: [],
1758
+ dimensions: [composition.displayColumn.qualifiedId ?? composition.displayColumn.id],
1759
+ filters: [{
1760
+ field: composition.predicateColumn.qualifiedId ?? composition.predicateColumn.id,
1761
+ value: 'true',
1762
+ }],
1763
+ },
1764
+ rejectedCandidates: [],
1765
+ confidence: 'high',
1766
+ missingInformation: [],
1767
+ recommendedRoute: 'exploratory',
1768
+ compatibilityOutcome: 'modeling_gap',
1769
+ };
1770
+ const resolvedAnalyticalPlan = buildResolvedAnalyticalPlan({
1771
+ question,
1772
+ resolution: exploratoryMeaning,
1773
+ evidence,
1774
+ candidates: snapshotCandidates,
1775
+ mode: 'authoritative',
1776
+ });
1777
+ const exploratoryPlanFrozen = resolvedAnalyticalPlan.capability === 'bounded_exploration';
1778
+ if (!exploratoryPlanFrozen)
1779
+ return undefined;
1155
1780
  const coverageFor = (source) => coverage.find((item) => item.source === source);
1156
1781
  const skippedAttempt = (tier, source, reason) => {
1157
1782
  const item = coverageFor(source);
@@ -1184,7 +1809,7 @@ function complementaryExploratoryDecision(base, evidence, candidates, question)
1184
1809
  outcome: 'executable',
1185
1810
  candidateIds: composition.candidateIds,
1186
1811
  reason: composition.reason,
1187
- planFrozen: false,
1812
+ planFrozen: exploratoryPlanFrozen,
1188
1813
  },
1189
1814
  ];
1190
1815
  const analyticalCascadeDecision = buildAnalyticalCascadeDecision({
@@ -1192,7 +1817,7 @@ function complementaryExploratoryDecision(base, evidence, candidates, question)
1192
1817
  sourceCoverage: coverage,
1193
1818
  attempts,
1194
1819
  selectedTier: 'exploratory_sql',
1195
- planFrozen: false,
1820
+ planFrozen: exploratoryPlanFrozen,
1196
1821
  stopReason: 'selected',
1197
1822
  });
1198
1823
  return {
@@ -1204,27 +1829,10 @@ function complementaryExploratoryDecision(base, evidence, candidates, question)
1204
1829
  depth: 'quick',
1205
1830
  reason: `${composition.reason} Certified and semantic execution did not freeze; generated SQL remains review_required.`,
1206
1831
  requiresClarification: false,
1207
- retrievalEvidence: retrievalTrace(evidence, candidates),
1832
+ retrievalEvidence: retrievalTrace(evidence, snapshotCandidates),
1208
1833
  analyticalCascadeDecision,
1209
- resolvedAnalyticalPlan: undefined,
1210
- meaningResolution: {
1211
- interpretedQuestion: question,
1212
- questionType: questionTypeFromText(question),
1213
- selectedConceptIds: composition.candidateIds,
1214
- recommendedExecutionId: composition.displayColumn.qualifiedId ?? composition.displayColumn.id,
1215
- queryIntent: {
1216
- measures: [],
1217
- dimensions: [composition.displayColumn.qualifiedId ?? composition.displayColumn.id],
1218
- filters: [{
1219
- field: composition.predicateColumn.qualifiedId ?? composition.predicateColumn.id,
1220
- value: 'true',
1221
- }],
1222
- },
1223
- rejectedCandidates: [],
1224
- confidence: 'low',
1225
- missingInformation: ['No certified or semantic route proved the complete composed request.'],
1226
- recommendedRoute: 'exploratory',
1227
- },
1834
+ resolvedAnalyticalPlan,
1835
+ meaningResolution: exploratoryMeaning,
1228
1836
  };
1229
1837
  }
1230
1838
  function cascadeForResolution(input) {
@@ -1262,6 +1870,7 @@ function cascadeForResolution(input) {
1262
1870
  attempts,
1263
1871
  ...(selectedTier ? { selectedTier } : {}),
1264
1872
  planFrozen: frozen,
1873
+ ...(input.terminalGap ? { terminalGap: input.terminalGap } : {}),
1265
1874
  stopReason: input.reconciliation.outcome === 'ready' ? 'selected' : input.reconciliation.outcome === 'clarify' ? 'ambiguous' : input.reconciliation.outcome === 'policy_blocked' ? 'denied' : 'coverage_gap',
1266
1875
  });
1267
1876
  }
@@ -1304,6 +1913,9 @@ function routeDecisionForResolution(base, evidence, candidates, resolution, sour
1304
1913
  const analytical = routedResolution.questionType === "diagnosis" ||
1305
1914
  routedResolution.questionType === "research";
1306
1915
  const reason = reconciliation.reason;
1916
+ const terminalGap = reconciliation.outcome === 'modeling_gap'
1917
+ ? terminalGapWitnessForResolutionFailure(resolvedAnalyticalPlan)
1918
+ : undefined;
1307
1919
  const analyticalCascadeDecision = cascadeForResolution({
1308
1920
  evidence,
1309
1921
  candidates,
@@ -1311,12 +1923,22 @@ function routeDecisionForResolution(base, evidence, candidates, resolution, sour
1311
1923
  plan: resolvedAnalyticalPlan,
1312
1924
  reconciliation,
1313
1925
  question,
1926
+ ...(terminalGap ? { terminalGap: cascadeTerminalRelationshipGap(terminalGap) } : {}),
1314
1927
  });
1315
- const terminalGap = reconciliation.outcome === 'modeling_gap'
1316
- ? terminalGapWitnessForResolutionFailure(resolvedAnalyticalPlan)
1317
- : undefined;
1928
+ const declaredDimensionAssumptions = assumptionsForDeclaredDimensionAlternatives({
1929
+ question,
1930
+ evidence,
1931
+ candidates,
1932
+ plan: resolvedAnalyticalPlan,
1933
+ });
1934
+ // The deterministic pre-router may attach a generic soft clarification
1935
+ // while retrieval is still incomplete. Once this same turn has bound and
1936
+ // frozen a plan, that stale prompt must not leak into a completed answer or
1937
+ // its trace. Keep a clarification only when reconciliation itself says the
1938
+ // final immutable tuple is genuinely ambiguous.
1939
+ const { clarifyingQuestion: _staleClarifyingQuestion, clarificationOptions: _staleClarificationOptions, clarifySoft: _staleClarifySoft, ...baseWithoutStaleClarification } = base;
1318
1940
  return {
1319
- ...base,
1941
+ ...baseWithoutStaleClarification,
1320
1942
  action: needsClarification
1321
1943
  ? "clarify"
1322
1944
  : terminallyBlocked
@@ -1359,8 +1981,12 @@ function routeDecisionForResolution(base, evidence, candidates, resolution, sour
1359
1981
  ...(needsClarification
1360
1982
  ? {
1361
1983
  clarifyingQuestion: reconciliation.question,
1984
+ ...(_staleClarifySoft ? { clarifySoft: true } : {}),
1362
1985
  }
1363
1986
  : {}),
1987
+ ...(declaredDimensionAssumptions.length > 0
1988
+ ? { assumptions: declaredDimensionAssumptions }
1989
+ : {}),
1364
1990
  };
1365
1991
  }
1366
1992
  /**
@@ -1524,6 +2150,7 @@ function reconcileResolvedPlanOutcome(resolution, plan, candidates) {
1524
2150
  ...plan.query.measures.map((binding) => ({ kind: 'measure', binding })),
1525
2151
  ...plan.query.dimensions.map((binding) => ({ kind: 'dimension', binding })),
1526
2152
  ...plan.query.filters.map((filter) => ({ kind: 'filter', binding: filter.binding })),
2153
+ ...(plan.outputContract.requiredOutputs ?? []).map((binding) => ({ kind: 'output', binding })),
1527
2154
  ].filter(({ binding }) => binding.status !== 'resolved');
1528
2155
  const qualifiedChoiceIds = [...new Set(bindings.flatMap(({ binding }) => binding.candidateIds))].sort();
1529
2156
  const userResolvableBinding = bindings.some(({ binding }) => binding.candidateIds.length > 0);
@@ -1686,19 +2313,57 @@ function routedClarificationQuestion(resolution, bindings, options) {
1686
2313
  return resolution.clarifyingQuestion
1687
2314
  ?? `Which governed binding should I use before running this query?`;
1688
2315
  }
1689
- function continueCascadeAfterIncompleteSelection(base, evidence, candidates, selected, question) {
2316
+ function continueCascadeAfterIncompleteSelection(base, evidence, candidates, selected, question, selectedConceptIds = [selected.id]) {
1690
2317
  // Preserve the exact stable ID emitted by the clarification option. A
1691
2318
  // candidate may also carry a source-qualified execution alias, but replacing
1692
2319
  // the clicked ID here makes the persisted ambiguity contract and the later
1693
2320
  // diagnostic receipt disagree even though the user chose a legitimate item.
1694
2321
  const selectedId = selected.id;
2322
+ // A server-issued display-key click has already passed the snapshot and
2323
+ // metric-capability checks. If its tuple is pre-freeze-ineligible, preserve
2324
+ // both the metric authority and the exact clicked ID through the typed gap
2325
+ // or same-snapshot exploratory continuation. Reconstructing only the
2326
+ // metric made restart receipts falsely look as though no choice was made.
2327
+ const preservedSelectionIds = [...new Set([
2328
+ ...selectedConceptIds.filter((id) => Boolean(id?.trim())),
2329
+ selectedId,
2330
+ ])];
2331
+ // A structured choice is validated against its exact stable identity, but an
2332
+ // incomplete selected capability must evaluate physical eligibility against
2333
+ // the entire immutable snapshot—not the compact model package that happened
2334
+ // to carry the clarification. This is one same-snapshot extension only.
2335
+ const snapshotCandidates = immutableSnapshotCandidates(evidence, candidates);
1695
2336
  const requirements = buildAnalyticalRequirementSet({ question, parsedIntent: evidence.parsedIntent });
1696
2337
  const requiredPhysicalFieldTerms = [
1697
2338
  ...(evidence.parsedIntent?.dimensions ?? []),
1698
2339
  ...(evidence.parsedIntent?.filters ?? []).map((filter) => filter.field),
1699
2340
  ];
1700
- const physicalPath = hasSafeExploratoryPhysicalPath(requirements, candidates, requirements.dimensions, requiredPhysicalFieldTerms);
1701
- const coverage = sourceCoverageFromEvidence(evidence, candidates);
2341
+ const physicalPath = hasSafeExploratoryPhysicalPath(requirements, snapshotCandidates, requirements.dimensions, requiredPhysicalFieldTerms);
2342
+ // A structured clarification consumes the exact selected ID once, but an
2343
+ // incomplete governed capability is not a post-freeze terminal. Reuse the
2344
+ // router-owned physical cascade so the same-snapshot exploratory closure is
2345
+ // resolved and frozen *before* SQL is generated. The host may later attach
2346
+ // only an authorization receipt for that frozen plan; it must not select or
2347
+ // freeze another plan after SQL exists.
2348
+ if (physicalPath.ok) {
2349
+ return preFreezePhysicalCascadeDecision({
2350
+ base,
2351
+ evidence,
2352
+ candidates: snapshotCandidates,
2353
+ question,
2354
+ requirements,
2355
+ missingTerms: [...new Set([
2356
+ ...requirements.dimensions,
2357
+ ...requirements.entityTerms,
2358
+ ...requirements.entityDisplayTerms,
2359
+ ])],
2360
+ requiredPhysicalFieldTerms,
2361
+ messagePrefix: `The selected governed meaning ${selectedId} did not prove the complete requested tuple. DQL consumed that selection once and did not substitute a correlated metric or execute a different artifact;`,
2362
+ terminalCandidateIds: preservedSelectionIds,
2363
+ requireRankingMetric: Boolean(requirements.ranking),
2364
+ });
2365
+ }
2366
+ const coverage = sourceCoverageFromEvidence(evidence, snapshotCandidates);
1702
2367
  const governedCoverage = coverage.find((item) => item.source === 'governed_relational');
1703
2368
  const attempts = [
1704
2369
  {
@@ -1713,7 +2378,7 @@ function continueCascadeAfterIncompleteSelection(base, evidence, candidates, sel
1713
2378
  version: 1,
1714
2379
  tier: 'semantic',
1715
2380
  outcome: 'ineligible',
1716
- candidateIds: [...new Set([selectedId, ...(coverage.find((item) => item.source === 'semantic')?.candidateIds ?? [])])],
2381
+ candidateIds: [...new Set([...preservedSelectionIds, ...(coverage.find((item) => item.source === 'semantic')?.candidateIds ?? [])])],
1717
2382
  reason: `The explicit selection ${selectedId} was consumed once but did not prove the complete semantic tuple.`,
1718
2383
  planFrozen: false,
1719
2384
  },
@@ -1744,7 +2409,7 @@ function continueCascadeAfterIncompleteSelection(base, evidence, candidates, sel
1744
2409
  sourceCoverage: coverage,
1745
2410
  attempts: physicalPath.ok
1746
2411
  ? attempts
1747
- : [...attempts, { version: 1, tier: 'clarify_or_gap', outcome: 'unavailable', candidateIds: [selectedId], reason: message, planFrozen: false }],
2412
+ : [...attempts, { version: 1, tier: 'clarify_or_gap', outcome: 'unavailable', candidateIds: preservedSelectionIds, reason: message, planFrozen: false }],
1748
2413
  ...(physicalPath.ok ? { selectedTier: 'exploratory_sql' } : {}),
1749
2414
  planFrozen: false,
1750
2415
  stopReason: physicalPath.ok ? 'selected' : 'coverage_gap',
@@ -1752,7 +2417,7 @@ function continueCascadeAfterIncompleteSelection(base, evidence, candidates, sel
1752
2417
  const meaningResolution = {
1753
2418
  interpretedQuestion: question,
1754
2419
  questionType: 'value',
1755
- selectedConceptIds: [selectedId],
2420
+ selectedConceptIds: preservedSelectionIds,
1756
2421
  queryIntent: defaultQueryIntent(evidence),
1757
2422
  rejectedCandidates: [],
1758
2423
  confidence: 'low',
@@ -1763,7 +2428,7 @@ function continueCascadeAfterIncompleteSelection(base, evidence, candidates, sel
1763
2428
  code: 'INCOMPLETE_SELECTED_CAPABILITY',
1764
2429
  field: 'selected capability',
1765
2430
  message,
1766
- candidateIds: [selectedId],
2431
+ candidateIds: preservedSelectionIds,
1767
2432
  }],
1768
2433
  };
1769
2434
  if (!physicalPath.ok) {
@@ -1775,7 +2440,7 @@ function continueCascadeAfterIncompleteSelection(base, evidence, candidates, sel
1775
2440
  source: 'heuristic',
1776
2441
  category: 'data_lookup',
1777
2442
  depth: 'quick',
1778
- retrievalEvidence: retrievalTrace(evidence, candidates),
2443
+ retrievalEvidence: retrievalTrace(evidence, snapshotCandidates),
1779
2444
  requiresClarification: false,
1780
2445
  resolvedAnalyticalPlan: undefined,
1781
2446
  meaningResolution,
@@ -1784,7 +2449,7 @@ function continueCascadeAfterIncompleteSelection(base, evidence, candidates, sel
1784
2449
  kind: 'modeling_gap',
1785
2450
  code: 'ANALYTICAL_MODELING_GAP',
1786
2451
  message,
1787
- candidateIds: [selectedId],
2452
+ candidateIds: preservedSelectionIds,
1788
2453
  ...(physicalPath.gap ? { gap: physicalPath.gap } : {}),
1789
2454
  },
1790
2455
  };
@@ -1797,7 +2462,7 @@ function continueCascadeAfterIncompleteSelection(base, evidence, candidates, sel
1797
2462
  source: 'heuristic',
1798
2463
  category: 'data_lookup',
1799
2464
  depth: 'quick',
1800
- retrievalEvidence: retrievalTrace(evidence, candidates),
2465
+ retrievalEvidence: retrievalTrace(evidence, snapshotCandidates),
1801
2466
  requiresClarification: false,
1802
2467
  resolvedAnalyticalPlan: undefined,
1803
2468
  meaningResolution,
@@ -1857,12 +2522,38 @@ function enforceAnalyticalCompatibility(resolution, evidence, candidates) {
1857
2522
  const metricEvidence = candidates.filter((candidate) => candidate.kind === "semantic_metric" &&
1858
2523
  candidate.analyticalCapability?.metricId &&
1859
2524
  metricIds.has(candidate.analyticalCapability.metricId));
2525
+ // Preserve an already validated semantic member when the resulting frame
2526
+ // proves that the selected metric owns that exact dimension. This is how a
2527
+ // server-issued “Account Name” clarification continues alongside the
2528
+ // explicit Revenue metric; stripping it here made trace meaning IDs and
2529
+ // durable continuation state look empty even though the frame was valid.
2530
+ const selectedFrameMembers = resolution.selectedConceptIds.flatMap((id) => {
2531
+ const member = candidates.find((candidate) => candidate.id === id);
2532
+ if (member?.kind !== 'semantic_member')
2533
+ return [];
2534
+ const resolvesIntoFrame = metricEvidence.some((metric) => {
2535
+ const identities = [
2536
+ member.qualifiedId,
2537
+ member.id,
2538
+ member.name,
2539
+ ...(member.aliases ?? []),
2540
+ ].filter((identity) => Boolean(identity));
2541
+ return identities.some((identity) => {
2542
+ const dimension = resolveMetricCapabilityDimension(metric, identity);
2543
+ return Boolean(dimension && result.frame.dimensions.some((binding) => binding.dimensionId === dimension.dimensionId));
2544
+ });
2545
+ });
2546
+ return resolvesIntoFrame ? [member.id] : [];
2547
+ });
1860
2548
  return {
1861
2549
  ...resolution,
1862
2550
  analyticalFrame: result.frame,
1863
2551
  analyticalPolicyIds: result.policyIds,
1864
2552
  ...(metricEvidence.length > 0
1865
- ? { selectedConceptIds: metricEvidence.map((candidate) => candidate.id) }
2553
+ ? { selectedConceptIds: [...new Set([
2554
+ ...metricEvidence.map((candidate) => candidate.id),
2555
+ ...selectedFrameMembers,
2556
+ ])] }
1866
2557
  : {}),
1867
2558
  recommendedExecutionId: result.candidateId,
1868
2559
  recommendedRoute: result.route,
@@ -1904,7 +2595,12 @@ function enforceAnalyticalCompatibility(resolution, evidence, candidates) {
1904
2595
  analyticalFrame: result.frame,
1905
2596
  analyticalPolicyIds: result.policyIds,
1906
2597
  confidence: result.status === "clarify" ? "low" : resolution.confidence,
1907
- recommendedRoute: "clarify",
2598
+ // A compatibility block is a pre-freeze failure of the nominated tier,
2599
+ // not a new model decision to route somewhere else. Retain the semantic
2600
+ // nomination so the authoritative cascade can inspect the complete
2601
+ // same-snapshot qualified physical closure. Only genuine multi-option
2602
+ // ambiguity changes the route to clarification.
2603
+ recommendedRoute: result.status === 'clarify' ? 'clarify' : resolution.recommendedRoute,
1908
2604
  compatibilityOutcome,
1909
2605
  compatibilityFailures,
1910
2606
  missingInformation: [
@@ -1981,6 +2677,9 @@ function persistedClarificationSelectionContext(request) {
1981
2677
  const sourceTurnId = typeof pendingRecord.sourceTurnId === 'string' && pendingRecord.sourceTurnId.trim()
1982
2678
  ? pendingRecord.sourceTurnId.trim()
1983
2679
  : undefined;
2680
+ const sourceQuestion = typeof pendingRecord.sourceQuestion === 'string' && pendingRecord.sourceQuestion.trim()
2681
+ ? pendingRecord.sourceQuestion.trim()
2682
+ : undefined;
1984
2683
  const threadId = typeof envelope?.threadId === 'string' && envelope.threadId.trim()
1985
2684
  ? envelope.threadId.trim()
1986
2685
  : undefined;
@@ -2016,6 +2715,7 @@ function persistedClarificationSelectionContext(request) {
2016
2715
  ...(requirements ? { requirements } : {}),
2017
2716
  ...(snapshotId ? { snapshotId } : {}),
2018
2717
  ...(sourceTurnId ? { sourceTurnId } : {}),
2718
+ ...(sourceQuestion ? { sourceQuestion } : {}),
2019
2719
  ...(threadId ? { threadId } : {}),
2020
2720
  serverIssued: !invalidReason,
2021
2721
  ...(invalidReason ? { invalidReason } : {}),
@@ -2100,17 +2800,182 @@ function mergeClarificationRequirements(current, persisted) {
2100
2800
  entityTerms: [...new Set([...current.entityTerms, ...(persisted.entityTerms ?? [])])],
2101
2801
  entityDisplayTerms: [...new Set([...current.entityDisplayTerms, ...(persisted.entityDisplayTerms ?? [])])],
2102
2802
  memberTerms: [...new Set([...current.memberTerms, ...(persisted.memberTerms ?? [])])],
2803
+ ...(current.ranking || persisted.ranking
2804
+ ? {
2805
+ ranking: current.ranking && persisted.ranking
2806
+ ? {
2807
+ ...current.ranking,
2808
+ metricTerms: [...new Set([...current.ranking.metricTerms, ...persisted.ranking.metricTerms])],
2809
+ entityTerms: [...new Set([...current.ranking.entityTerms, ...persisted.ranking.entityTerms])],
2810
+ }
2811
+ : current.ranking ?? persisted.ranking,
2812
+ }
2813
+ : {}),
2814
+ ...(current.time || persisted.time
2815
+ ? {
2816
+ time: current.time && persisted.time
2817
+ ? {
2818
+ ...current.time,
2819
+ grain: current.time.grain ?? persisted.time.grain,
2820
+ fiscalPeriod: current.time.fiscalPeriod ?? persisted.time.fiscalPeriod,
2821
+ requiresDeclaredFiscalCalendar: current.time.requiresDeclaredFiscalCalendar
2822
+ || persisted.time.requiresDeclaredFiscalCalendar,
2823
+ }
2824
+ : current.time ?? persisted.time,
2825
+ }
2826
+ : {}),
2827
+ };
2828
+ }
2829
+ /**
2830
+ * A structured choice continues the server-rendered analytical frame. The
2831
+ * browser may echo `clarificationSourceQuestion`, but the persisted server
2832
+ * envelope wins whenever it is available so a choice label cannot be parsed as
2833
+ * a new question on reload or restart.
2834
+ */
2835
+ function structuredClarificationRequirements(request, persisted) {
2836
+ const sourceQuestion = persisted?.serverIssued && persisted.sourceQuestion
2837
+ ? persisted.sourceQuestion
2838
+ : request.clarificationSourceQuestion?.trim() || request.question;
2839
+ return mergeClarificationRequirements(buildAnalyticalRequirementSet({ question: sourceQuestion }), persisted?.serverIssued ? persisted.requirements : undefined);
2840
+ }
2841
+ /**
2842
+ * A dimension clarification is not an alternate definition of the measure.
2843
+ * Find the exact semantic metric that owns the persisted explicit measure
2844
+ * before accepting a server-issued semantic member as a display/grouping
2845
+ * selection. Multiple metrics remain a real ambiguity; we never use a
2846
+ * dimension click to guess one of them.
2847
+ */
2848
+ function uniqueStructuredSelectionMetric(candidates, requirements) {
2849
+ const measures = explicitMeasureTermsForClarification(requirements);
2850
+ if (measures.length !== 1)
2851
+ return undefined;
2852
+ const byMetricId = new Map();
2853
+ for (const candidate of canonicalizeMetricMeasureCandidates(candidates)) {
2854
+ if (candidate.kind !== 'semantic_metric'
2855
+ || candidate.eligible === false
2856
+ // The first clarification deliberately exists because the metric's
2857
+ // display/entity role was not yet fully bound. A partial fit can still
2858
+ // be the unique, complete semantic capability for the explicit measure;
2859
+ // after the server-issued dimension choice binds it, the ordinary
2860
+ // immutable-plan compatibility checks remain authoritative. Only a
2861
+ // proven incompatible metric is disqualified at this identity boundary.
2862
+ || candidate.compatibility === 'incompatible'
2863
+ || candidateConflictsWithExplicitRankingMeasure(candidate, requirements)
2864
+ || !candidateProvesMetricTerm(candidate, measures[0]))
2865
+ continue;
2866
+ const normalized = normalizeEvidenceAnalyticalCapability(candidate);
2867
+ if (normalized.status !== 'complete' || !normalized.capability)
2868
+ continue;
2869
+ const current = byMetricId.get(normalized.capability.metricId);
2870
+ if (!current
2871
+ || candidate.exactMatch && !current.exactMatch
2872
+ || candidate.relevanceScore > current.relevanceScore) {
2873
+ byMetricId.set(normalized.capability.metricId, candidate);
2874
+ }
2875
+ }
2876
+ return byMetricId.size === 1 ? [...byMetricId.values()][0] : undefined;
2877
+ }
2878
+ /**
2879
+ * A resolver can offer a qualified semantic capability dimension without a
2880
+ * standalone retrieval card. That is expected: the option is an authored
2881
+ * child of a metric capability, not an independently ranked candidate. On a
2882
+ * later click, restore that child only when the active server-issued
2883
+ * clarification contract names the exact option and the *same immutable
2884
+ * snapshot* still proves it through one exact metric capability.
2885
+ *
2886
+ * This is deliberately not a fuzzy retrieval fallback. A malformed,
2887
+ * foreign, stale, or client-invented ID never reaches this helper because the
2888
+ * host-only envelope/snapshot checks occur before the capability projection.
2889
+ */
2890
+ function rehydrateServerIssuedCapabilityDimensionSelection(input) {
2891
+ const selectedId = input.request.selectedEvidenceId?.trim();
2892
+ if (!selectedId)
2893
+ return undefined;
2894
+ const persisted = persistedClarificationSelectionContext(input.request);
2895
+ if (!persisted?.serverIssued || persisted.snapshotId !== input.evidence.snapshotId)
2896
+ return undefined;
2897
+ const offeredIds = new Set([...persisted.optionIds, ...persisted.ambiguityCandidateIds]);
2898
+ if (!offeredIds.has(selectedId))
2899
+ return undefined;
2900
+ const requirements = structuredClarificationRequirements(input.request, persisted);
2901
+ const metricCandidate = uniqueStructuredSelectionMetric(input.candidates, requirements);
2902
+ if (!metricCandidate)
2903
+ return undefined;
2904
+ const dimension = resolveMetricCapabilityDimension(metricCandidate, selectedId);
2905
+ if (!dimension)
2906
+ return undefined;
2907
+ if (requirements.ranking && !dimension.supportedRoles.includes('rank_entity'))
2908
+ return undefined;
2909
+ if (!requirements.ranking
2910
+ && !dimension.supportedRoles.includes('group_by')
2911
+ && !dimension.supportedRoles.includes('filter'))
2912
+ return undefined;
2913
+ return {
2914
+ // Keep the exact server-issued ID as the router identity and persistable
2915
+ // choice binding. The metric capability's authored ID stays available as
2916
+ // provenance for semantic-frame construction.
2917
+ id: selectedId,
2918
+ qualifiedId: dimension.dimensionId,
2919
+ kind: 'semantic_member',
2920
+ semanticObjectType: 'dimension',
2921
+ trustTier: 'semantic',
2922
+ name: dimension.label || qualifiedIdLabel(dimension.dimensionId),
2923
+ ...(dimension.aliases?.length ? { aliases: dimension.aliases } : {}),
2924
+ ...(metricCandidate.domain ? { domain: metricCandidate.domain } : {}),
2925
+ ...(metricCandidate.semanticModel ? { semanticModel: metricCandidate.semanticModel } : {}),
2926
+ relevanceScore: metricCandidate.relevanceScore,
2927
+ matchReasons: ['server-issued snapshot capability binding'],
2928
+ compatibility: 'compatible',
2929
+ eligible: true,
2103
2930
  };
2104
2931
  }
2932
+ /**
2933
+ * Validate a selected display/grouping member against the selected metric's
2934
+ * physical semantic capability. The candidate must already have been offered
2935
+ * by the server; this only determines which typed role it can complete.
2936
+ */
2937
+ function structuredDimensionSelection(selected, candidates, requirements) {
2938
+ if (selected.kind !== 'semantic_member'
2939
+ || selected.eligible === false
2940
+ || selected.compatibility === 'incompatible')
2941
+ return undefined;
2942
+ const roles = evidenceCandidateRoles(selected);
2943
+ if (!roles.some((role) => role === 'entity_label'
2944
+ || role === 'categorical_dimension'
2945
+ || role === 'time_dimension'))
2946
+ return undefined;
2947
+ const metricCandidate = uniqueStructuredSelectionMetric(candidates, requirements);
2948
+ if (!metricCandidate)
2949
+ return undefined;
2950
+ const dimension = resolveMetricCapabilityDimension(metricCandidate, selected.qualifiedId ?? selected.id);
2951
+ if (!dimension)
2952
+ return undefined;
2953
+ // A top/bottom clarification must select an authored rankable entity, not a
2954
+ // filter-only field. For ordinary dimensional requests, grouping is enough.
2955
+ if (requirements.ranking && !dimension.supportedRoles.includes('rank_entity'))
2956
+ return undefined;
2957
+ if (!requirements.ranking
2958
+ && !dimension.supportedRoles.includes('group_by')
2959
+ && !dimension.supportedRoles.includes('filter'))
2960
+ return undefined;
2961
+ return {
2962
+ kind: 'dimension',
2963
+ metricCandidate,
2964
+ selectedDimensionId: selected.id,
2965
+ dimensionId: dimension.dimensionId,
2966
+ };
2967
+ }
2968
+ function compatibleStructuredClarificationCandidates(candidates, requirements) {
2969
+ return candidates.filter((candidate) => candidateOwnsExplicitClarificationRoles(candidate, requirements)
2970
+ || Boolean(structuredDimensionSelection(candidate, candidates, requirements)));
2971
+ }
2105
2972
  function validateStructuredClarificationSelection(input) {
2106
2973
  const persisted = persistedClarificationSelectionContext(input.request);
2107
- const sourceQuestion = input.request.clarificationSourceQuestion?.trim() || input.request.question;
2108
- const requirements = mergeClarificationRequirements(
2109
2974
  // A click/reload continuation is an identity action. Use the original user
2110
- // question (and, when available, the server-persisted typed contract), not
2111
- // arbitrary inherited parser hints from an unrelated previous turn.
2112
- buildAnalyticalRequirementSet({ question: sourceQuestion }), persisted?.requirements);
2113
- const compatible = compatibleClarificationCandidates(input.candidates, requirements);
2975
+ // question and server-persisted typed contract, not arbitrary inherited
2976
+ // parser hints or the rendered option label.
2977
+ const requirements = structuredClarificationRequirements(input.request, persisted);
2978
+ const compatible = compatibleStructuredClarificationCandidates(input.candidates, requirements);
2114
2979
  const choiceIds = compatible.map((candidate) => candidate.id);
2115
2980
  if (!persisted?.serverIssued) {
2116
2981
  return {
@@ -2132,7 +2997,10 @@ function validateStructuredClarificationSelection(input) {
2132
2997
  if (!input.selected) {
2133
2998
  return { ok: false, requirements, choiceIds, reason: 'The selected governed identifier is no longer present in the retrieved snapshot.' };
2134
2999
  }
2135
- if (!candidateOwnsExplicitClarificationRoles(input.selected, requirements)) {
3000
+ const selection = candidateOwnsExplicitClarificationRoles(input.selected, requirements)
3001
+ ? { kind: 'metric' }
3002
+ : structuredDimensionSelection(input.selected, input.candidates, requirements);
3003
+ if (!selection) {
2136
3004
  return {
2137
3005
  ok: false,
2138
3006
  requirements,
@@ -2161,7 +3029,7 @@ function validateStructuredClarificationSelection(input) {
2161
3029
  reason: 'The selected governed identifier is incompatible with the current typed requirement set.',
2162
3030
  };
2163
3031
  }
2164
- return { ok: true, requirements, choiceIds };
3032
+ return { ok: true, requirements, choiceIds, selection };
2165
3033
  }
2166
3034
  function invalidStructuredSelectionDecision(input) {
2167
3035
  const coverage = sourceCoverageFromEvidence(input.evidence, input.candidates);
@@ -2411,7 +3279,68 @@ function preventDegenerateRankingResolution(resolution, evidence, candidates, qu
2411
3279
  : `I found ${selected.name}, but it counts the ranked entity and cannot identify the top individual customers. Which measure should I use for the ranking?`,
2412
3280
  };
2413
3281
  }
2414
- function directResolution(request, evidence, candidate, candidates) {
3282
+ function isSameSnapshotCategoricalExtensionForMetric(candidate, metricCandidate) {
3283
+ const extension = candidate.sameSnapshotRoleExtension;
3284
+ if (!extension
3285
+ || extension.version !== 1
3286
+ || extension.role !== 'categorical_dimension'
3287
+ || (extension.basis !== 'sole_metricflow_grouping_dimension'
3288
+ && extension.basis !== 'exact_metricflow_grouping_dimension')
3289
+ || candidate.kind !== 'semantic_member'
3290
+ || (candidate.qualifiedId ?? candidate.id) !== extension.dimensionId)
3291
+ return false;
3292
+ const metricIds = new Set([
3293
+ metricCandidate.id,
3294
+ metricCandidate.qualifiedId,
3295
+ normalizeEvidenceAnalyticalCapability(metricCandidate).capability?.metricId,
3296
+ ].filter((id) => Boolean(id)));
3297
+ if (!metricIds.has(extension.metricId))
3298
+ return false;
3299
+ const capability = normalizeEvidenceAnalyticalCapability(metricCandidate).capability;
3300
+ return Boolean(capability?.dimensions.some((dimension) => dimension.dimensionId === extension.dimensionId
3301
+ && dimension.supportedRoles.includes('group_by')));
3302
+ }
3303
+ /**
3304
+ * A model chooses from the bounded package, but it cannot remove a unique
3305
+ * host-required categorical grouping whose exact qualified field is already
3306
+ * declared by the selected metric's immutable capability. This is a binding,
3307
+ * not a semantic guess: zero or multiple capability-backed cards leave the
3308
+ * requirement unresolved for the normal clarify/gap path.
3309
+ */
3310
+ function hostBoundCategoricalExtensions(input) {
3311
+ const bound = [];
3312
+ for (const requested of categoricalDimensionRequirementTerms(input.requirements)) {
3313
+ const matches = input.candidates.filter((candidate) => isSameSnapshotCategoricalExtensionForMetric(candidate, input.metricCandidate)
3314
+ && normalizeMetricPhrase(candidate.sameSnapshotRoleExtension.requestedTerm)
3315
+ === normalizeMetricPhrase(requested));
3316
+ if (matches.length === 1)
3317
+ bound.push(matches[0]);
3318
+ }
3319
+ return bound.filter((candidate, index, all) => all.findIndex((item) => item.id === candidate.id) === index);
3320
+ }
3321
+ function directResolution(request, evidence, candidate, candidates, selectedDimensionIds = [], selectedDimensionConceptIds = selectedDimensionIds) {
3322
+ // Keep deterministic/direct routing on the same host-owned tuple as the
3323
+ // model path. Raw parser terms are retrieval hints only: they may not turn a
3324
+ // normalized business alias back into a phantom metric or dimension after
3325
+ // the meaning boundary has been intentionally skipped.
3326
+ const requirementSeed = buildAnalyticalRequirementSeedV1({
3327
+ question: request.question,
3328
+ parsedIntent: evidence.parsedIntent,
3329
+ fiscalCalendar: declaredFiscalCalendar(evidence, candidates),
3330
+ });
3331
+ const hostOwnedEvidence = {
3332
+ ...evidence,
3333
+ parsedIntent: {
3334
+ ...evidence.parsedIntent,
3335
+ measures: requirementSeed.queryIntent.measures,
3336
+ dimensions: requirementSeed.queryIntent.dimensions,
3337
+ filters: requirementSeed.queryIntent.filters,
3338
+ ...(requirementSeed.queryIntent.timeRange ? { timeRange: requirementSeed.queryIntent.timeRange } : {}),
3339
+ ...(requirementSeed.queryIntent.timeGrain ? { timeGrain: requirementSeed.queryIntent.timeGrain } : {}),
3340
+ ...(requirementSeed.queryIntent.order ? { order: requirementSeed.queryIntent.order } : {}),
3341
+ ...(requirementSeed.queryIntent.limit !== undefined ? { limit: requirementSeed.queryIntent.limit } : {}),
3342
+ },
3343
+ };
2415
3344
  const inferredQuestionType = questionTypeFromText(request.question);
2416
3345
  const questionType = inferredQuestionType === 'definition'
2417
3346
  && candidate.kind === 'semantic_metric'
@@ -2421,16 +3350,40 @@ function directResolution(request, evidence, candidate, candidates) {
2421
3350
  && !/\b(?:define|definition|meaning|mean)\b/i.test(request.question)
2422
3351
  ? 'value'
2423
3352
  : inferredQuestionType;
2424
- const metricCandidates = explicitlyRequestedMetricCandidates(request.question, evidence, candidate, candidates);
3353
+ const metricCandidates = explicitlyRequestedMetricCandidates(request.question, hostOwnedEvidence, candidate, candidates);
3354
+ const requirements = requirementSeed.requirements;
3355
+ const hostBoundExtensions = hostBoundCategoricalExtensions({
3356
+ metricCandidate: candidate,
3357
+ candidates,
3358
+ requirements,
3359
+ });
3360
+ const resolvedSelectedDimensionIds = [...new Set([
3361
+ ...selectedDimensionIds,
3362
+ ...hostBoundExtensions.map((item) => item.qualifiedId ?? item.id),
3363
+ ])];
2425
3364
  const analyticalFrame = buildDeterministicAnalyticalFrame({
2426
3365
  question: request.question,
2427
3366
  questionType,
2428
- evidence,
3367
+ evidence: hostOwnedEvidence,
2429
3368
  metricCandidate: candidate,
2430
3369
  metricCandidates,
3370
+ entityTerms: requirements.entityTerms,
3371
+ entityDisplayTerms: requirements.entityDisplayTerms,
3372
+ selectedDimensionIds: resolvedSelectedDimensionIds,
2431
3373
  candidates,
2432
3374
  });
2433
- const defaultIntent = defaultQueryIntent(evidence);
3375
+ const defaultIntent = defaultQueryIntent(hostOwnedEvidence);
3376
+ // `top` without a count is a deterministic product convention, not a
3377
+ // missing business meaning. Bind the default into the direct execution
3378
+ // nomination while the typed requirements retain `defaultedLimit: true` for
3379
+ // the answer/receipt to disclose the assumption.
3380
+ const rankedDefaultIntent = requirements.ranking
3381
+ ? {
3382
+ ...defaultIntent,
3383
+ order: defaultIntent.order ?? (requirements.ranking.direction === 'bottom' ? 'asc' : 'desc'),
3384
+ limit: defaultIntent.limit ?? requirements.ranking.limit,
3385
+ }
3386
+ : defaultIntent;
2434
3387
  // An exact authored certified example has already proved this block's own
2435
3388
  // output contract. Parser wording such as "food and drink" describes the
2436
3389
  // values of the block's declared `category` output; it must not manufacture
@@ -2444,15 +3397,45 @@ function directResolution(request, evidence, candidate, candidates) {
2444
3397
  && certifiedCandidateExplicitlyCoversMeasures(candidate, defaultIntent.measures)
2445
3398
  && (candidate.dimensions?.length ?? 0) > 0;
2446
3399
  const queryIntent = exactCertifiedExample
2447
- ? { ...defaultIntent, dimensions: candidate.dimensions ?? [] }
2448
- : defaultIntent;
3400
+ ? { ...rankedDefaultIntent, dimensions: candidate.dimensions ?? [] }
3401
+ : rankedDefaultIntent;
3402
+ // Preserve a selected semantic field as a typed dimension binding rather
3403
+ // than folding its label into the question. The ID comes from the selected
3404
+ // metric's capability contract, so the immutable plan will revalidate it
3405
+ // against the same snapshot before it can freeze.
3406
+ const selectedQueryDimensions = analyticalFrame?.dimensions
3407
+ // Server-issued structured selections may add their qualified identity to
3408
+ // the intent carrier. A host-bound same-snapshot extension instead binds
3409
+ // through the V2 frame below; adding its ID as a second text dimension
3410
+ // would create a duplicate request (`region` plus its qualified field).
3411
+ .filter((binding) => selectedDimensionIds.includes(binding.dimensionId))
3412
+ .map((binding) => binding.dimensionId) ?? [];
3413
+ // A direct host resolution can bind a categorical capability dimension from
3414
+ // the current question without a model-selected member card. Carry the
3415
+ // *actual* same-snapshot card into the resolution as well, otherwise the
3416
+ // immutable solver sees a valid V2 frame but cannot prove the selected
3417
+ // MetricFlow grouping evidence and incorrectly marks the tuple partial.
3418
+ // This is not a fuzzy name lookup: the candidate must be the dimension's
3419
+ // own qualified identity or the already-recorded same-snapshot extension.
3420
+ const hostBoundDimensionConceptIds = analyticalFrame?.dimensions.flatMap((binding) => hostBoundExtensions
3421
+ .filter((item) => (item.qualifiedId ?? item.id) === binding.dimensionId)
3422
+ .map((item) => item.id)) ?? [];
3423
+ const queryIntentWithSelectedDimensions = selectedQueryDimensions.length > 0
3424
+ ? {
3425
+ ...queryIntent,
3426
+ dimensions: [...new Set([
3427
+ ...queryIntent.dimensions,
3428
+ ...selectedQueryDimensions,
3429
+ ])],
3430
+ }
3431
+ : queryIntent;
2449
3432
  const memberCandidates = candidates.filter((item) => {
2450
3433
  if (item.kind !== 'semantic_member' || item.compatibility === 'incompatible')
2451
3434
  return false;
2452
3435
  const identities = [item.name, ...(item.aliases ?? [])].map(normalizeMetricPhrase).filter(Boolean);
2453
- return queryIntent.filters.some((filter) => identities.includes(normalizeMetricPhrase(filter.value)));
3436
+ return queryIntentWithSelectedDimensions.filters.some((filter) => identities.includes(normalizeMetricPhrase(filter.value)));
2454
3437
  });
2455
- const canonicalFilters = queryIntent.filters.map((filter) => {
3438
+ const canonicalFilters = queryIntentWithSelectedDimensions.filters.map((filter) => {
2456
3439
  const member = memberCandidates.find((item) => [item.name, ...(item.aliases ?? [])]
2457
3440
  .map(normalizeMetricPhrase)
2458
3441
  .includes(normalizeMetricPhrase(filter.value)));
@@ -2461,18 +3444,138 @@ function directResolution(request, evidence, candidate, candidates) {
2461
3444
  return {
2462
3445
  interpretedQuestion: request.question,
2463
3446
  questionType,
2464
- selectedConceptIds: [...metricCandidates, ...memberCandidates].map((item) => item.id),
3447
+ selectedConceptIds: [...metricCandidates, ...memberCandidates]
3448
+ .map((item) => item.id)
3449
+ .concat(selectedDimensionConceptIds, hostBoundDimensionConceptIds)
3450
+ .filter((id, index, all) => all.indexOf(id) === index),
2465
3451
  recommendedExecutionId: candidate.id,
2466
- queryIntent: { ...queryIntent, filters: canonicalFilters },
3452
+ queryIntent: { ...queryIntentWithSelectedDimensions, filters: canonicalFilters },
2467
3453
  rejectedCandidates: [],
2468
3454
  confidence: "high",
2469
3455
  missingInformation: [],
2470
3456
  recommendedRoute: routeForEvidenceCandidate(candidate),
3457
+ // Direct/exact routing deliberately bypasses the model, not the host
3458
+ // meaning boundary. Carry the same immutable seed into plan binding so
3459
+ // display-key projection and downstream receipts cannot fall back to raw
3460
+ // parser wording.
3461
+ hostRequirementSeed: requirementSeed,
3462
+ ...(selectedDimensionIds.length > 0
3463
+ ? { structuredDimensionIds: [...new Set(selectedDimensionIds)] }
3464
+ : {}),
2471
3465
  ...(analyticalFrame ? { analyticalFrame } : {}),
2472
3466
  };
2473
3467
  }
3468
+ /**
3469
+ * Build the V2 execution frame after the one candidate-ID meaning call.
3470
+ *
3471
+ * The model's response has already passed package membership validation. It
3472
+ * may therefore identify a supplied semantic metric and supplied semantic
3473
+ * members, but it is never allowed to author the frame itself. This helper
3474
+ * rebuilds the frame from the immutable host seed plus those qualified
3475
+ * identities, exactly as {@link directResolution} does for a zero-call path.
3476
+ *
3477
+ * Keeping this boundary here is important: a candidate-only model response
3478
+ * used to bind a perfectly valid semantic tuple into a V1 plan. V1 plans
3479
+ * bypass the immutable analytical execution graph and fall into the legacy
3480
+ * semantic SQL path, where generic aggregation validation cannot prove a
3481
+ * MetricFlow capability. A host-built V2 frame keeps the selected semantic
3482
+ * route compiler-owned without letting the model change the requested tuple.
3483
+ */
3484
+ function attachHostOwnedAnalyticalFrame(input) {
3485
+ const { request, evidence, candidates, requirementSeed, resolution } = input;
3486
+ if (resolution.recommendedRoute !== 'semantic' || resolution.questionType === 'definition') {
3487
+ return resolution;
3488
+ }
3489
+ const matchesIdentity = (candidate, identity) => Boolean(identity && (candidate.id === identity || candidate.qualifiedId === identity));
3490
+ const metricCandidate = candidates.find((candidate) => candidate.kind === 'semantic_metric'
3491
+ && matchesIdentity(candidate, resolution.recommendedExecutionId))
3492
+ ?? candidates.find((candidate) => candidate.kind === 'semantic_metric'
3493
+ && resolution.selectedConceptIds.some((identity) => matchesIdentity(candidate, identity)));
3494
+ if (!metricCandidate || normalizeEvidenceAnalyticalCapability(metricCandidate).status !== 'complete') {
3495
+ return resolution;
3496
+ }
3497
+ // Parsed intent is retrieval evidence only. The deterministic frame reads
3498
+ // the same host-owned seed that the plan will later bind, so stale retrieval
3499
+ // terms cannot re-enter as a metric, dimension, filter, output, or time
3500
+ // constraint merely because the model selected an otherwise valid card.
3501
+ const hostOwnedEvidence = {
3502
+ ...evidence,
3503
+ parsedIntent: {
3504
+ ...evidence.parsedIntent,
3505
+ measures: [...requirementSeed.queryIntent.measures],
3506
+ dimensions: [...requirementSeed.queryIntent.dimensions],
3507
+ filters: requirementSeed.queryIntent.filters.map((filter) => ({ ...filter })),
3508
+ ...(requirementSeed.queryIntent.timeRange
3509
+ ? { timeRange: requirementSeed.queryIntent.timeRange }
3510
+ : {}),
3511
+ ...(requirementSeed.queryIntent.timeGrain
3512
+ ? { timeGrain: requirementSeed.queryIntent.timeGrain }
3513
+ : {}),
3514
+ ...(requirementSeed.queryIntent.order
3515
+ ? { order: requirementSeed.queryIntent.order }
3516
+ : {}),
3517
+ ...(requirementSeed.queryIntent.limit !== undefined
3518
+ ? { limit: requirementSeed.queryIntent.limit }
3519
+ : {}),
3520
+ },
3521
+ };
3522
+ const modelSelectedDimensionIds = resolution.selectedConceptIds.flatMap((identity) => {
3523
+ const candidate = candidates.find((item) => matchesIdentity(item, identity));
3524
+ return candidate?.kind === 'semantic_member'
3525
+ ? [candidate.qualifiedId ?? candidate.id]
3526
+ : [];
3527
+ });
3528
+ const hostBoundExtensions = hostBoundCategoricalExtensions({
3529
+ metricCandidate,
3530
+ candidates,
3531
+ requirements: requirementSeed.requirements,
3532
+ });
3533
+ const selectedDimensionIds = [...new Set([
3534
+ ...modelSelectedDimensionIds,
3535
+ ...hostBoundExtensions.map((candidate) => candidate.qualifiedId ?? candidate.id),
3536
+ ])];
3537
+ const analyticalFrame = buildDeterministicAnalyticalFrame({
3538
+ question: requirementSeed.sourceQuestion,
3539
+ questionType: resolution.questionType,
3540
+ evidence: hostOwnedEvidence,
3541
+ metricCandidate,
3542
+ metricCandidates: explicitlyRequestedMetricCandidates(requirementSeed.sourceQuestion, hostOwnedEvidence, metricCandidate, candidates),
3543
+ entityTerms: requirementSeed.requirements.entityTerms,
3544
+ entityDisplayTerms: requirementSeed.requirements.entityDisplayTerms,
3545
+ selectedDimensionIds,
3546
+ candidates,
3547
+ });
3548
+ if (!analyticalFrame)
3549
+ return resolution;
3550
+ const hostBoundConceptIds = hostBoundExtensions.map((candidate) => candidate.id);
3551
+ const selectedConceptIds = [...new Set([
3552
+ ...resolution.selectedConceptIds,
3553
+ ...hostBoundConceptIds,
3554
+ ])];
3555
+ const overrideReceipts = hostBoundConceptIds.length > 0
3556
+ ? [
3557
+ ...(resolution.overrideReceipts ?? []),
3558
+ {
3559
+ version: 1,
3560
+ field: 'candidate_selection',
3561
+ action: 'host_preserved',
3562
+ reason: 'A unique, same-snapshot MetricFlow grouping field is required by the host-owned categorical dimension and cannot be removed by a model omission.',
3563
+ candidateIds: hostBoundConceptIds,
3564
+ },
3565
+ ]
3566
+ : resolution.overrideReceipts;
3567
+ return {
3568
+ ...resolution,
3569
+ selectedConceptIds,
3570
+ analyticalFrame,
3571
+ ...(overrideReceipts?.length ? { overrideReceipts } : {}),
3572
+ };
3573
+ }
2474
3574
  function explicitlyRequestedMetricCandidates(question, evidence, primary, candidates) {
2475
- const requested = evidence.parsedIntent?.measures ?? [];
3575
+ const requirements = buildAnalyticalRequirementSet({ question, parsedIntent: evidence.parsedIntent });
3576
+ const requested = requirements.ranking?.metricTerms.length
3577
+ ? requirements.ranking.metricTerms
3578
+ : requirements.measures;
2476
3579
  const requestedTerms = [...new Set(requested.map(normalizeMetricPhrase).filter(Boolean))];
2477
3580
  // A single business metric can be retrieved alongside technical dbt measure
2478
3581
  // shims and registry aliases that share its words. Those are execution
@@ -2489,6 +3592,8 @@ function explicitlyRequestedMetricCandidates(question, evidence, primary, candid
2489
3592
  const metrics = candidates.filter((candidate) => {
2490
3593
  if (candidate.kind !== 'semantic_metric' || candidate.compatibility === 'incompatible')
2491
3594
  return false;
3595
+ if (candidateConflictsWithExplicitRankingMeasure(candidate, requirements))
3596
+ return false;
2492
3597
  if (!normalizeEvidenceAnalyticalCapability(candidate).capability)
2493
3598
  return false;
2494
3599
  if (candidate.id === primary.id)
@@ -2607,6 +3712,29 @@ mayAssumeInterpretation = true) {
2607
3712
  }
2608
3713
  return unresolvedAnalyticalPlanDecision(base, evidence, candidates, request.question);
2609
3714
  }
3715
+ /**
3716
+ * A bounded meaning call may return malformed JSON or a syntactically valid
3717
+ * low-confidence response with no candidate binding. It must not override a
3718
+ * plan the host can prove from one exact current-turn semantic identity. The
3719
+ * predicate deliberately requires a frozen executable plan: ordinary metric
3720
+ * ambiguity, a coverage gap, policy denial, and every unfrozen candidate still
3721
+ * take their existing clarification/block paths.
3722
+ */
3723
+ function isFrozenExecutableHostFallback(decision) {
3724
+ return decision.analyticalCascadeDecision?.planFrozen === true
3725
+ && Boolean(decision.resolvedAnalyticalPlan)
3726
+ && !decision.requiresClarification
3727
+ && !decision.terminalOutcome;
3728
+ }
3729
+ function meaningResolutionHasNoBinding(resolution) {
3730
+ return resolution.selectedConceptIds.length === 0
3731
+ && !resolution.recommendedExecutionId
3732
+ && (resolution.emptyCandidateBinding === true || (!resolution.clarifyingQuestion
3733
+ && resolution.recommendedRoute !== 'clarify'
3734
+ && resolution.compatibilityOutcome !== 'clarify'
3735
+ && resolution.missingInformation.length === 0
3736
+ && resolution.rejectedCandidates.length === 0));
3737
+ }
2610
3738
  /** Leaf identity of a governed candidate, ignoring its source qualification. */
2611
3739
  function candidateLeafName(candidate) {
2612
3740
  const identity = candidate.qualifiedId ?? candidate.id;
@@ -2919,7 +4047,11 @@ function deterministicPrePlanClarification(request, base, evidence, candidates)
2919
4047
  // requires a declared calendar at compilation time; this router simply must
2920
4048
  // not manufacture one or report an absence before relational/runtime context
2921
4049
  // has had a chance to bind the declared date role.
2922
- const requestedDimensions = uniqueNormalizedTerms(evidence.parsedIntent?.dimensions ?? [])
4050
+ // The host-owned requirements are the canonical reader tuple. Parsed intent
4051
+ // is only a retrieval seed: carrying its raw phrase here made "sales based
4052
+ // on the region" look like a literal physical dimension even after the
4053
+ // requirement seed had correctly normalized it to `region`.
4054
+ const requestedDimensions = requirements.dimensions
2923
4055
  .filter((dimension) => !/^(?:date|day|week|month|quarter|year|fiscal year|fy\d{2,4})$/.test(dimension));
2924
4056
  const modeledFilterFields = new Set((evidence.parsedIntent?.filters ?? []).flatMap((filter) => candidates.some((candidate) => isCompatibleQualifiedMember(candidate)
2925
4057
  && candidateIdentityTerms(candidate).some((term) => metricTermsMatch(term, normalizeMetricPhrase(filter.value))))
@@ -2984,6 +4116,16 @@ function deterministicPrePlanClarification(request, base, evidence, candidates)
2984
4116
  messagePrefix: `The certified and semantic candidates did not prove ${requestedLabel}.${temporalNote}`,
2985
4117
  });
2986
4118
  }
4119
+ // A single declared alternative can only be assumed when an executable
4120
+ // metric capability in this same snapshot explicitly contains it. A bare
4121
+ // standalone semantic member (for example `location_name` marked as an
4122
+ // alternative for `region`) is evidence of a possible wording, not proof
4123
+ // that it can safely answer this tuple. Without that closure, retain the
4124
+ // concise modeled-gap clarification rather than letting a later meaning
4125
+ // path substitute an unrelated field or metric.
4126
+ if (alternatives.length === 1
4127
+ && declaredDimensionAlternativeCompletesExecutableTuple(alternatives[0], candidates, requirements))
4128
+ return undefined;
2987
4129
  const requestedLabel = missingDimensions.map((term) => `“${term}”`).join(' and ');
2988
4130
  const alternativeLabels = alternatives.map(renderCandidateChoice);
2989
4131
  return {
@@ -3009,6 +4151,22 @@ function deterministicPrePlanClarification(request, base, evidence, candidates)
3009
4151
  }
3010
4152
  return undefined;
3011
4153
  }
4154
+ /**
4155
+ * The broad router terminal witness can carry reader-safe missing-role prose
4156
+ * for several gap classes. Observability persists only the relationship
4157
+ * variant, and only as an enumerated proof requirement, so an export cannot
4158
+ * accidentally turn an unresolved question term into trace content.
4159
+ */
4160
+ function cascadeTerminalRelationshipGap(gap) {
4161
+ if (!gap || gap.code !== 'MISSING_RELATIONSHIP')
4162
+ return undefined;
4163
+ return {
4164
+ version: 1,
4165
+ code: 'MISSING_RELATIONSHIP',
4166
+ requirement: 'certified_relationship_or_allocation_proof',
4167
+ witnessCandidateIds: [...new Set(gap.witnessCandidateIds)].sort().slice(0, 32),
4168
+ };
4169
+ }
3012
4170
  /**
3013
4171
  * A clarification with NO selectable options is unanswerable, and asking it is a
3014
4172
  * dead end rather than a safety measure.
@@ -3249,10 +4407,73 @@ function candidateProvesMetricTerm(candidate, requested) {
3249
4407
  .some((term) => metricTermsMatch(term, requested));
3250
4408
  }
3251
4409
  function candidateIsDeclaredDimensionAlternative(candidate, missingDimensions) {
4410
+ const extension = candidate.sameSnapshotRoleExtension;
4411
+ if (extension?.role === 'categorical_dimension'
4412
+ && (extension.basis === 'sole_metricflow_grouping_dimension'
4413
+ || extension.basis === 'exact_metricflow_grouping_dimension')
4414
+ && missingDimensions.some((requested) => normalizeMetricPhrase(requested) === normalizeMetricPhrase(extension.requestedTerm)))
4415
+ return true;
3252
4416
  const facts = candidate.compatibilityFacts?.map(normalizeMetricPhrase) ?? [];
3253
4417
  return missingDimensions.some((requested) => facts.includes(`alternative for ${requested}`)
3254
4418
  || facts.includes(`dimension alternative for ${requested}`));
3255
4419
  }
4420
+ function declaredDimensionAlternativeCompletesExecutableTuple(alternative, candidates, requirements) {
4421
+ // A substitution is executable only when a metric capability in the same
4422
+ // snapshot names this exact qualified dimension. Do not treat a lone
4423
+ // semantic-member card as an authorization to change the reader's request.
4424
+ if (requirements.measures.length === 0)
4425
+ return false;
4426
+ const alternativeId = alternative.qualifiedId ?? alternative.id;
4427
+ return candidates.some((candidate) => {
4428
+ if (candidate.kind !== 'semantic_metric' || candidate.compatibility === 'incompatible')
4429
+ return false;
4430
+ if (!requirements.measures.every((measure) => candidateProvesMetricTerm(candidate, measure)))
4431
+ return false;
4432
+ const normalized = normalizeEvidenceAnalyticalCapability(candidate);
4433
+ return normalized.status === 'complete'
4434
+ && Boolean(normalized.capability?.dimensions.some((dimension) => dimension.dimensionId === alternativeId));
4435
+ });
4436
+ }
4437
+ /**
4438
+ * A declared dimension alternative is safe only after the immutable plan has
4439
+ * bound that exact qualified field. This makes the one permitted vocabulary
4440
+ * substitution visible to the reader without letting a raw parser phrase or
4441
+ * model response authorize it.
4442
+ */
4443
+ function assumptionsForDeclaredDimensionAlternatives(input) {
4444
+ const requirements = buildAnalyticalRequirementSet({
4445
+ question: input.question,
4446
+ parsedIntent: input.evidence.parsedIntent,
4447
+ });
4448
+ const boundDimensionIds = new Set(input.plan.query.dimensions
4449
+ .filter((binding) => binding.status === 'resolved' && binding.qualifiedId)
4450
+ .map((binding) => binding.qualifiedId));
4451
+ const assumptions = [];
4452
+ for (const requested of requirements.dimensions) {
4453
+ const alternatives = input.candidates
4454
+ .filter(isCompatibleQualifiedMember)
4455
+ .filter((candidate) => candidateIsDeclaredDimensionAlternative(candidate, [requested]))
4456
+ .filter((candidate) => boundDimensionIds.has(candidate.qualifiedId ?? candidate.id));
4457
+ if (alternatives.length !== 1)
4458
+ continue;
4459
+ const alternative = alternatives[0];
4460
+ const extension = alternative.sameSnapshotRoleExtension;
4461
+ const assumption = assumeDominantCandidate({
4462
+ about: 'dimension',
4463
+ candidates: [{
4464
+ id: alternative.qualifiedId ?? alternative.id,
4465
+ label: alternative.name,
4466
+ score: alternative.relevanceScore,
4467
+ }],
4468
+ because: (chosen) => extension
4469
+ ? `“${requested}” is bound to ${chosen.label ?? chosen.id}, the sole same-snapshot MetricFlow grouping field shared by the selected metric.`
4470
+ : `“${requested}” is not modeled directly; ${chosen.label ?? chosen.id} is the sole compatible, declared alternative in this snapshot.`,
4471
+ });
4472
+ if (assumption)
4473
+ assumptions.push(assumption);
4474
+ }
4475
+ return assumptions;
4476
+ }
3256
4477
  /**
3257
4478
  * A missing optional parsed-intent projection must not erase positive metric
3258
4479
  * evidence already retrieved for the question. Only complete, compatible,
@@ -3350,12 +4571,50 @@ function renderCandidateChoice(candidate) {
3350
4571
  * metrics therefore remain ambiguous and continue to clarification/resolution.
3351
4572
  */
3352
4573
  function uniqueExecutableSemanticMetric(evidence, candidates) {
3353
- const requestedTokens = metricTokens(evidence.parsedIntent?.measures ?? []);
4574
+ const requestedMeasures = [...new Set((evidence.parsedIntent?.measures ?? [])
4575
+ .map(normalizeMetricPhrase)
4576
+ .filter(Boolean))];
4577
+ const requestedTokens = metricTokens(requestedMeasures);
3354
4578
  if (requestedTokens.size === 0)
3355
4579
  return undefined;
4580
+ // Do this before capability normalization. Local metadata can retain an
4581
+ // executable semantic metric card while its pre-plan compatibility hint is
4582
+ // still partial/unknown; a selected provider binding correctly reaches the
4583
+ // capability solver in that state. The exact leaf identity is nevertheless
4584
+ // a real, qualified current-snapshot meaning. Dedupe only representations
4585
+ // of the same qualified metric, never two same-named metrics from different
4586
+ // models, and let the immutable cascade prove execution afterwards.
4587
+ if (requestedMeasures.length === 1) {
4588
+ const requested = requestedMeasures[0];
4589
+ const exactByMetricIdentity = new Map();
4590
+ for (const candidate of candidates) {
4591
+ if (candidate.kind !== 'semantic_metric'
4592
+ || candidate.compatibility === 'incompatible'
4593
+ || candidate.eligible === false
4594
+ || !candidateMetricLeafIdentities(candidate).includes(requested))
4595
+ continue;
4596
+ const normalized = normalizeEvidenceAnalyticalCapability(candidate);
4597
+ const metricIdentity = normalized.capability?.metricId
4598
+ ?? candidate.qualifiedId
4599
+ ?? candidate.id;
4600
+ const current = exactByMetricIdentity.get(metricIdentity);
4601
+ if (!current || candidate.relevanceScore > current.relevanceScore) {
4602
+ exactByMetricIdentity.set(metricIdentity, candidate);
4603
+ }
4604
+ }
4605
+ if (exactByMetricIdentity.size === 1) {
4606
+ return [...exactByMetricIdentity.values()][0];
4607
+ }
4608
+ }
3356
4609
  const byMetricId = new Map();
3357
4610
  for (const candidate of candidates) {
3358
- if (candidate.kind !== "semantic_metric" || candidate.compatibility !== "compatible")
4611
+ // Retrieval compatibility is a pre-plan hint. A current snapshot can mark
4612
+ // a MetricFlow metric `unknown`/`partial` until its selected grouping is
4613
+ // bound, even though the capability itself is complete. The direct route
4614
+ // below still solves the full tuple before freeze, so exclude only an
4615
+ // explicitly incompatible metric here rather than turning exact `revenue`
4616
+ // into an artificial choice against `product_revenue`.
4617
+ if (candidate.kind !== "semantic_metric" || candidate.compatibility === "incompatible")
3359
4618
  continue;
3360
4619
  if (/\bdbt\s+measure\b/i.test(candidate.provenance ?? ""))
3361
4620
  continue;
@@ -3374,8 +4633,33 @@ function uniqueExecutableSemanticMetric(evidence, candidates) {
3374
4633
  byMetricId.set(normalized.capability.metricId, candidate);
3375
4634
  }
3376
4635
  }
4636
+ // A malformed/empty candidate-ID meaning response may also safely fall back
4637
+ // when a complete capability proves one exact semantic metric identity from
4638
+ // the current-turn requirement seed. This is intentionally stricter than
4639
+ // the token fallback below: `order_items.revenue` has the leaf identity
4640
+ // `revenue`, whereas `order_items.product_revenue` is a different meaning
4641
+ // and must not force a clarification when the reader asked only for
4642
+ // revenue. Multi-metric questions retain their dedicated exact tuple path.
4643
+ if (requestedMeasures.length === 1) {
4644
+ const requested = requestedMeasures[0];
4645
+ const exactMatches = [...byMetricId.values()].filter((candidate) => candidateMetricLeafIdentities(candidate).includes(requested));
4646
+ if (exactMatches.length === 1)
4647
+ return exactMatches[0];
4648
+ }
3377
4649
  return byMetricId.size === 1 ? [...byMetricId.values()][0] : undefined;
3378
4650
  }
4651
+ function candidateMetricLeafIdentities(candidate) {
4652
+ return [...new Set([
4653
+ candidate.id,
4654
+ candidate.qualifiedId ?? '',
4655
+ candidate.name,
4656
+ ...(candidate.aliases ?? []),
4657
+ ].flatMap((identity) => {
4658
+ const normalized = normalizeMetricPhrase(identity);
4659
+ const leaf = normalizeMetricPhrase(identity.split(/[.:/]/).at(-1) ?? identity);
4660
+ return [normalized, leaf].filter(Boolean);
4661
+ }))];
4662
+ }
3379
4663
  function metricTokens(values) {
3380
4664
  const ignored = new Set([
3381
4665
  "a", "an", "and", "as", "at", "by", "current", "for", "from", "is",
@@ -3478,6 +4762,78 @@ function rethrowCancellation(error, ...signals) {
3478
4762
  if (error instanceof Error && error.name === "AbortError")
3479
4763
  throw error;
3480
4764
  }
4765
+ /**
4766
+ * Admit the one narrowly typed extension produced by metadata only when it
4767
+ * closes an actually unmet requested categorical role and its source metric is
4768
+ * already in the compact meaning package. The extension was derived from the
4769
+ * same immutable metric capability snapshot; this function does not search,
4770
+ * infer a join, or create an alias. Multiple candidates for a role stay an
4771
+ * ambiguity instead of being silently ranked into a new business meaning.
4772
+ */
4773
+ function sameSnapshotRoleTargetedMeaningExtensions(input) {
4774
+ const extensionsByRole = new Map();
4775
+ const requestedCategoricalTerms = categoricalDimensionRequirementTerms(input.requirements);
4776
+ for (const candidate of input.clarificationCandidates) {
4777
+ const extension = candidate.sameSnapshotRoleExtension;
4778
+ if (!extension
4779
+ || extension.version !== 1
4780
+ || extension.role !== 'categorical_dimension'
4781
+ || (extension.basis !== 'sole_metricflow_grouping_dimension'
4782
+ && extension.basis !== 'exact_metricflow_grouping_dimension')
4783
+ || candidate.kind !== 'semantic_member'
4784
+ || (candidate.qualifiedId ?? candidate.id) !== extension.dimensionId)
4785
+ continue;
4786
+ if (!requestedCategoricalTerms.some((requested) => normalizeMetricPhrase(requested) === normalizeMetricPhrase(extension.requestedTerm)))
4787
+ continue;
4788
+ // A raw/dbt column can lexically satisfy "product category", but it is
4789
+ // not a MetricFlow binding for this semantic metric. Do not let that
4790
+ // broad retrieval hit suppress the exact same-snapshot semantic member:
4791
+ // otherwise a package contains only the raw column, the model cannot bind
4792
+ // the metric-native grouping, and the cascade falsely reports absence.
4793
+ // An already-admitted semantic member remains sufficient and keeps this
4794
+ // extension bounded to genuinely unmet semantic roles.
4795
+ if (input.candidates.some((admitted) => admitted.kind === 'semantic_member'
4796
+ && candidateMatchesCategoricalDimensionRequirement(admitted, [extension.requestedTerm])))
4797
+ continue;
4798
+ const sourceMetricAdmitted = input.candidates.some((admitted) => admitted.kind === 'semantic_metric'
4799
+ && (admitted.id === extension.metricId || admitted.qualifiedId === extension.metricId));
4800
+ if (!sourceMetricAdmitted)
4801
+ continue;
4802
+ const role = normalizeMetricPhrase(extension.requestedTerm);
4803
+ const values = extensionsByRole.get(role) ?? [];
4804
+ if (!values.some((value) => value.id === candidate.id))
4805
+ values.push(candidate);
4806
+ extensionsByRole.set(role, values);
4807
+ }
4808
+ return [...extensionsByRole.values()]
4809
+ .flatMap((values) => values.length === 1 ? values : [])
4810
+ .sort((left, right) => left.id.localeCompare(right.id));
4811
+ }
4812
+ /**
4813
+ * Keep the role-targeted form of a qualified candidate when retrieval has
4814
+ * emitted both the ordinary catalog card and a same-snapshot extension with
4815
+ * the identical stable ID. The extension is not a second object or a new
4816
+ * join: it is the metadata proof explaining why that exact qualified object
4817
+ * closes a currently unmet role for a selected metric. Dropping it merely
4818
+ * because the less-specific catalog card appeared first makes the compact
4819
+ * meaning package lose a declared MetricFlow grouping and can manufacture a
4820
+ * false coverage gap.
4821
+ */
4822
+ function consolidateClarificationCandidates(input) {
4823
+ const byId = new Map();
4824
+ for (const candidate of input.candidates) {
4825
+ if (candidate.eligible === false
4826
+ || candidateConflictsWithExplicitRankingMeasure(candidate, input.requirements))
4827
+ continue;
4828
+ const existing = byId.get(candidate.id);
4829
+ if (!existing || (!existing.sameSnapshotRoleExtension && candidate.sameSnapshotRoleExtension)) {
4830
+ // Map replacement preserves the original stable ordering while retaining
4831
+ // the stricter same-snapshot proof for this exact candidate identity.
4832
+ byId.set(candidate.id, candidate);
4833
+ }
4834
+ }
4835
+ return [...byId.values()];
4836
+ }
3481
4837
  /**
3482
4838
  * Build a retrieval-first hybrid router. Narrow conversational/app preflight is
3483
4839
  * deterministic. Every other turn loads structured evidence before any
@@ -3541,6 +4897,10 @@ export function createHybridRouter(options = {}) {
3541
4897
  // boundary so every downstream plan/meaning path consumes the same
3542
4898
  // typed measure + entity/dimension requirements.
3543
4899
  evidence = withNormalizedAnalyticalRequirements(evidence, request.question);
4900
+ // Keep the complete snapshot result for trace-only lifecycle evidence
4901
+ // before canonicalization and bounded meaning-package admission prune it.
4902
+ const retrievedCandidates = evidence.candidates;
4903
+ traceRetrievalLanesBeforePruning(request, evidence, retrievedCandidates);
3544
4904
  evidence = {
3545
4905
  ...evidence,
3546
4906
  candidates: canonicalizeMetricMeasureCandidates(evidence.candidates),
@@ -3551,26 +4911,99 @@ export function createHybridRouter(options = {}) {
3551
4911
  ]).filter((candidate) => evidence.clarificationCandidates.some((item) => item.id === candidate.id)) }
3552
4912
  : {}),
3553
4913
  };
4914
+ // Ranking measure identity is resolved from the same immutable
4915
+ // request/evidence frame that drives package reservation. A metric
4916
+ // may remain in broad retrieval context, but it cannot become a
4917
+ // clarification or meaning-selection alternative after an explicit
4918
+ // comparator already bound a different ranking measure.
4919
+ const persistedSelection = request.selectedEvidenceId
4920
+ ? persistedClarificationSelectionContext(request)
4921
+ : undefined;
4922
+ // A structured click is a continuation of the original server turn,
4923
+ // not a new parse of its display label. Retain the typed partial frame
4924
+ // (ranking/time included) before candidate pruning can affect it.
4925
+ const continuationQuestion = persistedSelection?.serverIssued && persistedSelection.sourceQuestion
4926
+ ? persistedSelection.sourceQuestion
4927
+ // A free-text turn is always a new source question. A browser may
4928
+ // retain clarificationSourceQuestion for display, but it must never
4929
+ // make stale retrieval intent or a previous result authoritative.
4930
+ : request.question;
4931
+ // Retrieval parser output is never an independent source of request
4932
+ // authority, but it can refine a host-owned source question when every
4933
+ // business term is demonstrably present in that question. In
4934
+ // particular, "active seats" is a legitimate current-turn metric
4935
+ // refinement for "Which workspaces have the most active seats?";
4936
+ // omitting it altogether forced the seed to erase an explicit metric
4937
+ // before the model had a chance only to bind it. The shared
4938
+ // requirement builder rejects stale parser terms (for example a prior
4939
+ // rollover balance, member filter, or top-N), so this remains a
4940
+ // source-question-plus-server-selection tuple rather than retrieval
4941
+ // intent becoming plan authority.
4942
+ // A server-composed child Ask (currently a Research hypothesis) may
4943
+ // carry an exact host-owned seed. Planner prose is not user wording:
4944
+ // reparsing it can turn an asset namespace or explanatory phrase into
4945
+ // a second requested measure. Accept the seed only when it belongs
4946
+ // to this exact source question and there is no structured
4947
+ // continuation to merge; public request parsers never hydrate this
4948
+ // host-only field.
4949
+ const hostSeed = !request.selectedEvidenceId
4950
+ && request.hostRequirementSeed?.version === 1
4951
+ && request.hostRequirementSeed.sourceQuestion === continuationQuestion
4952
+ ? request.hostRequirementSeed
4953
+ : undefined;
4954
+ const sourceQuestionRequirements = hostSeed?.requirements
4955
+ ?? buildAnalyticalRequirementSet({
4956
+ question: continuationQuestion,
4957
+ parsedIntent: evidence.parsedIntent,
4958
+ });
4959
+ const analyticalRequirements = request.selectedEvidenceId
4960
+ ? mergeClarificationRequirements(sourceQuestionRequirements, persistedSelection?.serverIssued ? persistedSelection.requirements : undefined)
4961
+ : sourceQuestionRequirements;
4962
+ // The host freezes the reader's requested tuple before the bounded
4963
+ // meaning call. A model may bind only supplied candidate identities;
4964
+ // it cannot erase explicit outputs, ranking, time, or a new question's
4965
+ // independent scope by rephrasing the request.
4966
+ const requirementSeed = hostSeed
4967
+ ? hostSeed
4968
+ : buildAnalyticalRequirementSeedV1({
4969
+ question: continuationQuestion,
4970
+ parsedIntent: evidence.parsedIntent,
4971
+ requirements: analyticalRequirements,
4972
+ fiscalCalendar: declaredFiscalCalendar(evidence, evidence.candidates),
4973
+ });
3554
4974
  // Retrieval can retain more context internally; only this compact,
3555
4975
  // role-balanced package reaches the one meaning call. The question is
3556
4976
  // supplied so explicit revenue, entity labels, and time roles cannot be
3557
4977
  // pruned by unrelated lexical matches.
3558
- let candidates = buildMeaningEvidencePackage(evidence, options.maxMeaningCandidates ?? 16, request.question);
3559
- // Clarification is local and never provider-bound, so it can inspect the
3560
- // complete already-retrieved set. A raw selectedEvidenceId must be
3561
- // validated against this set before any route can fall through to a
3562
- // generic/generated answer; this is deliberately outside the bounded
3563
- // package length guard so an empty retrieval cannot fail open.
3564
- const clarificationCandidates = [
3565
- ...evidence.candidates,
3566
- ...(evidence.clarificationCandidates ?? []),
3567
- ].filter((candidate, index, all) => candidate.eligible !== false && all.findIndex((other) => other.id === candidate.id) === index);
4978
+ let candidates = buildMeaningEvidencePackage(evidence, options.maxMeaningCandidates ?? 16, continuationQuestion);
4979
+ traceCandidateLifecycleBeforePruning(request, evidence, retrievedCandidates, candidates);
4980
+ // The complete already-retrieved set is retained only to validate a
4981
+ // server-issued stable selection before any route can fall through to
4982
+ // a generic/generated answer. New free-text choices are constrained to
4983
+ // the admitted package below, so a pruned candidate cannot reappear as
4984
+ // an unrelated clarification option.
4985
+ const clarificationCandidates = consolidateClarificationCandidates({
4986
+ candidates: [
4987
+ ...evidence.candidates,
4988
+ ...(evidence.clarificationCandidates ?? []),
4989
+ ],
4990
+ requirements: analyticalRequirements,
4991
+ });
3568
4992
  // A structured clarification selection is identity input, not a new
3569
4993
  // fuzzy-search phrase. Look in BOTH lists: ranking-measure choices are
3570
4994
  // supplemental clarification candidates, not execution candidates.
3571
- const selectedEvidence = request.selectedEvidenceId
4995
+ let selectedEvidence = request.selectedEvidenceId
3572
4996
  ? clarificationCandidates.find((candidate) => candidate.id === request.selectedEvidenceId)
3573
4997
  : undefined;
4998
+ if (!selectedEvidence && request.selectedEvidenceId) {
4999
+ selectedEvidence = rehydrateServerIssuedCapabilityDimensionSelection({
5000
+ request,
5001
+ evidence,
5002
+ candidates: clarificationCandidates,
5003
+ });
5004
+ if (selectedEvidence)
5005
+ clarificationCandidates.push(selectedEvidence);
5006
+ }
3574
5007
  const structuredSelection = request.selectedEvidenceId
3575
5008
  ? validateStructuredClarificationSelection({
3576
5009
  request,
@@ -3589,10 +5022,77 @@ export function createHybridRouter(options = {}) {
3589
5022
  validation: structuredSelection,
3590
5023
  });
3591
5024
  }
3592
- if (selectedEvidence && !candidates.some((candidate) => candidate.id === selectedEvidence.id)) {
3593
- candidates = [selectedEvidence, ...candidates.filter((candidate) => candidate.id !== selectedEvidence.id)]
3594
- .slice(0, options.maxMeaningCandidates ?? 16);
5025
+ const selectedDimensionBinding = structuredSelection?.ok
5026
+ && structuredSelection.selection.kind === 'dimension'
5027
+ ? structuredSelection.selection
5028
+ : undefined;
5029
+ const stableSelectionCandidates = [
5030
+ ...(selectedDimensionBinding ? [selectedDimensionBinding.metricCandidate] : []),
5031
+ ...(selectedEvidence ? [selectedEvidence] : []),
5032
+ ].filter((candidate, index, all) => all.findIndex((item) => item.id === candidate.id) === index);
5033
+ // One host-authored, role-targeted extension may enter the same
5034
+ // snapshot package before absence is declared. It is eligible only
5035
+ // when a selected/retrieved semantic metric itself proves one unique
5036
+ // MetricFlow grouping dimension for the unmet business role. This is
5037
+ // not a lexical location→region rule and never creates a raw join.
5038
+ const roleTargetedExtensions = sameSnapshotRoleTargetedMeaningExtensions({
5039
+ candidates,
5040
+ clarificationCandidates,
5041
+ requirements: analyticalRequirements,
5042
+ });
5043
+ const extensions = [
5044
+ ...stableSelectionCandidates,
5045
+ ...roleTargetedExtensions,
5046
+ ].filter((candidate, index, all) => all.findIndex((item) => item.id === candidate.id) === index
5047
+ // A role-targeted card may intentionally share the stable ID of an
5048
+ // unscoped catalog card already admitted by generic relevance. In
5049
+ // that case replace the card in-place below so the provider sees the
5050
+ // same qualified object plus the immutable role proof; do not drop
5051
+ // the proof just because its base record was admitted first.
5052
+ && (() => {
5053
+ const existing = candidates.find((item) => item.id === candidate.id);
5054
+ return !existing || (!existing.sameSnapshotRoleExtension && candidate.sameSnapshotRoleExtension);
5055
+ })());
5056
+ if (extensions.length > 0) {
5057
+ // Keep the exact metric and the separately selected semantic member
5058
+ // together. The choice never becomes a synthetic metric merely to
5059
+ // fit the bounded meaning package.
5060
+ candidates = [...extensions, ...candidates.filter((candidate) => !extensions.some((extension) => extension.id === candidate.id))]
5061
+ .slice(0, Math.max(1, Math.min(16, options.maxMeaningCandidates ?? 16)));
5062
+ const observer = askTraceObserverForV1(request);
5063
+ for (const extension of extensions) {
5064
+ const roles = evidenceCandidateRoles(extension);
5065
+ for (const role of roles) {
5066
+ observer.recordCandidateDecision({
5067
+ // This must stay on the router's stable resolver identity. The
5068
+ // qualifiedId is display/provenance data and can differ from the
5069
+ // candidate id used by meaning validation and model selection.
5070
+ candidateId: extension.id,
5071
+ role,
5072
+ source: traceSourceForCandidate(extension),
5073
+ decision: 'extended',
5074
+ reasonCode: 'same_snapshot_extension',
5075
+ compatibilityCode: 'unknown',
5076
+ });
5077
+ }
5078
+ }
3595
5079
  }
5080
+ // Only package-admitted, role-compatible candidates may become a new
5081
+ // free-text clarification option. The complete snapshot is retained
5082
+ // above exclusively to validate a server-issued stable selection or
5083
+ // to prove a terminal safety boundary; it must never let a pruned,
5084
+ // unrelated metric re-enter the answer flow as a choice.
5085
+ const admittedClarificationCandidates = candidates.filter((candidate) => candidate.eligible !== false
5086
+ && candidate.compatibility !== 'incompatible'
5087
+ && !candidateConflictsWithExplicitRankingMeasure(candidate, analyticalRequirements));
5088
+ const attributionGap = attributionRequiredRelationshipGapDecision({
5089
+ request,
5090
+ base,
5091
+ evidence,
5092
+ requirements: analyticalRequirements,
5093
+ });
5094
+ if (attributionGap)
5095
+ return attributionGap;
3596
5096
  // The compact meaning package can be empty when every ranked card was
3597
5097
  // reserved for a role that the parser marked missing. A unique authored
3598
5098
  // certified example is still an authoritative snapshot fact, so it
@@ -3614,7 +5114,13 @@ export function createHybridRouter(options = {}) {
3614
5114
  const fiscalClarification = fiscalCalendarClarification(request, base, evidence, candidates);
3615
5115
  if (fiscalClarification)
3616
5116
  return fiscalClarification;
3617
- const explicit = selectedEvidence ?? findExplicitEvidenceReference(request.question, candidates);
5117
+ // A selected display/grouping field completes a persisted frame; the
5118
+ // matched semantic metric remains the only execution/measure
5119
+ // authority. A selected dimension must therefore never flow through
5120
+ // the metric-only direct-resolution path as the primary candidate.
5121
+ const explicit = selectedDimensionBinding?.metricCandidate
5122
+ ?? selectedEvidence
5123
+ ?? findExplicitEvidenceReference(request.question, candidates);
3618
5124
  const explicitMeaningBinding = Boolean(explicit && (request.selectedEvidenceId
3619
5125
  || /@(metric|block|model|table|column)\(/i.test(request.question)));
3620
5126
  const shouldUseMeaningCall = requireMeaningCall
@@ -3627,7 +5133,7 @@ export function createHybridRouter(options = {}) {
3627
5133
  // execution shim as the answer and never let the meaning model see
3628
5134
  // the ranking entity/measure distinction.
3629
5135
  if (!shouldUseMeaningCall && !explicitMeaningBinding) {
3630
- const deterministicClarification = deterministicPrePlanClarification(request, base, evidence, clarificationCandidates);
5136
+ const deterministicClarification = deterministicPrePlanClarification(request, base, evidence, admittedClarificationCandidates);
3631
5137
  if (deterministicClarification)
3632
5138
  return deterministicClarification;
3633
5139
  }
@@ -3638,7 +5144,7 @@ export function createHybridRouter(options = {}) {
3638
5144
  && !hasExplicitRankingMeasure(request.question, evidence)) {
3639
5145
  return rankingMetricChoiceDecision(base, evidence, candidates, explicit, request.question);
3640
5146
  }
3641
- const decision = routeDecisionForResolution(base, evidence, candidates, directResolution(request, evidence, explicit, candidates), "heuristic", request.question, options.resolvedPlanMode ?? 'authoritative');
5147
+ const decision = routeDecisionForResolution(base, evidence, candidates, directResolution(request, evidence, explicit, candidates, selectedDimensionBinding ? [selectedDimensionBinding.dimensionId] : [], selectedDimensionBinding ? [selectedDimensionBinding.selectedDimensionId] : []), "heuristic", request.question, options.resolvedPlanMode ?? 'authoritative');
3642
5148
  // A valid stable semantic choice may still be pre-freeze-ineligible
3643
5149
  // in this local snapshot (for example, the semantic adapter cannot
3644
5150
  // execute the selected tuple). That is a normal cascade condition:
@@ -3648,10 +5154,16 @@ export function createHybridRouter(options = {}) {
3648
5154
  const selectedPreFreezeModelingGap = decision.terminalOutcome?.kind === 'modeling_gap'
3649
5155
  && decision.resolvedAnalyticalPlan?.capability === 'blocked'
3650
5156
  && decision.analyticalCascadeDecision?.planFrozen === false;
3651
- return selectedEvidence
5157
+ return explicit
3652
5158
  && structuredSelection?.ok
3653
5159
  && (decision.requiresClarification || selectedPreFreezeModelingGap)
3654
- ? continueCascadeAfterIncompleteSelection(base, evidence, candidates, selectedEvidence, request.question)
5160
+ ? continueCascadeAfterIncompleteSelection(base, evidence, candidates,
5161
+ // Preserve the display-key identity the user clicked in
5162
+ // user-facing gap/continuation evidence; the paired metric
5163
+ // remains recorded below as execution provenance.
5164
+ selectedEvidence ?? explicit, request.question, selectedDimensionBinding
5165
+ ? [selectedDimensionBinding.metricCandidate.id, selectedDimensionBinding.selectedDimensionId]
5166
+ : [explicit.id])
3655
5167
  : decision;
3656
5168
  }
3657
5169
  const multiMetricPrimary = !shouldUseMeaningCall
@@ -3690,38 +5202,90 @@ export function createHybridRouter(options = {}) {
3690
5202
  if (request.runBudget && !request.runBudget.mayStartDiscovery('clarify')) {
3691
5203
  return softBoundaryDecision(request, base, 'clarify');
3692
5204
  }
3693
- const resolution = options.resolveMeaning
3694
- ? await options.resolveMeaning({
3695
- question: request.question,
3696
- history: effectiveConversationHistory(request),
3697
- // The resolver/provider receives the same bounded evidence
3698
- // package as its candidate argument. Supplemental qualified
3699
- // cards are a host-only clarification aid and must not leak
3700
- // through this richer carrier.
3701
- evidence: {
3702
- ...evidence,
5205
+ let resolution;
5206
+ try {
5207
+ resolution = options.resolveMeaning
5208
+ ? await options.resolveMeaning({
5209
+ question: requirementSeed.sourceQuestion,
5210
+ history: request.conversationBinding && request.conversationBinding !== 'none'
5211
+ ? effectiveConversationHistory(request)
5212
+ : undefined,
5213
+ // The resolver/provider receives the same bounded evidence
5214
+ // package as its candidate argument. Supplemental qualified
5215
+ // cards are a host-only clarification aid and must not leak
5216
+ // through this richer carrier.
5217
+ evidence: {
5218
+ ...evidence,
5219
+ candidates,
5220
+ clarificationCandidates: undefined,
5221
+ },
3703
5222
  candidates,
3704
- clarificationCandidates: undefined,
3705
- },
3706
- candidates,
3707
- signal: request.signal ?? options.signal,
3708
- })
3709
- : options.complete
3710
- ? parseMeaningResolution(await options.complete({
3711
- system: buildMeaningSystemPrompt(),
3712
- user: buildMeaningUserPrompt(request, evidence, candidates),
5223
+ requirementSeed,
3713
5224
  signal: request.signal ?? options.signal,
3714
- }))
3715
- : undefined;
5225
+ })
5226
+ : options.complete
5227
+ ? parseMeaningResolution(await options.complete({
5228
+ system: buildMeaningSystemPrompt(),
5229
+ user: buildMeaningUserPrompt(request, evidence, candidates, requirementSeed),
5230
+ signal: request.signal ?? options.signal,
5231
+ request,
5232
+ phase: 'meaning_resolution',
5233
+ }), requirementSeed)
5234
+ : undefined;
5235
+ }
5236
+ catch (error) {
5237
+ throw error;
5238
+ }
3716
5239
  if (resolution) {
5240
+ // A model is allowed to say that it cannot bind any supplied
5241
+ // card, but that is not a new business ambiguity by itself. If
5242
+ // the host can prove one exact current-turn semantic identity,
5243
+ // take the ordinary direct/cascade path instead of preserving an
5244
+ // empty selection as a clarification. This is intentionally
5245
+ // evaluated before merge/validation so the host never treats an
5246
+ // empty provider response as an authoritative analytical frame.
5247
+ if (meaningResolutionHasNoBinding(resolution)) {
5248
+ const hostFallback = routeWithoutMeaningModel(request, base, evidence, candidates, options.resolvedPlanMode ?? 'authoritative', true);
5249
+ if (isFrozenExecutableHostFallback(hostFallback)) {
5250
+ return remember(key, hostFallback);
5251
+ }
5252
+ }
5253
+ // Merge exactly once at the model boundary. Candidate IDs remain
5254
+ // subject to the bounded-package validator below; model route,
5255
+ // SQL-adjacent frame, rephrased question, and query intent never
5256
+ // become downstream authority.
5257
+ resolution = mergeMeaningResolutionWithRequirementSeed({
5258
+ seed: requirementSeed,
5259
+ resolution,
5260
+ candidates,
5261
+ });
3717
5262
  // Repair an incomplete certified nomination before generic
3718
5263
  // evidence validation. This preserves the real semantic choices
3719
5264
  // instead of turning a false certified selection into an opaque
3720
5265
  // invalid-evidence gap.
3721
5266
  const certifiedSafeResolution = repairIncompleteCertifiedMeasureSelection(resolution, evidence, candidates);
3722
- const validated = validateMeaningResolution(certifiedSafeResolution, candidates, evidence.parsedIntent?.measures ?? certifiedSafeResolution.queryIntent.measures);
5267
+ const validated = validateMeaningResolution(certifiedSafeResolution, candidates, evidence.parsedIntent?.measures ?? certifiedSafeResolution.queryIntent.measures, { requirements: analyticalRequirements });
3723
5268
  if (validated.ok) {
3724
- const safeResolution = preventDegenerateRankingResolution(validated.resolution, evidence, candidates, request.question);
5269
+ // The meaning model may select only supplied candidate IDs. It
5270
+ // intentionally cannot return an analytical frame, because a
5271
+ // model-owned frame could add a metric, dimension, output, or
5272
+ // prior-turn scope after the host froze the requirement seed.
5273
+ //
5274
+ // Do not leave that candidate-only result on the legacy V1
5275
+ // path, though: a valid selected semantic metric plus its
5276
+ // selected supplied members must receive the same *host-built*
5277
+ // V2 frame as a zero-call/direct resolution. Otherwise the
5278
+ // router freezes a V1 semantic plan, the answer loop skips the
5279
+ // immutable semantic execution graph, and legacy SQL is
5280
+ // incorrectly checked against generic aggregation metadata.
5281
+ const frameBoundResolution = attachHostOwnedAnalyticalFrame({
5282
+ request,
5283
+ evidence,
5284
+ candidates,
5285
+ requirementSeed,
5286
+ resolution: validated.resolution,
5287
+ });
5288
+ const safeResolution = preventDegenerateRankingResolution(frameBoundResolution, evidence, candidates, request.question);
3725
5289
  // Meaning interpretation is still required for a fresh turn,
3726
5290
  // but it cannot invent a ranking measure when the user only
3727
5291
  // supplied an entity. Preserve the precise follow-up after the
@@ -3770,24 +5334,27 @@ export function createHybridRouter(options = {}) {
3770
5334
  .some((item) => /measure|metric/i.test(item));
3771
5335
  const assumedRanking = resolverFlagged
3772
5336
  ? undefined
3773
- : assumableRankingMeasure(request.question, clarificationCandidates);
5337
+ : assumableRankingMeasure(request.question, admittedClarificationCandidates);
3774
5338
  if (assumedRanking) {
3775
5339
  return {
3776
5340
  ...routeDecisionForResolution(base, evidence, candidates, directResolution(request, evidence, assumedRanking.candidate, candidates), 'heuristic', request.question),
3777
5341
  assumptions: [assumedRanking.assumption],
3778
5342
  };
3779
5343
  }
3780
- return bareRankingClarification(base, retrievalTrace(evidence, candidates), request.question, evidence,
3781
- // Supplemental clarification cards carry the ranking
3782
- // measures for the requested entity, which the execution
3783
- // candidate set deliberately does not.
3784
- clarificationCandidates, !resolverFlagged);
5344
+ return bareRankingClarification(base, retrievalTrace(evidence, candidates), request.question, evidence, admittedClarificationCandidates, !resolverFlagged);
3785
5345
  }
3786
- const deterministicGap = deterministicPrePlanClarification(request, base, evidence, clarificationCandidates);
5346
+ const deterministicGap = deterministicPrePlanClarification(request, base, evidence, admittedClarificationCandidates);
3787
5347
  if (deterministicGap && safeResolution.recommendedRoute === 'clarify') {
3788
5348
  return deterministicGap;
3789
5349
  }
3790
- return remember(key, routeDecisionForResolution(base, evidence, candidates, safeResolution, "llm", request.question, options.resolvedPlanMode ?? 'authoritative'));
5350
+ const meaningDecision = routeDecisionForResolution(base, evidence, candidates, safeResolution, "llm", request.question, options.resolvedPlanMode ?? 'authoritative');
5351
+ return remember(key, continuePreFreezeModelingGapThroughPhysicalSnapshot({
5352
+ decision: meaningDecision,
5353
+ base,
5354
+ evidence,
5355
+ candidates,
5356
+ question: request.question,
5357
+ }));
3791
5358
  }
3792
5359
  const invalidResolution = {
3793
5360
  interpretedQuestion: request.question,
@@ -3823,11 +5390,14 @@ export function createHybridRouter(options = {}) {
3823
5390
  const fallbackDecision = routeWithoutMeaningModel(request, base, evidence, candidates, options.resolvedPlanMode ?? 'authoritative', meaningResolverReachable);
3824
5391
  if (!shouldUseMeaningCall)
3825
5392
  return fallbackDecision;
5393
+ if (isFrozenExecutableHostFallback(fallbackDecision)) {
5394
+ return fallbackDecision;
5395
+ }
3826
5396
  // The provider was unavailable or returned malformed JSON. Apply the
3827
5397
  // deterministic clarification only after the bounded meaning attempt
3828
5398
  // has been exhausted; this preserves a precise recovery path without
3829
5399
  // allowing the generic governed error to terminate the question.
3830
- return deterministicPrePlanClarification(request, base, evidence, clarificationCandidates)
5400
+ return deterministicPrePlanClarification(request, base, evidence, admittedClarificationCandidates)
3831
5401
  ?? fallbackDecision;
3832
5402
  }
3833
5403
  }
@@ -3890,6 +5460,8 @@ export function createHybridRouter(options = {}) {
3890
5460
  system: buildSystemPrompt(),
3891
5461
  user: buildUserPrompt(request, catalogContext),
3892
5462
  signal: request.signal ?? options.signal,
5463
+ request,
5464
+ phase: 'classification',
3893
5465
  });
3894
5466
  const classification = parseClassification(raw);
3895
5467
  if (classification)