Skip to content

Rule: when chart data stays inline vs. requires a dataset (expressibility boundary) #2502

Closed

Description

@os-zhuang

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/or compound — 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)

  1. 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).
  2. Computed / derived column — a field that must be expressed, not stored (margin = revenue - cost; a CASE bucket).
  3. Aggregate of an aggregate (multi-level) — e.g. average of per-account totals.
  4. Window / sequential — rolling N-day, running total, rank, period-over-period.
  5. Pivot / reshape — rows→columns (matrix), or a grain group-by can't produce.
  6. Union of sources — one series combining rows from two objects.
  7. 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)

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.query actually 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 to datasetRef, and whether it's reversible.
  • Encoding — write the rule into the objectstack-ui skill (datasets/analytics section) so the build agent reads it (same pattern as the react-blocks contract).
  • Optional enforcement — an os build lint 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

  1. os-zhuang commented on Jul 18, 2026

    @os-zhuang
    ContributorAuthor

    Evaluation — pinned to the real analytics.query / dataset engine

    Traced end-to-end against current framework + objectui source. 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:

    • Dashboard widget: dataset required, values.min(1) required; the legacy inline object+categoryField+valueField+aggregate "was removed in the single-form cutover" — packages/spec/src/ui/dashboard.zod.ts:148-160, 211-212.
    • Report: dataset required (or blocks) — report.zod.ts:118-123, 177-192.
    • List type:'chart' view: inline xAxisField/aggregation removed — 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 (inline aggregate only — no dataset prop 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 to analytics/dataset/query today 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:

    # Trigger Dataset supports it? Evidence
    1 Join changing grain (to-many) No — to-one only; grain = base object dataset.zod.ts:114; dataset-compiler.ts:167-171
    2 Computed / derived column Partial — derived measures (ratio/sum/diff/product of measures) + date buckets; no CASE/bin/computed dimension dataset.zod.ts:59,83-93
    3 Aggregate-of-aggregate No — deliberately forbidden analytics-service.ts:143-146
    4 Window / rolling / rank / running Mostly no — only compareTo analytics.zod.ts:137-171; query.zod.ts:328-419 (present but unreachable from a dataset)
    5 Pivot / reshape No at dataset level — presentation-layer reshape report.zod.ts:124-132; DatasetWidget.tsx:63-80
    6 Union of sources No — single base object dataset.zod.ts:106
    7 Query-reshaping parameter No — datasets have no params contracts/analytics-service.ts:123-151

    Only #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_agg and 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.ts MetricSchema.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:

    • one base object + to-one joins ≤3 hops (include); to-many / grain-changing → no
    • 0..N flat dimensions; date bucket day/week/month/quarter/year
    • N measures; aggregates count / sum / avg / min / max / count_distinct (array_agg/string_agg rejected)
    • derived measures: ratio / sum / difference / product of named measures only
    • filters: compound $and/$or/$not on the base object (3 layers, all ANDed) + measure-scoped filters
    • compareTo (previous period/year) + totals (true re-aggregation)
    • No HAVING, window, union, computed dimension, or params.

    4. The ambiguous cases — resolved

    • Parent→child aggregate: "tasks per project" works only when the dataset is based on the child and grouped by the parent lookup (grain = child). A rollup onto the parent grain ("count of related tasks on project") is out of scope (dataset.zod.ts:114) — use a stored rollup field or a child-based dataset. to-one enrichment (revenue by account.industry) is fine and doesn't change grain.
    • Computed-column line: formatting/currency → 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).
    • Lookup filter depth: base-object compound filtering is solid & tested; one-hop lookup-attribute filtering is unwired/untested (the compiler validates dims/measures against 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.
    • Promotion (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.
    • Encoding: the react-blocks contract is generated ("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 Widgets section (iron-rule style) + JSON evals + an extension of the existing ADR-0021 os validate binding lint.

    5. Suggested re-scope

    • Level A (the real, checkable rule): every chart is dataset-backed; a dataset expresses the need iff it fits the envelope in §3. Escalate beyond a dataset — to a hand-authored Cube, a stored rollup/formula field, or app code — when it needs to-many/grain-changing rollup, HAVING/aggregate-of-aggregate, window functions, union, CASE/computed dimension, or per-request reshaping.
    • Level B (be explicit that this axis is governance): an inline draft and a named dataset have identical expressibility, so choosing between them is reuse/governance (canonical metric, RLS, shared labels) — which this issue said the rule must not decide. Worth stating outright rather than implying it's expressibility.
    • Drop the inline-envelope + 7-trigger framing as not engine-backed; keep the deterministic, expressibility-first spirit.

    6. Cleanups this surfaced (candidate spin-offs)

    1. Cross-repo spec skew — land the ADR-0021 spec bump in objectui, delete the legacy inline widget path.
    2. Stale JSDoc — dashboard.zod.ts:280-302 @example still shows the removed inline widget shape.
    3. Stale service-analytics/README.md — advertises HAVING / analytics.query({ having }) the real IAnalyticsService doesn't expose.
    4. Lookup-path filtering — wire + test one-hop, or document as unsupported.

    Related: #2501 (shipped) confirms trigger #7's boundary — a param that plugs into a WHERE is an inline per-chart filterBindings, not a dataset; only a reshaping param would be a dataset concern, and those don't exist yet.


    Generated by Claude Code

  2. os-zhuang commented on Jul 18, 2026

    @os-zhuang
    ContributorAuthor

    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 validate binding 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 revenue once 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 dataset be either a name (reference a governed dataset) or an inline embedded Dataset object. 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:

    • a one-off chart becomes one artifact with zero cross-file name references → the empty-render failure class disappears for the common case;
    • promotion (this issue's open checklist item) becomes the natural governance step: name an inline draft and lift it out only when a metric is shared / needs RLS / needs a canonical definition.

    Notably the runtime already supports an inline Dataset draft — analytics/dataset/query accepts { 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 requiring dataset to 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

  3. self-assigned this
    on Jul 21, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

enhancementNew feature or request

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions