@duckcodeailabs/dql-agent 1.8.1 → 1.8.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/dist/agent-run-engine.d.ts +11 -1
  2. package/dist/agent-run-engine.d.ts.map +1 -1
  3. package/dist/agent-run-engine.js +13 -2
  4. package/dist/agent-run-engine.js.map +1 -1
  5. package/dist/agent-run-gates.d.ts.map +1 -1
  6. package/dist/agent-run-gates.js +2 -1
  7. package/dist/agent-run-gates.js.map +1 -1
  8. package/dist/agent-run-store.d.ts +33 -0
  9. package/dist/agent-run-store.d.ts.map +1 -0
  10. package/dist/agent-run-store.js +167 -0
  11. package/dist/agent-run-store.js.map +1 -0
  12. package/dist/answer-loop.d.ts +32 -3
  13. package/dist/answer-loop.d.ts.map +1 -1
  14. package/dist/answer-loop.js +496 -49
  15. package/dist/answer-loop.js.map +1 -1
  16. package/dist/cascade/budgets.d.ts +3 -1
  17. package/dist/cascade/budgets.d.ts.map +1 -1
  18. package/dist/cascade/budgets.js +10 -1
  19. package/dist/cascade/budgets.js.map +1 -1
  20. package/dist/grounding/context-ledger.d.ts +4 -0
  21. package/dist/grounding/context-ledger.d.ts.map +1 -1
  22. package/dist/grounding/context-ledger.js.map +1 -1
  23. package/dist/index.d.ts +5 -3
  24. package/dist/index.d.ts.map +1 -1
  25. package/dist/index.js +4 -2
  26. package/dist/index.js.map +1 -1
  27. package/dist/intent-controller.d.ts +7 -0
  28. package/dist/intent-controller.d.ts.map +1 -1
  29. package/dist/intent-controller.js.map +1 -1
  30. package/dist/metadata/analysis-planner.d.ts +8 -0
  31. package/dist/metadata/analysis-planner.d.ts.map +1 -1
  32. package/dist/metadata/analysis-planner.js +50 -2
  33. package/dist/metadata/analysis-planner.js.map +1 -1
  34. package/dist/metadata/analytical-policy.d.ts +40 -2
  35. package/dist/metadata/analytical-policy.d.ts.map +1 -1
  36. package/dist/metadata/analytical-policy.js +276 -53
  37. package/dist/metadata/analytical-policy.js.map +1 -1
  38. package/dist/metadata/block-fit.js +32 -8
  39. package/dist/metadata/block-fit.js.map +1 -1
  40. package/dist/metadata/catalog.d.ts +7 -0
  41. package/dist/metadata/catalog.d.ts.map +1 -1
  42. package/dist/metadata/catalog.js +57 -3
  43. package/dist/metadata/catalog.js.map +1 -1
  44. package/dist/metadata/dbt-first-safety.d.ts +8 -1
  45. package/dist/metadata/dbt-first-safety.d.ts.map +1 -1
  46. package/dist/metadata/dbt-first-safety.js +6 -1
  47. package/dist/metadata/dbt-first-safety.js.map +1 -1
  48. package/dist/metadata/health.d.ts +19 -0
  49. package/dist/metadata/health.d.ts.map +1 -0
  50. package/dist/metadata/health.js +72 -0
  51. package/dist/metadata/health.js.map +1 -0
  52. package/dist/metadata/sql-context-validation.d.ts +5 -1
  53. package/dist/metadata/sql-context-validation.d.ts.map +1 -1
  54. package/dist/metadata/sql-context-validation.js +93 -0
  55. package/dist/metadata/sql-context-validation.js.map +1 -1
  56. package/dist/router.d.ts.map +1 -1
  57. package/dist/router.js +27 -3
  58. package/dist/router.js.map +1 -1
  59. package/dist/tools/registry.js +9 -9
  60. package/dist/tools/registry.js.map +1 -1
  61. package/package.json +4 -4
@@ -26,7 +26,7 @@ import { createContextLedger } from './grounding/context-ledger.js';
26
26
  import { validateAnswerResultShape } from './answer-shape.js';
27
27
  import { fanoutWarningsForSql } from './metadata/grain-ledger.js';
28
28
  import { evaluateDbtFirstGeneratedSql } from './metadata/dbt-first-safety.js';
29
- import { planAnalyticalPath } from './metadata/analytical-policy.js';
29
+ import { planAnalyticalPath, humanizeAnalyticalEntityId, analyticalPolicyUserFacingReason, } from './metadata/analytical-policy.js';
30
30
  import { planCertifiedAdaptation } from './metadata/block-adapt.js';
31
31
  import { compactSqlSnippet, extractSimpleSelectShape, selectExpressionOutputName, } from './metadata/sql-shape.js';
32
32
  import { composeSemanticQueryForQuestion, composeSemanticQueryFromMembers, } from './semantic-bridge/compose.js';
