Skip to content

Business questions guide

57 min read · Updated 2 Nov 2026

A business question is a stored analytical question over a semantic layer, such as “show sales amount by product category.” It is versioned with the semantic layer and includes governed dimensions and measures, business context, advisory chart suggestions, and execution defaults. Applications execute stored questions by ID. For an unsaved question, send the same analytical definition to astrato_data_execute_query; you never construct engine JSON.

Business questions are available only when enabled for the Astrato environment. MCP clients should discover the live tool list; every business-question tool is omitted when the feature is off.

See the Business question YAML reference for the complete V3 property inventory, examples and V2 compatibility rules.

Questions can use named dimension and measure maps. A key such as month identifies a selection inside the question; it can reference a global dimension or contain an inline periodAxis block. Comparisons refer to local keys when dimensions are named. With reference-array dimensions, named measure bindings refer to global dimension names, which follow global renames. Sorting uses the keys of its named collection or global names for its reference-array collection. See Period axes and comparisons for complete YAML examples, relative dates and ranges, and measure overrides.

Named BQs execute by question ID and open a result table in Data Explorer. When saving a question, inline axes and reusable local measure definitions become named semantic entities and the BQ references them. Result formulas remain question-local. Publish those entities before running the saved question. Use the complete definition to inspect them and the execute result’s column names to identify their output. Do not rebuild such a question from its base-field summary.

The core fields used by clients are:

{
"id": "Sales by product category",
"title": "Sales by product category",
"description": "Compares total sales across product categories.",
"categories": ["overview", "sales"],
"preferredCharts": [
{ "type": "bar", "reason": "Best for accurate category comparison." },
{ "type": "table", "reason": "Best for exact values." },
],
"fields": {
"dimensions": [{ "fieldKey": "[6 Productcategory].[Product Category]" }],
"measures": [{ "fieldKey": "[calculation].[Sales Amount]" }],
},
"defaults": { "topN": 10, "showOthers": true, "othersLabel": "Everything else" },
"outputKinds": ["table"],
}
  • id is unique within one semantic layer, never globally. Treat it as opaque. Every call is scoped by tenant and semantic layer.

  • For ordinary stored references, fields uses the same opaque engine fieldKey values as the SDK and the visualization definitions. Use them exactly as returned; never parse or construct them.

  • preferredCharts is ranked and advisory. A consumer may choose another compatible renderer; the recommendation never changes query validity.

  • Table is always the default output. An explicit output binding is needed only when a stored question changes table projection or adds another output family.

  • issues appears when the layer’s validation rules report something on the question in the draft, as astrato_semantic_layer_validate does (source-check findings are not included): each with severity, code, subPath and message on astrato_semantic_layer_get_question, and one { severity, code } per kind on astrato_semantic_layer_list_questions, which also counts them in withIssues. Most errors, and the warnings question-set-keyed-by-grouping and filter-value-count, mean executing the question is refused; the message says what to change. A rule that tightens can leave a saved question in this state, so check issues before running one. A warning on a business question holds up neither publishing nor an Ossie export.

  • defaults controls execution: topN ranks and bounds the answer, while hideZeroValues enables suppression of zero and null measure values. showOthers adds an aggregated Others row; othersPosition and othersLabel control its placement and label when enabled. Set defaults.ranking to { "reference": { "measure": "Revenue" }, "direction": "descending" } to rank independently of the final sorting. reference.measure names a selected measure key (the measure name for array selections). Turning on Top N in the editor, or saving a question with topN and no ranking through MCP or Nash, stores a ranking by the first selected measure, keeping its highest values. An unsaved query without ranking follows its first measure sort. With a measure and more than one dimension, topN counts ranked values rather than rows, and you choose which dimension it ranks. For revenue by country and category with topN: 2:

    Setting Rows kept
    Neither setting (default) The top 2 categories within each country.
    rankedDimension: { "dimension": "country" } The top 2 countries by revenue across all categories, with every category.
    rankedDimension: { "dimension": "category" } The top 2 categories across all countries, for every country.
    allMode: "independent" Rows whose country is in the top 2 countries and whose category is in the top 2 categories.

    dimension names a selected dimension the way an orderBy target does. allMode applies only without rankedDimension. The editor and Data Explorer label the default Within all other dimensions and allMode Rank each dimension individually. When neither is set, the result’s resultNotices and layer validation (warning question-top-n-unranked) say which dimension was ranked and what to set. To number rows within a dimension without removing any, use a rank calculation over the answer with partitionBy instead. A question without measures keeps topN as a cap of that many rows. Paging with limit and offset remains separate from these analytical settings.

  • A question needs at least one dimension or measure. See List questions without measures.

  • description may be empty for some questions.

  • The entity includes its complete canonical stored definition, but does not expose an engine query. Questions execute by reference, so clients never build or send engine JSON.

The stored definition keeps query behaviour in canonical semantic-layer terms. Ordinary filters run before aggregation and may reference fields or dimensions. Result filters run after measures are aggregated and may additionally reference a measure; whether they run before or after totals, shares and ranks is set out in Result filters and advanced calculations. Sorting is an ordered list: array order is priority, and every entry names its target through reference. Omitted direction means ascending. Named selections use their question-local keys; array selections use the referenced semantic entity names.

{
"filters": {
"expression": "{f1}",
"f1": {
"operator": "equals",
"arguments": [{ "reference": { "dimension": "Country" } }, { "value": "NZ" }],
},
},
"resultFilters": {
"expression": "{f1}",
"f1": {
"operator": "greaterThan",
"arguments": [{ "reference": { "measure": "Sales Amount" } }, { "value": 100000 }],
},
},
"orderBy": [
{ "reference": { "measure": "Sales Amount" }, "direction": "descending" },
{ "reference": { "dimension": "Country" }, "direction": "ascending" },
],
}

A condition’s operator is one of equals, notEquals, greaterThan, greaterThanOrEqual, lessThan, lessThanOrEqual, between, notBetween, inList, notInList, contains, notContains, like, notLike, set, notSet, hideNotSet or geoIntersects. The engine’s lowercase names — gte, inlist, notset and so on, as the query catalogue lists them — are accepted too, and so is any spelling of the six comparisons that authoring accepts: =, >= and greater_than_or_equal all mean greaterThanOrEqual. Any other spelling is refused by validation with the condition’s path and the operator it most likely meant, for example filters.f1.operator: Filter operator "isNull" is not recognised. Use "notSet".

like and notLike take a pattern in Astrato’s search syntax, not SQL: * matches any run of characters and ? one character, while % and _ match themselves. {"value": "LG*"} keeps text starting with LG, and a value with no wildcard matches anywhere in the text. A value with an unescaped %, such as LG%, is refused with the pattern to send instead by astrato_data_execute_query, and by MCP question authoring when the layer does not already hold that value. Layer validation reports it as the warning like-sql-wildcard on a question already stored. See filter conditions for stored measure filters.

Conditions sit beside expression, each under its own name. Names are generated as f1, f2 and so on, and a name you choose is kept unless it is empty, is expression or parameters, or holds braces or a line break. expression names conditions in braces and combines them with AND, OR and parentheses, for example "{f1} AND ({f2} OR {f3})". It names every condition of the same filter exactly once, and a parameter’s name is not a condition. NOT is accepted only in associative filters. A condition’s arguments are its subject followed by its values.

