@duckcodeailabs/dql-cli 1.13.1 → 1.13.2

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 (88) hide show
  1. package/dist/apps-api.d.ts +225 -5
  2. package/dist/apps-api.d.ts.map +1 -1
  3. package/dist/apps-api.js +1950 -117
  4. package/dist/apps-api.js.map +1 -1
  5. package/dist/assets/dql-notebook/assets/{AgentLogPage-D6fgEAEy.js → AgentLogPage-BlUE3qsX.js} +1 -1
  6. package/dist/assets/dql-notebook/assets/{AiBuildDialog-8miXFPse.js → AiBuildDialog-O88iK6UW.js} +1 -1
  7. package/dist/assets/dql-notebook/assets/{AiBuildResult-DPJ_SI0R.js → AiBuildResult-D-6G4Sh3.js} +8 -8
  8. package/dist/assets/dql-notebook/assets/{AiSidePanel-NVA6lZnn.js → AiSidePanel-MRSGtQDV.js} +1 -1
  9. package/dist/assets/dql-notebook/assets/AnalyticsHome-CcRVO6zN.js +6 -0
  10. package/dist/assets/dql-notebook/assets/AppsView-BTkGC-wl.js +5588 -0
  11. package/dist/assets/dql-notebook/assets/{BlockStudio--jNw9XUI.js → BlockStudio-BrTNaDpb.js} +10 -15
  12. package/dist/assets/dql-notebook/assets/{BusinessArtifactView-DtRMiaF4.js → BusinessArtifactView-itEIUrYy.js} +1 -1
  13. package/dist/assets/dql-notebook/assets/DbtFirstModelingPage-CbAqOpNc.js +22 -0
  14. package/dist/assets/dql-notebook/assets/{GitPage-CazC-eTR.js → GitPage-BxOqRYu2.js} +1 -1
  15. package/dist/assets/dql-notebook/assets/GlobalAiRail-Tfkx76CI.js +1 -0
  16. package/dist/assets/dql-notebook/assets/GovernedContextPage-CeaDbepP.js +7 -0
  17. package/dist/assets/dql-notebook/assets/{HelpDocsPage-DMHPsVqP.js → HelpDocsPage-D1lAryYv.js} +1 -1
  18. package/dist/assets/dql-notebook/assets/{HomePage-DcSI4dnU.js → HomePage-GB6se6xW.js} +1 -1
  19. package/dist/assets/dql-notebook/assets/{LineageDAG-BJ6xz06u.js → LineageDAG-zCyN-hdg.js} +1 -1
  20. package/dist/assets/dql-notebook/assets/{LineageDetailView-8WrQm-MF.js → LineageDetailView-Cg0TgG2Y.js} +1 -1
  21. package/dist/assets/dql-notebook/assets/{LineageDrawer-hnqCW_t-.js → LineageDrawer-DwMcenDf.js} +1 -1
  22. package/dist/assets/dql-notebook/assets/{LineagePathBreadcrumb-B4LZTUsl.js → LineagePathBreadcrumb-DUZx3ksU.js} +1 -1
  23. package/dist/assets/dql-notebook/assets/{MiniLineageGraph-CotnRuxa.js → MiniLineageGraph-DT_ycl6s.js} +1 -1
  24. package/dist/assets/dql-notebook/assets/{NewBlockModal-DHn-CEEW.js → NewBlockModal-qdhzeJPF.js} +1 -1
  25. package/dist/assets/dql-notebook/assets/{NewNotebookModal-BPGp-8oq.js → NewNotebookModal-BnaDzs1A.js} +1 -1
  26. package/dist/assets/dql-notebook/assets/{NotebookEditor-B0OTHM6W.js → NotebookEditor-CouPFzdo.js} +5 -5
  27. package/dist/assets/dql-notebook/assets/{ReadinessPage-CoOIpxWq.js → ReadinessPage-DB_KbH9Y.js} +1 -1
  28. package/dist/assets/dql-notebook/assets/{SetupOnboarding-DxK_mFzf.js → SetupOnboarding-CamQq_N2.js} +1 -1
  29. package/dist/assets/dql-notebook/assets/SkillsPage--mYkOAK2.js +10 -0
  30. package/dist/assets/dql-notebook/assets/{TrustBadge-ZuD5N5Ww.js → TrustBadge-ClEYiYeA.js} +1 -1
  31. package/dist/assets/dql-notebook/assets/UnifiedAgentRunPanel-DBfsmXes.js +88 -0
  32. package/dist/assets/dql-notebook/assets/{answer-to-notebook-WAVAYjBv.js → answer-to-notebook-Cg3UyGwh.js} +1 -1
  33. package/dist/assets/dql-notebook/assets/{arrow-left-L8odLbyN.js → arrow-left-DnYPGqlr.js} +1 -1
  34. package/dist/assets/dql-notebook/assets/{arrow-right-WIF1V9a9.js → arrow-right-Ct_YGrmU.js} +1 -1
  35. package/dist/assets/dql-notebook/assets/{book-open-text-BpqWMa4m.js → book-open-text-DULlwbj6.js} +1 -1
  36. package/dist/assets/dql-notebook/assets/{circle-x-CQBOM0b0.js → circle-x-ByYxoiS9.js} +1 -1
  37. package/dist/assets/dql-notebook/assets/{dagre.esm-DlZi7NR3.js → dagre.esm-BjYSO53F.js} +1 -1
  38. package/dist/assets/dql-notebook/assets/{external-link-DYqdQIGp.js → external-link-DprWh9Yf.js} +1 -1
  39. package/dist/assets/dql-notebook/assets/{grip-vertical-HmUijbwN.js → grip-vertical-CrEEYkXN.js} +1 -1
  40. package/dist/assets/dql-notebook/assets/{index-Cpx47jCH.js → index-9w93YPF9.js} +127 -126
  41. package/dist/assets/dql-notebook/assets/index-B3shyZsg.css +1 -0
  42. package/dist/assets/dql-notebook/assets/link-2-BkW-rkEC.js +6 -0
  43. package/dist/assets/dql-notebook/assets/{list-tree-DGKqCxmz.js → list-tree-BipBLmIE.js} +1 -1
  44. package/dist/assets/dql-notebook/assets/{minimize-2-C8dvqCxX.js → minimize-2-BhXsglrO.js} +1 -1
  45. package/dist/assets/dql-notebook/assets/{panel-right-open-yuVkIo5n.js → panel-right-open-BoCSilYB.js} +1 -1
  46. package/dist/assets/dql-notebook/assets/{bot-CLR1V6dO.js → play-mdoZawPu.js} +7 -2
  47. package/dist/assets/dql-notebook/assets/{rotate-ccw-Di0kRnrM.js → rotate-ccw-ELiDRUmj.js} +1 -1
  48. package/dist/assets/dql-notebook/assets/{semantic-fields-BMQV0li8.js → semantic-fields-DVNRLfeU.js} +1 -1
  49. package/dist/assets/dql-notebook/assets/{sliders-horizontal-alebptlJ.js → sliders-horizontal-GeUzpmKz.js} +1 -1
  50. package/dist/assets/dql-notebook/assets/{star-CgCQbnXc.js → star-D63O81Wp.js} +1 -1
  51. package/dist/assets/dql-notebook/assets/{triangle-alert-SyuSZ4ht.js → triangle-alert-BWrCxQVE.js} +1 -1
  52. package/dist/assets/dql-notebook/assets/upload-CI-ldXhC.js +6 -0
  53. package/dist/assets/dql-notebook/assets/usePersistedAgentThreadId-Cv7Yq2hK.js +32 -0
  54. package/dist/assets/dql-notebook/assets/{user-round-J4PQHxo9.js → user-round-Do21aFGo.js} +1 -1
  55. package/dist/assets/dql-notebook/assets/{wand-sparkles-c4WDXTCk.js → wand-sparkles-DT7u7F_x.js} +1 -1
  56. package/dist/assets/dql-notebook/assets/{workflow-NtRLaq7n.js → workflow-DHh2mMbn.js} +1 -1
  57. package/dist/assets/dql-notebook/assets/{wrench-CS1f1Zv4.js → wrench-7tYr_GKb.js} +1 -1
  58. package/dist/assets/dql-notebook/assets/{x-CbKEfe5m.js → x-CcYceFMz.js} +1 -1
  59. package/dist/assets/dql-notebook/index.html +2 -2
  60. package/dist/commands/app.d.ts.map +1 -1
  61. package/dist/commands/app.js +65 -133
  62. package/dist/commands/app.js.map +1 -1
  63. package/dist/commands/model.js +21 -2
  64. package/dist/commands/model.js.map +1 -1
  65. package/dist/llm/providers/dql-agent-provider.d.ts +16 -0
  66. package/dist/llm/providers/dql-agent-provider.d.ts.map +1 -1
  67. package/dist/llm/providers/dql-agent-provider.js +65 -2
  68. package/dist/llm/providers/dql-agent-provider.js.map +1 -1
  69. package/dist/local-runtime.d.ts +65 -0
  70. package/dist/local-runtime.d.ts.map +1 -1
  71. package/dist/local-runtime.js +1008 -125
  72. package/dist/local-runtime.js.map +1 -1
  73. package/dist/package.json +10 -10
  74. package/dist/sql-result-filter.d.ts +9 -0
  75. package/dist/sql-result-filter.d.ts.map +1 -1
  76. package/dist/sql-result-filter.js +25 -0
  77. package/dist/sql-result-filter.js.map +1 -1
  78. package/package.json +10 -10
  79. package/dist/assets/dql-notebook/assets/AgentAnswerCard-c8QUo6NH.js +0 -5
  80. package/dist/assets/dql-notebook/assets/AnalyticsHome-DgwnRW71.js +0 -6
  81. package/dist/assets/dql-notebook/assets/AppsView-B6LYUpgm.js +0 -4578
  82. package/dist/assets/dql-notebook/assets/DbtFirstModelingPage-DhBsLCYr.js +0 -22
  83. package/dist/assets/dql-notebook/assets/GlobalAiRail-1Tgweuu2.js +0 -1
  84. package/dist/assets/dql-notebook/assets/GovernedContextPage-C7qGrH3d.js +0 -16
  85. package/dist/assets/dql-notebook/assets/UnifiedAgentRunPanel-duFXXNV_.js +0 -88
  86. package/dist/assets/dql-notebook/assets/index-DKu4CZt3.css +0 -1
  87. package/dist/assets/dql-notebook/assets/upload-BPhfea_E.js +0 -11
  88. package/dist/assets/dql-notebook/assets/usePersistedAgentThreadId-Bnl9gWhj.js +0 -28
@@ -15,7 +15,7 @@ import { buildMixedSourceWarehouseFallbackSql, findMentionedNotebookDataset, pla
15
15
  import { resolveNpmInvocation } from './npm-runtime.js';
16
16
  import { ensureDqlGitignore, isGovernedSourceFile, isLegacyBroadDqlIgnore, } from './git-contract.js';
17
17
  import { buildExecutionPlan, createWelcomeNotebook, deserializeNotebook, getConnectorFormSchemas, hasSemanticRefs, hasStandaloneSemanticRef, resolveSemanticRefs, } from '@duckcodeailabs/dql-notebook';
18
- import { loadSemanticLayerFromDir, normalizeDqlArtifactReference, serializeMetricDefinitionToYaml, resolveSemanticLayerAsync, resolveRepoSource, getDialect, Parser, NodeKind, blockParameterDefinitions, buildLineageGraph, buildManifest, collectInputFiles, findAppDocuments, findDashboardsForApp, isBlockIdRef, loadAppDocument, loadDashboardDocument, analyzeImpact, buildTrustChain, detectDomainFlows, getDomainTrustOverview, queryLineage, queryBusiness360, queryCompleteLineagePaths, LineageGraph, canonicalize, canonicalizeNotebook, diffDQL, diffNotebook, domainFolderSlug, previewModelingChange, previewModelingChanges, applyModelingChange, previewDbtSourcePatch, applyDbtSourcePatch, loadDomainPackageRegistry, loadDbtNodeAuthoringDetail, normalizeAnalyticalFailureV1, relationshipValidationProofFingerprint, renderSemanticBlockSource, discoverDbtDomains, renderDomainDeclaration, ProjectSnapshotService, analyzeSqlReferences, semanticDimensionReference, } from '@duckcodeailabs/dql-core';
18
+ import { loadSemanticLayerFromDir, normalizeDqlArtifactReference, serializeMetricDefinitionToYaml, resolveSemanticLayerAsync, resolveRepoSource, getDialect, Parser, NodeKind, blockParameterDefinitions, buildLineageGraph, buildManifest, collectInputFiles, findAppDocuments, findDashboardsForApp, isBlockIdRef, loadAppDocument, loadDashboardDocument, analyzeImpact, buildTrustChain, detectDomainFlows, getDomainTrustOverview, queryLineage, queryBusiness360, queryCompleteLineagePaths, LineageGraph, canonicalize, canonicalizeNotebook, diffDQL, diffNotebook, domainFolderSlug, previewModelingChange, previewModelingChanges, applyModelingChange, previewDbtSourcePatch, applyDbtSourcePatch, loadDomainPackageRegistry, loadDbtNodeAuthoringDetail, normalizeAnalyticalFailureV1, relationshipValidationProofFingerprint, renderSemanticBlockSource, discoverDbtDomains, collectDbtRelationshipTests, renderDomainDeclaration, ProjectSnapshotService, analyzeSqlReferences, semanticDimensionReference, modelAreaLocalId, DEFAULT_MODEL_AREA_ID, } from '@duckcodeailabs/dql-core';
19
19
  import { load as loadYaml } from 'js-yaml';
20
20
  import { listBlockTemplates } from './block-templates.js';
21
21
  import { getRunner as getLLMRunner } from './llm/index.js';
@@ -25,7 +25,7 @@ import { resolveRetrievalHealthStatus } from './retrieval-health.js';
25
25
  import { createDqlAgentProviderRunner, resolveAgentFollowUpContext } from './llm/providers/dql-agent-provider.js';
26
26
  import { listRemoteMcpSettings, saveRemoteMcpSettings } from './llm/mcp-config.js';
27
27
  import { ClaudeProvider, ConversationStore, advanceThreadState, buildConversationSnapshot, conversationHistoryFromContext, recallRelevantTurns, renderConversationEnvelopeForPrompt, GeminiProvider, MemoryStore, OllamaProvider, OpenAIProvider, buildBlockBusinessFingerprint, buildBlockSqlFingerprints, buildAnalysisQuestionPlan, composeSemanticQueryForQuestion, aggregationIntegrityIssuesForSql, buildLocalContextPack, applyContextPackCompatibility, toAgentRetrievalEvidence, prepareConversationPath, defaultMemoryPath, ensureDefaultMemoryFiles, ensureAgentProjectReady, isAgentProjectIndexReady, currentMetadataFingerprint, ensureMetadataCatalogFresh, readIndexedDomainKnowledge, readIndexedKnowledge360, compactSemanticRuntimeFailure, classifyAnalyticalFailure, normalizeWarehouseSqlFailure, parseProposal, propose, proposePlan, recordGovernedCorrection, HintStore, defaultHintIndexPath, ensureHintIndexFresh, listHintsFromGit, getHintEvaluationFromGit, getCorrectionTraceFromGit, inspectGovernedHint, editGovernedHintCandidate, reopenGovernedHint, retireHint, supersedeHint, hintsConflict, mineJoinPatterns, reviewGovernedHint, AgentRunEngine, SqliteAgentRunStore, defaultAgentRunGates, createLlmAgentRunPlanner, createHybridRouter, computeResultStats, buildDeterministicDashboardStory, synthesizeAnswer, streamOrGenerate, narrateResult, buildProposePreview, buildFromPrompt, internalRelationIdsInSql, defaultAgentRunStorePath, defaultAgentRunSqlitePath, resolveLocalOwner, resolveProposeConfig, recordQueryRun, recordRuntimeSchemaSnapshot, latestRuntimeSchemaSnapshotForProject, loadSkills, migrateLegacySkills, configuredSkillsPath, skillsDir, draftDomainSkillBootstrap, buildDomainSkillBootstrapPrompt, mergeDomainSkillBootstrapEnrichment, writeSkill, previewSkillChange, buildContextAuthoringProposal, contextAuthoringDependencyClosure, FileContextAuthoringProposalStore, deleteSkill, deriveGeneratedDraftSlug, deriveAnalyticalRepair, reindexProject, invalidateAgentProjectState, recordAgentRuntimeVersion, resolveDomainContextEnvelope, projectEmbeddingProvider, isHashedEmbeddingProvider, clearProjectEmbeddingCache, upgradeVectorIndexForProject, openMetadataCatalog, defaultKgPath, planAppFromPrompt, KGStore, planResearch, loadSemanticMetrics, cascadeTraceToEvidenceRouteSteps, createCascadeAnswerResult, createCascadeTrace, routeReasoningEffort, routeForCascadeAnswerTier, clampReasoningEffort, bumpReasoningEffort, resolveThinkingMode, coerceThinkingMode, upsertGeneratedDqlArtifactDraft, loadAgentSemanticLayer, isTrustedConversationTurn, resolveInternalRelationIds, analyticalError, tagAnalyticalError, withAnalyticalErrorOrigin, withAnalyticalErrorOriginSync, } from '@duckcodeailabs/dql-agent';
28
- import { addSqlResultFilter, filterableResultColumns, replaceBlockStudioSql } from './sql-result-filter.js';
28
+ import { addSqlResultFilter, dashboardFilterableResultColumns, filterableResultColumns, replaceBlockStudioSql } from './sql-result-filter.js';
29
29
  import { gatherProposeEnrichment } from './propose-enrich.js';
30
30
  import { handleAppsApi, proposeAppAiBuild, recommendVisualization, } from './apps-api.js';
31
31
  import { getActiveProvider, getEffectiveProviderConfig, isProviderSettingsId, listProviderSettings, saveProviderSettings, } from './settings/provider-settings.js';
@@ -87,7 +87,16 @@ function hasDbtSemanticArtifacts(projectRoot, dbtProjectDir, configuredManifestP
87
87
  }
88
88
  return false;
89
89
  }
90
- const AGENT_RUN_REQUESTED_MODES = new Set(['auto', 'ask', 'research', 'sql', 'block', 'app']);
90
+ // Every member of `AgentRunRequestedMode`. The `Record` (rather than a bare
91
+ // Set literal) is deliberate: a `Set<AgentRunRequestedMode>` happily accepts a
92
+ // subset, which is how 'modeling' and 'skill' went missing here — the parser
93
+ // dropped them to `undefined`, `selectRoute` never saw an explicit mode, and
94
+ // Modeling AI silently ran the governed answer loop instead of proposing a
95
+ // model. A Record makes an omission a compile error.
96
+ const AGENT_RUN_REQUESTED_MODE_MEMBERS = {
97
+ auto: true, ask: true, research: true, sql: true, block: true, app: true, modeling: true, skill: true,
98
+ };
99
+ const AGENT_RUN_REQUESTED_MODES = new Set(Object.keys(AGENT_RUN_REQUESTED_MODE_MEMBERS));
91
100
  /** AGT-007 / AGT-022: App-specific context adapter into the uniform AgentRun. */
92
101
  export function buildAppCopilotResearchAgentRequest(input) {
93
102
  const memoOnly = input.mode === 'memo_only';
@@ -1503,59 +1512,6 @@ export async function startLocalServer(opts) {
1503
1512
  });
1504
1513
  return { domainContext, contextPack };
1505
1514
  };
1506
- const buildAgentPromptArtifact = async (request, target, repair) => {
1507
- try {
1508
- await reindexProject(projectRoot, { kgPath: defaultKgPath(projectRoot) });
1509
- }
1510
- catch {
1511
- // Best-effort: buildFromPrompt can still use any existing KG/cache state.
1512
- }
1513
- const skills = loadSkills(projectRoot).skills;
1514
- // On a repair re-run, target the prior failure and (for blocks) revise in place.
1515
- const isRepair = (repair?.attempt ?? 0) > 0 && Boolean(repair?.repairHint);
1516
- const mode = target === 'block' && (isRepair || agentRunWorkspaceValue(request, 'mode') === 'edit')
1517
- ? 'edit'
1518
- : 'create';
1519
- const prompt = isRepair
1520
- ? `${request.question}\n\nFix the previous attempt: ${repair?.repairHint}`
1521
- : request.question;
1522
- const domain = target === 'block' ? agentRunWorkspaceValue(request, 'domain') : undefined;
1523
- const modelAreaId = agentRunWorkspaceValue(request, 'modelAreaId');
1524
- const purpose = agentRunWorkspaceValue(request, 'purpose');
1525
- const { domainContext, contextPack } = await buildDirectBuildContext({
1526
- question: prompt,
1527
- domain,
1528
- purpose,
1529
- modelAreaId,
1530
- surface: target === 'cell' ? 'notebook' : 'block',
1531
- selectedContext: { target, mode },
1532
- });
1533
- return buildFromPrompt({
1534
- projectRoot,
1535
- prompt,
1536
- context: {
1537
- cellSql: agentRunWorkspaceValue(request, 'cellSql'),
1538
- selection: agentRunWorkspaceValue(request, 'selection'),
1539
- },
1540
- target,
1541
- mode,
1542
- blockPath: target === 'block'
1543
- ? agentRunWorkspaceValue(request, 'blockPath')
1544
- ?? (request.selectedObject?.kind === 'block' && request.selectedObject.path?.endsWith('.dql')
1545
- ? request.selectedObject.path
1546
- : undefined)
1547
- : undefined,
1548
- owner: agentRunWorkspaceValue(request, 'owner'),
1549
- domain,
1550
- modelAreaId,
1551
- contextPack,
1552
- domainContext,
1553
- userId: agentRunWorkspaceValue(request, 'userId'),
1554
- skills,
1555
- dbtManifestPath: resolveDbtManifestPath(projectRoot, projectConfig),
1556
- executionProbe: target === 'block' ? runBlockReflectionProbe : undefined,
1557
- });
1558
- };
1559
1515
  const agentRunEvaluation = (id, label, passed, severity, message, evidence) => ({ id, label, passed, severity, message, evidence });
