Business questions guide
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.
Named dimensions and period calculations
Section titled “Named dimensions and period calculations”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 entity
Section titled “The entity”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"],}-
idis unique within one semantic layer, never globally. Treat it as opaque. Every call is scoped by tenant and semantic layer. -
For ordinary stored references,
fieldsuses the same opaque enginefieldKeyvalues as the SDK and the visualization definitions. Use them exactly as returned; never parse or construct them. -
preferredChartsis 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.
-
issuesappears when the layer’s validation rules report something on the question in the draft, asastrato_semantic_layer_validatedoes (source-check findings are not included): each withseverity,code,subPathandmessageonastrato_semantic_layer_get_question, and one{ severity, code }per kind onastrato_semantic_layer_list_questions, which also counts them inwithIssues. Most errors, and the warningsquestion-set-keyed-by-groupingandfilter-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 checkissuesbefore running one. A warning on a business question holds up neither publishing nor an Ossie export. -
defaultscontrols execution:topNranks and bounds the answer, whilehideZeroValuesenables suppression of zero and null measure values.showOthersadds an aggregated Others row;othersPositionandothersLabelcontrol its placement and label when enabled. Setdefaults.rankingto{ "reference": { "measure": "Revenue" }, "direction": "descending" }to rank independently of the final sorting.reference.measurenames a selected measure key (the measure name for array selections). Turning on Top N in the editor, or saving a question withtopNand norankingthrough MCP or Nash, stores a ranking by the first selected measure, keeping its highest values. An unsaved query withoutrankingfollows its first measure sort. With a measure and more than one dimension,topNcounts ranked values rather than rows, and you choose which dimension it ranks. For revenue bycountryandcategorywithtopN: 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. dimensionnames a selected dimension the way anorderBytarget does.allModeapplies only withoutrankedDimension. The editor and Data Explorer label the default Within all other dimensions andallModeRank each dimension individually. When neither is set, the result’sresultNoticesand layer validation (warningquestion-top-n-unranked) say which dimension was ranked and what to set. To number rows within a dimension without removing any, use arankcalculation over the answer withpartitionByinstead. A question without measures keepstopNas a cap of that many rows. Paging withlimitandoffsetremains separate from these analytical settings. -
A question needs at least one dimension or measure. See List questions without measures.
-
descriptionmay 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
equalsornotEquals; the refusal says when swapping the operands helps. An ordering comparison such aslessThanwould 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.
Associative filters
Section titled “Associative filters”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.
Ordering and hidden output columns
Section titled “Ordering and hidden output columns”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.
Calculations over the answer
Section titled “Calculations over the answer”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.
Result filters and advanced calculations
Section titled “Result filters and advanced calculations”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
totalorcompareToTotal(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. ANDsplits a filter between the two stages.ORcannot 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.
Computing a measure at a finer grain
Section titled “Computing a measure at a finer grain”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.
Formulas over completed selections
Section titled “Formulas over completed selections”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.
Document migration
Section titled “Document migration”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.
MCP tools
Section titled “MCP tools”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.
Next business questions
Section titled “Next business questions”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.
Findings
Section titled “Findings”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.
Ranking
Section titled “Ranking”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.
Concentration
Section titled “Concentration”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.
Gap to target
Section titled “Gap to target”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.
Related questions
Section titled “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 fromastrato_semantic_layer_suggest_questions, whose suggestions define the measures and dimensions they need indefinition.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.
Drivers
Section titled “Drivers”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.
Run and save an unsaved question
Section titled “Run and save an unsaved question”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.
List questions without measures
Section titled “List questions without measures”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:
topNkeeps only the first N rows, in the question’sorderByorder.showOthersis rejected, because there is no measure to total for the rows that were left out.hideZeroValueshas 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.
Choose a question
Section titled “Choose a question”See From exploration to a dashboard when deciding which questions a new dashboard should answer.
- Review stored questions for the selected semantic layer before constructing a new query. Use
astrato_semantic_layer_list_questions, optionally filtered bycategory, or the query-only catalogue described above when you hold onlydata:query. - 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.
- Read the complete selected definition with
astrato_semantic_layer_get_questionor the query-only catalogue, including its filters, parameters, sorting, ranking and period settings. - Use the first compatible
preferredChartsentry 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.
Find a question by name
Section titled “Find a question by name”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
questionwording, 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
bestMatchwithastrato_semantic_layer_execute_questiondirectly. - When the ask adds a breakdown, filter, period or measure that the name does not state, read the
question with
astrato_semantic_layer_get_questionfirst. A breakdown by one of itsdrillLevelsruns withparameters.drillLevelsset 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.
From exploration to a dashboard
Section titled “From exploration to a dashboard”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.
- 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.
- 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’schange, 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. - 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.
- 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.
From question to chart
Section titled “From question to chart”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.
Executing a question directly
Section titled “Executing a question directly”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.
Limitations
Section titled “Limitations”- 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.
Large query results
Section titled “Large query results”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.