between and notBetween take exactly two values, a lower and an upper bound. A single comparison such as equals or contains takes exactly one, and inList and notInList take at least one. In a query, a condition with any other number of literal values is refused before it runs, because the engine either refuses it or silently ignores the extra values: filters.f1.arguments: Filter f1 on [Part].[P Size]: between requires exactly two values, a lower and an upper bound; got 1 value. For one bound use greaterThanOrEqual or lessThanOrEqual. This covers filters, resultFilters and the filter of a measure the query defines in local; a stored measure’s, view’s or dataset’s filter is not checked. A generated date range, a parameter or a field stands for its own values and is not counted. In a stored business question, a condition the engine refuses (a between with one bound, a comparison or list with no value) is a validation error, and extra values the engine would ignore are a warning. A subject’s date transform takes the date part and level names described for field dimensions.

To filter on a period relative to today, compare the date with a generated range. A generated range always uses between and is a single argument; period names it, for example previousMonth, yearToDate or last30Days:

{
"expression": "{f1}",
"f1": {
"operator": "between",
"arguments": [
{ "reference": { "dimension": "Order Date" } },
{ "date": { "kind": "predefinedRange", "period": "previousMonth" } }
]
}
}

For fixed dates, give each value the data type of the field it is compared with. A saved question refuses a date value without one:

{
"expression": "{f1}",
"f1": {
"operator": "between",
"arguments": [
{ "reference": { "dimension": "Order Date" } },
{ "value": "2026-08-01", "dataType": "date" },
{ "value": "2026-08-31", "dataType": "date" }
]
}
}

Filters that cannot reach a measure’s rows

Section titled “Filters that cannot reach a measure’s rows”

A filter applies to a measure through the rows that measure aggregates. When the layer’s joins give each of those rows many rows of another view, none of them carries the fields of that view, or of a view the joins reach only through it (an order’s customer, for example, when each line joins many orders). Execution refuses two kinds of filter on such fields instead of returning a wrong number:

  • A measure’s own filter. It cannot be evaluated on the measure’s rows, so the measure would be wrong, usually 0. Filter the question on that field instead: an ordinary filter on one view’s field still selects the measure’s rows through the join.
  • An ordinary filter comparing such a field with a field the measure’s rows do carry, such as a line’s ship date before its order’s date. Execution applies it to those rows only when the unreachable field comes first and the comparison is equals or notEquals; the refusal says when swapping the operands helps. An ordering comparison such as lessThan would be applied reversed in that order and ignored in the other, so it is refused either way. A comparison between two unreachable fields is ignored and refused too.

The refusal names the fields and the view. This usually means a join is declared the wrong way round: if each measured row belongs to one row of the other view, declare that join many-to-one. Join quality shows which side holds the duplicates. When the joins are right, as for an order measure filtered on its lines, that measure cannot answer the question; a measure on the lines can. The check runs wherever a question executes through the MCP tools or a stored question runs; astrato_data_prepare_query reports it as queryAnalysisRejected. Data Explorer does not run it.

Use associative filters to select entities by related records. Each named branch owns ordinary filters; an outer expression combines membership using intersection, union and branch-level NOT. The selected target field and custom names survive saving and reopening.

Use orderBy for final ordering and for a running calculation’s own ordering. Each entry uses reference and an optional direction: ascending (the default) or descending. These scopes are independent: accumulate chronologically and return the newest result first if you need to. Duplicate targets within one ordering list are invalid.

dimensions declares the grouping grain and measures declares the calculations. An empty dimensions list with one measure returns a single total. Optional output chooses the returned columns and their order. Omit it to return all dimensions followed by all measures. An explicit list must contain at least one selection and cannot repeat a target. A hidden measure can still order the answer. A hidden dimension still groups the answer and can partition or order calculations: hiding it does not merge its groups or deduplicate rows. Do not add an unrelated dimension just to sort an aggregate at a different grain. List order is the default column order.

{
"dimensions": [{ "key": "region", "reference": { "dimension": "Region" } }],
"measures": [
{ "key": "sales", "reference": { "measure": "Revenue" } },
{ "key": "rank", "reference": { "measure": "Revenue" }, "advancedCalculation": { "rank": {} } }
],
"orderBy": [{ "reference": { "measure": "sales" }, "direction": "descending" }],
"output": [{ "reference": { "dimension": "region" } }, { "reference": { "measure": "rank" } }]
}

dimensions and measures are ordered lists. Selection definitions reference reusable semantic entities, and an entry may carry a key: ordering and output references then name selection keys, as above. A list keys every entry or none. This keeps two calculations of the same base measure distinct. Name raw-field selections before referencing them in ordering. An output entry may also use reference: {view, field} for an existing physical dimension in an unkeyed list; repeated identical physical selections are projected in occurrence order. This never adds a grouping field. Questions with explicit output run as a table in Data Explorer.

Existing stored documents using sorting are converted when loaded and written back with reference-based orderBy; legacy flat ranking targets become defaults.ranking.reference. MCP, HTTP and editor authoring accept only orderBy; they reject sorting and flat ranking targets. A stored question containing both names is a conflict that requires repair.

Running totals and running rates order by their declared dimensions and restart at their declared partitions. Removing a dimension in cascade mode also removes calculations that require it; hiding an output column preserves them. Moving calculations retain the engine’s row-window semantics: the first dimension is the moving axis, and remaining dimensions partition the window. Changing dimension order changes that axis; output order does not. Window bounds count rows, not calendar months. Moving calculations do not accept an explicit orderBy or partitionBy.

A saved question keeps its selections in the order they are listed, and a reorder is saved like any other edit. Selections were once stored as name-keyed maps, whose key order did not survive storage: a map came back shorter key first, then alphabetically, which could move a moving calculation’s axis. Those questions are read as the keyed list the map names, in the order it was stored. A name-keyed map is still accepted from an API or MCP caller and saved as a list.

A keyed measure selection can carry advancedCalculation. Choose at most one calculation, one group comparison or summary, and an optional final series value. Execution follows that order regardless of the order of properties in YAML or JSON.

The editor offers one calculation per measure selection: moving calculation, running total or running rate, or rank, total or compare to total. A running calculation accumulates along one dimension there, and total and compare to total use an aggregate or control-value reducer. To combine stages, accumulate along more than one dimension, or use row comparisons, first or last values, percentile reducers, or a separate current group, edit the document or use the API or MCP. The editor keeps these settings when it opens a selection that already has them. Data Explorer shows each measure selection with an advanced calculation as its own column, headed by the selection’s key, beside any other selection of the same measure. To compare calculations of one measure side by side, add the measure again from Add measure: each copy is a separate keyed selection, such as Revenue 2, with its own calculation.

A calculation often changes what the number means: a share of a currency measure is a percentage, and a rank is a position. A keyed measure selection can carry format: { pattern: "0.0%" } to replace the stored measure’s format for that selection only; without it, the selection uses the stored measure’s format. In the editor, Display label and Format appear under a measure selection once it has a calculation or nested dimensions, and removing the last of them removes its format; Measure’s format clears the override.

Stage Operations Meaning
Calculation movingCalculation, runningTotal, runningRate, rank, interRecord Operate on aggregated answer rows.
Compare or summarize total, compareToTotal Reduce the completed calculation within the chosen group.
First or last value seriesValue Repeat the first or last non-null value while retaining answer grain.

For example, accumulate revenue within each region, then calculate each region’s share of the month’s accumulated revenue. month and region below are keys in this question’s dimensions.

measures:
- key: runningShare
reference: { measure: Revenue }
advancedCalculation:
runningTotal:
aggregate: SUM
orderBy:
- reference: { dimension: month }
direction: ascending
partitionBy:
- reference: { dimension: region }
compareToTotal:
comparison: ratio
aggregate: SUM
partitionBy:
- reference: { dimension: month }

The editor retains every ordering and partition entry. A running calculation has its own ordered list, independent of final answer sorting. Moving calculations use the effective question ordering and partition by every dimension after the first. Row comparisons also use question ordering but partition by the last dimension. They do not accept private ordering or partition settings.

interRecord takes comparison: value | diff | ratio | growth and a nonzero integer offset. -1 means the previous available row, not the previous calendar month. Use periodComparison for calendar comparisons. A positive offset looks forward. Moving bounds are inclusive and count rows; a full-window requirement counts non-null values, not merely physical rows. On a Snowflake connection a moving window spans at most 1,000 rows, so windowBegin: -1000 with windowEnd: 0 is refused before it runs. Astrato counts a window as windowEnd - windowBegin + 1 rows, which is how Snowflake counts one that ends at the current row. For a cumulative total along a dimension, use runningTotal instead of a wide moving window.

For total or compareToTotal, choose exactly one reducer:

  • aggregate: SUM | AVG | MIN | MAX;
  • percentile: {value: 0.95, method: continuous | discrete, approximate: false};
  • controlValue: {standardDeviations: 2}, meaning the mean plus that multiple of sample standard deviation.

A compareToTotal with controlValue takes only comparison: diff: ratio and growth are refused before the question runs. For a row divided by a control value, add a total with that controlValue and divide by it in a formula. In the editor, Aggregation offers Control value with a Standard deviations field for Total, and for Compare to total with the Difference comparison; Ratio and Growth are disabled beside it.

total.partitionBy groups the reduction. compareToTotal.partitionBy groups the comparison side; comparison is diff, ratio or growth. Its optional currentPartitionBy distinguishes the current row (omitted), all answer rows ([]), and an explicit list of grouping dimensions. Do not substitute one for another.

seriesValue takes select: first | last and optional partitionBy. It follows the effective dimension ordering. Omitted grouping keeps all dimensions except the final ordered dimension; [] explicitly selects across the whole answer. It repeats the selected value without collapsing rows. Hiding a column with output also preserves grain. Totals retain the query’s existing policy; they are not produced by summing displayed advanced results.

Percent rank, standard score, annualized row growth, private moving/row-comparison ordering, and approximate group percentiles are unavailable. The same restrictions apply to document editing, API requests and agents. Database support for exact percentile and statistical functions still determines whether those operations can execute.

Each condition in resultFilters runs at the earliest stage that can evaluate it:

  • A condition on a plain measure selection without a period comparison, or on a formula over such selections, filters the aggregated rows before any advanced calculation. A total or compareToTotal (with its percentile or control value), rank, running, moving or row-comparison value beside it covers only the rows the condition keeps: shares add up to 1 over those rows, and a mean plus three standard deviations is computed from rows already beyond the threshold.
  • A condition on a selection with an advancedCalculation, or on a formula over one, runs after the calculations, which cover every row. A filter on a rank of 1 returns one row whose total is still the total of all rows.
  • AND splits a filter between the two stages. OR cannot join a condition of each kind, and the query is refused.
  • When the question selects a period comparison, inline or through a stored measure such as a year-over-year growth measure, a condition on that comparison runs after the calculations, which cover every row. The engine refuses a condition on a measure without a period comparison in such a question.

defaults.topN behaves like the first case: totals and shares cover the rows it keeps. For a total of every row beside a filtered list, query the total without the filter. When a result filter on a plain measure without a period comparison sits beside a total or compareToTotal selection, the result’s resultNotices names them, unless the question also selects a period comparison, a period axis or a nested grain (inline or through a stored measure); then no such notice is given.

A keyed measure selection can carry nestedCalculation to compute the measure per combination of other dimensions before the answer aggregates it: revenue per customer, then the average for each region, gives typical customer revenue by region. The dimensions need not be in the question. aggregate is SUM, AVG, MIN or MAX. SUM and AVG need a numeric measure: a nested AVG of a date measure, such as each customer’s first order date, is refused, and MIN or MAX keeps the earliest or latest date instead. In the editor, open a measure’s settings and choose Nested dimensions, then Then take. The full shape and the older reduction along a time axis are in the YAML reference.

The grain belongs on the measure selection, not on the measure. A measure’s own nestedCalculation, stored or defined in query.local, reduces along a time axis only, and it cannot see the question’s selection keys. If a measure definition without a period comparison or period binding names any other dimension, or a selection key, validation refuses it. The refusal names the measure and gives the measure selection to send instead. A grain names a stored or query.local dimension, or the key of one the question selects. To use a raw field as the grain, define it in query.local.dimensions first.

Choose Add result formula to combine completed selections. Formula operands use the existing semantic expression grammar, but their reference.measure names a sibling selection in this question. A reference selection continues to name a reusable measure. There is no fallback between these two scopes.

measures:
- key: runningRevenue
reference: { measure: Revenue }
advancedCalculation:
runningTotal:
aggregate: SUM
orderBy: [{ reference: { dimension: month } }]
partitionBy: [{ reference: { dimension: region } }]
- key: currentRevenue
reference: { measure: Revenue }
- key: earlierShare
expression: '({accumulated} - {current}) / {accumulated}'
accumulated: { reference: { measure: runningRevenue } }
current: { reference: { measure: currentRevenue } }
format: { pattern: '0.00%' }
output:
- reference: { dimension: month }
- reference: { measure: earlierShare }

Operand names such as accumulated are preserved. Generated names such as m1 are conveniences, not requirements. Typed scalar values, declared parameters, arithmetic, comparisons and if use the shared expression controls. The result type is inferred from the completed inputs. A formula cannot aggregate or call a UDF: select a measure that computes it as a selection of its own, and read that selection’s key. A query can define that measure in query.local.measures without saving it to the layer. Reusable business formulas belong in reusable measures.

Each {name} in expression reads the block of that name in the same entry, never a sibling directly: {currentRevenue} alone is refused, and the refusal shows the block to add (currentRevenue: { reference: { measure: currentRevenue } }) and lists the sibling keys. A block that expression never uses is refused too.

Hidden selections remain calculation dependencies. Cycles and missing siblings are rejected. Removing a selection also removes formulas that depend on it; any surviving result filter that names a removed selection requires repair. Formulas cannot depend on a final seriesValue.

A formula can carry its own advancedCalculation when it reads plain selections. When it reads a selection that has an advanced calculation, such as a rank or a total, the formula is evaluated after that calculation and cannot take another one. The exception is a seriesValue when every calculation the formula reads is a total or compareToTotal. Such a question is refused when it runs, and validating the layer reports it as an error, which blocks publishing. Put the calculation on a selection the formula reads instead. For a share of the top N, use Top N or a rank result filter with a total on the plain measure.

The same holds for a formula that reads a period comparison, inline, through a stored measure or through another formula: the engine evaluates it last and returns its values without the calculation, so a compareToTotal on {x} over a compared change would return the change itself. Any calculation but seriesValue on such a formula is refused, and validation reports it. Put a total, running total or moving calculation on the compared selection itself instead; for a share of a compared value, divide it by that total in a formula, such as {change} / {change total}. A rank or row comparison of a compared value has no supported form yet.