@@ -158,12 +158,43 @@ const BUSINESS_CONTEXT_KINDS = ['term', 'business_view', 'domain', 'skill', 'rel
158
158
  const ARTIFACT_KINDS = [...EXECUTABLE_ARTIFACT_KINDS, ...BUSINESS_CONTEXT_KINDS];
159
159
  const SEMANTIC_KINDS = ['metric', 'dimension', 'measure', 'entity', 'semantic_model', 'saved_query'];
160
160
  const MANIFEST_KINDS = ['dbt_model', 'dbt_source'];
161
+ /**
162
+ * Business-language explanation for a failed SQL context validation. The chat
163
+ * surface shows THIS; the validator's machine message (relation ids, repair
164
+ * tool guidance) belongs in refusalDetails/validationWarnings only.
165
+ */
166
+ function renderContextValidationRefusalForUser(code, machineError, memberBindings) {
167
+ switch (code) {
168
+ case 'unknown_relation':
169
+ return 'I drafted a query, but it uses a table that was not part of the metadata retrieved for this question, so I did not run it. Ask again (retrieval usually finds it on a follow-up), name the table or metric explicitly, or re-sync the dbt metadata.';
170
+ case 'unknown_column':
171
+ return 'I drafted a query, but it references a column that is not in the inspected metadata for those tables, so I did not run it. Name the exact field you mean, or re-sync the dbt metadata if the column is new.';
172
+ case 'misbound_filter': {
173
+ const binding = memberBindings?.[0];
174
+ const target = binding ? `${humanizeAnalyticalEntityId(binding.dimension)} "${binding.values[0] ?? ''}"` : 'the requested value';
175
+ return `I need to filter this answer to ${target}, but the query I drafted did not apply that filter to a matching column, so I did not run it. Tell me which column identifies ${binding ? humanizeAnalyticalEntityId(binding.dimension) : 'that value'}, or rephrase with the exact field name.`;
176
+ }
177
+ case 'ambiguous_filter':
178
+ return 'The value you asked about matches more than one column in the inspected data, so I did not guess. Tell me which field it belongs to and I will run it.';
179
+ case 'missing_baseline':
180
+ return 'This comparison needs a baseline period or value that the drafted query did not include. Say what to compare against (for example, the prior month) and I will run it.';
181
+ case 'unsafe_sql':
182
+ return 'The drafted query used a statement type that is not allowed in this governed preview, so I did not run it.';
183
+ case 'insufficient_context':
184
+ default:
185
+ return machineError.trim().length > 0 && !/inspect_metadata_context/i.test(machineError)
186
+ ? `I could not prepare a governed query yet: ${machineError.replace(/\s*Use inspect_metadata_context[^.]*\.\s*$/i, '').trim()}`
187
+ : 'I could not prepare a governed query from the retrieved metadata. Name the specific metric or table and how to break it down, and I can generate a review-required draft.';
188
+ }
189
+ }
161
190
  function refusalCodeForValidation(code) {
162
191
  if (code === 'unknown_relation' || code === 'unknown_column' || code === 'insufficient_context' || code === 'missing_baseline') {
163
192
  return 'grounding_gap';
164
193
  }
165
194
  if (code === 'ambiguous_filter')
166
195
  return 'ambiguous';
196
+ if (code === 'misbound_filter')
197
+ return 'grounding_gap';
167
198
  return 'model_declined';
168
199
  }
169
200
  /**
@@ -179,14 +210,34 @@ function refusalCodeForAnalyticalPolicy(code, hasExploratoryCandidate = false) {
179
210
  return 'policy_blocked';
180
211
  }
181
212
  /**
182
- * The governed guard is intentionally strict. Only the two outcomes that mean
213
+ * The governed guard is intentionally strict. Only outcomes that mean
183
214
  * "this repository has not modeled this join yet" may be handed to a host's
184
215
  * exploratory lane. All other decisions are explicit governance/safety
185
216
  * denials and must remain terminal in this loop.
186
217
  */
187
218
  function exploratoryCandidateFromDbtFirstGuard(sql, decision) {
219
+ // The policy service's disposition is authoritative: a declared draft path
220
+ // re-bound to this SQL's actual joins enters the bounded lane directly.
221
+ if (decision.disposition === 'exploratory_candidate') {
222
+ return {
223
+ kind: 'dbt_grounded_exploration',
224
+ reason: 'relationship_not_certified',
225
+ sql,
226
+ message: decision.technicalDetail ?? decision.message
227
+ ?? 'This query follows a declared but uncertified DQL relationship path.',
228
+ userFacingReason: decision.userFacingReason,
229
+ modeledEntityIds: decision.entities,
230
+ relationshipIds: decision.relationshipIds,
231
+ exploratoryPath: decision.exploratoryPath,
232
+ executionStatus: 'not_executed',
233
+ };
234
+ }
188
235
  const reason = decision.code;
189
- if (decision.safe || (reason !== 'unbound_relation' && reason !== 'unplanned_join' && reason !== 'relationship_not_certified')) {
236
+ const missingPathWithoutUnsafeProof = reason === 'unsafe_relationship' && decision.relationshipIds.length === 0;
237
+ if (decision.safe || (!missingPathWithoutUnsafeProof
238
+ && reason !== 'unbound_relation'
239
+ && reason !== 'unplanned_join'
240
+ && reason !== 'relationship_not_certified')) {
190
241
  return undefined;
191
242
  }
192
243
  // `unplanned_join` can also mean the model ignored an existing certified
@@ -201,6 +252,7 @@ function exploratoryCandidateFromDbtFirstGuard(sql, decision) {
201
252
  sql,
202
253
  message: decision.message
203
254
  ?? 'This query is grounded in dbt metadata but has no certified DQL relationship path yet.',
255
+ userFacingReason: decision.userFacingReason,
204
256
  modeledEntityIds: decision.entities,
205
257
  relationshipIds: decision.relationshipIds,
206
258
  executionStatus: 'not_executed',
@@ -914,9 +966,10 @@ async function runAnswerLoop(input) {
914
966
  // generation, which can express the breakdown.
915
967
  const wantedBreakdown = questionPlan.requestedShape.dimensions.length > 0
916
968
  || questionPlan.dimensionTerms.length > 0;
969
+ const requiredGroupingCount = requestedGroupingDimensions(questionPlan).length;
917
970
  const dropsBreakdown = Boolean(composed)
918
971
  && wantedBreakdown
919
- && composed.dimensions.length === 0
972
+ && composed.dimensions.length < Math.max(1, requiredGroupingCount)
920
973
  && !composed.timeDimension;
921
974
  if (composed && !dropsBreakdown) {
922
975
  semanticBridgeAnswer = composed;
@@ -1047,6 +1100,14 @@ async function runAnswerLoop(input) {
1047
1100
  });
1048
1101
  }
1049
1102
  catch (err) {
1103
+ // A request-level cancellation/deadline is orchestration state, not an
1104
+ // upstream-provider outage. Preserve the AbortSignal reason so the run
1105
+ // engine can report a bounded deadline (or explicit user cancellation)
1106
+ // instead of the misleading generic "Provider error" label.
1107
+ if (input.signal?.aborted)
1108
+ throw input.signal.reason ?? err;
1109
+ if (err instanceof Error && (err.name === 'AbortError' || err.name === 'TimeoutError'))
1110
+ throw err;
1050
1111
  const text = `Provider error: ${err.message}`;
1051
1112
  return {
1052
1113
  kind: 'no_answer',
@@ -1119,6 +1180,7 @@ async function runAnswerLoop(input) {
1119
1180
  intent,
1120
1181
  initial: { raw: proposed, parsed },
1121
1182
  contextLedger,
1183
+ followUp: input.followUp,
1122
1184
  executeGeneratedSql: input.executeGeneratedSql,
1123
1185
  signal: input.signal,
1124
1186
  reasoningEffort: input.reasoningEffort,
@@ -1177,7 +1239,8 @@ async function runAnswerLoop(input) {
1177
1239
  || (input.contextPack?.allowedSqlContext?.relations.length ?? 0) > 0
1178
1240
  || (input.contextPack?.allowedSqlContext?.sourceBlockSql.length ?? 0) > 0
1179
1241
  || contextBlocks.length > 0;
1180
- if (!parsed.sql && !governedMetricAnswer && wantsGeneratedData && hasGeneratableContext && analyticalPlan?.safe !== false) {
1242
+ if (!parsed.sql && !governedMetricAnswer && wantsGeneratedData && hasGeneratableContext
1243
+ && (analyticalPlan?.safe !== false || analyticalPlanAllowsDbtExploration(analyticalPlan))) {
1181
1244
  try {
1182
1245
  proposed = await generateProposalWithOptionalTools({
1183
1246
  provider,
@@ -1192,7 +1255,12 @@ async function runAnswerLoop(input) {
1192
1255
  });
1193
1256
  parsed = parseProposal(proposed);
1194
1257
  }
1195
- catch {
1258
+ catch (err) {
1259
+ // Deadline/cancellation must surface as itself, never as a policy refusal.
1260
+ if (input.signal?.aborted)
1261
+ throw input.signal.reason ?? err;
1262
+ if (err instanceof Error && (err.name === 'AbortError' || err.name === 'TimeoutError'))
1263
+ throw err;
1196
1264
  // keep the original decline; fall through to the honest no_answer.
1197
1265
  }
1198
1266
  }
@@ -1205,9 +1273,15 @@ async function runAnswerLoop(input) {
1205
1273
  // consistent, actionable message so the same question yields the same outcome
1206
1274
  // every run and every surface — instead of passing through the model's varying
1207
1275
  // text. A genuinely context-less ask keeps the plain honest message.
1276
+ // The user-facing text is BUSINESS language; the machine policy detail
1277
+ // (qualified ids, codes) lives only in refusalDetails + validationWarnings.
1208
1278
  const declinedDespiteContext = wantsGeneratedData && hasGeneratableContext;
1279
+ const policyDetail = analyticalPlan?.safe === false
1280
+ ? analyticalPlan.technicalDetail ?? analyticalPlan.message ?? 'DQL could not prove a safe analytical relationship path.'
1281
+ : undefined;
1209
1282
  const text = analyticalPlan?.safe === false
1210
- ? analyticalPlan.message ?? 'DQL could not prove a safe analytical relationship path.'
1283
+ ? analyticalPlan.userFacingReason
1284
+ ?? analyticalPolicyUserFacingReason(analyticalPlan.code ?? 'unsafe_relationship', analyticalPlan.entities)
1211
1285
  : declinedDespiteContext
1212
1286
  ? 'I could not compose a governed query for this from the available tables and metrics. This usually needs a clearer join path or an explicit metric and grouping — name the specific measure and how to break it down, and I can generate a review-required draft.'
1213
1287
  : parsed.text || 'No answer (the model declined to propose SQL).';
@@ -1219,14 +1293,17 @@ async function runAnswerLoop(input) {
1219
1293
  confidence: 0.1,
1220
1294
  text,
1221
1295
  refusalCode: analyticalPlan?.safe === false
1222
- ? refusalCodeForAnalyticalPolicy(analyticalPlan.code)
1296
+ ? analyticalPlan.disposition === 'exploratory_candidate'
1297
+ ? 'modeling_gap'
1298
+ : refusalCodeForAnalyticalPolicy(analyticalPlan.code, analyticalPlanAllowsDbtExploration(analyticalPlan))
1223
1299
  : 'model_declined',
1224
1300
  refusalDetails: {
1225
1301
  code: analyticalPlan?.safe === false
1226
1302
  ? analyticalPlan.code ?? 'unsafe_relationship'
1227
1303
  : 'model_declined',
1228
- message: text,
1304
+ message: policyDetail ?? text,
1229
1305
  },
1306
+ validationWarnings: policyDetail ? [`Analytical policy detail: ${policyDetail}`] : undefined,
1230
1307
  answer: text,
1231
1308
  citations: [],
1232
1309
  memoryContext: input.memoryContext,
@@ -1280,16 +1357,66 @@ async function runAnswerLoop(input) {
1280
1357
  intent,
1281
1358
  filterValues: input.followUp?.filters,
1282
1359
  trustedFilterValues: trustedFollowUpFilterValues(input.followUp),
1360
+ memberBindings: input.followUp?.memberBindings,
1283
1361
  });
1284
- contextValidation = initialValidation.ok
1285
- ? { ok: true, warnings: initialValidation.warnings }
1286
- : {
1362
+ const rankedGrainGap = missingRankedGrainOutput(questionPlan, parsed.sql, semanticBridgeAnswer?.dimensions);
1363
+ contextValidation = initialValidation.ok && rankedGrainGap
1364
+ ? {
1287
1365
  ok: false,
1288
- code: initialValidation.code,
1289
- error: initialValidation.error,
1366
+ code: 'insufficient_context',
1367
+ error: 'Generated SQL omits the ranked grouping dimension ' + rankedGrainGap + '. Include every requested ranking dimension in SELECT and GROUP BY before execution.',
1290
1368
  warnings: initialValidation.warnings,
1291
- offending: initialValidation.offending,
1292
- };
1369
+ }
1370
+ : initialValidation.ok
1371
+ ? { ok: true, warnings: initialValidation.warnings }
1372
+ : {
1373
+ ok: false,
1374
+ code: initialValidation.code,
1375
+ error: initialValidation.error,
1376
+ warnings: initialValidation.warnings,
1377
+ offending: initialValidation.offending,
1378
+ };
1379
+ // A canonical member value that resolves to exactly one inspected column is
1380
+ // compiler-owned request state, not creative SQL. If the proposal omitted
1381
+ // that predicate entirely, inject it deterministically before spending the
1382
+ // bounded AI validation repair. Misbound or ambiguous predicates are never
1383
+ // rewritten here; they continue through the guarded correction/refusal path.
1384
+ if (!contextValidation.ok
1385
+ && contextValidation.code === 'misbound_filter'
1386
+ && !contextValidation.offending?.relation
1387
+ && !contextValidation.offending?.column
1388
+ && input.followUp?.memberBindings?.length) {
1389
+ const injected = injectUniqueResolvedMemberBindings(parsed.sql, input.followUp.memberBindings, schemaContext);
1390
+ if (injected) {
1391
+ parsed.sql = injected.sql;
1392
+ const rebound = contextLedger.validateSql(parsed.sql, {
1393
+ question,
1394
+ intent,
1395
+ filterValues: input.followUp?.filters,
1396
+ trustedFilterValues: trustedFollowUpFilterValues(input.followUp),
1397
+ memberBindings: input.followUp.memberBindings,
1398
+ });
1399
+ const reboundGrainGap = rebound.ok
1400
+ ? missingRankedGrainOutput(questionPlan, parsed.sql, semanticBridgeAnswer?.dimensions)
1401
+ : undefined;
1402
+ contextValidation = rebound.ok && !reboundGrainGap
1403
+ ? { ok: true, warnings: [...rebound.warnings, ...injected.notes] }
1404
+ : rebound.ok
1405
+ ? {
1406
+ ok: false,
1407
+ code: 'insufficient_context',
1408
+ error: 'Generated SQL omits the ranked grouping dimension ' + reboundGrainGap + '. Include every requested ranking dimension in SELECT and GROUP BY before execution.',
1409
+ warnings: [...rebound.warnings, ...injected.notes],
1410
+ }
1411
+ : {
1412
+ ok: false,
1413
+ code: rebound.code,
1414
+ error: rebound.error,
1415
+ warnings: [...rebound.warnings, ...injected.notes],
1416
+ offending: rebound.offending,
1417
+ };
1418
+ }
1419
+ }
1293
1420
  // Semantic compilation owns metric meaning, but a later dimensional join can
1294
1421
  // still make a compiled bare expression ambiguous at the warehouse binder.
1295
1422
  // Revoke governed trust for an invalid composition so the bounded repair lane
@@ -1305,9 +1432,12 @@ async function runAnswerLoop(input) {
1305
1432
  // stands instead of guessing a measure.
1306
1433
  // 2) Otherwise fall back to a CLEAN governed-metric definition (direct or exact
1307
1434
  // leaf-measure; no fuzzy family guess that could answer the wrong measure).
1308
- if (!contextValidation.ok && semanticMetricRoute && semanticMetricMatch) {
1435
+ if (!contextValidation.ok && semanticMetricRoute && semanticMetricMatch && !rankedGrainGap) {
1309
1436
  const grounded = contextLedger.validateRuntimeGrounding(parsed.sql);
1310
- if (grounded?.ok) {
1437
+ // Runtime grounding proves that relations and columns exist; it cannot prove
1438
+ // that a resolved business member was bound to the right dimension. Never
1439
+ // let this recovery lane bypass AGT-012's typed member invariant.
1440
+ if (grounded?.ok && !(input.followUp?.memberBindings?.length)) {
1311
1441
  contextValidation = { ok: true, warnings: grounded.warnings };
1312
1442
  }
1313
1443
  else if (scalarGovernedMetricRecoveryAllowed) {
@@ -1346,28 +1476,43 @@ async function runAnswerLoop(input) {
1346
1476
  intent,
1347
1477
  filterValues: input.followUp?.filters,
1348
1478
  trustedFilterValues: trustedFollowUpFilterValues(input.followUp),
1479
+ memberBindings: input.followUp?.memberBindings,
1349
1480
  });
1350
- contextValidation = revalidated.ok
1481
+ const regroundedGrainGap = revalidated.ok
1482
+ ? missingRankedGrainOutput(questionPlan, parsed.sql, semanticBridgeAnswer?.dimensions)
1483
+ : undefined;
1484
+ contextValidation = revalidated.ok && regroundedGrainGap
1351
1485
  ? {
1352
- ok: true,
1353
- warnings: [
1354
- ...revalidated.warnings,
1355
- ...merged.notes.map((note) => `Re-grounded metadata context before repair: ${note}`),
1356
- ],
1357
- }
1358
- : {
1359
1486
  ok: false,
1360
- code: revalidated.code,
1361
- error: revalidated.error,
1362
- warnings: [
1363
- ...revalidated.warnings,
1364
- ...merged.notes.map((note) => `Re-grounded metadata context before repair: ${note}`),
1365
- ],
1366
- offending: revalidated.offending,
1367
- };
1487
+ code: 'insufficient_context',
1488
+ error: 'Generated SQL omits the ranked grouping dimension ' + regroundedGrainGap + '. Include every requested ranking dimension in SELECT and GROUP BY before execution.',
1489
+ warnings: revalidated.warnings,
1490
+ }
1491
+ : revalidated.ok
1492
+ ? {
1493
+ ok: true,
1494
+ warnings: [
1495
+ ...revalidated.warnings,
1496
+ ...merged.notes.map((note) => `Re-grounded metadata context before repair: ${note}`),
1497
+ ],
1498
+ }
1499
+ : {
1500
+ ok: false,
1501
+ code: revalidated.code,
1502
+ error: revalidated.error,
1503
+ warnings: [
1504
+ ...revalidated.warnings,
1505
+ ...merged.notes.map((note) => `Re-grounded metadata context before repair: ${note}`),
1506
+ ],
1507
+ offending: revalidated.offending,
1508
+ };
1368
1509
  }
1369
1510
  }
1370
- catch {
1511
+ catch (err) {
1512
+ if (input.signal?.aborted)
1513
+ throw input.signal.reason ?? err;
1514
+ if (err instanceof Error && (err.name === 'AbortError' || err.name === 'TimeoutError'))
1515
+ throw err;
1371
1516
  // Re-grounding is best-effort; the bounded self-repair below still runs.
1372
1517
  }
1373
1518
  }
@@ -1378,14 +1523,18 @@ async function runAnswerLoop(input) {
1378
1523
  // provider get the same single chance; any provider failure keeps the honest
1379
1524
  // refusal below. This mirrors the engine-level repair loop, applied to the
1380
1525
  // context-validation gate that previously refused on first failure.
1381
- if (!contextValidation.ok && !governedMetricAnswer && canUseLaneRepair(repairBudgetState, 'reground')) {
1382
- recordLaneRepair(repairBudgetState, 'reground');
1526
+ if (!contextValidation.ok && !governedMetricAnswer && canUseLaneRepair(repairBudgetState, 'validation')) {
1527
+ recordLaneRepair(repairBudgetState, 'validation');
1383
1528
  try {
1384
1529
  const failedSql = parsed.sql ?? '';
1385
1530
  const repairPrompt = [
1386
1531
  `Your SQL was rejected before execution: ${contextValidation.error}`,
1387
1532
  formatOffendingValidationToken(contextValidation.offending),
1388
1533
  formatValidationWarningsForPrompt(contextValidation.warnings),
1534
+ renderRequestedShapeForRepair(questionPlan),
1535
+ 'For every required grouping alias, select the best matching inspected business column, project it with that exact alias, and include the same expression in GROUP BY.',
1536
+ 'When the warehouse uses a different physical name (for example a location field for a requested region), keep the inspected physical column and alias it to the requested business name. Do not silently drop the grouping.',
1537
+ 'Preserve every requested dimension and measure that was already correct; change only what the validation error requires.',
1389
1538
  'Correct it using ONLY the relations and columns from the inspected context above.',
1390
1539
  'If a needed column lives on a different relation, JOIN that relation using the suggested join paths.',
1391
1540
  'If the requested column does not exist anywhere in the inspected context, return the closest answerable SQL and say what is missing.',
@@ -1400,8 +1549,12 @@ async function runAnswerLoop(input) {
1400
1549
  intent,
1401
1550
  filterValues: input.followUp?.filters,
1402
1551
  trustedFilterValues: trustedFollowUpFilterValues(input.followUp),
1552
+ memberBindings: input.followUp?.memberBindings,
1403
1553
  });
1404
- if (revalidated.ok) {
1554
+ const repairedGrainGap = revalidated.ok
1555
+ ? missingRankedGrainOutput(questionPlan, reparsed.sql, semanticBridgeAnswer?.dimensions)
1556
+ : undefined;
1557
+ if (revalidated.ok && !repairedGrainGap) {
1405
1558
  const repairedSql = reparsed.sql;
1406
1559
  parsed.sql = repairedSql;
1407
1560
  parsed.text = reparsed.text || parsed.text;
@@ -1414,7 +1567,11 @@ async function runAnswerLoop(input) {
1414
1567
  }
1415
1568
  }
1416
1569
  }
1417
- catch {
1570
+ catch (err) {
1571
+ if (input.signal?.aborted)
1572
+ throw input.signal.reason ?? err;
1573
+ if (err instanceof Error && (err.name === 'AbortError' || err.name === 'TimeoutError'))
1574
+ throw err;
1418
1575
  // Repair is best-effort — the refusal below stays the honest fallback.
1419
1576
  }
1420
1577
  }
@@ -1434,7 +1591,10 @@ async function runAnswerLoop(input) {
1434
1591
  }
1435
1592
  }
1436
1593
  if (!contextValidation.ok) {
1437
- const text = `I could not safely prepare this review-required DQL artifact from the inspected context. The SQL preview failed validation: ${contextValidation.error}`;
1594
+ // Business-language chat text; the validator's machine message (with
1595
+ // relation ids and tool guidance for the repair prompt) stays in
1596
+ // refusalDetails + validationWarnings for the Inspect surface.
1597
+ const text = renderContextValidationRefusalForUser(contextValidation.code, contextValidation.error, input.followUp?.memberBindings);
1438
1598
  const analysisPlan = buildAnalysisPlan({
1439
1599
  question,
1440
1600
  intent,
@@ -1468,7 +1628,11 @@ async function runAnswerLoop(input) {
1468
1628
  trustLabel: input.contextPack?.trustLabel,
1469
1629
  sourceCertifiedBlock: followUpSourceBlock?.name ?? input.followUp?.sourceBlockName,
1470
1630
  contextPackId: input.contextPack?.id,
1471
- validationWarnings: [...contextValidation.warnings, ...deepCandidateNotes],
1631
+ validationWarnings: [
1632
+ ...contextValidation.warnings,
1633
+ `SQL context validation detail: ${contextValidation.error}`,
1634
+ ...deepCandidateNotes,
1635
+ ],
1472
1636
  selectedEvidence: input.contextPack?.evidenceRoles?.slice(0, 12),
1473
1637
  citations: [],
1474
1638
  memoryContext: input.memoryContext,
@@ -1493,8 +1657,12 @@ async function runAnswerLoop(input) {
1493
1657
  ? evaluateDbtFirstGeneratedSql(parsed.sql, input.manifest, input.domainContext?.purpose, input.domainContext)
1494
1658
  : undefined;
1495
1659
  if (dbtFirstJoinSafety && !dbtFirstJoinSafety.safe) {
1496
- const text = dbtFirstJoinSafety.message
1660
+ // Business-language text for the chat surface; the machine policy message
1661
+ // stays in refusalDetails + validationWarnings for Inspect.
1662
+ const policyDetail = dbtFirstJoinSafety.technicalDetail ?? dbtFirstJoinSafety.message
1497
1663
  ?? 'DQL could not prove this generated join from certified analytical relationships.';
1664
+ const text = dbtFirstJoinSafety.userFacingReason
1665
+ ?? analyticalPolicyUserFacingReason(dbtFirstJoinSafety.code ?? 'unsafe_relationship', dbtFirstJoinSafety.entities);
1498
1666
  const exploratoryCandidate = exploratoryCandidateFromDbtFirstGuard(parsed.sql, dbtFirstJoinSafety);
1499
1667
  const analysisPlan = buildAnalysisPlan({
1500
1668
  question,
@@ -1522,13 +1690,16 @@ async function runAnswerLoop(input) {
1522
1690
  proposedSql: parsed.sql,
1523
1691
  sql: parsed.sql,
1524
1692
  ...(exploratoryCandidate ? { exploratoryCandidate } : {}),
1525
- refusalCode: refusalCodeForAnalyticalPolicy(dbtFirstJoinSafety.code, Boolean(exploratoryCandidate)),
1693
+ refusalCode: dbtFirstJoinSafety.disposition === 'exploratory_candidate'
1694
+ ? 'modeling_gap'
1695
+ : refusalCodeForAnalyticalPolicy(dbtFirstJoinSafety.code, Boolean(exploratoryCandidate)),
1526
1696
  refusalDetails: {
1527
1697
  code: dbtFirstJoinSafety.code ?? 'unsafe_relationship',
1528
- message: text,
1698
+ message: policyDetail,
1529
1699
  },
1530
1700
  validationWarnings: [
1531
1701
  ...contextValidation.warnings,
1702
+ `Analytical policy detail: ${policyDetail}`,
1532
1703
  `DQL v3 relationship guard: ${dbtFirstJoinSafety.code ?? 'unsafe relationship'}.`,
1533
1704
  ...(exploratoryCandidate
1534
1705
  ? ['A bounded DBT-grounded exploratory route may validate this missing modeled relationship; governed SQL was not executed.']
@@ -1603,6 +1774,7 @@ async function runAnswerLoop(input) {
1603
1774
  intent,
1604
1775
  filterValues: input.followUp?.filters,
1605
1776
  trustedFilterValues: trustedFollowUpFilterValues(input.followUp),
1777
+ memberBindings: input.followUp?.memberBindings,
1606
1778
  });
1607
1779
  if (localRepairValidation.ok) {
1608
1780
  repairAttempts = 1;
@@ -1646,6 +1818,7 @@ async function runAnswerLoop(input) {
1646
1818
  intent,
1647
1819
  filterValues: input.followUp?.filters,
1648
1820
  trustedFilterValues: trustedFollowUpFilterValues(input.followUp),
1821
+ memberBindings: input.followUp?.memberBindings,
1649
1822
  });
1650
1823
  if (repairedValidation.ok) {
1651
1824
  repairAttempts += 1;
@@ -2180,6 +2353,27 @@ function renderAnalyticalPlanPrompt(plan) {
2180
2353
  if (!plan || plan.entities.length < 2)
2181
2354
  return undefined;
2182
2355
  if (!plan.safe) {
2356
+ if (analyticalPlanAllowsDbtExploration(plan)) {
2357
+ const declaredPath = plan.exploratoryPath?.edges.length
2358
+ ? [
2359
+ 'DECLARED (UNCERTIFIED) DRAFT JOIN PATH — suggestion only, NOT authorization:',
2360
+ ...plan.exploratoryPath.edges.map((edge, index) => [
2361
+ `${index + 1}. ${edge.fromEntity} (${edge.fromRelation ?? 'bound dbt model'}) -> ${edge.toEntity} (${edge.toRelation ?? 'bound dbt model'})`,
2362
+ ` relationship=${edge.relationshipId}; keys=${edge.keys.map((key) => `${key.from}=${key.to}`).join(', ')}; cardinality=${edge.cardinality}; fanout=${edge.fanout}; status=${edge.lifecycle}`,
2363
+ ].join('\n')),
2364
+ 'Prefer these declared relations and exact key pairs over inferring joins from column names. The result remains analyst-review-required.',
2365
+ ]
2366
+ : [];
2367
+ return [
2368
+ 'DQL GOVERNED RELATIONSHIP COVERAGE: MISSING.',
2369
+ plan.message ?? 'No certified analytical relationship path is available.',
2370
+ `Policy code: ${plan.code ?? 'unsafe_relationship'}.`,
2371
+ ...declaredPath,
2372
+ 'This is not authorization for governed SQL. You MAY propose one bounded, read-only DBT-grounded exploratory SELECT/WITH using only the inspected relations and columns supplied below.',
2373
+ 'Use explicit aliases and equality join keys visible in the inspected schema. Preserve the requested member bindings exactly. Do not introduce an uninspected relation, a cross-domain path, an attribution/allocation rule, or more than four joins.',
2374
+ 'The host will independently parse, bound, probe, validate, and execute the candidate as analyst-review-required. If the inspected evidence cannot express the request, return no SQL.',
2375
+ ].join('\n');
2376
+ }
2183
2377
  return [
2184
2378
  'DQL ANALYTICAL POLICY DECISION: BLOCKED.',
2185
2379
  plan.message ?? 'No certified analytical relationship path is available.',
@@ -2197,6 +2391,21 @@ function renderAnalyticalPlanPrompt(plan) {
2197
2391
  'Use only these relationships and exact key pairs. dbt DAG lineage and same-named columns are not join authorization. Any different join must be refused.',
2198
2392
  ].join('\n');
2199
2393
  }
2394
+ /**
2395
+ * Missing modeling may enter the narrow EXP-001 host-validated lane. Explicit
2396
+ * unsafe, cross-domain, attribution, expiry, purpose, and proof failures never do.
2397
+ * The planner's `disposition` is authoritative; the code checks remain only for
2398
+ * plans produced by older manifests/paths that predate the disposition contract.
2399
+ */
2400
+ function analyticalPlanAllowsDbtExploration(plan) {
2401
+ if (plan.disposition === 'exploratory_candidate')
2402
+ return true;
2403
+ if (plan.safe)
2404
+ return false;
2405
+ if (plan.code === 'unbound_relation' || plan.code === 'relationship_not_certified')
2406
+ return true;
2407
+ return plan.code === 'unsafe_relationship' && plan.relationshipIds.length === 0;
2408
+ }
2200
2409
  // Metric-anchored generation (Tier 2.5): a governed metric matched the question but
2201
2410
  // the semantic layer couldn't compose the exact shape. Reuse the metric's CERTIFIED
2202
2411
  // definition as the measure rather than reinventing it, so the number stays consistent
@@ -2693,6 +2902,12 @@ function renderFollowUpContext(followUp) {
2693
2902
  followUp.dimensions?.length ? `requested dimensions: ${followUp.dimensions.join(', ')}` : '',
2694
2903
  followUp.priorResultColumns?.length ? `prior result columns: ${followUp.priorResultColumns.join(', ')}` : '',
2695
2904
  followUp.priorResultValues ? `prior result values: ${formatPriorResultValues(followUp.priorResultValues)}` : '',
2905
+ followUp.memberBindings?.length
2906
+ ? `required member bindings: ${followUp.memberBindings.map((binding) => `${binding.dimension} = ${binding.values.join(' | ')} (${binding.confidence})`).join('; ')}`
2907
+ : '',
2908
+ followUp.memberBindings?.length
2909
+ ? 'binding enforcement: apply every required member value only to a column representing its named dimension. Never substitute another dimension; if the named dimension is unavailable, report that gap.'
2910
+ : '',
2696
2911
  followUp.priorResultRef ? renderPriorResultReference(followUp.priorResultRef) : '',
2697
2912
  followUp.priorDqlArtifact ? renderPriorDqlArtifactReference(followUp.priorDqlArtifact) : '',
2698
2913
  followUp.priorLimit ? `prior result limit: ${followUp.priorLimit}` : '',
@@ -3033,14 +3248,243 @@ function escapeDqlArtifactTripleString(value) {
3033
3248
  function generatedResultShapeIsPartial(resultShape) {
3034
3249
  return resultShape.missingOutputs.length >= 2;
3035
3250
  }
3251
+ /**
3252
+ * A ranked query without its ranked entity is a different answer, even when the
3253
+ * aggregate executes. Catch this before execution so the bounded validation
3254
+ * correction can restore the requested grain instead of presenting a region-only
3255
+ * total for a top-product question.
3256
+ */
3257
+ export function missingRankedGrainOutput(questionPlan, sql, governedSemanticDimensions = []) {
3258
+ const grain = questionPlan.requestedShape.grain;
3259
+ if (!grain || questionPlan.requestedShape.topN?.scope !== 'per_group')
3260
+ return undefined;
3261
+ const shape = extractSimpleSelectShape(sql);
3262
+ if (!shape)
3263
+ return undefined;
3264
+ const columns = shape.selectExpressions
3265
+ .map(selectExpressionOutputName)
3266
+ .filter((column) => Boolean(column));
3267
+ const requiredGroupingDimensions = rankedGroupingDimensions(questionPlan);
3268
+ const literalGap = requiredGroupingDimensions.find((dimension) => !columns.some((output) => outputConceptMatches(output, dimension)));
3269
+ if (!literalGap)
3270
+ return undefined;
3271
+ // A bounded Lane-2 meaning selection may map a user's business word to a
3272
+ // differently named governed dimension (for example "region" to
3273
+ // `location_name`). Trust that mapping only when the semantic compiler
3274
+ // projected every selected governed dimension and the selection covers the
3275
+ // complete grouping cardinality. Arbitrary generated aliases never receive
3276
+ // this exception.
3277
+ const projectedGovernedDimensions = governedSemanticDimensions.filter((dimension) => columns.some((output) => outputConceptMatches(output, dimension)));
3278
+ if (projectedGovernedDimensions.length >= requiredGroupingDimensions.length)
3279
+ return undefined;
3280
+ return literalGap;
3281
+ }
3282
+ function requestedGroupingDimensions(questionPlan) {
3283
+ const grain = questionPlan.requestedShape.grain;
3284
+ const dimensions = [
3285
+ ...(grain ? [grain] : []),
3286
+ ...questionPlan.requestedShape.dimensions,
3287
+ ].filter((dimension) => {
3288
+ const isGrain = Boolean(grain) && conceptsEquivalent(dimension, grain);
3289
+ if (isGrain && !isCompoundMeasurePhrase(dimension, questionPlan.requestedShape.measures))
3290
+ return true;
3291
+ return !isMeasurePhrase(dimension, questionPlan.requestedShape.measures);
3292
+ });
3293
+ const seen = new Set();
3294
+ return dimensions.filter((dimension) => {
3295
+ const key = normalizedConceptTokens(dimension).join('_');
3296
+ if (!key || seen.has(key))
3297
+ return false;
3298
+ seen.add(key);
3299
+ return true;
3300
+ });
3301
+ }
3302
+ function rankedGroupingDimensions(questionPlan) {
3303
+ const grain = questionPlan.requestedShape.grain;
3304
+ if (!grain || questionPlan.requestedShape.topN?.scope !== 'per_group')
3305
+ return [];
3306
+ return requestedGroupingDimensions(questionPlan);
3307
+ }
3308
+ function renderRequestedShapeForRepair(questionPlan) {
3309
+ return 'Required answer contract: ' + JSON.stringify({
3310
+ grain: questionPlan.requestedShape.grain,
3311
+ dimensions: questionPlan.requestedShape.dimensions,
3312
+ measures: questionPlan.requestedShape.measures,
3313
+ requiredOutputs: questionPlan.requestedShape.requiredOutputs,
3314
+ requiredGroupingAliases: rankedGroupingDimensions(questionPlan),
3315
+ topN: questionPlan.requestedShape.topN,
3316
+ });
3317
+ }
3318
+ function outputConceptMatches(output, concept) {
3319
+ const outputTokens = normalizedConceptTokens(output);
3320
+ const conceptTokens = normalizedConceptTokens(concept);
3321
+ return conceptTokens.length > 0 && conceptTokens.every((token) => outputTokens.includes(token));
3322
+ }
3323
+ function isCompoundMeasurePhrase(concept, measures) {
3324
+ const conceptTokens = normalizedConceptTokens(concept);
3325
+ return measures.some((measure) => {
3326
+ const measureTokens = normalizedConceptTokens(measure);
3327
+ return measureTokens.length > 0
3328
+ && conceptTokens.length > measureTokens.length
3329
+ && measureTokens.every((token) => conceptTokens.includes(token));
3330
+ });
3331
+ }
3332
+ function isMeasurePhrase(concept, measures) {
3333
+ const conceptTokens = normalizedConceptTokens(concept);
3334
+ return measures.some((measure) => {
3335
+ const measureTokens = normalizedConceptTokens(measure);
3336
+ return measureTokens.length > 0
3337
+ && conceptTokens.length >= measureTokens.length
3338
+ && measureTokens.every((token) => conceptTokens.includes(token));
3339
+ });
3340
+ }
3341
+ function conceptsEquivalent(left, right) {
3342
+ const leftTokens = normalizedConceptTokens(left);
3343
+ const rightTokens = normalizedConceptTokens(right);
3344
+ return leftTokens.length === rightTokens.length
3345
+ && leftTokens.every((token, index) => token === rightTokens[index]);
3346
+ }
3347
+ function normalizedConceptTokens(value) {
3348
+ return value
3349
+ .toLowerCase()
3350
+ .replace(/[^a-z0-9]+/g, ' ')
3351
+ .trim()
3352
+ .split(/\s+/)
3353
+ .filter((token) => token && token !== 'name' && token !== 'label')
3354
+ .map((token) => token.endsWith('s') && token.length > 3 ? token.slice(0, -1) : token);
3355
+ }
3036
3356
  function trustedFollowUpFilterValues(followUp) {
3037
3357
  const filters = new Set((followUp?.filters ?? []).map((value) => value.trim()).filter(Boolean));
3038
- if (!filters.size || !followUp?.priorResultValues)
3358
+ const boundValues = (followUp?.memberBindings ?? [])
3359
+ .flatMap((binding) => binding.values)
3360
+ .map((value) => value.trim())
3361
+ .filter(Boolean);
3362
+ const priorValues = Object.values(followUp?.priorResultValues ?? {}).flat().map((value) => value.trim()).filter(Boolean);
3363
+ const trustedCandidates = uniqueDrilldownStrings([...boundValues, ...priorValues]);
3364
+ if (!trustedCandidates.length)
3039
3365
  return undefined;
3040
- const priorValues = Object.values(followUp.priorResultValues).flat().map((value) => value.trim()).filter(Boolean);
3041
- const trusted = priorValues.filter((value) => filters.has(value));
3366
+ // Typed bindings were resolved by the deterministic member resolver and are
3367
+ // trusted even if a legacy caller omitted the mirrored flat `filters` array.
3368
+ const trusted = trustedCandidates.filter((value) => boundValues.includes(value) || filters.has(value));
3042
3369
  return trusted.length > 0 ? uniqueDrilldownStrings(trusted) : undefined;
3043
3370
  }
3371
+ function injectUniqueResolvedMemberBindings(sql, bindings, schemaContext) {
3372
+ if (!/^\s*select\b/i.test(sql) || /;\s*\S/.test(sql))
3373
+ return undefined;
3374
+ const relationAliases = sqlRelationAliases(sql);
3375
+ const predicates = [];
3376
+ const notes = [];
3377
+ for (const binding of bindings) {
3378
+ const values = Array.from(new Set(binding.values.map((value) => value.trim()).filter(Boolean)));
3379
+ if (values.length === 0)
3380
+ continue;
3381
+ // Do not add a second interpretation when the proposal already contains a
3382
+ // canonical bound value. The validator will determine whether it is on the
3383
+ // correct dimension and reject a misbinding.
3384
+ if (values.some((value) => sql.toLowerCase().includes(sqlStringLiteral(value).toLowerCase())))
3385
+ continue;
3386
+ const candidates = schemaContext.flatMap((table) => table.columns.flatMap((column) => {
3387
+ if (!outputConceptMatches(column.name, binding.dimension))
3388
+ return [];
3389
+ if (values.some((value) => /[\p{L}]/u.test(value)) && isJoinKeyColumn(column.name))
3390
+ return [];
3391
+ const samples = new Set((column.sampleValues ?? []).map(normalizeValueIndexText));
3392
+ // The typed binding is already canonical request state. Samples may
3393
+ // strengthen the match when this turn has them, but snapshot scoping must
3394
+ // not erase a prior governed value. A conflicting non-empty sample set is
3395
+ // rejected; an absent sample set may proceed only through the unique
3396
+ // dimension-column + existing-query-path proof below.
3397
+ if (samples.size > 0 && !values.every((value) => samples.has(normalizeValueIndexText(value))))
3398
+ return [];
3399
+ const alias = findSqlAliasForRelation(table.relation, relationAliases);
3400
+ if (!alias || !safeSqlIdentifier(alias) || !safeSqlIdentifier(column.name))
3401
+ return [];
3402
+ return [{ alias, column: column.name }];
3403
+ }));
3404
+ const uniqueCandidates = Array.from(new Map(candidates.map((candidate) => [
3405
+ `${candidate.alias.toLowerCase()}.${candidate.column.toLowerCase()}`,
3406
+ candidate,
3407
+ ])).values());
3408
+ if (uniqueCandidates.length !== 1)
3409
+ return undefined;
3410
+ const candidate = uniqueCandidates[0];
3411
+ const qualifiedColumn = `${candidate.alias}.${candidate.column}`;
3412
+ const predicate = values.length === 1
3413
+ ? `${qualifiedColumn} = ${sqlStringLiteral(values[0])}`
3414
+ : `${qualifiedColumn} IN (${values.map(sqlStringLiteral).join(', ')})`;
3415
+ predicates.push(predicate);
3416
+ notes.push(`Applied the resolved ${binding.dimension} member binding deterministically on ${qualifiedColumn}.`);
3417
+ }
3418
+ if (predicates.length === 0)
3419
+ return undefined;
3420
+ const clauseIndex = firstTopLevelSqlClauseIndex(sql, ['group by', 'having', 'qualify', 'order by', 'limit']);
3421
+ const head = clauseIndex >= 0 ? sql.slice(0, clauseIndex).trimEnd() : sql.trimEnd();
3422
+ const tail = clauseIndex >= 0 ? ` ${sql.slice(clauseIndex).trimStart()}` : '';
3423
+ const joiner = /\bwhere\b/i.test(head) ? ' AND ' : ' WHERE ';
3424
+ return { sql: `${head}${joiner}${predicates.join(' AND ')}${tail}`, notes };
3425
+ }
3426
+ function sqlRelationAliases(sql) {
3427
+ const matches = [];
3428
+ const pattern = /\b(?:from|join)\s+((?:["`]?\w+["`]?\s*\.\s*)*["`]?\w+["`]?)\s*(?:as\s+)?([A-Za-z_][\w$]*)?/gi;
3429
+ for (const match of sql.matchAll(pattern)) {
3430
+ const relation = (match[1] ?? '').replace(/["`\s]/g, '');
3431
+ if (!relation)
3432
+ continue;
3433
+ const implicitAlias = relation.split('.').pop() ?? relation;
3434
+ const candidateAlias = match[2] && !SQL_CLAUSE_WORDS.has(match[2].toLowerCase()) ? match[2] : implicitAlias;
3435
+ matches.push({ relation, alias: candidateAlias });
3436
+ }
3437
+ return matches;
3438
+ }
3439
+ const SQL_CLAUSE_WORDS = new Set(['where', 'join', 'left', 'right', 'inner', 'outer', 'full', 'cross', 'group', 'having', 'qualify', 'order', 'limit', 'on']);
3440
+ function findSqlAliasForRelation(relation, aliases) {
3441
+ const normalized = relation.replace(/["`\s]/g, '').toLowerCase();
3442
+ const short = normalized.split('.').pop();
3443
+ return aliases.find((candidate) => {
3444
+ const candidateNormalized = candidate.relation.toLowerCase();
3445
+ return candidateNormalized === normalized || candidateNormalized.split('.').pop() === short;
3446
+ })?.alias;
3447
+ }
3448
+ function firstTopLevelSqlClauseIndex(sql, clauses) {
3449
+ let depth = 0;
3450
+ let quote;
3451
+ for (let index = 0; index < sql.length; index += 1) {
3452
+ const char = sql[index];
3453
+ if (quote) {
3454
+ if (char === quote) {
3455
+ if (quote === "'" && sql[index + 1] === "'")
3456
+ index += 1;
3457
+ else
3458
+ quote = undefined;
3459
+ }
3460
+ continue;
3461
+ }
3462
+ if (char === "'" || char === '"' || char === '`') {
3463
+ quote = char;
3464
+ continue;
3465
+ }
3466
+ if (char === '(') {
3467
+ depth += 1;
3468
+ continue;
3469
+ }
3470
+ if (char === ')') {
3471
+ depth = Math.max(0, depth - 1);
3472
+ continue;
3473
+ }
3474
+ if (depth !== 0)
3475
+ continue;
3476
+ const rest = sql.slice(index).toLowerCase();
3477
+ if (clauses.some((clause) => rest.startsWith(clause) && (!rest[clause.length] || /\s/.test(rest[clause.length]))))
3478
+ return index;
3479
+ }
3480
+ return -1;
3481
+ }
3482
+ function safeSqlIdentifier(value) {
3483
+ return /^[A-Za-z_][\w$]*$/.test(value);
3484
+ }
3485
+ function sqlStringLiteral(value) {
3486
+ return `'${value.replace(/'/g, "''")}'`;
3487
+ }
3044
3488
  function buildCandidateJoinPaths(schemaContext) {
3045
3489
  const tables = [...schemaContext]
3046
3490
  .filter((table) => table.columns.some((column) => isJoinKeyColumn(column.name)))
@@ -3972,6 +4416,9 @@ async function scoreDeepGeneratedProposalCandidate(input, candidate) {
3972
4416
  const validation = input.contextLedger.validateSql(parsed.sql, {
3973
4417
  question: input.question,
3974
4418
  intent: input.intent,
4419
+ filterValues: input.followUp?.filters,
4420
+ trustedFilterValues: trustedFollowUpFilterValues(input.followUp),
4421
+ memberBindings: input.followUp?.memberBindings,
3975
4422
  });
3976
4423
  validationOk = validation.ok;
3977
4424
  if (validation.ok) {
@@ -4692,13 +5139,13 @@ function cascadeBudgetEvidenceRouteSteps(trace) {
4692
5139
  if (!trace)
4693
5140
  return [];
4694
5141
  const { usage, limits } = trace;
4695
- const used = usage.laneRegroundAttemptsUsed + usage.laneExecutionAttemptsUsed + usage.engineEscalationsUsed;
5142
+ const used = usage.laneRegroundAttemptsUsed + usage.laneValidationAttemptsUsed + usage.laneExecutionAttemptsUsed + usage.engineEscalationsUsed;
4696
5143
  if (used === 0)
4697
5144
  return [];
4698
5145
  return [{
4699
5146
  tool: 'cascade_budget',
4700
5147
  status: 'checked',
4701
- label: `Repair budget used: re-ground ${usage.laneRegroundAttemptsUsed}/${limits.lane.reground}, execution ${usage.laneExecutionAttemptsUsed}/${limits.lane.execution}`,
5148
+ label: `Repair budget used: re-ground ${usage.laneRegroundAttemptsUsed}/${limits.lane.reground}, validation ${usage.laneValidationAttemptsUsed}/${limits.lane.validation}, execution ${usage.laneExecutionAttemptsUsed}/${limits.lane.execution}`,
4702
5149
  detail: `engine escalations ${usage.engineEscalationsUsed}/${limits.engineEscalations}`,
4703
5150
  }];
4704
5151
  }