Repository navigation
Rule: when chart data stays inline vs. requires a dataset (expressibility boundary) #2502
Description
Why
Charts can carry their data query inline (self-contained: objectName + aggregate + filter) or reference a named dataset. We need one mechanical, checkable rule so an author — especially the build agent — deterministically picks the right one. Without it we get drift both ways: datasets created for trivial queries (over-abstraction, hidden coupling) and inline queries that quietly can't express what's asked (under-abstraction).
Decision principle
The choice is made purely on expressibility — can the inline chart-query engine express this query? It is not made on reuse, on "a dashboard filter drives it", or on "it might change later".
"Should this be a governed / canonical metric" is a separate human promotion (save as dataset), layered on top — never something the agent infers from the query. The agent's rule is expressibility only.
The rule (draft — details below to standardize)
Default inline. A dataset is required only when the query cannot be expressed inline.
Inline envelope (what a chart's own query may hold)
- exactly one object (+ its direct lookup fields for filtering / labels)
- filters: field/op/value,
and/orcompound — all resolvable on that object (or a direct lookup) - group by: 0..N fields on that object
- aggregate: one function (
count/sum/avg/min/max) over one field - sort + limit
Fits entirely inside this → inline; do not create a dataset.
Dataset triggers (any one → dataset)
- Join across objects that changes grain — mixes rows/fields from >1 object, beyond "aggregate a child by a parent field". e.g. revenue (Invoice) by industry (Account).
- Computed / derived column — a field that must be expressed, not stored (
margin = revenue - cost; a CASE bucket). - Aggregate of an aggregate (multi-level) — e.g. average of per-account totals.
- Window / sequential — rolling N-day, running total, rank, period-over-period.
- Pivot / reshape — rows→columns (matrix), or a grain group-by can't produce.
- Union of sources — one series combining rows from two objects.
- A parameter that reshapes the query (grain / window / join) — not one that plugs into a
WHERE(that's a dashboard filter → Dashboard-level filters (date / region) driving multiple charts #2501).
Explicitly NOT triggers (stay inline)
- Appears in 2+ charts (agent writes multiple copies; harmless drift).
- A dashboard filter drives it (dashboard variable + per-chart field binding → Dashboard-level filters (date / region) driving multiple charts #2501).
- Might change later (agent edits inline).
- "Want it governed / canonical" — a human promotion, not an agent inference.
Details to standardize (the point of this issue)
The rule above is the shape; the edges need pinning to the real engine:
- Pin the inline envelope to what
ObjectChart/analytics.queryactually supports — this is the source of truth, not the prose above. Enumerate: relationship rollups, multiple series, multiple group-by fields, HAVING-style filters on the aggregate, date-bucketing (by month/quarter). - Is a direct parent→child relationship aggregate inline or a join trigger? (e.g. "task count per project" over a lookup.) Decide and document; it's the most common ambiguous case.
- Computed column boundary — display formatting / unit conversion (still inline?) vs. a real derived expression (dataset). Where's the line.
- Filter depth on lookups — one hop inline; multi-hop → dataset?
- Promotion flow — inline →
save as dataset: how the chart switches from an inline query todatasetRef, and whether it's reversible. - Encoding — write the rule into the
objectstack-uiskill (datasets/analytics section) so the build agent reads it (same pattern as the react-blocks contract). - Optional enforcement — an
os buildlint that flags (a) a dataset created for an inline-expressible query, and (b) an inline query using a construct the engine can't express (should have been a dataset).
Acceptance
Given a data need, the build agent deterministically picks inline vs dataset per the pinned envelope; a lint (if built) flags both over- and under-abstraction; the ambiguous cases above have a documented answer.
Related: #2501 (dashboard-level filters — the "parameter that plugs into a WHERE" side of trigger #7).
Activity
os-zhuang commentedon Jul 18, 2026
Evaluation — pinned to the real
analytics.query/datasetengineTraced end-to-end against current
framework+objectuisource. Net: the decision principle (deterministic, expressibility-first) is sound, but the rule's shape doesn't match what shipped — the inline↔dataset axis has largely collapsed, and a dataset is narrower than the draft assumes.1. The premise is mostly already decided by ADR-0021
The "single-form cutover" removed the inline chart query from every persisted surface:
datasetrequired,values.min(1)required; the legacy inlineobject+categoryField+valueField+aggregate"was removed in the single-form cutover" —packages/spec/src/ui/dashboard.zod.ts:148-160, 211-212.datasetrequired (orblocks) —report.zod.ts:118-123, 177-192.type:'chart'view: inlinexAxisField/aggregationremoved —view.zod.ts:359-373.So for persisted charts the platform already answers "inline or dataset?" → always a dataset. A live choice remains only in (a) the hand-authored react
<ObjectChart>block (inlineaggregateonly — nodatasetprop in the generated react-blocks contract), and (b)objectui's transitional runtime that still honors the legacy inline widget as an override (code comments call it "cross-repo spec skew" —DatasetWidget.tsx:22-27,DashboardRenderer.tsx:490-493). The "inline query" POSTed toanalytics/dataset/querytoday is actually an inline Dataset draft for Studio preview (rest-server.ts:4879-4982) — same expressibility as a saved dataset, just unsaved.2. A dataset is a deliberately narrower, governed layer — 5 of the 7 triggers aren't dataset-expressible
dataset.zod.ts:18-20: "deliberately SMALLER than QuerySchema: no raw SQL, no hand-authored join predicates, no window/having grammar in the author surface." Judged against the compiler/executor:dataset.zod.ts:114;dataset-compiler.ts:167-171dataset.zod.ts:59,83-93analytics-service.ts:143-146compareToanalytics.zod.ts:137-171;query.zod.ts:328-419(present but unreachable from a dataset)report.zod.ts:124-132;DatasetWidget.tsx:63-80dataset.zod.ts:106contracts/analytics-service.ts:123-151Only #2 (partly) and #5 (as a render mode) are real. The other five would have the build agent author a dataset that then fails to compile (
array_agg/string_aggand undeclared join paths already throw —dataset-compiler.ts:24,82-87,183-197) or silently under-delivers. The real escape hatch for advanced analytics is a hand-authored Cube (raw SQL, explicit joins —analytics.zod.tsMetricSchema.sql/CubeJoinSchema.sql), a lower layer the draft rule never points at.3. Envelope, pinned (checklist item 1)
Since every persisted chart is dataset-backed, the envelope is the Dataset envelope:
include); to-many / grain-changing → noarray_agg/string_aggrejected)$and/$or/$noton the base object (3 layers, all ANDed) + measure-scoped filterscompareTo(previous period/year) +totals(true re-aggregation)4. The ambiguous cases — resolved
project") is out of scope (dataset.zod.ts:114) — use a stored rollup field or a child-based dataset. to-one enrichment (revenue byaccount.industry) is fine and doesn't change grain.format/currency; arithmetic over declared measures → derived measure; CASE / numeric bins /revenue*0.8/ computed dimension → not expressible (Cube SQL or a stored formula field).include, not filter fields —dataset-compiler.ts:183-197); multi-hop unsupported. Prefer modeling a parent attribute as a dimension over a lookup-path filter.save as dataset): does not exist in either repo. Since an inline draft and a saved dataset are the same shape it's trivial to build, but it's unbuilt — suggest splitting into its own feature issue."GENERATED … do not edit"), so a hand-authored decision-contract JSON would be a new precedent. Encode as prose in the skill's existing### Dataset-Bound Widgetssection (iron-rule style) + JSON evals + an extension of the existing ADR-0021os validatebinding lint.5. Suggested re-scope
6. Cleanups this surfaced (candidate spin-offs)
objectui, delete the legacy inline widget path.dashboard.zod.ts:280-302@examplestill shows the removed inline widget shape.service-analytics/README.md— advertisesHAVING/analytics.query({ having })the realIAnalyticsServicedoesn't expose.Related: #2501 (shipped) confirms trigger #7's boundary — a param that plugs into a
WHEREis an inline per-chartfilterBindings, not a dataset; only a reshaping param would be a dataset concern, and those don't exist yet.Generated by Claude Code
os-zhuang commentedon Jul 18, 2026
Follow-up: the AI-authoring angle (strengthens the "promotion flow" checklist item)
A point from the build-agent's perspective, because it changes what this rule should optimize for.
Mandatory "one named dataset per widget" is over-ceremony for the common one-off dashboard, and it introduces the error class LLMs are worst at. For a dashboard of N unrelated charts (different objects, no shared metric), the agent must emit 2N artifacts (N datasets + N widgets), and each widget references its dataset's dimensions/measures by string name. Cross-artifact by-name references are a top agent failure mode: a typo or an undeclared name doesn't throw — it silently renders an empty chart (exactly why the ADR-0021
os validatebinding lint had to exist). For zero reuse benefit that's 2× the surface area plus a new class of silent failure — the "over-abstraction" this issue set out to prevent, now made mandatory.Conversely, for a genuine shared-metric dashboard, datasets reduce agent error — define
revenueonce as a thin, validated, governed layer instead of hand-writing 6 subtly-divergent inline aggregates (a real correctness bug, not the "harmless drift" the draft assumes). So the goal isn't "inline everything"; it's don't force naming when nothing is shared.Concrete proposal that keeps one engine + one code path but fixes the ergonomics: let a widget's
datasetbe either a name (reference a governed dataset) or an inline embeddedDatasetobject. Same Dataset shape, same validation, same executor — so no return to the old inline/dataset dual-form skew, and no new expressibility axis (they're identical in power — see Level A/B in the evaluation above). For the agent:Notably the runtime already supports an inline
Datasetdraft —analytics/dataset/queryaccepts{ dataset?: <inline Dataset>, selection }, and Studio's Live Canvas preview uses exactly this (rest-server.ts:4879-4982). The only thing blocking persistence is the widget schema requiringdatasetto be a name string (dashboard.zod.ts:156). So this is a schema/authoring change, not a new engine.Net for the rule: default to an inline (embedded) dataset — one artifact, validated, no by-name references — and promote to a named dataset only for reuse/governance (Level B). That preserves ADR-0021's single-form / single-engine win while removing the ceremony that makes AI-built dashboards more complex and more error-prone.
Generated by Claude Code
docs(objectstack-ui): standardize the inline-vs-dataset chart rule (#…