Data Explorer shows each result formula as its own column, headed by the selection’s key, and runs it with the calculations of the selections it reads. A chart or KPI that shows a formula without its inputs still computes them; it just does not show them. Removing a selection in Data Explorer removes the formulas and calculations that read it, and a formula over a selection Data Explorer cannot show is named in its notice instead. Data Explorer shows every selection as a column, so a question’s own output choice does not apply there.

A measure selection with a nested grain also opens as its own column in Data Explorer, headed by its key. Its grain names stored dimensions, so it keeps running when a chart or KPI hides a dimension the question selects. A nested calculation that reduces along a time axis is named in the notice instead, because Data Explorer leaves out the period comparison it depends on.

For a question whose measures are keyed, resultFilters also uses sibling selection keys and filters completed results. Questions with unkeyed measures retain reusable-measure references. This lets you filter a running share without accidentally filtering its underlying revenue.

New documents use fileVersion: 3. Loading a V2 document converts advancedCalculation: {type: runningTotal, ...} to advancedCalculation: {runningTotal: {...}}, preserving complete lists and defaults. Mixed spellings are rejected. New authoring requests accept only named operation blocks.

V2 result filters on reusable measures are mapped to the equivalent selected calculation. If the measure was not selected, migration adds a hidden dependency and preserves the visible columns. Differently calculated selections of the same source require an explicit choice before migration; the reader does not guess which result the filter meant. Saved history replays in its original vocabulary before the reconstructed document is migrated.

Read and prepare tools require sl:read; applying a change requires sl:write; execution requires data:query. See MCP setup for connection and discovery details.

Tool Purpose
astrato_semantic_layer_list_questions List compact stored identities, metadata and output kinds, or find the ones a text names
astrato_semantic_layer_get_question Read the complete canonical stored definition
astrato_semantic_layer_suggest_questions Generate unsaved proposals; unsupported shapes are rejected explicitly, with the reason
astrato_semantic_layer_prepare_question_change Read the current target and exact reusable dimensions and measures at one draft revision
astrato_semantic_layer_apply_question_change Atomically create, replace, rename or delete one question without publishing
astrato_semantic_layer_create_next_questions Create a stored question’s drivers or related questions, linked as its follow-ups
astrato_semantic_layer_execute_question Execute the stored question against the published semantic layer

semanticLayerId follows the same rule as every other tool: omit it to use the server’s default layer; pass an ID from astrato_semantic_layer_list to target another layer.

The apply tool accepts the same canonical definition as query execution, including transient local.dimensions and local.measures. It promotes local definitions into named entities and saves the BQ references in the same revision. Conflicting names are rejected; saving does not publish. It also accepts the same filters, associativeFilters, resultFilters (each accepting inline content or reference: { filter: "Name" } for a matching named filter), ordered orderBy, preferredCharts, and defaults properties stored in the semantic-layer document. There is no older defaults.sort shorthand: sorting is always the ordered array shown above.

followUps is a list of references to other stored questions, not free text. Each entry is the name of a question that already exists in the same semantic layer, spelled exactly as stored, and a question cannot follow up with itself. astrato_semantic_layer_prepare_question_change returns every available name in available.businessQuestions; choose from that list. To follow up with a question that does not exist yet, save that question first and then reference it.

An entry that names no other question in the layer is dropped rather than stored, and the apply result reports the discarded names in droppedFollowUps. The rest of the change still commits, so a follow-up that cannot be resolved never costs the question itself.

Every proposed change crosses the shared semantic-layer document validator before a draft revision is written. A rejected change returns machine-readable issues including code, entityKey, subPath, reference, and message, so an MCP client can repair the exact invalid property and retry. Engine-generated suggestions cross the same validator. Invalid suggestions remain unsaved and appear in rejected[].issues; they are never simplified into a different question. Unsaved suggestions use the current semantic-layer draft, including unpublished edits. Changing the draft invalidates cached suggestions. A generation failure is returned to the caller. When a canonical candidate could be constructed, rejected[].definition contains that candidate so the client can edit the paths named by the issues and submit a corrected definition. A suggestion’s name and question text are worded for readers, with the layer’s own measure and dimension names, such as Top 10 Product Names by Net Revenue or Share of Revenue by Region. Suggestions that read the same count once, and questions you have already saved keep their names. Suggestions include period-over-period comparisons, such as the current year to date against the previous one. When a suggestion needs an aggregate that no stored measure computes, or groups by a field that no dimension wraps, its definition selects that measure or dimension by name and defines it in local.measures or local.dimensions. Save the definition as returned: they are created with the question, as they are when you create the suggestion in the editor. A suggestion that would need more than four new measures is rejected, and rejected[].needsMeasure names the first: view and field of the aggregated field, aggregate, distinct and a proposed name.

A question’s next business questions are the questions to ask after it, linked as its follow-ups. They come in two kinds:

  • Drivers explain why the question’s KPI changed between two periods.
  • Gap-to-target questions show where a question’s KPI falls short of its target: the KPI, its target, the gap, each member’s share of the gap and attainment by one dimension, most under target first. They are for a question that compares nothing and whose KPI has a target measure, and are offered only where a few members hold most of the gap.
  • Concentration questions show where a question’s KPI is concentrated: the KPI by one dimension, largest first, with each member’s share of the total. They are for a question that compares nothing, and are offered only where a few members hold most of the total.
  • Related questions are what the query engine suggests asking next about the question: other cuts of its measures, rankings, shares of the total, running totals and percentiles. They need no period comparison.

astrato_semantic_layer_create_next_questions creates them from a stored base question. kind chooses auto, drivers, gapToTarget, concentration or related. With auto, the default, a base question that compares periods gets drivers; any other gets gap-to-target questions where its KPI has a target and one passes the threshold, else concentration questions where one passes the threshold, and related questions otherwise; passing a drivers-only option, such as methods, also means drivers, and passing targetMeasure means gap-to-target; targetMeasure with another explicit kind is refused rather than overriding it. The result names the kind it created.

Creating next questions through MCP requires sl:write. Driver, gap-to-target and concentration questions also require data:query because they run evidence queries before saving. Related questions need no data:query grant, including when the tool chooses related questions automatically; without data:query, auto skips both findings and creates related questions for a base question that compares no periods. A base question that compares periods always means drivers, so without data:query auto reports a scope error for it instead.

In the editor, creating a question asks whether to create its next questions too, choosing the kind the same way; choosing Create saves them. Through MCP, the result of creating a question with astrato_semantic_layer_apply_question_change includes nextQuestions, which tells the agent to offer them. Nash doesn’t offer them in Builder.

Each driver question is run in the published layer before it is saved, and the result carries what its answer says about the change as evidence: memberCount, the parts the change is split into; topMemberShareOfDelta, the largest part as a share of the total change; maxShareShift, the largest move in a member’s share of the total between the two periods (member splits only); topMemberShare, the largest-moving part’s share of the total in whichever period it was larger (member splits only); residual, the change the rows read leave over; and truncated, set when the rows read are not the whole split. The rows read are the top 25 by contribution, largest first, so the largest negative movers can fall into residual. The customer bridge outputs no share of the total: its shares are relative to the rows read and its residual is always 0. A question that cannot run in the published layer, for example one over a measure not yet published, is listed in skipped with the reason; only when no candidate in the plan could run is it still created, carrying evidenceUnavailable with the reason (see Ranking below). The evidence also carries change, the total change in the measure’s unit; leading, the part that moved the total most with its label, change and, where the answer holds them, its before and now values; and offsetting, the parts (each with its label and change) that moved against the total and their summed change. Related questions carry no evidence.

The editor gives each evidence request 60 seconds. A timeout leaves that candidate unscored; if all evidence requests fail, questions retain the planner’s order.

Each created driver question with evidence also carries finding: one deterministic sentence of what its answer shows, built from the evidence alone, so the editor, MCP and Nash say the same thing. It uses contribution language, “explains” and “offsets”, never “caused”. The editor shows the same sentence under each offered driver question; it needs a layer saved to Astrato and shows nothing for a question that cannot run. Per method:

  • Additive: the leading segment, its own change and its share of the drop or rise, then the segments that moved the other way and the share left in parts not read, for example “EMEA fell 12% (−$1.2M) and explains 80% of the drop; APAC and LATAM rose, offsetting $300K.” A share can pass 100% when the other segments offset the leader. A segment with no prior value has no relative change. In every method, when the leading part opposes the total, the sentence says so, “against a drop of”, instead of giving a share.

  • Customer bridge: the leading bucket and what it took or added, then each offsetting bucket with its own amount, for example “Churned customers took $900K, 173% of the drop; New customers added $400K.” When the rows read are not every customer, the sentence is scoped “in the customers read”.

  • Multiplicative: the total change, the leading driver effect’s amount and share of it and that driver’s own change, then the offsetting effect and what the drivers leave unexplained, for example “Revenue fell 90: the Customers effect (−95) explains 106% of the drop (Customers fell 9%); Revenue per Customers rose, offsetting 10; 6% is not explained by the drivers.”

  • Concentration: how many of the dimension’s members hold what share of the total, and the largest member’s own share, for example “3 of 42 Customer are 61% of Revenue; the top one, Acme, alone is 34%.” With three members or fewer, the largest one is the finding: “Bikes alone is 96% of Revenue across 3 Product Category”; with one, “Acme is all of Revenue.” The member count is the engine’s own count, so it is exact however many rows were read.

  • Gap to target: how far the KPI is from its target, and the member holding most of the gap with its share of it and its own attainment, for example “Revenue is 7% under Revenue Budget; West accounts for 65% of the shortfall at 81% attainment.” Over target, the sentence says “over” and “excess”. Within half a percent of target it reads “Revenue is on Revenue Budget”, though the ranking does not create such a question.

Amounts are compact (1.2M, 300K) and carry the currency sign of the KPI’s stored format, which the result returns as format; a percentage format writes changes in points. Shares and relative changes are rounded to whole percents. A change of zero reads “did not change between the periods”, though the ranking does not create such a question.

The finding is stored with the created question as finding, beside findingEvidence: the method, the total change, the leading part and the offsetting parts the sentence reads. A concentration finding stores the total as change, the largest member as leading with its value as change, and concentration: the dimension, its memberCount, and topShare and topThreeShare, the largest one and three members’ shares of the total. A gap-to-target finding stores the total gap (KPI minus target, signed) as change, the member holding most of it as leading with its gap as change, its KPI as now and its target as before, and gapToTarget: the dimension, the target measure, the memberCount, the KPI’s total attainment of its target, and the leading member’s leadingShare of the gap and own leadingAttainment. Both describe the data when the question was created and are never refreshed; run the question for what it shows now. astrato_semantic_layer_get_question returns finding beside the definition, which carries both properties, astrato_semantic_layer_list_questions returns finding per question, and astrato_semantic_layer_apply_question_change keeps both through a replace or a patch, whether they are resent as read or left out. Only creating a driver, gap-to-target or concentration question with evidence writes them: a create that carries them, or a replace, patch or clear that would change them, is refused. A question created any other way, including a related question, has no finding. In the editor, the question’s About tab shows the stored finding as read-only text, marked as from the data when the question was created, and version compare shows the finding and its evidence as rows of the question when they differ between versions.

Every driver candidate the layer models is planned and run first, and the candidates are ranked by what their answers explain before the best count (3 by default, 10 at most) are created. Each created question carries its score, and one with evidence its finding (see Findings above): impact, the largest part’s share of the change (capped at 1), times significance, how far that part’s share of the total moved between the periods. A product split has no shares, so its impact stands in for significance. Two things rank a candidate down: a split with more than 25 parts, such as one by customer id, and a leading part smaller than 1% of the total. Candidates that explain nothing are not created and are listed in skipped with their name and the reason: every part moved in proportion, the change is zero, the largest part is rows with no value for the dimension (a blank, “(not set)”, “NA” or “Others” member), the question could not run in the published layer, or it ranked below the count. The same unnamed-member rule skips a concentration or gap-to-target candidate. So created may be empty. When no candidate could run at all, for example in a layer never published, the planner’s order is kept and every score is 0. The editor offers the same ranked list, with the finding under each question, once the ranking arrives; a layer not saved to Astrato cannot run the candidates and is offered the planner’s first questions without a finding.

For a base question that compares no periods, one candidate question is planned per dimension: the base question’s own stored dimensions first, then the categorical dimensions that join to the KPI. Identifiers, calendar parts (a year, month or weekday, by name or by a date source) and true/false flags are not chosen automatically, the base question’s own dimensions included: a month split says when, not where, and a flag’s two members concentrate by construction. One named through segmentDimensions is still planned. Each selects the KPI by that dimension, ordered largest first, with the KPI’s share of the total as a second column (compareToTotal with the ratio comparison), keeps the base question’s filters and associative filters, and suggests a bar chart, largest first, as a Pareto bar, then a table. Each candidate is run in the published layer, and its evidence is the member count, the largest member and the largest one and three members’ shares of the total.

A candidate is offered when its largest member’s share exceeds an even split, one over the member count, by at least ten points; four regions at 25% to 30% each concentrate nothing. The candidates are ranked by that excess and the best two are created by default (count lowers it to one; it never raises it above two), each with its score and finding. Unlike drivers, a candidate that could not run is never created, and the rest are listed in skipped with the reason. With auto, when no candidate passes, related questions are created instead; with kind: "concentration", created is empty. The questions are filed under the concentration category and appended to the base question’s followUps. The editor offers the same ranked questions for a level question, with the finding under each, and the related questions only when none passes; a layer not saved to Astrato cannot run the candidates and is offered related questions.

For a base question that compares no periods and whose KPI has a target measure, one candidate question is planned per dimension, chosen as for concentration. A target is recognised by name, not declared by a property: the stored measure the base question selects beside its KPI whose name says target, budget, plan, goal, quota or forecast (“Revenue” beside “Revenue Target”), else a measure in the layer named as the KPI plus that word (“Revenue Budget” for “Revenue”; “Forecast Renewal ARR” is not ARR’s target). Through MCP, targetMeasure names one explicitly, and kind: "gapToTarget" without a recognised target reports why; in the editor the same measure is recognised automatically, with no control to name one. A measure named differently, such as “Plan 2026”, is not recognised: through MCP name it with targetMeasure; in the editor, rename it or select it beside the KPI in the base question.

Each candidate selects the KPI, the target, the gap as the KPI minus the target, the gap’s share of the total gap (compareToTotal with the ratio comparison), attainment as the KPI over the target (0 where the target is 0 or negative) and the KPI’s share of the total, by that dimension, ordered by the gap with the most under target first, keeps the base question’s filters and associative filters, and suggests a bar chart of each member’s gap, then a table. Each candidate is run in the published layer, and its evidence is the member count, the KPI’s total attainment, and the member holding most of the gap in the total’s direction with its share of the gap and its own attainment. The rows read are the 25 most under target, so when the KPI is over target as a whole and more members than that exist, the members leading the excess are not read; such a candidate is skipped with that reason rather than named after a member that was read.

A candidate is offered when the leading member’s share of the gap exceeds an even split, one over the member count, by at least ten points, and the KPI is at least 1% off its target as a whole; a KPI on target has no gap to place, and an over-target answer the row limit cut off is skipped as above. The candidates are ranked by that excess and the best two are created by default (count lowers it to one; it never raises it above two), each with its score and finding. As for concentration, a candidate that could not run is never created, and the rest are listed in skipped with the reason. With auto, when no candidate passes, concentration is tried next, then related questions; with kind: "gapToTarget", created is empty. The questions are filed under the gap to target category and appended to the base question’s followUps. The editor offers the same ranked questions for a level question whose KPI has a target, with the finding under each, before concentration and related questions; a layer not saved to Astrato cannot run the candidates and is offered related questions.

The engine’s suggestions are anchored on the base question’s text. A suggestion is kept only when the layer can store it as a question:

  • A measure the engine writes out as an aggregate over a field, such as the sum of an amount, is stored as the measure that computes exactly that, such as Revenue. When no stored measure does, the suggestion is not kept and no measure is created for it; its rejection carries needsMeasure. Create that measure with the measure tools, as one aggregate input of that field, and ask again. This differs from astrato_semantic_layer_suggest_questions, whose suggestions define the measures and dimensions they need in definition.local.
  • A field that a stored dimension wraps is selected as that dimension.
  • Rankings become Top N, a share of the total becomes the compare-to-total calculation, and a running total or percentile becomes the matching advanced calculation.
  • A suggestion comparing this period with the previous one, such as this year to date against the previous year to date, is stored with a Time Axis over the date field it filters to, placed first, and the matching period comparison on its measure. Its next questions are then its drivers. A comparison of one day with the same day a year or month earlier is stored as a Time Axis over today with that comparison period, so it reads as of the day it runs; a one-day comparison on any other date, more than a day from the clock, is still refused as a fixed day. A window compared by a coarser grain, such as this month against the same month last year, is stored the same way; a week is compared only with the same week a year earlier, since weeks do not fit inside months or quarters. A window compared by a finer grain, a window a Time Axis cannot hold, or a date field on a table the measure’s rows do not lead to through many-to-one or one-to-one joins, is not kept, and its rejection says why.

Suggestions about the base question’s own measure come first, then those cutting by a dimension the base question does not use, each kept in the engine’s order. Each set of dimensions, the base question’s included, is used once before any is used again, so the questions look beyond the base question’s own cut whenever the engine suggests another. A suggestion that asks what the base question or another question already asks, or is worded as one, is skipped. Three questions are created by default, at most ten. Suggestions the layer cannot store are returned in rejected with the reason; they are never simplified into a different question. A rejection waiting only on a stored measure carries needsMeasure in this tool’s rejected; create the measure and ask again.

Each driver question writes the change as a sum of parts that add up exactly to the total change, so the parts reconcile with the base question’s own answer.

The KPI is the base question’s compared measure, or its first stored measure when nothing is compared. A stored measure that compares its own calculation, such as a growth measure written over the same sum as Revenue, counts as comparing periods: its KPI is the stored measure that computes that calculation without a comparison. When no such measure exists, the question cannot be explained by drivers. The new questions reuse the base question’s comparison and window, its filters, associative filters and set filter. When the base question compares over a time axis it does not select, as a last-30-days measure does, the new questions do not select it either, so each answers one row per segment for the whole window rather than one per segment and day. A base question that compares nothing is explained year to date against the same point of the previous year, using a time axis or date field that joins to the KPI.

Three questions are created by default, at most ten. The planner tries these methods in order and skips any the layer cannot support:

Method Question it answers What the layer needs
multiplicative Why did it change? KPI = count × rate × average, split by Shapley values One or two count measures joined to the KPI
additive Where did it change? Each segment’s change in points of the prior total A categorical dimension joined to the KPI
priceVolumeMix Volume, mix and price effects for each product Not available: always skipped
customerBridge New, expansion, contraction and churn per customer A customer dimension

The multiplicative split uses Shapley values rather than logarithms: result formulas support arithmetic and if, and Shapley values are exact and do not depend on the order the drivers are taken in. With two count measures, such as customers and orders, the KPI is customers × orders per customer × KPI per order. A Not explained by the drivers column shows what the effects leave over: it is zero while every count is positive, and shows the gap when a count is zero but the KPI is not, such as a distinct count over an empty identifier. The additive method creates one question per segment dimension, so remaining slots go to further segments. Each segment’s share of the change divides its change by a total of the compared change, and its contribution in points of the prior total divides it by a total of the prior period, both grouped by the compared period. In the multiplicative split, a period with no activity takes the other period’s rates, so a change that comes entirely from new customers is credited to the count rather than to the average.

priceVolumeMix is always returned in skipped: its effects need the total units of the products sold in both periods, a total of a formula over a period comparison, which the engine does not compute. For the same reason a customer bridge has no bucket totals and no net or gross revenue retention.

Pass allocation: "sequential" to split a multiplicative change by sequential substitution instead: each driver changes in turn (count, then frequency, then average, in componentMeasures order) with those before it at their new values. It is simpler to explain, but another order would share the change differently; Shapley values, the default, do not depend on order.

Pass methods to choose and order the methods, and segmentDimensions, componentMeasures (one or two), quantityMeasure, productDimension or customerDimension to choose the inputs instead of letting the planner pick them. Methods that do not fit are returned in skipped with the reason. Statistical driver models, such as regression, marketing mix models, SHAP or causal methods, are not generated.

Each question suggests a table for the exact values first, then a waterfall chart from the prior value through each part to the current one. Chart suggestions are advisory.

The new questions are saved to the draft in one revision, filed under the drivers category and appended to the base question’s followUps. Driver, concentration and gap-to-target questions are named after the base question they explain, such as Revenue this year: Revenue change drivers, so the questions created for several base questions over one KPI do not collide. As with any saved comparison, the comparisons and inline time axes become named entities, so publish the layer before executing the new questions. Nothing is published by this tool.

Use the same query object for execution and as the definition in a prepared BQ create or replace operation. Keep paging, timeouts and runtime parameter values outside it:

{
"query": {
"dimensions": [{ "key": "Country", "reference": { "dimension": "Country" } }],
"measures": [{ "key": "Revenue", "reference": { "measure": "Revenue" } }],
"orderBy": [{ "reference": { "measure": "Revenue" }, "direction": "descending" }],
"defaults": { "topN": 10 }
},
"parameters": { "measures": { "Revenue": { "factor": 2 } } },
"limit": 5
}

The example assumes Revenue declares a numeric factor parameter. Overrides are keyed by the measure selection key and then parameter name. Filter overrides use parameters.filters and parameters.resultFilters; association overrides use parameters.associativeFilters[branchName][parameterName]. Omitted values use their declared defaults; unknown names or wrong JSON types are rejected. astrato_semantic_layer_execute_question accepts the same execution options. Measure overrides use the same type aliases and timestamp conversion as their declared defaults: integer accepts a JSON number, bool a JSON boolean, and number or decimal a JSON number. Integers must be safely representable; numeric strings are not coerced. Values such as 0, false and empty text remain meaningful overrides.

For a measure’s aggregate-filter parameter, supply a timestamp as a full ISO date and time (for example "2026-01-02T03:04:05.123"). A timestamp_tz must include Z or an explicit offset, such as "2026-01-02T03:04:05.123+13:00". Both support up to millisecond precision. A timestamp without a zone keeps its wall-clock value regardless of the server’s timezone; an explicit offset identifies an instant. Timestamp overrides are supported for aggregate-filter parameters, including inherited parameters on derived measures. Timestamp formula overrides are rejected because the query engine does not support that parameter scope. Overrides stay execution-only and never replace the saved defaults. When a derived measure and an inherited measure declare the same parameter name with compatible types (including aliases), one override applies to every formula and filter scope that uses it. The derived measure’s declaration takes precedence for the reported default. Incompatible types for the same name are rejected; use distinct parameter names for distinct types.

SQL list parameters (int_list and string_list) use strings such as "1,2,3" and "east,west"; numeric or boolean overrides are rejected for these types. Data Explorer keeps your selections, filters, result filters, sorting and ranking in an unsaved Business Question. Header result filters and confirmed chart selections update that same question. You can inspect and clear result filters in the filter menu. Chart types can disable selections they cannot display; disabled selections leave the active question. Filters remain applied. Formatting and layout remain presentation choices. Switching editor tabs retains the exploration for the open layer session.

When a selected measure cannot be broken down by a selected dimension, the query still runs: the measure reads 0 under each of the dimension’s values, and its whole value lands in a row where the dimension is empty. Data Explorer marks that measure’s chip with a warning triangle. Hover or focus it to see the query engine’s message and the fixes it suggests, such as reviewing the joins between the two views or removing the measure or the dimension. When the chip is folded into the +N button, that button carries the triangle too.

Choose Save question to create a saved Business Question. Besides its name, the save dialog takes the question’s optional context: description, categories, meaning and why it matters. These are the same fields as the question’s About tab. Raw-field aggregations become named measures at that point; exploring alone does not change the semantic layer. Saving never replaces an existing question with the same name. A saved question preserves its result predicates and independent ranking when reopened. Publish any newly created measures before running the saved question against the published layer.

A chart or result table in a Nash answer can be saved the same way with its Save as business question icon; see Nash conversations.

In the editor, each of a question’s panel tabs says what it holds: Filters counts the conditions applied, About counts which of its five fields are filled, and a tab holding one of the question’s issues carries that issue’s mark and count. A dataset’s Data and Filters tabs are marked the same way. In the questions list’s Charts view, a chart that cannot be drawn shows why in a quiet placeholder.

Result filters on a selected measure use that selection’s calculated result, including its execution parameter overrides. This also applies to calculated measures and nested AND/OR predicates. Keep literal values in semantic form: for example, { "dataType": "date", "value": "2026-09-23" }.

Break one question down by different levels

Section titled “Break one question down by different levels”

A question can group by a published drill-down dimension, which is an ordered set of other dimensions or fields. One general question then answers every breakdown it covers: “What are my sales?” grouped by a drill-down of Region, Channel and Product also answers “sales by channel”.

{
"dimensions": [{ "key": "Breakdown", "reference": { "dimension": "Sales breakdown" } }],
"measures": [{ "key": "Sales", "reference": { "measure": "Sales" } }]
}

Without a choice, the question groups by the drill-down’s first level. To group by another, pass parameters.drillLevels, keyed by the dimension selection key:

{ "parameters": { "drillLevels": { "Breakdown": { "dimension": "Channel" } } } }

The value is one of the drill-down’s levels: the dimension it names, or the field it groups by, such as { "view": "Orders", "field": "Channel" }. A field and a dimension that wraps that field name the same level. Matching ignores case. A value that is not one of the levels is rejected, and the error lists the levels you can use. For an array of selections, the key is the selection’s index, such as "0". The level choice applies to that one execution and is never saved into the question.

astrato_semantic_layer_get_question, and astrato_data_prepare_query with scope: "questions", return drillLevels beside the definition of a question that groups by a drill-down. It lists each level’s reference and label, so you can pick the level that matches the breakdown you were asked for. When you ask Nash for a breakdown by one of the levels, it looks for such a question and runs it with that level.

A question that asks for a list, such as “which cities do we sell in?”, selects dimensions and no measures. Leave measures empty or omit it. The answer contains each distinct combination of the selected dimension values. Filters and sorting work as usual:

{
"dimensions": [{ "reference": { "dimension": "City" } }],
"orderBy": [{ "reference": { "dimension": "City" }, "direction": "ascending" }]
}

A list has no measure to rank by. On a list question:

  • topN keeps only the first N rows, in the question’s orderBy order.
  • showOthers is rejected, because there is no measure to total for the rows that were left out.
  • hideZeroValues has no effect.

A question that selects neither a dimension nor a measure is rejected. In the editor, the Create button stays disabled until the question has at least one measure or dimension. Each measure or dimension you add in the editor is also added to the question’s sorting: measures sort high to low, dimensions A to Z, and they sort in the order you added them. Switch a row off or change its direction under Sorting.

To aggregate a published raw field, define a measure in the query and reference it:

{
"dimensions": [],
"measures": [{ "key": "Revenue", "reference": { "measure": "Local Revenue" } }],
"local": {
"measures": {
"Local Revenue": {
"resultType": "float",
"expression": "{m1}",
"m1": { "aggregate": "SUM", "arguments": [{ "reference": { "view": "Orders", "field": "Amount" } }] }
}
}
}
}

Use references and aggregate capabilities from astrato_data_prepare_query. local.dimensions accepts canonical field, bucket and period-axis definitions for inline execution. Mapping and drillthrough definitions need publication before execution. Saving promotes the definitions, retains parameterization and excludes execution override values. Nash’s Save button copies the query that actually ran, including its filters, sorting and analytical Top N.

Clients holding only data:query can discover IDs and full definitions with astrato_data_prepare_query and scope: "questions". This catalogue is available when Business Questions is enabled. If a definition cannot fit in a page, its entry contains id and definitionOmitted: "size" instead of definition. You can execute that ID with astrato_semantic_layer_execute_question using the same data:query authority, and continue discovery with nextCursor. A question that uses newly promoted entities cannot execute until you publish them.

See From exploration to a dashboard when deciding which questions a new dashboard should answer.

  1. Review stored questions for the selected semantic layer before constructing a new query. Use astrato_semantic_layer_list_questions, optionally filtered by category, or the query-only catalogue described above when you hold only data:query.
  2. Select a question whose meaning matches the intended analysis. Stored questions offer governed definitions; their presence does not mean they cover every analytical need. Keep each ID opaque.
  3. Read the complete selected definition with astrato_semantic_layer_get_question or the query-only catalogue, including its filters, parameters, sorting, ranking and period settings.
  4. Use the first compatible preferredCharts entry by default, let the user choose another compatible option, or render the default table.

The list response is intentionally compact. Read the selected question before building a chart or relying on its complete fields, preferences, and defaults.

When a user types a stored question’s name, or something close to it, find it with a search rather than a full listing. Pass their wording as query to astrato_semantic_layer_list_questions, or as search to astrato_data_prepare_query with scope: "questions" when you hold only data:query. Matches come back best first, each with a score from 0 to 100. The list tool returns at most limit of them, five by default. The query catalogue pages them like any other catalogue page, up to its own limit (300 by default) within its response-size budget, so a short or common search with scope: "questions" can return many more than five:

  • Case, punctuation and a missing question mark are ignored: “which team has the best win rate” scores 100 against “Which team has the best win rate?”.
  • A slipped letter or two, a different word order, or part of the name still ranks the question first, with a lower score.
  • A description or meaning that mentions the text counts for less than the name. The stored question wording, where a question has one, counts as a name.
  • Text no question is about returns no matches.

