From 3cd98a1f59f4a6d4e4ff00ca33a6de7afaf5fb44 Mon Sep 17 00:00:00 2001 From: Doug Mealing Date: Fri, 9 Oct 2026 17:41:37 -0400 Subject: [PATCH 01/30] docs(fr-044): plan 4, the Cube exporter (cube-model reference generator) The plan for the cube-model reference helper: the mapping contract per spec section 5 with a golden fixture per row, the rollup rules, name and escaping rules, the canonical golden, and a live lane against a pinned Cube instance. The Cube shapes were executed against Cube v1.7.43 and Postgres 16 before writing; @spine and @default rows are provisional on the zero-rows-and-defaults plan. --- .../2026-10-09-fr-044-plan-4-cube-exporter.md | 634 ++++++++++++++++++ 1 file changed, 634 insertions(+) create mode 100644 docs/superpowers/plans/2026-10-09-fr-044-plan-4-cube-exporter.md diff --git a/docs/superpowers/plans/2026-10-09-fr-044-plan-4-cube-exporter.md b/docs/superpowers/plans/2026-10-09-fr-044-plan-4-cube-exporter.md new file mode 100644 index 000000000..9c1bb27d8 --- /dev/null +++ b/docs/superpowers/plans/2026-10-09-fr-044-plan-4-cube-exporter.md @@ -0,0 +1,634 @@ +# FR-044 Plan 4 — The Cube exporter (`cube-model`) + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Ship the `cube-model` reference generator in TypeScript. It writes the reporting vocabulary of a model as Cube data model files: one `model/cubes/.yml` per entity that declares dimensions or measures, with joins from to-one references, dimensions (time dimensions with their grains), measures, segments, and one rollup pre-aggregation per served report. It is a reference helper: listed by `meta gen --list`, ejectable with `meta eject`, drift-checked by `meta verify --codegen`, with no runtime. Each row of the spec's §5 mapping table is backed by a golden fixture. A live lane loads the output into a real Cube instance against the persistence-conformance Postgres database and compares each report's Cube query with the report view. + +**Architecture:** The exporter has three pure stages and no I/O: `buildCubeModel(root, options)` turns the loaded model into a `CubeModel` (plain data: cubes, members, joins, rollups, views); `renderCubeYaml(cube)` writes one deterministic YAML file per cube; the `cubeModel()` generator wires the two into `meta gen`. Every SQL fragment in the output (a column reference, a filter, a relative date, a ratio's cast) comes from the same functions the report view lowering uses, moved into one shared module, so a filter means the same thing in the view and in Cube. Cube computes the aggregates and the time truncation itself; the exporter only names the column and the aggregate type. The live check is a new integration lane, separate from the unit gate and from `ts-slow`. + +**Tech Stack:** TypeScript (Bun). Cube `cubejs/cube:v1.7.43` (released 2026-09-21) with its embedded Cube Store in development mode. PostgreSQL 16 (`postgres:16-alpine`). Docker CLI, as the existing integration lanes use it. + +**Spec:** `docs/superpowers/specs/2026-10-02-fr-044-core-reporting-design.md`: R6 (exporters), §5 (the mapping contract), §7 (acceptance: "golden fixtures; a real Cube instance accepts the output; the Cube query result equals the report view result on the conformance data"), §8 D5 (MetricFlow on first adopter demand). Earlier plans: [Plan 1](2026-10-03-fr-044-plan-1-reporting-vocabulary.md) (vocabulary), [Plan 2](2026-10-03-fr-044-plan-2-report-view-lowering.md) (lowering; its Tables C to F are the SQL this plan reuses), [Plan 3](2026-10-04-fr-044-plan-3-report-read-routes.md) (routes). Work in parallel: PR [#411](https://github.com/metaobjectsdev/metaobjects/pull/411), plan `2026-10-09-fr-044-zero-rows-and-measure-defaults.md`, which adds `@spine` and an integer `@default`. **#411 was not merged when this plan was written**, so every statement here about those two attributes rests on its proposed design and is provisional. Tasks 9 and 10 cover them and come last. + +**This is Plan 4 of 5.** Not in this plan: the dbt MetricFlow exporter (D5: first adopter demand), exporters in other ports (the files are language-neutral), the `reporting` library and calendar spine (R7, Plan 5), `measure.derived`, a dashboard generator, #395, #222, #8, #393. + +## How this plan was verified + +Every path, function and test file cited below was read in the tree at `c512a8177` (the Plan 3 list-hook merge), unless it is marked **UNVERIFIED**. Cube's behaviour was checked against its current reference pages (docs.cube.dev: cube, dimensions, measures, joins, segments, pre-aggregations, syntax, Jinja, REST query format, read 2026-10-09) and then **executed**: + +- **A live spike, removed afterwards.** `postgres:16-alpine` and `cubejs/cube:v1.7.43` ran on a private Docker network, with the Cube API published on an ephemeral `127.0.0.1` port. The committed `fixtures/persistence-conformance/canonical/schema.postgres.sql` was applied and seeded with the union of the report scenarios' seed rows, plus one week for program 5 and two programs created 3 and 60 days before the database clock. A hand-written model in the shape of Table H was loaded. Each of the six canonical reports was queried through `/cubejs-api/v1/load` and compared with `SELECT * FROM `. **All six matched**, value for value, after the normalization in Table I. The same spike executed every row of the "What Cube does" table below. +- **The Cube SQL.** For each query the spike also read `/cubejs-api/v1/sql`, so the aggregates Cube generates (`count(x)`, `COUNT(DISTINCT x)`, `count(CASE WHEN (c) THEN x END)`, `sum(x)`) are what this plan's measure table relies on, not a reading of the docs. +- **Bun.** `bun test ` runs a file whose name does not match bun's test pattern (`*.live.ts`) when the path is given (executed with bun 1.3.14). The live lane relies on this to stay out of every `bun test` that walks a directory. + +**Not executed:** MySQL output in Cube, a TPH subtype's cube, an int-backed enum dimension, a one-to-one join held by the far entity, a multi-hop `@via` with two paths to one entity, a Cube view that includes a non-public member under an alias, and Cube's production mode (a separate Cube Store). Each is in [Unverified items](#unverified-items). + +What Cube does that its documents do not say, or that the spec's §5 table does not anticipate. Each one changes a table or a task below: + +| The documents say | What Cube 1.7.43 does | +|---|---| +| A member may reference "a column or member" of another cube (`{cube}.column`, `{cube.member}`; syntax page) | A **dimension** whose `sql` reads another cube's **column** (`{Program}."title"`) fails the whole model: `Member 'Week.programTitle' references foreign cubes: Program. Please split and move this definition to corresponding cubes.` A **member** reference (`{Program.title}`) works, also when that member is `public: false`. So a `@via` dimension needs a member on the cube it reaches (Table E). | +| `count_distinct` takes "a non-aggregated expression" (one; no multi-column form documented) | `sql: 'ROW({CUBE}."programId", {CUBE}."durationMinutes")'` with a `filters` entry `… IS NOT NULL AND … IS NOT NULL` renders `COUNT(DISTINCT CASE WHEN (…) THEN ROW(…) END)` and returns the view's tuple count, including when a component is null (it is not counted). | +| A measure `number` type is "arithmetic on other measures"; the page shows no division-by-zero guard | `CAST({longWeeks} AS NUMERIC) / NULLIF({weeks}, 0)` inlines both operands' full aggregates, conditions included, and returns the view's ratio (`0.75000000000000000000`). Over an empty set it is null, as in the view. | +| `min` and `max` take a "non-aggregated numeric expression" | `type: max` over a `TIMESTAMP` column and `type: min` over a `VARCHAR` column load and return the engine's `MAX`/`MIN`. | +| A rollup has one `time_dimension` and a `granularity` (the reference page documents no other form) | A `time_dimensions:` list of `{ dimension, granularity }` entries loads, builds and serves a query with two time dimensions (`AssetActivity`: `recordedAt:hour`, `asOfDate:week`). | +| Additive measure types are `count`, `sum`, `min`, `max`, `count_distinct_approx` (whether others may be in a rollup is not stated) | A rollup may list `count_distinct`, `avg` and `number` measures. Cube serves a query from it only when the query's dimensions equal the rollup's (it re-aggregates with `sum`, which is right for one row per group). A coarser query (no dimensions, or a subset) is answered from the source table instead, with the right numbers. | +| (spec §5, amended by #411) a `@spine` report maps to "the spine entity's cube joins the fact cube `one_to_many`, and the rollup is rooted on it" | A rollup declared on the spine cube over the fact cube's measures is **built from the fact cube**: 3 rows, the programs with no weeks missing. Worse, with the spine cube's `one_to_many` join in place the cube query `{ measures: [Week.weeks], dimensions: [Program.id] }` returns all 7 programs from the source tables, and it returns 3 once that rollup exists. A **Cube view** whose `join_path` starts at the spine cube returns 7 rows with `weeks` `0` for the empty ones (Task 10). | +| (spec §5) a relative filter maps to a query `dateRange` "last 7 days" | Segments and measure filters are SQL, so a relative filter there must be SQL anyway, and the view's own SQL (`now()` arithmetic) is exact. A `dateRange` such as `"from 30 days ago to now"` resolves to day bounds in the query time zone, and the spike's query was answered from a **month** rollup on month buckets. The exporter maps a relative filter to SQL and emits no rollup for a report that has one (Table F, open question 2). | +| YAML models are processed by Jinja; in YAML "escape literal braces with a backslash" | `\{` and `\}` inside a string literal reach the SQL as `{` and `}`. A backslash does **not** stop Jinja: a literal containing `{%` fails the model (`syntax error: unknown statement z`), and an unescaped `{{x}}` fails it (`x is not defined`). Wrapping the literal in `{% raw %}…{% endraw %}` and escaping its braces works (Table G). | +| A `time` dimension "should be TIMESTAMP, so cast other temporal types in `sql`"; queries take a `timezone` | Cube's Postgres driver renders `date_trunc('', (x::timestamptz AT TIME ZONE ''))`. With the database's default time zone set to `America/New_York`, a naive `TIMESTAMP` column and a `CAST( AS TIMESTAMP)` still bucketed exactly as the view does: the driver's session runs in UTC. | +| Every join compiles to a `LEFT JOIN` (joins page) | Confirmed. The view joins a required belongs-to reference `INNER` (#209). The two differ only for a fact row whose reference matches no row, which a declared foreign key forbids (Table J). | + +## Global Constraints + +- **No new vocabulary.** Nothing is registered. `metamodelVersion` stays `1.1`; `fixtures/registry-conformance/expected-registry.json` is not touched. Anything outside spec §3.1 (and §3.2 once #411 merges) needs a new agreement. +- **A reference helper, TypeScript only** (ADR-0034 Amendment 3). The emitted files import nothing and need no MetaObjects package at runtime: `runtimePackages: []`, `runtimePeers: []`. Other ports get no exporter (spec R6); `fixtures/generator-registry-conformance/registry.json` lists `cube-model` for `typescript` only. +- **Opt-in.** `meta init` wires no generator; a project that does not configure `cube-model` gets no file. A model that declares no `dimension`, `measure` or `segment` gets no file even when it is configured. +- **Lossless or an error.** Anything this mapping cannot express is a generation error that names the node and says why. Nothing is dropped silently (spec §3 obligation 3). The error list is Table G and Table C's last rows. The `ERR_CUBE_*` names are this generator's own, printed in its messages: they are not loader codes and do not enter `fixtures/conformance/ERROR-CODES.json`. +- **The inert rules hold.** An entity with no table (abstract, or no writable `source.rdb`) gets no cube. A report gets a rollup only when it is served (Plan 3 Table A: concrete, read source `@kind: view`); a sourceless report contributes nothing. +- **One definition of the SQL.** Column quoting, filter clauses, literals and relative dates in the output come from the functions the report view lowering uses (`report-ddl-emit.ts`, `time-sql.ts`, `extract-report-spec.ts`), moved into a shared module without changing a byte of any view. No second filter-to-SQL translator. +- **No query-time engine and no SQL in the metamodel.** The output contains SQL fragments, as the view lowering's output does; the metadata does not. +- **Names are kept as written.** A cube is named after its entity and a member after its dimension, measure or segment, so a report field and its Cube member have the same name. A name Cube cannot take is an error, never a rename (Table G). +- **The live check never touches anything shared.** A private Docker network per run, an ephemeral host port bound to `127.0.0.1`, pinned image tags, generous timeouts, and every container and the network force-removed on every exit path. It does not use the shared Postgres sidecar. No hosted Cube service and no paid API. +- **Ordering around #411.** Tasks 1 to 8 use only the vocabulary on `main`. Tasks 9 and 10 start by rebasing over the #411 build; if that build has not merged, stop and report rather than build against its plan. +- ADR-0039: read effective properties with resolving accessors. Any `own*()` call carries a comment naming its sanctioned case. +- TS: named constants for metamodel strings, no `any`, never `instanceof` a node from another package. +- Public repo: no private project names, no absolute home paths, in code, fixtures, docs or commit messages. +- **Release hold continues:** `main` carries `metamodelVersion 1.1`, so no 1.0.x PATCH is cut from it. +- This machine is loaded. Run scoped tests while iterating and the full `scripts/ci-local.sh` once, on the final tree. Rerun a lone timeout in isolation before believing it. Never `pkill -f`. + +## Review Focus + +1. **A `@via` dimension reads a member, never a column, of the cube it reaches.** `programTitle` is `{Program.title}`, and `Program` carries `title` as a `public: false` dimension when no declared dimension covers it. A column reference loads in no Cube model. (Table E; fixture `dimension-via`.) +2. **A tuple distinct count does not count a tuple with a null component.** `ROW(...)` plus a not-null filter, never a string concatenation, which would collide (`'a|b','c'` against `'a','b|c'`) and would count a null component as the empty string. (Table D; fixture `measure-count-distinct-tuple`; live `ProgramMinutes.slots`.) +3. **No rollup that can disagree with the view.** None for a report with a relative date anywhere in its scope or its measures' conditions (the rollup would freeze "now" at build time), and none for a `@spine` report (the spike's 3-against-7 rows). (Table F.) +4. **A literal survives Jinja and Cube's reference syntax.** A segment value `a{b}c`, `{{x}}` or `{% x %}` reaches the SQL unchanged. (Table G; fixture `escaping`.) +5. **A model without the vocabulary is untouched,** and the existing inert test still holds every other generator to its files. (Task 6.) + +--- + +## Contract tables + +### Table A — what becomes a file + +| The model declares | Output | +|---|---| +| a concrete entity with a writable `source.rdb` table that declares (or inherits through `extends`) at least one `dimension`, `measure` or `segment` | `model/cubes/.yml`, one cube. Its rollups (one per served report whose `@from` it is) and report-scope segments are in the same file. | +| an entity that a dimension's `@via` reaches, and that is not already a cube | a **join-target cube** in `model/cubes/.yml`, `public: false`, holding only its primary key and the members the reaching dimensions read (Table E) | +| two or more to-one hops from one cube onto the same entity | one **alias cube** per hop, `model/cubes/_.yml`, `extends: `, `public: false` (Table E) | +| a served `@spine` report (#411, provisional) | `model/views/.yml`, a Cube view rooted at the spine cube (Task 10) | +| an abstract entity | nothing; its members are emitted on each concrete entity that inherits them | +| an entity with dimensions or measures and no table | nothing (inert, #248) | +| a TPH subtype with dimensions or measures | a cube whose `sql` selects the base table with the subtype's discriminator predicate, instead of `sql_table` (**UNVERIFIED** on Cube) | +| a sourceless or abstract report, or one whose read source is not `@kind: view` | nothing | +| none of the reporting vocabulary | no file at all | + +Paths are relative to the generator's target `outDir`. An adopter points the generator at Cube's project with a target (`targets: { cube: { outDir: "cube" } }`, `cubeModel({ target: "cube" })`), so the files land in `cube/model/cubes/`. Every file starts with `# @generated by @metaobjectsdev/codegen-ts — cube-model`. + +### Table B — the mapping contract (spec §5, one golden fixture per row) + +The fixtures are `fixtures/cube-model//` (Table K). "Live" means the row is also in the live lane's comparison. + +| MetaObjects | Cube | Fixture | Live | +|---|---|---|---| +| entity with dimensions, measures or segments | cube, `sql_table` (`"schema"."table"` when `@schema` is declared, else `"table"`); `title` and `description` from the node's common attributes when present | `entity-cube` | yes | +| `identity.primary` | one `primary_key: true` dimension per key field, named after the field (`public: false` by Cube's default) | `entity-cube` | yes | +| to-one `identity.reference` held by the entity | `joins` entry, `relationship: many_to_one`, `sql: '{CUBE}."fk" = {Target}."pk"'` | `join-many-to-one` | yes | +| to-one `relationship.*` whose reference the target holds | `joins` entry, `relationship: one_to_one` | `join-one-to-one` | no | +| two to-one hops onto one entity | one alias cube per hop (Table E) | `join-alias-cubes` | no | +| `dimension.attribute` | dimension, `type` by Table C | `dimension-attribute-types` | yes | +| `dimension.attribute` with `@via` | dimension `sql: '{.}'`, member added to the target cube if needed | `dimension-via` | yes | +| `dimension.time` + `@grains` | `type: time` dimension; `meta: { grains: [...] }` (Cube has no way to restrict granularities; it offers all of them) | `dimension-time` (instant, naive, date) | yes | +| `measure.aggregate` `count` | `type: count`, `sql` the `@of` column | `measure-count` | yes | +| `count` + `@distinct`, one column | `type: count_distinct` | `measure-count-distinct` | yes | +| `count` + `@distinct`, tuple | `type: count_distinct`, `sql: 'ROW(…)'`, plus a not-null filter (Table D) | `measure-count-distinct-tuple` | yes | +| `sum` / `avg` / `min` / `max` | `type: sum` / `avg` / `min` / `max` | `measure-sum-avg-min-max` | yes | +| a measure's `@segment` and `@filter` | one `filters` entry, the condition ANDed | `measure-conditions` | yes | +| `measure.ratio` | `type: number`, `CAST({num} AS NUMERIC) / NULLIF({den}, 0)` (Postgres) | `measure-ratio` | yes | +| `measure.derived` | not registered (spec §3.1); nothing to map | — | — | +| `segment.filter` | segment, `sql` the filter | `segment` | yes | +| relative filter `{ now: "-P7D" }` | the view's SQL: `((now() AT TIME ZONE 'UTC') - INTERVAL 'P7D')` for a naive timestamp, by Plan 2 Table E | `relative-date` | yes | +| `object.report`, served | a `rollup` pre-aggregation on the `@from` cube (Table F); its `@filter` becomes a segment `Scope` | `report-rollup` | yes | +| served report with a relative date in its scope or its measures | the scope segment, **no** rollup | `report-relative-no-rollup` | yes | +| sourceless report | nothing | `report-sourceless` | no | +| measure `@default: n` (#411, provisional) | `type: number`, `COALESCE({Raw}, n)`, with the original aggregate as `public: false` `Raw` | `measure-default` | yes | +| report `@spine` (#411, provisional) | a `one_to_many` join from the spine cube to the fact cube, and a Cube view rooted at the spine; no rollup | `report-spine` | yes | +| (no Cube counterpart) | a generation error (Table G) | `error-*` | no | + +Two rows change the spec's §5 table: a relative filter maps to SQL rather than to a query `dateRange`, and a `@spine` report maps to a Cube view rather than a rollup rooted on the spine. Task 11 amends §5 to say so, after #411's own amendment merges. + +### Table C — dimension types + +The `@of` field's subtype decides, as it decides the report view's column type (Plan 2 Table B). + +| `@of` field | Cube `type` | `sql` | +|---|---|---| +| `string`, `enum` (string-backed), `uuid`, `time` | `string` | the column | +| `enum` with `@intValueMap` | `string` | `CASE WHEN 1 THEN 'A' … END`, so the wire carries the member symbol, as every port's read does (**UNVERIFIED** on Cube) | +| `int`, `long`, `double`, `float`, `decimal`, `currency` | `number` | the column | +| `boolean` | `boolean` | the column | +| `date` (attribute or time dimension) | `time` | `CAST( AS TIMESTAMP)` | +| `timestamp` (with or without `@localTime`) | `time` | the column | +| `isArray`, `object`, `map`, or a field with `@objectRef` | none: **error** `ERR_CUBE_UNMAPPABLE_DIMENSION`, naming the dimension and its field. Cube has no array or JSON dimension type. | — | + +`` is `{CUBE}.""` on the owning cube, quoted unconditionally (Table G). + +### Table D — measures + +`x` is the `@of` column on `{CUBE}`. `c` is the measure's condition (its `@segment` filter, then its `@filter`, ANDed by the lowering's own `andOf`), rendered by the lowering's own `cond`. + +| Measure | Cube | +|---|---| +| `count` | `type: count`, `sql: x` | +| `count` + `@distinct` | `type: count_distinct`, `sql: x` | +| `count` + `@distinct`, tuple `x1, x2` | Postgres `type: count_distinct`, `sql: 'ROW(x1, x2)'`, `filters: [{ sql: 'x1 IS NOT NULL AND x2 IS NOT NULL' }]`; MySQL `JSON_ARRAY(x1, x2)` with the same filter | +| `sum`, `avg`, `min`, `max` | `type: sum` / `avg` / `min` / `max`, `sql: x` | +| any of the above with condition `c` | the same, plus one `filters` entry `c` (for a tuple, ANDed after the not-null terms) | +| `measure.ratio` | `type: number`. Postgres `CAST({num} AS NUMERIC) / NULLIF({den}, 0)`; MySQL `{num} / NULLIF({den}, 0)` | + +`{num}` and `{den}` are member references, so each operand is its full Cube expression, condition included, and an operand need not be listed in any report. Cube computes `sum` with the engine's own type (`SUM(int)` is `numeric` on Postgres, where the view casts to `BIGINT`); the value is the same and Cube's REST API sends both as strings. `format` is set for no measure: Cube's named formats are display hints, and the model carries none. + +### Table E — joins, reached members, join-target and alias cubes + +| Case | Emitted | +|---|---| +| the cube holds an `identity.reference` onto an entity that is a cube | on the holder: `joins: - { name: , relationship: many_to_one, sql: '{CUBE}."" = {}.""' }`. One entry per reference, in declaration order. | +| a to-one `relationship.*` whose reference the target holds, onto a cube | on this cube: `relationship: one_to_one`, `sql: '{CUBE}."" = {}.""'` (**UNVERIFIED** on Cube) | +| a dimension with `@via` reaches a field of entity `T` | the dimension's `sql` is `'{.}'`. `` is a declared attribute dimension of `T` with no `@via` over the same field when there is one; otherwise a dimension named after the field, `public: false`, added to `T`'s cube | +| `T` is not a cube | a join-target cube for `T` (Table A): primary key plus the added members, `public: false` | +| a multi-hop `@via` (`Week.fkProgram.fkOrg`) | a join on each hop's holder; the dimension reads `{Org.}` and Cube follows the joins transitively. Refused (`ERR_CUBE_AMBIGUOUS_PATH`) when the cube graph reaches `Org` from the owning cube by more than one path, until the join-path form is verified (**UNVERIFIED**) | +| a cube with two or more to-one hops onto the same entity | Cube allows one join per target cube, so **each** such hop gets an alias cube `_` (`extends: `, `public: false`) and the join and the dimensions use it. Never the plain target for one hop and an alias for another: that would depend on declaration order. (Executed: an `extends` alias with its own join and a dimension reading it.) | +| a cube in a join that has no `identity.primary` | error `ERR_CUBE_NO_PRIMARY_KEY` (Cube needs a primary key on both sides of a join) | + +Joins are emitted between cubes only. The exporter never emits a cube just to give a reference somewhere to go: an `identity.reference` onto an entity that is neither a cube nor reached by a dimension is not a join. + +### Table F — reports: rollups, scope segments, and the query that matches them + +A **served** report (Plan 3 Table A) is written into its `@from` cube. + +| Report part | Rollup (`type: rollup`, `name: `) | +|---|---| +| attribute dimensions | `dimensions: [CUBE., …]` in listed order (a `@via` dimension is a member of the `@from` cube, so it is listed the same way) | +| one time dimension | `time_dimension: CUBE.` and `granularity: ` (the documented form) | +| two or more time dimensions | `time_dimensions: [{ dimension: CUBE., granularity: }, …]` in listed order (executed; not on the reference page) | +| measures | `measures: [CUBE., …]` in listed order; a `Week.weeks` item is the measure `weeks` | +| `@segment` | `segments: [CUBE.]` | +| `@filter` | a segment `Scope` on the `@from` cube (lower-camel report name, e.g. `recentProgramsScope`), public, `sql` the filter; listed after `@segment` in `segments` | +| no dimensions | a rollup with measures only (the totals row) | + +**No rollup** is emitted, and only the scope segment is, when the report's `@filter`, its segment, or any listed measure's condition contains a relative date (spike: a rollup is built at refresh time, so its "now" is the build's). **No rollup** for a `@spine` report (Task 10). Neither a `refresh_key` nor a partition is emitted: Cube's defaults apply, and partitioning is a deployment choice. + +**The Cube query that reproduces a report** (the live lane builds it from the report; `meta docs` does not document it in this plan): + +```json +{ "measures": [".", …], + "dimensions": [".", …], + "timeDimensions": [{ "dimension": ".