1560
1516
  const agentRunArtifact = (kind, title, payload, ref, trustState = 'review_required') => ({
1561
1517
  id: `${kind}:${Date.now()}`,
@@ -2591,6 +2547,171 @@ export async function startLocalServer(opts) {
2591
2547
  };
2592
2548
  };
2593
2549
  const contextProposalStore = new FileContextAuthoringProposalStore(projectRoot);
2550
+ /**
2551
+ * UI-019: authoring must never dead-end on a Domain/Area that does not exist
2552
+ * yet. Every modeling operation still belongs to exactly one Domain and Area
2553
+ * (`11-context-authoring-and-ai.md`); the difference is that DQL now creates
2554
+ * the missing scope as part of the same reviewable proposal instead of
2555
+ * blocking the author until they create it by hand. The synthesized
2556
+ * operations are ordinary reviewable patches, so nothing is written until the
2557
+ * proposal is explicitly committed.
2558
+ */
2559
+ const ensureAuthoringScopeOperations = (operations, manifest) => {
2560
+ const modeling = manifest.modeling;
2561
+ if (!modeling)
2562
+ return operations;
2563
+ const existingDomains = new Set(Object.keys(modeling.packages ?? {}));
2564
+ const existingAreas = new Set(Object.values(modeling.areas ?? {}).flatMap((area) => [`${area.domain}::${area.localId}`, `${area.domain}::${area.qualifiedId}`]));
2565
+ // A scope created earlier in this same batch counts as existing.
2566
+ for (const operation of operations) {
2567
+ if (operation.kind !== 'modeling_change')
2568
+ continue;
2569
+ if (operation.change.operation === 'upsert_domain')
2570
+ existingDomains.add(operation.change.value.id);
2571
+ if (operation.change.operation === 'upsert_area')
2572
+ existingAreas.add(`${operation.change.value.domain}::${operation.change.value.id}`);
2573
+ }
2574
+ const created = [];
2575
+ const domainOperationId = new Map();
2576
+ const areaOperationId = new Map();
2577
+ const defaultAreaName = manifest.dbtProvenance?.projectName
2578
+ ? titleCaseAuthoring(manifest.dbtProvenance.projectName)
2579
+ : 'Core models';
2580
+ const withScope = operations.map((operation) => {
2581
+ if (operation.kind !== 'modeling_change')
2582
+ return operation;
2583
+ const change = operation.change;
2584
+ if (change.operation !== 'upsert_entity' && change.operation !== 'upsert_relationship')
2585
+ return operation;
2586
+ const domain = change.value.domain;
2587
+ if (!domain)
2588
+ return operation;
2589
+ const dependsOn = [...(operation.dependsOn ?? [])];
2590
+ if (!existingDomains.has(domain)) {
2591
+ let id = domainOperationId.get(domain);
2592
+ if (!id) {
2593
+ id = `scope:domain:${domain}`;
2594
+ domainOperationId.set(domain, id);
2595
+ created.push({
2596
+ id,
2597
+ kind: 'modeling_change',
2598
+ evidence: ['created automatically so the requested modeling scope exists'],
2599
+ change: { operation: 'upsert_domain', value: { id: domain, name: titleCaseAuthoring(domain) } },
2600
+ });
2601
+ }
2602
+ if (!dependsOn.includes(id))
2603
+ dependsOn.push(id);
2604
+ }
2605
+ const areaId = change.value.areaId;
2606
+ if (areaId && !existingAreas.has(`${domain}::${areaId}`)) {
2607
+ const key = `${domain}::${areaId}`;
2608
+ let id = areaOperationId.get(key);
2609
+ if (!id) {
2610
+ id = `scope:area:${domain}:${areaId}`;
2611
+ areaOperationId.set(key, id);
2612
+ created.push({
2613
+ id,
2614
+ kind: 'modeling_change',
2615
+ dependsOn: domainOperationId.has(domain) ? [domainOperationId.get(domain)] : [],
2616
+ evidence: ['created automatically so the requested modeling scope exists'],
2617
+ change: {
2618
+ operation: 'upsert_area',
2619
+ value: {
2620
+ id: areaId,
2621
+ domain,
2622
+ name: areaId === DEFAULT_MODEL_AREA_ID ? defaultAreaName : titleCaseAuthoring(areaId),
2623
+ description: 'Subject area created automatically when modeling started. Rename or split it at any time.',
2624
+ },
2625
+ },
2626
+ });
2627
+ }
2628
+ if (!dependsOn.includes(id))
2629
+ dependsOn.push(id);
2630
+ }
2631
+ return dependsOn.length ? { ...operation, dependsOn } : operation;
2632
+ });
2633
+ return created.length ? [...created, ...withScope] : withScope;
2634
+ };
2635
+ /**
2636
+ * REL-004: when a batch binds dbt models, bring across the relationships dbt
2637
+ * already declares in its `relationships` tests. dbt states the exact key
2638
+ * pair and `dbt test` enforces it, so redrawing those edges by hand is pure
2639
+ * duplicated work.
2640
+ *
2641
+ * REL-001 is unchanged: a dbt test is evidence, not join authorization. Every
2642
+ * edge produced here is `status: 'draft'` with `fanout: 'unknown'` and no
2643
+ * validation receipt, so it can never prove an automatic join.
2644
+ */
2645
+ const appendDbtDeclaredRelationships = (operations, manifest) => {
2646
+ const manifestPath = manifest.dbtProvenance?.manifestPath;
2647
+ if (!manifestPath || !existsSync(manifestPath))
2648
+ return operations;
2649
+ // Entities this batch creates, plus entities already bound in the project.
2650
+ const bound = new Map();
2651
+ for (const entity of Object.values(manifest.modeling?.entities ?? {})) {
2652
+ bound.set(entity.dbtUniqueId, { id: entity.localId, domain: entity.domain, areaId: modelAreaLocalId(entity.areaId) });
2653
+ }
2654
+ let batchBindings = 0;
2655
+ for (const operation of operations) {
2656
+ if (operation.kind !== 'modeling_change' || operation.change.operation !== 'upsert_entity')
2657
+ continue;
2658
+ const value = operation.change.value;
2659
+ bound.set(value.dbtModel, { id: value.id, domain: value.domain, areaId: value.areaId, operationId: operation.id });
2660
+ batchBindings += 1;
2661
+ }
2662
+ if (batchBindings === 0 || bound.size < 2)
2663
+ return operations;
2664
+ let rawManifest;
2665
+ try {
2666
+ rawManifest = JSON.parse(readFileSync(manifestPath, 'utf8'));
2667
+ }
2668
+ catch {
2669
+ return operations;
2670
+ }
2671
+ const existingRelationshipIds = new Set([
2672
+ ...Object.values(manifest.modeling?.relationships ?? {}).map((relationship) => `${relationship.ownerDomain ?? ''}::${relationship.localId}`),
2673
+ ...operations.flatMap((operation) => operation.kind === 'modeling_change' && operation.change.operation === 'upsert_relationship'
2674
+ ? [`${operation.change.value.domain}::${operation.change.value.id}`]
2675
+ : []),
2676
+ ]);
2677
+ const harvested = [];
2678
+ for (const edge of collectDbtRelationshipTests(rawManifest, new Set(bound.keys()))) {
2679
+ if (edge.fromColumn === 'unknown' || edge.toColumn === 'unknown')
2680
+ continue;
2681
+ const from = bound.get(edge.fromDbtUniqueId);
2682
+ const to = bound.get(edge.toDbtUniqueId);
2683
+ if (!from || !to || from.id === to.id)
2684
+ continue;
2685
+ // Only offer edges this batch actually introduces, and keep cross-domain
2686
+ // links on the explicit export/import path.
2687
+ if (!from.operationId && !to.operationId)
2688
+ continue;
2689
+ if (from.domain !== to.domain)
2690
+ continue;
2691
+ const id = `${from.id}_to_${to.id}`;
2692
+ const key = `${from.domain}::${id}`;
2693
+ if (existingRelationshipIds.has(key))
2694
+ continue;
2695
+ existingRelationshipIds.add(key);
2696
+ harvested.push({
2697
+ id: `dbt-test:${edge.testUniqueId}`,
2698
+ kind: 'modeling_change',
2699
+ dependsOn: [from.operationId, to.operationId].filter((value) => Boolean(value)),
2700
+ evidence: [edge.testUniqueId, ...(edge.sourcePath ? [edge.sourcePath] : [])],
2701
+ change: {
2702
+ operation: 'upsert_relationship',
2703
+ value: {
2704
+ id, domain: from.domain, areaId: from.areaId ?? to.areaId,
2705
+ from: from.id, to: to.id,
2706
+ keys: [{ from: edge.fromColumn, to: edge.toColumn }],
2707
+ cardinality: 'many_to_one', fanout: 'unknown', status: 'draft',
2708
+ rationale: `Declared by the dbt relationships test ${edge.testUniqueId}. Validate against the warehouse before certifying.`,
2709
+ },
2710
+ },
2711
+ });
2712
+ }
2713
+ return harvested.length ? [...operations, ...harvested] : operations;
2714
+ };
2594
2715
  const previewContextAuthoring = (input) => {
2595
2716
  const snapshot = projectSnapshot();
2596
2717
  if (input.expectedSnapshotId && input.expectedSnapshotId !== snapshot.snapshotId) {
@@ -2637,30 +2758,48 @@ export async function startLocalServer(opts) {
2637
2758
  if (operation.change.operation === 'upsert_entity'
2638
2759
  && (input.origin === 'ai' || input.origin === 'yaml_import' || input.origin === 'dbt_discovery')
2639
2760
  && !operation.change.value.areaId) {
2640
- diagnostics.push({ code: 'MODEL_AREA_REQUIRED', severity: 'blocking', operationId: operation.id, message: 'Imported and AI-authored models must target one Model Area.' });
2761
+ // Every model still belongs to exactly one Domain and subject area. An
2762
+ // omitted area is filled with the default rather than blocking the
2763
+ // author; `ensureAuthoringScopeOperations` creates it if it is new.
2764
+ diagnostics.push({ code: 'MODEL_AREA_DEFAULTED', severity: 'info', operationId: operation.id, message: `No subject area was selected, so these models were placed in "${DEFAULT_MODEL_AREA_ID}". You can rename or move them later.` });
2765
+ return { ...operation, change: { ...operation.change, value: { ...operation.change.value, areaId: DEFAULT_MODEL_AREA_ID } } };
2641
2766
  }
2642
2767
  return operation;
2643
2768
  });
2644
- const operationIds = new Set(operations.map((operation) => operation.id));
2645
- for (const operation of operations) {
2769
+ // dbt discovery and Modeling AI bind models; give them the edges dbt already
2770
+ // declares. Manual authoring and YAML import bring their own relationships.
2771
+ const withDeclaredEdges = input.origin === 'dbt_discovery' || input.origin === 'ai'
2772
+ ? appendDbtDeclaredRelationships(operations, snapshot.manifest)
2773
+ : operations;
2774
+ const scopedOperations = ensureAuthoringScopeOperations(withDeclaredEdges, snapshot.manifest);
2775
+ const operationIds = new Set(scopedOperations.map((operation) => operation.id));
2776
+ for (const operation of scopedOperations) {
2646
2777
  for (const dependency of operation.dependsOn ?? []) {
2647
2778
  if (!operationIds.has(dependency))
2648
2779
  diagnostics.push({ code: 'DEPENDENCY_CONFLICT', severity: 'blocking', operationId: operation.id, message: `Required proposal operation is missing: ${dependency}` });
2649
2780
  }
2650
2781
  }
2651
2782
  const patches = [];
2652
- const modelingOperations = operations.filter((operation) => operation.kind === 'modeling_change');
2783
+ const modelingOperations = scopedOperations.filter((operation) => operation.kind === 'modeling_change');
2653
2784
  if (modelingOperations.length > 0) {
2654
2785
  const batch = previewModelingChanges(projectRoot, modelingOperations.map((operation) => operation.change));
2786
+ // The batch attributes each path while composing its shadow, so a change
2787
+ // that depends on an earlier one (an entity in a Domain this same batch
2788
+ // creates) is attributed correctly and without re-previewing per patch.
2789
+ const ownerByPath = new Map();
2790
+ batch.patchPathsByChange.forEach((paths, index) => {
2791
+ const operationId = modelingOperations[index]?.id;
2792
+ if (!operationId)
2793
+ return;
2794
+ for (const path of paths)
2795
+ if (!ownerByPath.has(path))
2796
+ ownerByPath.set(path, operationId);
2797
+ });
2655
2798
  for (const patch of batch.patches) {
2656
- const owner = modelingOperations.find((operation) => {
2657
- const single = previewModelingChange(projectRoot, operation.change);
2658
- return single.patches.some((candidate) => candidate.path === patch.path);
2659
- })?.id ?? modelingOperations[0].id;
2660
- patches.push({ ...patch, owner: 'dql', operationId: owner });
2799
+ patches.push({ ...patch, owner: 'dql', operationId: ownerByPath.get(patch.path) ?? modelingOperations[0].id });
2661
2800
  }
2662
2801
  }
2663
- for (const operation of operations) {
2802
+ for (const operation of scopedOperations) {
2664
2803
  if (operation.kind === 'skill_change') {
2665
2804
  const preview = previewSkillChange(projectRoot, operation.value, operation.targetQualifiedId, operation.expectedSourceHash);
2666
2805
  for (const patch of preview.patches) {
@@ -2698,7 +2837,7 @@ export async function startLocalServer(opts) {
2698
2837
  origin: input.origin,
2699
2838
  baseSnapshotId: snapshot.snapshotId,
2700
2839
  dependencyFingerprints,
2701
- operations,
2840
+ operations: scopedOperations,
2702
2841
  patches,
2703
2842
  diagnostics,
2704
2843
  sourceRunId: input.sourceRunId,
@@ -2710,34 +2849,75 @@ export async function startLocalServer(opts) {
2710
2849
  };
2711
2850
  const modelingAuthoringRunExecutor = async ({ runId, request }) => {
2712
2851
  const snapshot = projectSnapshot();
2713
- const operations = buildMetadataBoundModelingOperations(projectRoot, snapshot.manifest, request);
2852
+ // AGT-024: a configured provider designs the model; the deterministic
2853
+ // matcher is the documented provider-unavailable fallback, and the answer
2854
+ // says which one ran so an evidence-only suggestion is never mistaken for
2855
+ // an AI proposal.
2856
+ const provider = await createBlockStudioAssistProvider(projectRoot);
2857
+ let usedProvider = Boolean(provider);
2858
+ let operations;
2859
+ if (provider) {
2860
+ try {
2861
+ operations = await buildAiModelingOperations(projectRoot, snapshot.manifest, request, provider);
2862
+ }
2863
+ catch (error) {
2864
+ // Fall back only when the provider itself is unusable. A rejected
2865
+ // proposal (invented identifiers, nothing to propose) is a real result
2866
+ // and must surface, not be papered over by the template matcher.
2867
+ if (!/could not complete this modeling request/.test(error instanceof Error ? error.message : String(error)))
2868
+ throw error;
2869
+ usedProvider = false;
2870
+ operations = buildDeterministicModelingOperations(projectRoot, snapshot.manifest, request);
2871
+ }
2872
+ }
2873
+ else {
2874
+ operations = buildDeterministicModelingOperations(projectRoot, snapshot.manifest, request);
2875
+ }
2714
2876
  const proposal = previewContextAuthoring({
2715
2877
  origin: request.workspaceContext?.correction === true ? 'correction' : 'ai',
2716
2878
  operations,
2717
- expectedSnapshotId: typeof request.workspaceContext?.snapshotId === 'string' ? request.workspaceContext.snapshotId : snapshot.snapshotId,
2879
+ // Base the draft on the snapshot it was just built from, not on the id the
2880
+ // client captured at page load. Gating construction on the client's id
2881
+ // rejected a proposal assembled from `snapshot.manifest` moments earlier,
2882
+ // so any unrelated project write (a block edit, a dbt reapply) made every
2883
+ // Modeling AI request fail with PROPOSAL_STALE until a manual refresh.
2884
+ // Drift is still caught where it matters: applying the proposal re-checks
2885
+ // its `baseSnapshotId` and `dependencyFingerprints` and fails SOURCE_CHANGED.
2886
+ expectedSnapshotId: snapshot.snapshotId,
2718
2887
  sourceRunId: runId,
2719
2888
  revision: Number(request.workspaceContext?.revision ?? 1),
2720
2889
  });
2890
+ const blocked = proposal.diagnostics.some((item) => item.severity === 'blocking');
2721
2891
  return {
2722
- summary: `Prepared ${proposal.impact.modelingChanges} metadata-bound modeling change${proposal.impact.modelingChanges === 1 ? '' : 's'} for review.`,
2723
- answer: proposal.diagnostics.some((item) => item.severity === 'blocking')
2892
+ summary: `Prepared ${proposal.impact.modelingChanges} modeling change${proposal.impact.modelingChanges === 1 ? '' : 's'} for review.`,
2893
+ answer: blocked
2724
2894
  ? 'I prepared a modeling proposal, but the blocking items must be resolved before it can be saved.'
2725
- : 'I prepared an evidence-bound modeling proposal. Review the exact source diff before saving it as a draft.',
2895
+ : usedProvider
2896
+ ? 'I designed a modeling proposal from your dbt metadata. Every model and column was checked against the current snapshot. Review the exact source diff before saving it as a draft.'
2897
+ : 'No AI provider is configured, so these are evidence-only suggestions matched from dbt metadata rather than an AI proposal. Review the exact source diff before saving it as a draft.',
2726
2898
  status: 'needs_review',
2727
2899
  trustState: 'review_required',
2728
2900
  stopReason: 'human_review_required',
2729
- artifacts: [{ id: `${runId}:modeling-proposal`, kind: 'modeling_change_proposal', title: 'Modeling change proposal', trustState: 'review_required', ref: proposal.id, payload: proposal }],
2901
+ artifacts: [{ id: `${runId}:modeling-proposal`, kind: 'modeling_change_proposal', title: usedProvider ? 'Modeling change proposal' : 'Evidence-only modeling suggestions', trustState: 'review_required', ref: proposal.id, payload: proposal }],
2730
2902
  evaluations: proposal.diagnostics.map((diagnostic) => ({ id: `${proposal.id}:${diagnostic.code}`, label: diagnostic.code, passed: diagnostic.severity !== 'blocking', severity: diagnostic.severity, message: diagnostic.message })),
2731
2903
  nextActions: [{ id: 'review-modeling-proposal', label: 'Review in Modeling', artifactKind: 'modeling_change_proposal' }],
2732
2904
  };
2733
2905
  };
2734
2906
  const skillAuthoringRunExecutor = async ({ runId, request }) => {
2735
2907
  const snapshot = projectSnapshot();
2736
- const operations = buildMetadataBoundSkillOperations(projectRoot, snapshot.manifest, request);
2908
+ // SKILL-006: same contract as Modeling AI — a provider drafts the skill
2909
+ // body and vocabulary; the template is the labelled fallback.
2910
+ const provider = await createBlockStudioAssistProvider(projectRoot);
2911
+ const operations = provider
2912
+ ? await buildAiSkillOperations(projectRoot, snapshot.manifest, request, provider)
2913
+ .catch(() => buildMetadataBoundSkillOperations(projectRoot, snapshot.manifest, request))
2914
+ : buildMetadataBoundSkillOperations(projectRoot, snapshot.manifest, request);
2737
2915
  const proposal = previewContextAuthoring({
2738
2916
  origin: request.workspaceContext?.correction === true ? 'correction' : 'ai',
2739
2917
  operations,
2740
- expectedSnapshotId: typeof request.workspaceContext?.snapshotId === 'string' ? request.workspaceContext.snapshotId : snapshot.snapshotId,
2918
+ // Same reasoning as the modeling executor above: base the draft on the
2919
+ // snapshot it was built from; apply-time re-validation catches real drift.
2920
+ expectedSnapshotId: snapshot.snapshotId,
2741
2921
  sourceRunId: runId,
2742
2922
  revision: Number(request.workspaceContext?.revision ?? 1),
2743
2923
  });
@@ -4173,6 +4353,48 @@ export async function startLocalServer(opts) {
4173
4353
  return [];
4174
4354
  }
4175
4355
  };
4356
+ /**
4357
+ * CTX-008: the project's authored relationships, as plain join facts for the
4358
+ * relations a bounded repair can actually see.
4359
+ *
4360
+ * A repair prompt previously carried only the failure, the failed SQL, and a
4361
+ * bare relation list, so a repair could silently replace a correct governed
4362
+ * join with a shared-column guess. This is grounding only — it never widens
4363
+ * the relation scope and never authorizes a join (`REL-002` still governs
4364
+ * execution); trust is labelled so a draft edge reads as a draft.
4365
+ */
4366
+ const governedJoinFactsForRelations = (relations) => {
4367
+ const modeling = (() => {
4368
+ try {
4369
+ return projectSnapshot().manifest.modeling;
4370
+ }
4371
+ catch {
4372
+ return undefined;
4373
+ }
4374
+ })();
4375
+ if (!modeling)
4376
+ return [];
4377
+ const manifest = projectSnapshot().manifest;
4378
+ const normalize = (value) => value.toLowerCase().replace(/"/g, '').split('.').at(-1) ?? value.toLowerCase();
4379
+ const visible = new Set(relations.map(normalize));
4380
+ const relationFor = (entityKey) => {
4381
+ const entity = modeling.entities[entityKey];
4382
+ if (!entity)
4383
+ return undefined;
4384
+ const node = manifest.dbtProvenance?.nodes[entity.dbtUniqueId];
4385
+ return node?.relation ?? node?.name;
4386
+ };
4387
+ return Object.values(modeling.relationships).flatMap((relationship) => {
4388
+ const from = relationFor(relationship.from);
4389
+ const to = relationFor(relationship.to);
4390
+ if (!from || !to || !visible.has(normalize(from)) || !visible.has(normalize(to)))
4391
+ return [];
4392
+ const keys = relationship.keys.map((key) => `${from}.${key.from} = ${to}.${key.to}`).join(' AND ');
4393
+ const trust = relationship.automaticJoinAllowed ? 'certified' : `${relationship.status} (review required)`;
4394
+ const meaning = [relationship.verb, relationship.description].filter(Boolean).join(' — ');
4395
+ return [`${keys} (${relationship.cardinality}, fanout ${relationship.fanout}, ${trust}${meaning ? `; ${meaning}` : ''})`];
4396
+ }).slice(0, 12);
4397
+ };
4176
4398
  const boundedRepairError = (message, status, warehouseFailure) => Object.assign(new Error(message), { status, ...(warehouseFailure ? { warehouseFailure } : {}) });
4177
4399
  /**
4178
4400
  * Shared one-attempt SQL repair for Ask and Notebook. It never creates a
@@ -4209,6 +4431,9 @@ export async function startLocalServer(opts) {
4209
4431
  relation: table.relation,
4210
4432
  columns: table.columns.slice(0, 40).map((column) => ({ name: column.name, type: column.type })),
4211
4433
  }));
4434
+ // CTX-008: a repair that re-derives joins from column names can undo a
4435
+ // join the original query got right. Give it the authored key pairs.
4436
+ const governedJoins = governedJoinFactsForRelations(schemaContext.slice(0, 16).map((table) => table.relation));
4212
4437
  let raw;
4213
4438
  try {
4214
4439
  raw = await provider.generate([
@@ -4222,6 +4447,7 @@ export async function startLocalServer(opts) {
4222
4447
  ? 'Preserve every positional parameter placeholder exactly; do not add, remove, or renumber placeholders.'
4223
4448
  : '',
4224
4449
  'Use only relations and columns present in the supplied schema context.',
4450
+ 'When governedJoins is present, join on those exact key pairs rather than inferring a join from column names.',
4225
4451
  'Never emit internal DQL relation identities such as source::, model::, seed::, or snapshot::.',
4226
4452
  'Do not add writes, DDL, multiple statements, or commentary outside the JSON fence.',
4227
4453
  ].join(' '),
@@ -4238,6 +4464,7 @@ export async function startLocalServer(opts) {
4238
4464
  placeholder: `$${parameter.position}`,
4239
4465
  })),
4240
4466
  schema: boundedSchema,
4467
+ ...(governedJoins.length ? { governedJoins } : {}),
4241
4468
  }),
4242
4469
  },
4243
4470
  ], { maxTokens: 1200, temperature: 0 });
@@ -4307,6 +4534,8 @@ export async function startLocalServer(opts) {
4307
4534
  relation: table.relation,
4308
4535
  columns: table.columns.slice(0, 40).map((column) => ({ name: column.name, type: column.type })),
4309
4536
  }));
4537
+ // CTX-008: same as the SQL repair — keep authored joins out of the guesser.
4538
+ const governedJoins = governedJoinFactsForRelations(schemaContext.slice(0, 16).map((table) => table.relation));
4310
4539
  let raw;
4311
4540
  try {
4312
4541
  raw = await provider.generate([
@@ -4319,6 +4548,7 @@ export async function startLocalServer(opts) {
4319
4548
  'Preserve the block name, business fields, visualization, tests, requested grain, filters, output columns, and aggregation semantics.',
4320
4549
  'Preserve every declared parameter and every ${parameter} interpolation exactly. Do not add or remove parameters.',
4321
4550
  'Use only relations and columns present in the supplied schema context.',
4551
+ 'When governedJoins is present, join on those exact key pairs rather than inferring a join from column names.',
4322
4552
  'Never emit internal relation identities such as source::, model::, seed::, or snapshot::.',
4323
4553
  'Return exactly one custom block. Never certify it, bind a saved block, add writes, DDL, or multiple SQL statements.',
4324
4554
  ].join(' '),
@@ -4332,6 +4562,7 @@ export async function startLocalServer(opts) {
4332
4562
  dql: input.source.slice(0, 40_000),
4333
4563
  parameters: input.parameters ?? {},
4334
4564
  schema: boundedSchema,
4565
+ ...(governedJoins.length ? { governedJoins } : {}),
4335
4566
  }),
4336
4567
  },
4337
4568
  ], { maxTokens: 2400, temperature: 0 });
@@ -6621,10 +6852,13 @@ export async function startLocalServer(opts) {
6621
6852
  const selectedCandidateIds = Array.isArray(body.selectedCandidateIds)
6622
6853
  ? body.selectedCandidateIds.filter((value) => typeof value === 'string')
6623
6854
  : session.candidates.filter((candidate) => candidate.dialect !== 'unsupported' && candidate.action !== 'open_existing').map((candidate) => candidate.id);
6624
- const domain = typeof body.domain === 'string' ? body.domain.trim() : '';
6625
- const areaId = typeof body.areaId === 'string' ? body.areaId.trim() : '';
6626
- if (!domain || !areaId)
6627
- throw Object.assign(new Error('Choose exactly one Domain and Model Area for imported models.'), { code: 'MODEL_SCOPE_REQUIRED' });
6855
+ // An unspecified scope is defaulted, not rejected: the proposal carries
6856
+ // the `upsert_domain`/`upsert_area` patches so the author reviews the
6857
+ // scope alongside the models it will hold.
6858
+ const domain = typeof body.domain === 'string' && body.domain.trim()
6859
+ ? body.domain.trim()
6860
+ : defaultAuthoringDomainId(snapshot.manifest);
6861
+ const areaId = typeof body.areaId === 'string' && body.areaId.trim() ? body.areaId.trim() : DEFAULT_MODEL_AREA_ID;
6628
6862
  const planned = modelingImportOperations(session, selectedCandidateIds, domain, areaId, snapshot.manifest);
6629
6863
  const proposal = previewContextAuthoring({
6630
6864
  origin: 'yaml_import',
@@ -9008,20 +9242,23 @@ export async function startLocalServer(opts) {
9008
9242
  }));
9009
9243
  return;
9010
9244
  }
9011
- const appDashRun = path.match(/^\/api\/apps\/([^/]+)\/dashboards\/([^/]+)\/run$/);
9245
+ const appDashRun = path.match(/^\/api\/(apps|app-builds)\/([^/]+)\/dashboards\/([^/]+)\/run$/);
9012
9246
  if (req.method === 'POST' && appDashRun) {
9013
9247
  try {
9014
- const appId = decodeURIComponent(appDashRun[1]);
9015
- const dashboardId = decodeURIComponent(appDashRun[2]);
9248
+ const runSurface = appDashRun[1];
9249
+ const appId = decodeURIComponent(appDashRun[2]);
9250
+ const dashboardId = decodeURIComponent(appDashRun[3]);
9016
9251
  const body = await readJSON(req).catch(() => ({}));
9017
- const loaded = loadAppDashboard(projectRoot, appId, dashboardId);
9252
+ const loaded = runSurface === 'app-builds'
9253
+ ? loadAppBuildDraftDashboard(projectRoot, appId, dashboardId)
9254
+ : loadAppDashboard(projectRoot, appId, dashboardId);
9018
9255
  if (!loaded) {
9019
9256
  res.writeHead(404, { 'Content-Type': 'application/json; charset=utf-8' });
9020
- res.end(serializeJSON({ error: `Dashboard "${dashboardId}" not found in app "${appId}"` }));
9257
+ res.end(serializeJSON({ error: `Dashboard "${dashboardId}" not found in ${runSurface === 'app-builds' ? 'local App draft' : 'App'} "${appId}"` }));
9021
9258
  return;
9022
9259
  }
9023
9260
  const activeApp = activePersonaAppId();
9024
- if (activeApp && activeApp !== appId) {
9261
+ if (runSurface === 'apps' && activeApp && activeApp !== appId) {
9025
9262
  res.writeHead(403, { 'Content-Type': 'application/json; charset=utf-8' });
9026
9263
  res.end(serializeJSON({ error: `The active persona belongs to App "${activeApp}", not "${appId}".` }));
9027
9264
  return;
@@ -9152,8 +9389,14 @@ export async function startLocalServer(opts) {
9152
9389
  let draftTargetConnection;
9153
9390
  let draftRepairPlan;
9154
9391
  try {
9155
- const draftPath = join(loaded.appDir, item.draftAnalysis.ref);
9156
- draftAppRelativePath = relative(loaded.appDir, draftPath);
9392
+ const localDraftPath = loaded.draftArtifactsDir
9393
+ ? join(loaded.draftArtifactsDir, item.draftAnalysis.ref)
9394
+ : undefined;
9395
+ const draftRoot = localDraftPath && existsSync(localDraftPath)
9396
+ ? loaded.draftArtifactsDir
9397
+ : loaded.appDir;
9398
+ const draftPath = join(draftRoot, item.draftAnalysis.ref);
9399
+ draftAppRelativePath = relative(draftRoot, draftPath);
9157
9400
  if (draftAppRelativePath.startsWith('..') || draftAppRelativePath.startsWith('/')) {
9158
9401
  throw new Error('App-scoped analysis reference escapes the App directory.');
9159
9402
  }
@@ -9328,9 +9571,7 @@ export async function startLocalServer(opts) {
9328
9571
  throw new Error(`Semantic definition changed; missing model(s): ${missingModels.join(', ')}`);
9329
9572
  const targetConnection = requireActiveConnection(isConnectionConfig(body.connection) ? body.connection : connection);
9330
9573
  const tableMapping = await resolveSemanticTableMapping(executor, targetConnection, semanticLayer, projectRoot);
9331
- const activeFilters = Object.entries(dashboardVariables)
9332
- .filter(([field, value]) => item.semantic.dimensions?.includes(field) && value !== undefined && value !== null && value !== '')
9333
- .map(([dimension, value]) => ({ dimension, operator: 'equals', values: Array.isArray(value) ? value.map(String) : [String(value)] }));
9574
+ const activeFilters = dashboardSemanticFiltersForTile(loaded.dashboard, item, dashboardVariables);
9334
9575
  const staticFilters = (item.semantic.filters ?? []).map((filter) => ({
9335
9576
  dimension: filter.field,
9336
9577
  operator: filter.operator,
@@ -9516,6 +9757,8 @@ export async function startLocalServer(opts) {
9516
9757
  variables: { ...(blockPlan?.variables ?? {}), ...dashboardVariables },
9517
9758
  block,
9518
9759
  dashboard: loaded.dashboard,
9760
+ tileId: item.i,
9761
+ tileFilterBindings: item.filterBindings,
9519
9762
  });
9520
9763
  const prepared = prepareLocalExecution(blockFilterApplication.sql, blockTargetConnection, projectRoot, projectConfig);
9521
9764
  const result = await executor.executeQuery(prepared.sql, blockFilterApplication.sqlParams, runtimeVariables({ ...blockFilterApplication.variables, ...blockInvocation.values }), prepared.connection);
@@ -9656,6 +9899,36 @@ export async function startLocalServer(opts) {
9656
9899
  }
9657
9900
  }
9658
9901
  localApps?.close();
9902
+ // Which of each tile's result columns a viewer may safely filter on.
9903
+ // Decided here, from a real dialect parse of the SQL that actually ran,
9904
+ // for the same reason Ask does it server-side: an aggregate output needs
9905
+ // HAVING, and picking such a column off the table would quietly change
9906
+ // the measure. The browser must never re-derive this.
9907
+ const runDialect = (() => {
9908
+ try {
9909
+ return requireActiveConnection().driver || 'duckdb';
9910
+ }
9911
+ catch {
9912
+ return 'duckdb';
9913
+ }
9914
+ })();
9915
+ const tilesWithFilters = tiles.map((tile) => {
9916
+ const executedSql = tile.artifact?.sql;
9917
+ const columns = tile.result?.columns ?? [];
9918
+ if (typeof executedSql !== 'string' || columns.length === 0)
9919
+ return tile;
9920
+ try {
9921
+ const dashboardItem = loaded.dashboard.layout.items.find((item) => item.i === tile.tileId);
9922
+ const block = dashboardItem ? resolveDashboardItemBlock(dashboardItem, manifest) : null;
9923
+ const declaredDimensions = block?.status === 'certified' ? block.dimensions ?? [] : [];
9924
+ const filterable = dashboardFilterableResultColumns(executedSql, columns, declaredDimensions, runDialect);
9925
+ return filterable.length > 0 ? { ...tile, filterableColumns: filterable } : tile;
9926
+ }
9927
+ catch {
9928
+ return tile; // Unparseable SQL simply offers no filter candidates.
9929
+ }
9930
+ });
9931
+ const filterOptions = collectDashboardFilterOptions(loaded.dashboard, tilesWithFilters);
9659
9932
  const snapshot = projectSnapshot();
9660
9933
  const filterFingerprint = createHash('sha256').update(JSON.stringify(dashboardVariables)).digest('hex');
9661
9934
  const resultFingerprint = createHash('sha256').update(JSON.stringify(tiles.map((tile) => ({ tileId: tile.tileId, status: tile.status, result: tile.result })))).digest('hex');
@@ -9706,7 +9979,8 @@ export async function startLocalServer(opts) {
9706
9979
  filterFingerprint,
9707
9980
  resultFingerprint,
9708
9981
  personaFingerprint,
9709
- tiles,
9982
+ filterOptions,
9983
+ tiles: tilesWithFilters,
9710
9984
  facts: storyResult.facts,
9711
9985
  story: storyResult.story,
9712
9986
  }));
@@ -9718,7 +9992,11 @@ export async function startLocalServer(opts) {
9718
9992
  return;
9719
9993
  }
9720
9994
  // Apps, dashboards, persona — see apps-api.ts. Returns true if handled.
9721
- if (path.startsWith('/api/apps') || path.startsWith('/api/visualizations') || path === '/api/persona') {
9995
+ if (path.startsWith('/api/apps')
9996
+ || path.startsWith('/api/app-builds')
9997
+ || path.startsWith('/api/app-recoveries')
9998
+ || path.startsWith('/api/visualizations')
9999
+ || path === '/api/persona') {
9722
10000
  try {
9723
10001
  const handled = await handleAppsApi({
9724
10002
  req,
@@ -9764,6 +10042,18 @@ export async function startLocalServer(opts) {
9764
10042
  }
9765
10043
  return { ok: true, snapshotId: evidence.snapshotId, resultFingerprint: evidence.resultFingerprint };
9766
10044
  },
10045
+ verifyAppBuildPreview: ({ runId, draftId, dashboardId, tileIds }) => {
10046
+ const evidence = dashboardRunEvidence.get(runId);
10047
+ if (!evidence || evidence.expiresAt < Date.now())
10048
+ return { ok: false, error: 'The App preview receipt expired. Run the local draft again.' };
10049
+ if (evidence.appId !== draftId || evidence.dashboardId !== dashboardId)
10050
+ return { ok: false, error: 'The App preview receipt does not match this local draft page.' };
10051
+ const successful = new Set(evidence.successfulTileIds);
10052
+ const failed = tileIds.filter((tileId) => !successful.has(tileId));
10053
+ if (failed.length > 0)
10054
+ return { ok: false, error: `These App components did not complete successfully: ${failed.join(', ')}` };
10055
+ return { ok: true, snapshotId: evidence.snapshotId, resultFingerprint: evidence.resultFingerprint };
10056
+ },
9767
10057
  // Story narration for commit — LLM-backed with deterministic fallback.
9768
10058
  narrate: narrateForAgentRun,
9769
10059
  });
@@ -15888,6 +16178,61 @@ function loadAppDashboard(projectRoot, appId, dashboardId) {
15888
16178
  }
15889
16179
  return null;
15890
16180
  }
16181
+ /**
16182
+ * Resolves an App Studio draft directly from local SQLite for read-only preview.
16183
+ * The draft is never copied into the Git-owned `apps/` tree. This gives manual
16184
+ * and AI authoring the same execution path and the same filter/story receipts
16185
+ * as a published App while keeping publication explicit.
16186
+ */
16187
+ function loadAppBuildDraftDashboard(projectRoot, draftId, dashboardId) {
16188
+ const storage = new LocalAppStorage(defaultLocalAppsDbPath(projectRoot));
16189
+ try {
16190
+ const draft = storage.getAppBuildDraft(draftId);
16191
+ if (!draft)
16192
+ return null;
16193
+ const dashboard = draft.pages.find((page) => page.id === dashboardId);
16194
+ if (!dashboard)
16195
+ return null;
16196
+ const base = draft.baseApp ? findAppById(projectRoot, draft.baseApp.appId) : null;
16197
+ const app = base?.app ?? {
16198
+ version: 1,
16199
+ id: draft.appId,
16200
+ name: draft.name,
16201
+ description: draft.frame.goal,
16202
+ visibility: 'private',
16203
+ publicationIntent: 'personal',
16204
+ domain: dashboard.metadata.domain ?? 'general',
16205
+ usesDomains: [],
16206
+ requiredExports: [],
16207
+ audience: draft.frame.audience || 'stakeholders',
16208
+ lifecycle: 'draft',
16209
+ owners: ['local-author'],
16210
+ tags: ['app-studio', 'local-draft'],
16211
+ members: [],
16212
+ roles: [],
16213
+ policies: [],
16214
+ homepage: { type: 'dashboard', id: dashboard.id },
16215
+ };
16216
+ return {
16217
+ app: { ...app, id: draft.appId, name: draft.name, visibility: 'private', lifecycle: 'draft' },
16218
+ dashboard,
16219
+ appDir: base?.appDir ?? join(projectRoot, '.dql', 'local', 'app-builds', draft.id),
16220
+ draftArtifactsDir: join(projectRoot, '.dql', 'local', 'app-builds', draft.id),
16221
+ };
16222
+ }
16223
+ finally {
16224
+ storage.close();
16225
+ }
16226
+ }
16227
+ function findAppById(projectRoot, appId) {
16228
+ for (const path of findAppDocuments(projectRoot)) {
16229
+ const { document: app } = loadAppDocument(path);
16230
+ if (app?.id !== appId)
16231
+ continue;
16232
+ return { app, appDir: path.slice(0, -'/dql.app.json'.length) };
16233
+ }
16234
+ return null;
16235
+ }
15891
16236
  function resolveDashboardItemBlock(item, manifest) {
15892
16237
  if (!item.block)
15893
16238
  return null;
@@ -16224,6 +16569,8 @@ export function parseDomainInput(raw, fallbackId) {
16224
16569
  sourcePath: typeof domain.sourcePath === 'string' ? domain.sourcePath : undefined,
16225
16570
  };
16226
16571
  }
16572
+ /** Bounded discovery: an import scan reads at most this many YAML files. */
16573
+ const MODELING_IMPORT_FILE_LIMIT = 100;
16227
16574
  function modelingImportDirectory(projectRoot) {
16228
16575
  return join(projectRoot, '.dql', 'local', 'modeling-imports');
16229
16576
  }
@@ -16260,6 +16607,7 @@ function createModelingImportSession(projectRoot, gitRoot, loopback, source) {
16260
16607
  if (mode !== 'current_project' && mode !== 'path' && mode !== 'upload' && mode !== 'paste')
16261
16608
  throw Object.assign(new Error('Choose current_project, path, upload, or paste.'), { code: 'INVALID_REQUEST' });
16262
16609
  const files = [];
16610
+ const truncationWarnings = [];
16263
16611
  if (mode === 'paste' || mode === 'upload') {
16264
16612
  const content = typeof source.content === 'string' ? source.content : '';
16265
16613
  if (!content.trim() || Buffer.byteLength(content) > 1_000_000)
@@ -16278,11 +16626,17 @@ function createModelingImportSession(projectRoot, gitRoot, loopback, source) {
16278
16626
  const requested = realpathSync(requestedInput);
16279
16627
  if (requested !== allowedRoot && !requested.startsWith(`${allowedRoot}${sep}`))
16280
16628
  throw Object.assign(new Error('Import path must stay inside the current Git workspace.'), { code: 'PATH_ACCESS_FORBIDDEN' });
16281
- const paths = collectModelingYamlPaths(requested, 100);
16629
+ const paths = collectModelingYamlPaths(requested, MODELING_IMPORT_FILE_LIMIT);
16630
+ // Truncation used to be silent, which reads as "DQL did not find my file".
16631
+ if (paths.length >= MODELING_IMPORT_FILE_LIMIT) {
16632
+ truncationWarnings.push(`Only the first ${MODELING_IMPORT_FILE_LIMIT} YAML files in this location were scanned. Point the import at a narrower folder to see the rest.`);
16633
+ }
16282
16634
  for (const path of paths) {
16283
16635
  const size = statSync(path).size;
16284
- if (size > 1_000_000)
16636
+ if (size > 1_000_000) {
16637
+ truncationWarnings.push(`Skipped ${relative(requested, path).replace(/\\/g, '/')}: larger than the 1 MB import limit.`);
16285
16638
  continue;
16639
+ }
16286
16640
  const resolvedPath = realpathSync(path);
16287
16641
  if (resolvedPath !== allowedRoot && !resolvedPath.startsWith(`${allowedRoot}${sep}`))
16288
16642
  continue;
@@ -16301,6 +16655,7 @@ function createModelingImportSession(projectRoot, gitRoot, loopback, source) {
16301
16655
  updatedAt: now,
16302
16656
  source: { mode, label: mode === 'path' ? String(source.path ?? '') : mode === 'current_project' ? 'Current project' : files[0].name },
16303
16657
  candidates,
16658
+ ...(truncationWarnings.length ? { warnings: truncationWarnings } : {}),
16304
16659
  };
16305
16660
  writeModelingImportSession(projectRoot, session);
16306
16661
  return session;
@@ -16368,10 +16723,12 @@ function modelingImportOperations(session, selectedIds, domain, areaId, manifest
16368
16723
  throw Object.assign(new Error('Select at least one supported YAML candidate.'), { code: 'MODELING_IMPORT_EMPTY' });
16369
16724
  const packageExists = Boolean(manifest.modeling?.packages?.[domain]);
16370
16725
  const area = Object.values(manifest.modeling?.areas ?? {}).find((item) => item.domain === domain && (item.localId === areaId || item.qualifiedId === areaId));
16726
+ // A missing Domain/subject area is created by `ensureAuthoringScopeOperations`
16727
+ // as part of this same proposal, so it is reported, not blocked.
16371
16728
  if (!packageExists)
16372
- diagnostics.push({ code: 'DOMAIN_NOT_FOUND', severity: 'blocking', message: `Domain not found: ${domain}` });
16729
+ diagnostics.push({ code: 'DOMAIN_CREATED', severity: 'info', message: `Domain "${domain}" does not exist yet and will be created with these models.` });
16373
16730
  if (!area)
16374
- diagnostics.push({ code: 'MODEL_AREA_NOT_FOUND', severity: 'blocking', message: `Model Area not found in ${domain}: ${areaId}` });
16731
+ diagnostics.push({ code: 'MODEL_AREA_CREATED', severity: 'info', message: `Subject area "${areaId}" does not exist yet in ${domain} and will be created with these models.` });
16375
16732
  const nodes = Object.values(manifest.dbtProvenance?.nodes ?? {});
16376
16733
  const resolveDbt = (reference, operationId) => {
16377
16734
  const cleaned = reference.replace(/^ref\(['"]|['"]\)$/g, '').replace(/^model\./, '');
@@ -16381,6 +16738,15 @@ function modelingImportOperations(session, selectedIds, domain, areaId, manifest
16381
16738
  return matches.length === 1 ? matches[0].uniqueId : undefined;
16382
16739
  };
16383
16740
  const used = new Set(Object.values(manifest.modeling?.entities ?? {}).filter((entity) => entity.domain === domain).map((entity) => entity.localId));
16741
+ /**
16742
+ * REL-004: dbt already declares its relationships. Every model this import
16743
+ * binds is recorded here so the dbt `relationships` tests and MetricFlow
16744
+ * `entities:` blocks covering those models become draft edges instead of
16745
+ * leaving the author to redraw connections they already wrote down.
16746
+ */
16747
+ const boundEntityByDbtId = new Map();
16748
+ const semanticPrimaryByName = new Map();
16749
+ const semanticForeignRefs = [];
16384
16750
  const allocate = (value) => {
16385
16751
  const base = authoringSlug(value).replace(/-/g, '_') || 'model';
16386
16752
  let id = base;
@@ -16410,6 +16776,7 @@ function modelingImportOperations(session, selectedIds, domain, areaId, manifest
16410
16776
  continue;
16411
16777
  const sourceId = String(entity.id ?? nodes.find((node) => node.uniqueId === dbtModel)?.name ?? `model_${index + 1}`);
16412
16778
  const id = allocate(sourceId);
16779
+ boundEntityByDbtId.set(dbtModel, { id, operationId });
16413
16780
  importedEntities.set(sourceId, { id, operationId });
16414
16781
  importedEntities.set(String(entity.qualified_id ?? entity.qualifiedId ?? ''), { id, operationId });
16415
16782
  importedEntities.set(dbtModel, { id, operationId });
@@ -16460,7 +16827,91 @@ function modelingImportOperations(session, selectedIds, domain, areaId, manifest
16460
16827
  continue;
16461
16828
  }
16462
16829
  const id = existing?.localId ?? allocate(String(model.name ?? nodes.find((node) => node.uniqueId === dbtModel)?.name ?? `model_${index + 1}`));
16463
- operations.push({ id: operationId, kind: 'modeling_change', evidence: [candidate.path ?? candidate.name, dbtModel], change: { operation: 'upsert_entity', value: { id, domain, areaId: area?.localId ?? areaId, dbtModel, businessName: typeof model.name === 'string' ? titleCaseAuthoring(model.name) : undefined, businessContext: typeof model.description === 'string' ? model.description : undefined, status: 'draft', analyticalRole: 'unknown' } } });
16830
+ boundEntityByDbtId.set(dbtModel, { id, operationId });
16831
+ // MetricFlow states its own join graph in `entities:`; keep the primary
16832
+ // key so the semantic branch can pair it with foreign references below.
16833
+ const semanticEntities = contextArray(model.entities).map(contextRecord);
16834
+ const primary = semanticEntities.find((entry) => entry.type === 'primary');
16835
+ if (primary?.name)
16836
+ semanticPrimaryByName.set(String(primary.name), { dbtModel, column: String(primary.expr ?? primary.name) });
16837
+ for (const entry of semanticEntities) {
16838
+ if (entry.type !== 'foreign' || !entry.name)
16839
+ continue;
16840
+ semanticForeignRefs.push({ name: String(entry.name), column: String(entry.expr ?? entry.name), dbtModel, operationId });
16841
+ }
16842
+ const aggTimeDimension = contextRecord(model.defaults).agg_time_dimension;
16843
+ operations.push({ id: operationId, kind: 'modeling_change', evidence: [candidate.path ?? candidate.name, dbtModel], change: { operation: 'upsert_entity', value: { id, domain, areaId: area?.localId ?? areaId, dbtModel, businessName: typeof model.name === 'string' ? titleCaseAuthoring(model.name) : undefined, businessContext: typeof model.description === 'string' ? model.description : undefined, status: 'draft', analyticalRole: 'unknown', ...(typeof aggTimeDimension === 'string' ? { grain: aggTimeDimension } : {}), ...(primary?.name ? { keys: [String(primary.expr ?? primary.name)] } : {}) } } });
16844
+ }
16845
+ }
16846
+ }
16847
+ // REL-004: pair MetricFlow foreign references against the primary entity of
16848
+ // the same name. This is dbt's own declared join graph, so it needs no
16849
+ // guessing — but it is still only a draft candidate (REL-001).
16850
+ for (const reference of semanticForeignRefs) {
16851
+ const primary = semanticPrimaryByName.get(reference.name);
16852
+ if (!primary || primary.dbtModel === reference.dbtModel)
16853
+ continue;
16854
+ const from = boundEntityByDbtId.get(reference.dbtModel);
16855
+ const to = boundEntityByDbtId.get(primary.dbtModel);
16856
+ if (!from || !to)
16857
+ continue;
16858
+ operations.push({
16859
+ id: `semantic-entity:${from.id}:${to.id}:${reference.name}`,
16860
+ kind: 'modeling_change',
16861
+ dependsOn: [...new Set([from.operationId, to.operationId])],
16862
+ evidence: [`MetricFlow entity "${reference.name}" declared foreign in ${reference.dbtModel} and primary in ${primary.dbtModel}`],
16863
+ change: {
16864
+ operation: 'upsert_relationship',
16865
+ value: {
16866
+ id: `${from.id}_to_${to.id}`, domain, areaId: area?.localId ?? areaId,
16867
+ from: from.id, to: to.id,
16868
+ keys: [{ from: reference.column, to: primary.column }],
16869
+ cardinality: 'many_to_one', fanout: 'unknown', status: 'draft',
16870
+ verb: reference.name,
16871
+ rationale: 'Declared by the MetricFlow semantic entity graph. Validate against the warehouse before certifying.',
16872
+ },
16873
+ },
16874
+ });
16875
+ }
16876
+ // REL-004: dbt `relationships` tests state an exact key pair and are already
16877
+ // enforced by `dbt test`, which makes them the strongest available candidate.
16878
+ // They remain drafts: a dbt test is evidence, never join authorization.
16879
+ const manifestPath = manifest.dbtProvenance?.manifestPath;
16880
+ if (manifestPath && boundEntityByDbtId.size > 1) {
16881
+ let rawManifest;
16882
+ try {
16883
+ rawManifest = JSON.parse(readFileSync(manifestPath, 'utf8'));
16884
+ }
16885
+ catch {
16886
+ rawManifest = undefined;
16887
+ }
16888
+ if (rawManifest) {
16889
+ for (const edge of collectDbtRelationshipTests(rawManifest, new Set(boundEntityByDbtId.keys()))) {
16890
+ if (edge.fromColumn === 'unknown' || edge.toColumn === 'unknown')
16891
+ continue;
16892
+ const from = boundEntityByDbtId.get(edge.fromDbtUniqueId);
16893
+ const to = boundEntityByDbtId.get(edge.toDbtUniqueId);
16894
+ if (!from || !to || from.id === to.id)
16895
+ continue;
16896
+ const id = `${from.id}_to_${to.id}`;
16897
+ if (operations.some((operation) => operation.kind === 'modeling_change' && operation.change.operation === 'upsert_relationship' && operation.change.value.id === id))
16898
+ continue;
16899
+ operations.push({
16900
+ id: `dbt-test:${edge.testUniqueId}`,
16901
+ kind: 'modeling_change',
16902
+ dependsOn: [...new Set([from.operationId, to.operationId])],
16903
+ evidence: [edge.testUniqueId, ...(edge.sourcePath ? [edge.sourcePath] : [])],
16904
+ change: {
16905
+ operation: 'upsert_relationship',
16906
+ value: {
16907
+ id, domain, areaId: area?.localId ?? areaId,
16908
+ from: from.id, to: to.id,
16909
+ keys: [{ from: edge.fromColumn, to: edge.toColumn }],
16910
+ cardinality: 'many_to_one', fanout: 'unknown', status: 'draft',
16911
+ rationale: `Declared by the dbt relationships test ${edge.testUniqueId}. Validate against the warehouse before certifying.`,
16912
+ },
16913
+ },
16914
+ });
16464
16915
  }
16465
16916
  }
16466
16917
  }
@@ -16515,7 +16966,7 @@ function parseContextAuthoringOperations(value) {
16515
16966
  const evidence = Array.isArray(operation.evidence) ? operation.evidence.filter((item) => typeof item === 'string').slice(0, 32) : undefined;
16516
16967
  if (operation.kind === 'modeling_change') {
16517
16968
  const change = operation.change;
16518
- const allowed = new Set(['upsert_domain', 'upsert_area', 'upsert_entity', 'upsert_relationship', 'upsert_contract', 'upsert_export', 'upsert_import', 'remove_entity', 'remove_relationship', 'remove_area']);
16969
+ const allowed = new Set(['upsert_domain', 'upsert_area', 'upsert_entity', 'upsert_relationship', 'upsert_contract', 'upsert_export', 'upsert_import', 'remove_entity', 'remove_relationship', 'remove_area', 'remove_contract', 'remove_export', 'remove_import']);
16519
16970
  if (!change || typeof change !== 'object' || !allowed.has(change.operation) || !('value' in change) || !change.value || typeof change.value !== 'object')
16520
16971
  throw Object.assign(new Error(`Modeling operation ${id} is invalid.`), { code: 'INVALID_REQUEST' });
16521
16972
  return { id, kind: 'modeling_change', change, dependsOn, evidence };
@@ -16553,7 +17004,225 @@ function resolveContextPatchPath(projectRoot, gitRoot, relativePath) {
16553
17004
  }
16554
17005
  return absolute;
16555
17006
  }
16556
- function buildMetadataBoundModelingOperations(projectRoot, manifest, request) {
17007
+ /**
17008
+ * Modeling AI: propose modeling changes from repository metadata.
17009
+ *
17010
+ * `AGT-024` — the model receives manifest identifiers, columns, lineage, tests,
17011
+ * current modeling, and source locations, and never query rows. It proposes;
17012
+ * this function then validates every identifier against the live snapshot and
17013
+ * drops anything invented, so a hallucinated model or column cannot enter a
17014
+ * proposal. `previewContextAuthoring` independently forces `status: 'draft'`,
17015
+ * strips certification and validation, and blocks deletes and cross-domain
17016
+ * relationships, so nothing here can certify or authorize a join.
17017
+ */
17018
+ async function buildAiModelingOperations(projectRoot, manifest, request, provider) {
17019
+ const modeling = manifest.modeling;
17020
+ if (!modeling || !manifest.dbtProvenance)
17021
+ throw new Error('Modeling AI requires a compiled dbt-first project snapshot.');
17022
+ const context = request.workspaceContext ?? {};
17023
+ const requestedDomain = typeof context.domain === 'string'
17024
+ ? context.domain
17025
+ : request.selectedObject?.kind === 'domain' ? request.selectedObject.id : undefined;
17026
+ const domain = requestedDomain ?? Object.keys(modeling.packages)[0] ?? defaultAuthoringDomainId(manifest);
17027
+ const requestedArea = typeof context.modelAreaId === 'string' ? context.modelAreaId : typeof context.areaId === 'string' ? context.areaId : undefined;
17028
+ const area = Object.values(modeling.areas).find((candidate) => candidate.domain === domain
17029
+ && (!requestedArea || candidate.qualifiedId === requestedArea || candidate.localId === requestedArea));
17030
+ const areaId = area?.localId ?? modelAreaLocalId(requestedArea) ?? DEFAULT_MODEL_AREA_ID;
17031
+ const bound = new Set(Object.values(modeling.entities).map((entity) => entity.dbtUniqueId));
17032
+ const manifestPath = resolveDbtManifestPath(projectRoot);
17033
+ const describe = (uniqueId) => {
17034
+ const detail = manifestPath ? loadDbtNodeAuthoringDetail(manifestPath, uniqueId) : undefined;
17035
+ return {
17036
+ dbtUniqueId: uniqueId,
17037
+ name: manifest.dbtProvenance.nodes[uniqueId]?.name,
17038
+ relation: manifest.dbtProvenance.nodes[uniqueId]?.relation,
17039
+ description: detail?.description,
17040
+ columns: (detail?.columns ?? []).slice(0, 60).map((column) => ({ name: column.name, type: column.type, description: column.description })),
17041
+ tests: (detail?.tests ?? []).slice(0, 40),
17042
+ };
17043
+ };
17044
+ // Bound as candidates so a large project still fits a prompt.
17045
+ const unbound = Object.values(manifest.dbtProvenance.nodes).filter((node) => !bound.has(node.uniqueId));
17046
+ const tokens = authoringTokens(request.question);
17047
+ const ranked = [
17048
+ ...unbound.filter((node) => tokens.some((token) => `${node.name} ${node.uniqueId} ${node.sourcePath ?? ''}`.toLowerCase().includes(token))),
17049
+ ...unbound,
17050
+ ].filter((node, index, values) => values.findIndex((other) => other.uniqueId === node.uniqueId) === index).slice(0, 24);
17051
+ const payload = {
17052
+ request: request.question.trim(),
17053
+ targetDomain: domain,
17054
+ targetAreaId: areaId,
17055
+ currentModels: Object.values(modeling.entities).filter((entity) => entity.domain === domain).map((entity) => ({
17056
+ id: entity.localId, dbtUniqueId: entity.dbtUniqueId, businessName: entity.businessName,
17057
+ businessContext: entity.businessContext, grain: entity.grain, keys: entity.keys, analyticalRole: entity.analyticalRole,
17058
+ })),
17059
+ currentRelationships: Object.values(modeling.relationships).filter((relationship) => relationship.ownerDomain === domain).map((relationship) => ({
17060
+ id: relationship.localId, from: modeling.entities[relationship.from]?.localId, to: modeling.entities[relationship.to]?.localId,
17061
+ keys: relationship.keys, cardinality: relationship.cardinality, fanout: relationship.fanout, status: relationship.status,
17062
+ })),
17063
+ availableDbtModels: ranked.map((node) => describe(node.uniqueId)),
17064
+ focusedModel: request.selectedObject?.kind === 'model'
17065
+ ? Object.values(modeling.entities).find((entity) => entity.qualifiedId === request.selectedObject.id)
17066
+ : undefined,
17067
+ };
17068
+ let raw;
17069
+ try {
17070
+ raw = await provider.generate([
17071
+ {
17072
+ role: 'system',
17073
+ content: [
17074
+ 'You design analytical data models. Propose modeling changes for review; you are never applying them.',
17075
+ 'Return exactly one JSON object in a ```json fence: {"operations":[...]}. No prose outside the fence.',
17076
+ 'Each operation is {"kind":"upsert_entity","id":"<snake_case>","dbtModel":"<exact dbt unique id>","businessName":"...","businessContext":"...","grain":"...","keys":["..."],"analyticalRole":"event|dimension|snapshot|bridge|unknown"}',
17077
+ 'or {"kind":"upsert_relationship","id":"<snake_case>","from":"<entity id>","to":"<entity id>","keys":[{"from":"<column>","to":"<column>"}],"cardinality":"one_to_one|one_to_many|many_to_one|many_to_many|unknown","fanout":"safe|dedupe_required|attribution_required|forbidden|unknown","verb":"...","description":"...","rationale":"..."}.',
17078
+ 'Use ONLY dbt unique ids and column names present in the supplied metadata. Never invent an identifier.',
17079
+ 'businessContext states what one row means in business terms. Never restate the request back as business context.',
17080
+ 'Set cardinality and fanout from the evidence; use "unknown" when the metadata does not establish them. Never claim a relationship is certified or validated.',
17081
+ 'Do not propose deletions, cross-domain relationships, or changes to dbt-owned SQL, columns, tests, or descriptions.',
17082
+ ].join(' '),
17083
+ },
17084
+ { role: 'user', content: JSON.stringify(payload) },
17085
+ ], { maxTokens: 3000, temperature: 0 });
17086
+ }
17087
+ catch (error) {
17088
+ throw new Error(`The AI provider could not complete this modeling request: ${error instanceof Error ? error.message : String(error)}`);
17089
+ }
17090
+ const parsed = parseAiModelingOperations(raw);
17091
+ if (parsed.length === 0)
17092
+ throw new Error('Modeling AI did not return any usable modeling changes. Try naming the models or the relationship you want.');
17093
+ // Validate every identifier against the live snapshot. An operation that
17094
+ // references something the project does not have is dropped, not repaired.
17095
+ const knownDbtIds = new Set(Object.keys(manifest.dbtProvenance.nodes));
17096
+ const columnsFor = new Map();
17097
+ const proposedEntityIds = new Set(Object.values(modeling.entities).filter((entity) => entity.domain === domain).map((entity) => entity.localId));
17098
+ const operations = [];
17099
+ for (const candidate of parsed) {
17100
+ if (candidate.kind === 'upsert_entity') {
17101
+ if (!candidate.dbtModel || !knownDbtIds.has(candidate.dbtModel))
17102
+ continue;
17103
+ const id = authoringSlug(candidate.id || candidate.dbtModel.split('.').at(-1) || 'model').replace(/-/g, '_');
17104
+ if (!id)
17105
+ continue;
17106
+ proposedEntityIds.add(id);
17107
+ const detail = manifestPath ? loadDbtNodeAuthoringDetail(manifestPath, candidate.dbtModel) : undefined;
17108
+ const columns = new Set((detail?.columns ?? []).map((column) => column.name.toLowerCase()));
17109
+ columnsFor.set(id, columns);
17110
+ operations.push({
17111
+ id: `entity:${domain}:${id}`,
17112
+ kind: 'modeling_change',
17113
+ evidence: [candidate.dbtModel, ...(detail?.sourcePath ? [detail.sourcePath] : [])],
17114
+ change: {
17115
+ operation: 'upsert_entity',
17116
+ value: {
17117
+ id, domain, areaId, dbtModel: candidate.dbtModel,
17118
+ businessName: candidate.businessName?.trim() || titleCaseAuthoring(id),
17119
+ businessContext: candidate.businessContext?.trim() || undefined,
17120
+ grain: candidate.grain && columns.has(candidate.grain.toLowerCase()) ? candidate.grain : undefined,
17121
+ keys: (candidate.keys ?? []).filter((key) => columns.has(key.toLowerCase())),
17122
+ analyticalRole: parseImportedAnalyticalRole(candidate.analyticalRole) ?? 'unknown',
17123
+ status: 'draft',
17124
+ },
17125
+ },
17126
+ });
17127
+ continue;
17128
+ }
17129
+ const from = authoringSlug(candidate.from ?? '').replace(/-/g, '_');
17130
+ const to = authoringSlug(candidate.to ?? '').replace(/-/g, '_');
17131
+ if (!proposedEntityIds.has(from) || !proposedEntityIds.has(to) || from === to)
17132
+ continue;
17133
+ const knownColumns = (entityId) => {
17134
+ const cached = columnsFor.get(entityId);
17135
+ if (cached)
17136
+ return cached;
17137
+ const entity = Object.values(modeling.entities).find((item) => item.domain === domain && item.localId === entityId);
17138
+ const detail = entity && manifestPath ? loadDbtNodeAuthoringDetail(manifestPath, entity.dbtUniqueId) : undefined;
17139
+ const columns = new Set((detail?.columns ?? []).map((column) => column.name.toLowerCase()));
17140
+ columnsFor.set(entityId, columns);
17141
+ return columns;
17142
+ };
17143
+ const fromColumns = knownColumns(from);
17144
+ const toColumns = knownColumns(to);
17145
+ const keys = (candidate.keys ?? []).filter((key) => key?.from && key?.to && fromColumns.has(key.from.toLowerCase()) && toColumns.has(key.to.toLowerCase()));
17146
+ if (keys.length === 0)
17147
+ continue;
17148
+ const id = authoringSlug(candidate.id || `${from}_to_${to}`).replace(/-/g, '_');
17149
+ operations.push({
17150
+ id: `relationship:${domain}:${id}`,
17151
+ kind: 'modeling_change',
17152
+ dependsOn: [`entity:${domain}:${from}`, `entity:${domain}:${to}`].filter((dependency) => operations.some((operation) => operation.id === dependency)),
17153
+ evidence: ['Modeling AI proposal from dbt metadata; requires warehouse validation before certification'],
17154
+ change: {
17155
+ operation: 'upsert_relationship',
17156
+ value: {
17157
+ id, domain, areaId, from, to, keys,
17158
+ cardinality: parseImportedCardinality(candidate.cardinality),
17159
+ fanout: parseImportedFanout(candidate.fanout),
17160
+ status: 'draft',
17161
+ verb: candidate.verb?.trim() || undefined,
17162
+ description: candidate.description?.trim() || undefined,
17163
+ rationale: candidate.rationale?.trim() || undefined,
17164
+ },
17165
+ },
17166
+ });
17167
+ }
17168
+ if (operations.length === 0)
17169
+ throw new Error('Modeling AI referenced models or columns that are not in the current dbt snapshot, so nothing was proposed.');
17170
+ return operations;
17171
+ }
17172
+ /** Tolerant JSON extraction; a malformed provider reply yields no operations. */
17173
+ export function parseAiModelingOperations(raw) {
17174
+ const fenced = raw.match(/```json\s*([\s\S]*?)```/i)?.[1] ?? raw.match(/```\s*([\s\S]*?)```/)?.[1] ?? raw;
17175
+ let parsed;
17176
+ try {
17177
+ parsed = JSON.parse(fenced.trim());
17178
+ }
17179
+ catch {
17180
+ return [];
17181
+ }
17182
+ const text = (value) => (typeof value === 'string' ? value : undefined);
17183
+ return contextArray(contextRecord(parsed).operations).flatMap((value) => {
17184
+ const record = contextRecord(value);
17185
+ if (record.kind === 'upsert_entity') {
17186
+ return [{
17187
+ kind: 'upsert_entity',
17188
+ id: text(record.id),
17189
+ dbtModel: text(record.dbtModel),
17190
+ businessName: text(record.businessName),
17191
+ businessContext: text(record.businessContext),
17192
+ grain: text(record.grain),
17193
+ analyticalRole: record.analyticalRole,
17194
+ keys: contextStrings(record.keys),
17195
+ }];
17196
+ }
17197
+ if (record.kind === 'upsert_relationship') {
17198
+ return [{
17199
+ kind: 'upsert_relationship',
17200
+ id: text(record.id),
17201
+ from: text(record.from),
17202
+ to: text(record.to),
17203
+ verb: text(record.verb),
17204
+ description: text(record.description),
17205
+ rationale: text(record.rationale),
17206
+ cardinality: record.cardinality,
17207
+ fanout: record.fanout,
17208
+ keys: contextArray(record.keys).flatMap((entry) => {
17209
+ const key = contextRecord(entry);
17210
+ return typeof key.from === 'string' && typeof key.to === 'string' ? [{ from: key.from, to: key.to }] : [];
17211
+ }),
17212
+ }];
17213
+ }
17214
+ return [];
17215
+ });
17216
+ }
17217
+ /**
17218
+ * Evidence-only modeling suggestions: deterministic, no provider required.
17219
+ *
17220
+ * This is the `AGT-024` provider-unavailable fallback, not "Modeling AI". It
17221
+ * matches dbt model names against the request and pairs shared `*_id` columns.
17222
+ * Callers must label its output honestly so a user is never told a template
17223
+ * expansion was an AI proposal.
17224
+ */
17225
+ function buildDeterministicModelingOperations(projectRoot, manifest, request) {
16557
17226
  const modeling = manifest.modeling;
16558
17227
  if (!modeling || !manifest.dbtProvenance)
16559
17228
  throw new Error('Modeling AI requires a compiled dbt-first project snapshot.');
@@ -16589,7 +17258,7 @@ function buildMetadataBoundModelingOperations(projectRoot, manifest, request) {
16589
17258
  value: {
16590
17259
  id: selectedRelationship.localId,
16591
17260
  domain: selectedRelationship.ownerDomain ?? domain,
16592
- areaId: selectedRelationship.areaId?.split('::area::').at(-1),
17261
+ areaId: modelAreaLocalId(selectedRelationship.areaId),
16593
17262
  from: modeling.entities[selectedRelationship.from]?.localId ?? selectedRelationship.from,
16594
17263
  to: modeling.entities[selectedRelationship.to]?.localId ?? selectedRelationship.to,
16595
17264
  keys: selectedRelationship.keys,
@@ -16617,33 +17286,15 @@ function buildMetadataBoundModelingOperations(projectRoot, manifest, request) {
16617
17286
  ? Object.values(modeling.entities).find((item) => item.qualifiedId === selectedIdentity || item.id === selectedIdentity || item.localId === selectedIdentity)
16618
17287
  : undefined;
16619
17288
  if (selectedEntity) {
16620
- return [{
16621
- id: `entity:${selectedEntity.qualifiedId}`,
16622
- kind: 'modeling_change',
16623
- evidence: [selectedEntity.sourcePath, selectedEntity.dbtUniqueId],
16624
- change: {
16625
- operation: 'upsert_entity',
16626
- value: {
16627
- id: selectedEntity.localId,
16628
- domain: selectedEntity.domain,
16629
- areaId: selectedEntity.areaId?.split('::area::').at(-1),
16630
- dbtModel: selectedEntity.dbtUniqueId,
16631
- businessName: selectedEntity.businessName,
16632
- businessContext: request.question.trim(),
16633
- conceptRefs: selectedEntity.conceptRefs,
16634
- analyticalRole: selectedEntity.analyticalRole,
16635
- status: 'draft',
16636
- owner: selectedEntity.owner,
16637
- grain: selectedEntity.grain,
16638
- keys: selectedEntity.keys,
16639
- },
16640
- },
16641
- }];
17289
+ // Without a provider there is nothing evidence-backed to propose here.
17290
+ // Copying the raw request into `businessContext` would present the user's
17291
+ // own prompt back to them as authored business meaning.
17292
+ throw new Error('Writing business context needs an AI provider. Configure one in Settings, or edit this model directly in Modeling.');
16642
17293
  }
16643
17294
  const requestedArea = typeof context.modelAreaId === 'string' ? context.modelAreaId : typeof context.areaId === 'string' ? context.areaId : undefined;
16644
17295
  const existingArea = Object.values(modeling.areas).find((area) => area.domain === domain
16645
17296
  && (!requestedArea || area.qualifiedId === requestedArea || area.localId === requestedArea));
16646
- const areaId = existingArea?.localId ?? requestedArea?.split('::area::').at(-1) ?? `${authoringSlug(request.question).slice(0, 36) || 'metadata'}_models`;
17297
+ const areaId = existingArea?.localId ?? modelAreaLocalId(requestedArea) ?? `${authoringSlug(request.question).slice(0, 36) || 'metadata'}_models`;
16647
17298
  const operations = [];
16648
17299
  if (!existingArea) {
16649
17300
  operations.push({
@@ -16701,6 +17352,81 @@ function buildMetadataBoundModelingOperations(projectRoot, manifest, request) {
16701
17352
  }
16702
17353
  return operations;
16703
17354
  }
17355
+ /**
17356
+ * Skills AI: draft a domain skill's purpose, guardrails, and vocabulary.
17357
+ *
17358
+ * `SKILL-006` — the model sees qualified modeling/metric references, the
17359
+ * current skill, and the Domain/Area scope. It writes prose and vocabulary
17360
+ * only; every governed reference is taken from the deterministic base built
17361
+ * below, so it cannot invent a metric, block, or model. `previewContextAuthoring`
17362
+ * independently forces `status: 'draft'`, so a drafted skill never guides an
17363
+ * agent until a human activates it.
17364
+ */
17365
+ async function buildAiSkillOperations(projectRoot, manifest, request, provider) {
17366
+ const base = buildMetadataBoundSkillOperations(projectRoot, manifest, request);
17367
+ const operation = base[0];
17368
+ if (!operation || operation.kind !== 'skill_change')
17369
+ return base;
17370
+ const raw = await provider.generate([
17371
+ {
17372
+ role: 'system',
17373
+ content: [
17374
+ 'You write governed analytics guidance for one business domain.',
17375
+ 'Return exactly one JSON object in a ```json fence: {"description":"...","body":"...","triggers":["..."],"clarifyWhen":["..."],"vocabulary":{"term":"meaning"}}. No prose outside the fence.',
17376
+ 'body is markdown with a "## Purpose" section, a "## Guardrails" section, and a "## Clarify" section.',
17377
+ 'Write guidance an analyst would follow. Never restate the request back as the guidance.',
17378
+ 'Never name a metric, block, model, or relationship that is not in the supplied governed references.',
17379
+ 'Do not grant tools, authorize joins or data access, activate the skill, or write executable code.',
17380
+ ].join(' '),
17381
+ },
17382
+ {
17383
+ role: 'user',
17384
+ content: JSON.stringify({
17385
+ request: request.question.trim(),
17386
+ domain: operation.value.domain,
17387
+ modelAreaRefs: operation.value.modelAreaRefs,
17388
+ currentSkill: { id: operation.value.id, description: operation.value.description, body: operation.value.body },
17389
+ governedReferences: {
17390
+ metrics: operation.value.preferredMetrics,
17391
+ blocks: operation.value.preferredBlocks,
17392
+ modelingRefs: operation.value.sourceRefs,
17393
+ },
17394
+ }),
17395
+ },
17396
+ ], { maxTokens: 2000, temperature: 0.2 });
17397
+ const fenced = raw.match(/```json\s*([\s\S]*?)```/i)?.[1] ?? raw.match(/```\s*([\s\S]*?)```/)?.[1] ?? raw;
17398
+ let parsed;
17399
+ try {
17400
+ parsed = contextRecord(JSON.parse(fenced.trim()));
17401
+ }
17402
+ catch {
17403
+ return base;
17404
+ }
17405
+ const body = typeof parsed.body === 'string' ? parsed.body.trim() : '';
17406
+ if (!body)
17407
+ return base;
17408
+ const vocabulary = contextRecord(parsed.vocabulary);
17409
+ return [{
17410
+ ...operation,
17411
+ evidence: [...(operation.evidence ?? []), 'drafted by Skills AI from governed references only'],
17412
+ value: {
17413
+ ...operation.value,
17414
+ description: typeof parsed.description === 'string' && parsed.description.trim() ? parsed.description.trim() : operation.value.description,
17415
+ body,
17416
+ triggers: contextStrings(parsed.triggers).slice(0, 12).length ? contextStrings(parsed.triggers).slice(0, 12) : operation.value.triggers,
17417
+ clarifyWhen: contextStrings(parsed.clarifyWhen).slice(0, 8).length ? contextStrings(parsed.clarifyWhen).slice(0, 8) : operation.value.clarifyWhen,
17418
+ vocabulary: Object.fromEntries(Object.entries(vocabulary)
17419
+ .filter(([, value]) => typeof value === 'string')
17420
+ .slice(0, 24)),
17421
+ // Governed references always come from the deterministic base; the model
17422
+ // never gets to add one.
17423
+ preferredMetrics: operation.value.preferredMetrics,
17424
+ preferredBlocks: operation.value.preferredBlocks,
17425
+ sourceRefs: operation.value.sourceRefs,
17426
+ status: 'draft',
17427
+ },
17428
+ }];
17429
+ }
16704
17430
  function buildMetadataBoundSkillOperations(projectRoot, manifest, request) {
16705
17431
  const context = request.workspaceContext ?? {};
16706
17432
  const selectedIdentity = request.selectedObject?.kind === 'skill' ? request.selectedObject.id : undefined;
@@ -16761,6 +17487,18 @@ function buildMetadataBoundSkillOperations(projectRoot, manifest, request) {
16761
17487
  value,
16762
17488
  }];
16763
17489
  }
17490
+ /**
17491
+ * The Domain an author lands in when none is selected: the single existing
17492
+ * Domain if there is exactly one, otherwise a new one named after the dbt
17493
+ * project. Callers keep full control — this is only the prefilled default.
17494
+ */
17495
+ function defaultAuthoringDomainId(manifest) {
17496
+ const packages = Object.keys(manifest.modeling?.packages ?? {});
17497
+ if (packages.length === 1)
17498
+ return packages[0];
17499
+ const projectName = manifest.dbtProvenance?.projectName;
17500
+ return projectName ? authoringSlug(projectName).replace(/-/g, '_') || 'analytics' : 'analytics';
17501
+ }
16764
17502
  function authoringTokens(value) {
16765
17503
  const stop = new Set(['the', 'and', 'for', 'with', 'from', 'this', 'that', 'build', 'create', 'model', 'models', 'skill', 'please', 'into', 'about']);
16766
17504
  return [...new Set(value.toLowerCase().split(/[^a-z0-9_]+/).filter((token) => token.length > 2 && !stop.has(token)))];
@@ -18184,6 +18922,132 @@ export function dashboardRuntimeVariables(dashboard, overrides = {}) {
18184
18922
  }
18185
18923
  return { ...variables, ...overrides };
18186
18924
  }
18925
+ /** Resolve semantic filters only through the component's explicit mapping. */
18926
+ export function dashboardSemanticFiltersForTile(dashboard, item, dashboardValues) {
18927
+ if (!item.semantic)
18928
+ return [];
18929
+ return (dashboard.filters ?? []).flatMap((filter) => {
18930
+ if (filter.scope?.tileIds && !filter.scope.tileIds.includes(item.i))
18931
+ return [];
18932
+ const binding = item.filterBindings?.find((candidate) => candidate.filter === filter.id
18933
+ && candidate.capability !== 'unsupported'
18934
+ && !candidate.unsupportedReason);
18935
+ const dimension = binding?.binding;
18936
+ const value = dashboardValues[filter.id];
18937
+ if (!dimension || !item.semantic?.dimensions?.includes(dimension) || isEmptyDashboardFilterValue(value))
18938
+ return [];
18939
+ const values = Array.isArray(value) ? value.map(String) : [String(value)];
18940
+ return [{ dimension, operator: values.length > 1 ? 'in' : 'equals', values }];
18941
+ });
18942
+ }
18943
+ /**
18944
+ * Return bounded, run-scoped filter choices only from columns the server has
18945
+ * already proven safe for predicate filtering. These sampled labels are sent
18946
+ * to the browser for a searchable control and are never written to `.dqld`.
18947
+ */
18948
+ export function collectDashboardFilterOptions(dashboard, tiles, defaultLimit = 100) {
18949
+ const items = new Map(dashboard.layout.items.map((item) => [item.i, item]));
18950
+ const optionSets = [];
18951
+ for (const filter of dashboard.filters ?? []) {
18952
+ if (filter.type === 'boolean' || filter.type === 'relative_date' || filter.type === 'number_range')
18953
+ continue;
18954
+ const dateControl = filter.type === 'daterange' || filter.type === 'date';
18955
+ const limit = Math.max(1, Math.min(100, filter.optionSource?.limit ?? defaultLimit));
18956
+ const values = new Set();
18957
+ const sourceTileIds = [];
18958
+ let truncated = false;
18959
+ for (const tile of tiles) {
18960
+ const result = dashboardFilterOptionResult(tile.result);
18961
+ if (tile.status !== 'ok' || !result?.rows.length)
18962
+ continue;
18963
+ const item = items.get(tile.tileId);
18964
+ const binding = item?.filterBindings?.find((candidate) => candidate.filter === filter.id && candidate.capability !== 'unsupported');
18965
+ if (!binding)
18966
+ continue;
18967
+ const requestedColumn = dashboardOutputColumn(binding?.binding ?? filter.field?.name ?? filter.bindsTo ?? filter.id);
18968
+ if (!requestedColumn)
18969
+ continue;
18970
+ const safeColumn = tile.filterableColumns?.find((candidate) => normalizeDashboardOptionColumn(candidate.column) === normalizeDashboardOptionColumn(requestedColumn));
18971
+ if (!safeColumn)
18972
+ continue;
18973
+ const resultColumn = result.columns.find((column) => normalizeDashboardOptionColumn(column) === normalizeDashboardOptionColumn(safeColumn.column));
18974
+ if (!resultColumn)
18975
+ continue;
18976
+ sourceTileIds.push(tile.tileId);
18977
+ for (const row of result.rows) {
18978
+ const value = dashboardFilterOptionValue(row[resultColumn]);
18979
+ if (value !== null)
18980
+ values.add(value);
18981
+ }
18982
+ if (result.truncated || (result.rowCount ?? result.rows.length) > result.rows.length)
18983
+ truncated = true;
18984
+ }
18985
+ if (values.size === 0) {
18986
+ if (dateControl) {
18987
+ optionSets.push({
18988
+ filterId: filter.id,
18989
+ values: [],
18990
+ truncated,
18991
+ sourceTileIds: Array.from(new Set(sourceTileIds)),
18992
+ valueCount: 0,
18993
+ });
18994
+ }
18995
+ continue;
18996
+ }
18997
+ const sorted = Array.from(values).sort((left, right) => left.localeCompare(right, undefined, { numeric: true, sensitivity: 'base' }));
18998
+ const dateValues = dateControl
18999
+ ? sorted
19000
+ .map((value) => ({ value, timestamp: Date.parse(value) }))
19001
+ .filter((item) => Number.isFinite(item.timestamp))
19002
+ .sort((left, right) => left.timestamp - right.timestamp)
19003
+ : [];
19004
+ optionSets.push({
19005
+ filterId: filter.id,
19006
+ values: dateControl ? [] : sorted.slice(0, limit),
19007
+ truncated: dateControl ? truncated : truncated || sorted.length > limit,
19008
+ sourceTileIds: Array.from(new Set(sourceTileIds)),
19009
+ ...(dateControl ? {
19010
+ valueCount: dateValues.length,
19011
+ ...(dateValues.length ? {
19012
+ dateRange: {
19013
+ min: new Date(dateValues[0].timestamp).toISOString().slice(0, 10),
19014
+ max: new Date(dateValues[dateValues.length - 1].timestamp).toISOString().slice(0, 10),
19015
+ },
19016
+ } : {}),
19017
+ } : {}),
19018
+ });
19019
+ }
19020
+ return optionSets;
19021
+ }
19022
+ function dashboardFilterOptionResult(value) {
19023
+ if (!value || typeof value !== 'object' || Array.isArray(value))
19024
+ return null;
19025
+ const record = value;
19026
+ if (!Array.isArray(record.columns) || !record.columns.every((column) => typeof column === 'string'))
19027
+ return null;
19028
+ if (!Array.isArray(record.rows) || !record.rows.every((row) => Boolean(row) && typeof row === 'object' && !Array.isArray(row)))
19029
+ return null;
19030
+ return {
19031
+ columns: record.columns,
19032
+ rows: record.rows,
19033
+ ...(typeof record.rowCount === 'number' ? { rowCount: record.rowCount } : {}),
19034
+ ...(typeof record.truncated === 'boolean' ? { truncated: record.truncated } : {}),
19035
+ };
19036
+ }
19037
+ function normalizeDashboardOptionColumn(value) {
19038
+ return value.replace(/[`"[\]]/g, '').trim().split('.').filter(Boolean).at(-1)?.toLowerCase() ?? '';
19039
+ }
19040
+ function dashboardFilterOptionValue(value) {
19041
+ if (typeof value === 'string')
19042
+ return value.trim() ? value : null;
19043
+ if (typeof value === 'number' && Number.isFinite(value))
19044
+ return String(value);
19045
+ if (typeof value === 'boolean')
19046
+ return String(value);
19047
+ if (value instanceof Date && Number.isFinite(value.getTime()))
19048
+ return value.toISOString();
19049
+ return null;
19050
+ }
18187
19051
  /**
18188
19052
  * Resolve only declared tile parameter bindings. Dashboard filters remain
18189
19053
  * values; they never become SQL identifiers or an implicit name-based binding.
@@ -18224,6 +19088,10 @@ export function applyDashboardFiltersToBlockExecution(input) {
18224
19088
  const clauses = [];
18225
19089
  let nextPosition = sqlParams.reduce((max, param) => Math.max(max, param.position), 0);
18226
19090
  for (const filter of input.dashboard.filters ?? []) {
19091
+ if (filter.scope?.tileIds && input.tileId && !filter.scope.tileIds.includes(input.tileId)) {
19092
+ skippedFilters.push({ filter: filter.id, reason: 'component is outside this filter scope' });
19093
+ continue;
19094
+ }
18227
19095
  const value = dashboardFilterValue(filter, variables);
18228
19096
  if (isEmptyDashboardFilterValue(value)) {
18229
19097
  skippedFilters.push({ filter: filter.id, reason: 'no value supplied' });
@@ -18238,7 +19106,7 @@ export function applyDashboardFiltersToBlockExecution(input) {
18238
19106
  });
18239
19107
  continue;
18240
19108
  }
18241
- const binding = resolveDashboardFilterBinding(filter, input.block);
19109
+ const binding = resolveDashboardFilterBinding(filter, input.block, input.tileFilterBindings);
18242
19110
  if (!binding) {
18243
19111
  skippedFilters.push({ filter: filter.id, reason: `block "${input.block.name}" does not declare a compatible filter binding` });
18244
19112
  continue;
@@ -18360,8 +19228,16 @@ function bindDashboardFilterToExistingParams(filter, value, block, sqlParams, va
18360
19228
  }
18361
19229
  return [];
18362
19230
  }
18363
- function resolveDashboardFilterBinding(filter, block) {
19231
+ function resolveDashboardFilterBinding(filter, block, tileFilterBindings = []) {
18364
19232
  const candidates = uniqueDashboardStrings([filter.id, filter.bindsTo ?? '']).map(normalizeDashboardFilterName);
19233
+ for (const entry of tileFilterBindings ?? []) {
19234
+ const mode = entry.mode ?? (entry.binding ? 'predicate' : undefined);
19235
+ if (mode === 'predicate'
19236
+ && entry.binding
19237
+ && !entry.unsupportedReason
19238
+ && candidates.includes(normalizeDashboardFilterName(entry.filter)))
19239
+ return entry.binding;
19240
+ }
18365
19241
  for (const entry of block.filterBindings ?? []) {
18366
19242
  if (candidates.includes(normalizeDashboardFilterName(entry.filter)))
18367
19243
  return entry.binding;
@@ -18405,6 +19281,13 @@ function buildDashboardFilterPredicate(input) {
18405
19281
  const values = Array.isArray(input.value) ? input.value.filter((item) => !isEmptyDashboardFilterValue(item)) : [input.value];
18406
19282
  if (values.length === 0)
18407
19283
  return null;
19284
+ if (input.filterType === 'search' && values.length === 1) {
19285
+ const search = String(values[0] ?? '').trim();
19286
+ if (!search)
19287
+ return null;
19288
+ const param = addDashboardFilterParam(input, 'contains', `%${search}%`);
19289
+ return `LOWER(${input.expression}) LIKE LOWER($${param.position})`;
19290
+ }
18408
19291
  if (values.length === 1) {
18409
19292
  const param = addDashboardFilterParam(input, 'value', values[0]);
18410
19293
  return `${input.expression} = $${param.position}`;