The list response also reports matched, how many questions scored, and bestMatch when one question clearly is the one named. That means it scores at least 85, no other comes within 10 of it, and it spells the same symbols as the text, such as %, #, $ or €. The score ignores those symbols, so “Net Sales Δ” scores 100 against “Net Sales % Δ” without being that question. A name that adds words to the text, such as “Revenue by region and month” for “revenue by region”, is not taken as the one named either.

A bestMatch says which question the words name, not that it answers the ask:

  • When the ask is the question and nothing more, execute the bestMatch with astrato_semantic_layer_execute_question directly.
  • When the ask adds a breakdown, filter, period or measure that the name does not state, read the question with astrato_semantic_layer_get_question first. A breakdown by one of its drillLevels runs with parameters.drillLevels set to that level; without it, execution groups by the first level.
  • When the question does not cover the ask, use another match or the query tools.

Nash does the same when a reader names a question.

If no question matches, use astrato_data_prepare_query to discover published, queryable dimensions and measures before raw fields. Use the returned references and aggregation capabilities; matching a display name in the model is not enough to establish that a field can execute.

Explore before fixing the dashboard’s tiles. Inspect the relevant measures and dimensions, then run bounded queries to check grain, units, date coverage, category values, missing data and useful relationships. Retain the query definition and the findings that justify each proposed view.

Confirm what one source row represents before counting business entities: order lines are not necessarily orders. Use a governed business measure rather than treating a raw row count as its replacement. Nulls, zeros and empty results can be meaningful; investigate them in context instead of filling or discarding them merely to make a chart look complete.

  1. Reuse a matching question. List the semantic layer’s stored questions and read the complete definition of each candidate. Execute it with the intended filters and parameters. Match the analytical meaning, not just its title or underlying measure.
  2. Save missing questions. For every free-form App build, create useful saved questions for required analyses that existing questions do not cover, alongside the App. An App cannot read a question, so these are for reuse once the layer is published. Keep to the few that matter, and don’t let saving hold up the first build. Turn each verified exploratory query into a named Business Question in the semantic layer. Give it a clear title and description; preserve its dimensions, measures, filters, parameters, sorting, ranking and period meaning. Follow Run and save an unsaved question and the prepare/apply tools. Suggestions remain unsaved until applied. Create with ifExists: "skip" inside each item’s change, so a question someone else saved under that name keeps its definition, and read the revision again when a save reports it is stale. A skipped create saves nothing, so check the commit receipt, and use another name if the stored question answers something else.
  3. Check availability. Saving requires write access and changes the draft; it does not publish. Stored question execution uses the published model. If saving creates named measures or dimensions, follow Publish your changes before relying on them in a live app. With read-only access, reuse existing questions and retain proposed additions without claiming they were saved.
  4. Map questions to views. Record which stored question each tile answers, its runtime overrides and its verification evidence. Use Choose a chart and Compose a dashboard page for the presentation.

A chart built from a question’s fields does not automatically inherit its complete query. Check the integration limits below and preserve filters, calculations and ranking; disclose a capability gap rather than silently dropping part of the question.

The following example binds a question’s fields to a chart. It applies when those fields fully describe the intended analysis. It does not transfer filters, parameter values, sorting, Top N or period settings. For a richer question, use a supported rendering path that preserves those settings, or display the direct question result. A chart with the same field names can otherwise answer a different question. See the Visualizations reference for rendering capabilities.

import { createDefaultVSpec } from '@astrato-io/visualizations';
import {
compileVisualizationDefinition,
createVisualizationAvailableFields,
} from '@astrato-io/visualizations/definitions';
// entity: the result of astrato_semantic_layer_get_question.
// fieldMetadata: loaded once via the SDK — the selected app quickstart shows the full setup.
const availableFields = createVisualizationAvailableFields(fieldMetadata);
const compilation = await compileVisualizationDefinition({
definition: {
version: 1,
type: 'bar',
data: {
dimensions: entity.fields.dimensions.map((dimension, index) => ({
ref: `d${index}`,
fieldKey: dimension.fieldKey,
})),
measures: entity.fields.measures.map((measure, index) => ({
ref: `m${index}`,
fieldKey: measure.fieldKey,
...(measure.aggregation ? { aggregation: measure.aggregation } : {}),
})),
},
},
availableFields,
baseVSpec: createDefaultVSpec('bar'),
});

Not every value in preferredCharts[].type maps to a mountable visualization. Check the suggested type against the mountable registry before using it; if it is unavailable, fall back to bar or table.

After parsing the MCP JSON text into result, result.data identifies the question (semanticLayerId, revision, businessQuestionId, output) and result.data.data is the answer.

To read the SQL a question would run without running it, pass dryRun: true with the same execution options. result.data.data.statement then holds the compiled SQL, including sorting, filters and the row limit, and nothing reaches your warehouse. After a real execution, result.data.data.statement holds the SQL the engine reported when available and within the response budget; omittedMetadata.statement is true if it was omitted for size. In the Nash chat, each query step shows its SQL while it runs; open the step to read it.

For a slow warehouse query, astrato_semantic_layer_execute_question accepts queryTimeout in whole seconds (120–600, default 120). The stored question stays the same. The server can retry the read once after a temporary failure or timeout; see query timeouts and retries for client wait limits and cancellation behaviour.

Use astrato_semantic_layer_execute_question when you need the data without a chart. Column metadata is at result.data.data.columns[], in the same order as each row in result.data.data.rows, and the remaining properties of result.data.data describe the page.

Execution compiles semantic references against the published queryable catalog. The result includes bounded rows plus canonical table output metadata. A draft-only or stale selection is rejected instead of being resolved against a different item.

Rows are returned a page at a time. When present, result.data.data.totalRecords reports the answer’s row count; an absent total is unknown, not zero or proof that the page is complete. Use nextOffset to continue when supplied, as described below. limit sizes the page. Where a list (a question with no measure) stores its own topN, that bounds its whole answer rather than a page size: paging moves inside those rows, a larger limit is clamped to them, and an offset at or past the end is refused rather than answered with rows the question never asked for. A topN ranked by a measure keeps every member ranked topN or better, and members with equal values share a rank, so when members tie at the cutoff the answer has more than topN rows: all of them are returned, totalRecords counts them, and a resultNotices entry says how many more than topN share the cutoff rank. When orderBy is set, members that sort equal are ordered by the dimensions, ascending. Without a limit, the first page holds 100 rows, or the whole Top N answer when topN is larger, Others row included. A bounded answer or sample does not establish the source dimension’s full cardinality.

  • Advanced calculations. Query-relative calculations that do not yet have a lossless stored representation are rejected as a whole when suggested; they are never flattened to a different answer.
  • Output bindings. Table is implemented first. Pivot and chart-family bindings remain under feature-flagged development.
  • SDK support. There is no business-questions API in @astrato-io/sdk; MCP is the supported channel.

Use nextOffset to continue an executed table result with the same question, parameters, limit and sorting. Rows and cell values are delivered intact. Both query execution tools accept responseBudgetBytes for a smaller or larger bounded page; see query response budgets. Check omittedMetadata before relying on optional SQL or source attribution. Continuation reads live data and checks permissions again; it does not preserve a snapshot across calls.