Skip to content

Governed objects

5 min read · Updated 3 Nov 2026

A governed object takes its dimensions, measures, order and calculations from the semantic layer. The app adds presentation only: chart type, colours, formats and display names. Anyone who queries the layer, through MCP, another app or a Workbook, then gets the same figure the dashboard shows.

Every number on the page should come from a governed measure, either in a mounted object or in a query of a stored measure. Do not compute figures in the app from query results: no averages over fetched rows, no ratios of two query results, no recalculated per-row ratios. Such a figure has a second definition that can drift from the layer’s and cannot be reused.

When the page needs a value the layer lacks, add it to the layer:

The page needs Add to the layer
A sum, average or ratio A measure, such as SUM(a) / SUM(b)
A count of rows meeting a condition A 0/1 flag column in the view and a SUM measure over it
A per-row ratio that a slider compares against A column in the view (computed in the warehouse) that a selection can range over
A change against the previous period A business question measure with an interRecord calculation

The app may still compare a stored per-row value with a control’s value, for example to badge one application as passing or failing a threshold, because that is presentation of a governed value, not a new figure.

Write one business question per object, or per family of objects that share a shape, such as all headline cards. Give every dimension and measure selection a key. The keys become the object’s refs: the app names the question and the keys it keeps, and takes the field names, order and calculations from the question.

A Data App cannot read business questions at runtime (see Querying the engine). Copy the stored questions into the app as data at build time, with a script that reads them through the MCP server (astrato_semantic_layer_list_questions, then astrato_semantic_layer_get_question) and writes a module. Re-run the script whenever a question changes, and keep the script beside the app.

// questions.js — generated; do not edit by hand.
export const QUESTIONS = {
region: {
id: 'How do income and expenses compare by region?',
dimensions: [{ key: 'd_region', dimension: 'Region' }],
measures: [
{ key: 'inc', measure: 'Avg Monthly Income' },
{ key: 'exp', measure: 'Avg Total Expenses' },
],
orderBy: [{ key: 'inc', direction: 'descending' }],
},
};

Resolve each name against live metadata: a stored dimension through its [dimension] item, a stored measure through its [calculation] item. Build the visualization definition from those fields with the question’s keys as refs and its orderBy as the sorting. A name that does not resolve fails only the object that needs it: show that object’s error state and let the rest of the page load, as Check operation results shows. When a question measure carries interRecord, apply the matching setMeasureAdvancedCalculationType and setMeasureAdvancedOffset operations to that measure.

Execute every question through MCP once after publishing. A question that returns one row with a null dimension value usually means the dimension’s column is empty in the data, not that the question is wrong.

A headline figure with no breakdown needs no dimension. Mount the stored measure in a number object, or in a one-row table whose columns are all measures. Both narrow with the selections in their group, like any other object; see Selections on semantic layer items.

A KPI card’s change line can be a governed object:

  1. Store a question with the period dimension ordered descending and, for each headline measure, a second selection of the same measure with interRecord { comparison, offset: 1 }. Use growth for counts and amounts and diff for rates, so a rate changes in points.
  2. Mount a one-row table of the period and the change measure with queryLimit: 1. The window runs before the limit, so the first row still compares with the period before it.
  3. Show which period is compared, and label rates as a change in points. Colour by whether the direction is good for that measure, not by sign alone: rising expenses are bad news.

For month-on-month and quarter-on-quarter, store one question per period and mount the second set of change tables only when the reader first switches to it. Check whether the latest period is complete before comparing it; see the acceptance checks.

Measure names in the layer are written for modellers, such as Avg Monthly Income; readers expect Income. Keep a display-name map in the app, keyed by stored name.

  • Tables: setColumnCustomLabelEnabled and setColumnCustomLabelValue on each column.
  • Charts: legends and tooltips show each item’s name from the layer. Neither the definition nor an operation renames a chart’s measures or dimensions, and writing a label into the vSpec is not supported. Where the chart type has them, give axes and the legend reader-facing titles in the definition’s presentation.axes and presentation.legend. When readers need a different name in legends and tooltips, ask the layer owner to rename the item.

Sliders that set a policy, such as a maximum debt-to-income, write numeric between selections; see Inputs. To keep every figure governed:

  • Put the objects that show the passing population in their own selection group. Write every threshold into it as a range on a stored per-row column, and mirror the page’s other filters into it.
  • To count the rows breaking each rule, give each rule its own group holding only that rule’s breach range, mirror the page’s other filters into it as well, and mount an Applications-style count in each. Without the mirrored filters, a breach count covers a different population from the passing count. One query per rule replaces any counting in the app.
  • Proportions such as “share passing” need a ratio measure or a second count beside the first; do not divide two query results in the app.

The abbreviationAuto, abbreviationNoDecimal, abbreviation1Decimal and abbreviation2Decimal format types pick k, M or B for each value by its size, so one column can show 41k beside 296M. For one fixed scale, use a custom format on the measure:

{ "formatType": "custom", "formatString": "#,##0.0,,\"M\"" }

Each comma after the last digit placeholder divides the value by 1,000:

formatString 296,281,123.4 shows as
#,##0, 296,281
#,##0,"K" 296,281K
#,##0,,"M" 296M
#,##0.0,,"M" 296.3M
  • Put the commas after the decimal places. #,##0,,.0"M" does not divide: it shows 296,281,123.4M.
  • Include a suffix or state the unit in the label, because #,##0, alone looks like an unscaled figure.
  • A fixed scale applies to every value: 41,250 shows as 0M under #,##0,,"M".

Verified in a bar chart, a number object and a table with @astrato-io/visualizations 0.6.5.